from __future__ import annotations from datetime import datetime, timezone from typing import TYPE_CHECKING, Protocol, Literal from uuid import UUID from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.logging import get_logger from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus from models.schedule_items import ScheduleItem from models.schedule_subscriptions import SubscriptionPermission, SubscriptionStatus from v1.auth.gateway import SupabaseAuthGateway from v1.inbox_messages.repository import InboxMessageRepository from v1.schedule_items.repository import ScheduleItemRepository from v1.schedule_items.schemas import ( ScheduleItemCreateRequest, ScheduleItemListRequest, ScheduleItemMetadata, ScheduleItemResponse, ScheduleItemShareRequest, ScheduleItemShareResponse, ScheduleItemUpdateRequest, ScheduleItemSourceType, ScheduleItemStatus, ) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from v1.auth.schemas import UserByEmailResponse logger = get_logger("v1.schedule_items.service") class AuthByEmailGateway(Protocol): async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ... class ScheduleItemService(BaseService): _repository: ScheduleItemRepository _session: AsyncSession _auth_gateway: AuthByEmailGateway _inbox_repository: InboxMessageRepository def __init__( self, repository: ScheduleItemRepository, session: AsyncSession, current_user: CurrentUser | None, auth_gateway: AuthByEmailGateway | None = None, inbox_repository: InboxMessageRepository | None = None, ) -> None: super().__init__(current_user=current_user) self._repository = repository self._session = session self._auth_gateway = auth_gateway or SupabaseAuthGateway() if inbox_repository is None: raise ValueError("inbox_repository is required") self._inbox_repository = inbox_repository async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse: return await self._create_with_source( request=request, source_type=ScheduleItemSourceType.MANUAL, ) async def create_agent_generated( self, request: ScheduleItemCreateRequest ) -> ScheduleItemResponse: return await self._create_with_source( request=request, source_type=ScheduleItemSourceType.AGENT_GENERATED, ) async def _create_with_source( self, *, request: ScheduleItemCreateRequest, source_type: ScheduleItemSourceType, ) -> ScheduleItemResponse: user_id = self.require_user_id() normalized_start_at = self._to_utc_required(request.start_at) normalized_end_at = self._to_utc(request.end_at) if normalized_end_at and normalized_end_at <= normalized_start_at: raise HTTPException(status_code=400, detail="end_at must be after start_at") data = { "owner_id": user_id, "title": request.title, "description": request.description, "start_at": normalized_start_at, "end_at": normalized_end_at, "timezone": request.timezone, "extra_metadata": request.metadata.model_dump() if request.metadata else {}, "source_type": source_type, "status": ScheduleItemStatus.ACTIVE, "created_by": user_id, } try: item = await self._repository.create(data) await self._repository.create_subscription( { "item_id": item.id, "subscriber_id": user_id, "permission": SubscriptionPermission.OWNER, "status": SubscriptionStatus.ACTIVE, "created_by": user_id, } ) await self._session.commit() except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to create schedule item") raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) return self._to_response(item) async def get_by_id(self, item_id: UUID) -> ScheduleItemResponse: user_id = self.require_user_id() try: item = await self._repository.get_by_id(item_id) except SQLAlchemyError: logger.exception("Failed to get schedule item", item_id=str(item_id)) raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) if item is None: raise HTTPException(status_code=404, detail="Schedule item not found") is_owner = item.owner_id == user_id permission = 1 if not is_owner: subscription = await self._repository.get_subscription(item_id, user_id) if subscription: permission = subscription.permission return self._to_response(item, is_owner=is_owner, permission=permission) async def update( self, item_id: UUID, request: ScheduleItemUpdateRequest ) -> ScheduleItemResponse: user_id = self.require_user_id() try: existing = await self._repository.get_by_item_id(item_id, user_id) if existing is None: raise HTTPException(status_code=404, detail="Schedule item not found") # Build update dict from non-null fields update_data = request.model_dump(exclude_unset=True) # Handle metadata separately (model_dump returns dict) if "metadata" in update_data: metadata_value = update_data.pop("metadata") update_data["extra_metadata"] = ( metadata_value.model_dump() if hasattr(metadata_value, "model_dump") else metadata_value ) # Validate time range next_start = update_data.get("start_at", existing.start_at) next_end = update_data.get("end_at", existing.end_at) if isinstance(next_start, datetime): next_start = self._to_utc_required(next_start) update_data["start_at"] = next_start if isinstance(next_end, datetime): next_end = self._to_utc(next_end) update_data["end_at"] = next_end if next_end is not None: if not isinstance(next_start, datetime): raise HTTPException( status_code=400, detail="start_at must include timezone" ) if next_end <= next_start: raise HTTPException( status_code=400, detail="end_at must be after start_at" ) if not update_data: return self._to_response(existing) item = await self._repository.update_by_item_id( item_id, user_id, update_data ) await self._notify_subscribers(item_id, existing.title, "updated") await self._session.commit() except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to update schedule item", item_id=str(item_id)) raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) if item is None: raise HTTPException(status_code=404, detail="Schedule item not found") return self._to_response(item) async def delete(self, item_id: UUID) -> None: user_id = self.require_user_id() try: existing = await self._repository.get_by_item_id(item_id, user_id) if existing is None: raise HTTPException(status_code=404, detail="Schedule item not found") title = existing.title await self._repository.delete_subscriptions_by_item_id(item_id) await self._notify_subscribers(item_id, title, "deleted") await self._repository.delete_by_item_id(item_id, user_id) await self._session.commit() except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to delete schedule item", item_id=str(item_id)) raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) async def list_by_date_range( self, request: ScheduleItemListRequest ) -> list[ScheduleItemResponse]: user_id = self.require_user_id() normalized_start_at = self._to_utc_required(request.start_at) normalized_end_at = self._to_utc_required(request.end_at) if normalized_end_at <= normalized_start_at: raise HTTPException(status_code=400, detail="end_at must be after start_at") try: subscribed_items = ( await self._repository.list_subscribed_items_by_date_range( user_id, normalized_start_at, normalized_end_at ) ) results = [] for item, subscription in subscribed_items: is_owner = item.owner_id == user_id results.append( self._to_response( item, is_owner=is_owner, permission=subscription.permission ) ) results.sort(key=lambda x: x.start_at) return results except SQLAlchemyError: logger.exception("Failed to list schedule items") raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) async def list_paginated( self, *, page: int, page_size: int, query: str | None = None, ) -> tuple[list[ScheduleItemResponse], int]: user_id = self.require_user_id() if page < 1: raise HTTPException(status_code=400, detail="page must be >= 1") if page_size < 1 or page_size > 100: raise HTTPException(status_code=400, detail="page_size must be 1..100") try: items, total = await self._repository.list_paginated( user_id, page=page, page_size=page_size, query=query, ) except SQLAlchemyError: logger.exception( "Failed to list schedule items with pagination", page=page, page_size=page_size, ) raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) return [self._to_response(item) for item in items], total async def share( self, item_id: UUID, request: ScheduleItemShareRequest ) -> ScheduleItemShareResponse: user_id = self.require_user_id() try: item = await self._repository.get_by_id(item_id) if item is None: raise HTTPException(status_code=404, detail="Schedule item not found") inviter_permission = SubscriptionPermission.OWNER if item.owner_id != user_id: inviter_sub = await self._repository.get_subscription(item_id, user_id) if inviter_sub is None: raise HTTPException( status_code=403, detail="You don't have permission to share this calendar", ) inviter_permission = SubscriptionPermission(inviter_sub.permission) request_permission = request._permission_value() if request_permission > inviter_permission: raise HTTPException( status_code=403, detail=f"You can only share with permissions up to {inviter_permission}", ) target_user = await self._auth_gateway.get_user_by_email(request.email) recipient_id = UUID(target_user.id) existing = await self._repository.get_subscription(item_id, recipient_id) if existing: if existing.status == SubscriptionStatus.PENDING: pass elif existing.status == SubscriptionStatus.UNSUBSCRIBED: await self._repository.update_subscription_status( item_id, recipient_id, SubscriptionStatus.PENDING ) else: raise HTTPException( status_code=400, detail="User already has an active subscription to this calendar", ) else: await self._repository.create_subscription( { "item_id": item.id, "subscriber_id": recipient_id, "permission": request_permission, "status": SubscriptionStatus.PENDING, "created_by": user_id, } ) existing_msg = await self._inbox_repository.get_calendar_invite( item.id, recipient_id ) if existing_msg: if existing_msg.status == InboxMessageStatus.ACCEPTED: raise HTTPException( status_code=400, detail="User already subscribed to this calendar", ) elif existing_msg.status == InboxMessageStatus.PENDING: raise HTTPException( status_code=400, detail="User already has a pending invitation to this calendar", ) elif existing_msg.status == InboxMessageStatus.REJECTED: existing_msg.status = InboxMessageStatus.PENDING existing_msg.content = { "type": "invite", "permission": request_permission, "action": "pending", } else: message = InboxMessage( recipient_id=recipient_id, sender_id=user_id, message_type=InboxMessageType.CALENDAR, schedule_item_id=item.id, content={ "type": "invite", "permission": request_permission, "action": "pending", }, created_by=user_id, ) self._session.add(message) await self._session.commit() except HTTPException: raise except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to share schedule item", item_id=str(item_id)) raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) except ValueError: await self._session.rollback() logger.exception( "Auth lookup returned invalid user id", email=request.email ) raise HTTPException(status_code=503, detail="Auth lookup unavailable") return ScheduleItemShareResponse(message="Calendar invitation sent") def _to_response( self, item: ScheduleItem, is_owner: bool = False, permission: int = 1, ) -> ScheduleItemResponse: status_value = ( item.status.value if hasattr(item.status, "value") else item.status ) source_type_value = ( item.source_type.value if hasattr(item.source_type, "value") else item.source_type ) return ScheduleItemResponse( id=item.id, owner_id=item.owner_id, title=item.title, description=item.description, start_at=item.start_at, end_at=item.end_at, timezone=item.timezone, metadata=ScheduleItemMetadata.model_validate(item.extra_metadata) if item.extra_metadata else None, status=ScheduleItemStatus(str(status_value)), source_type=ScheduleItemSourceType(str(source_type_value)), created_at=item.created_at, updated_at=item.updated_at, permission=permission if not is_owner else 7, is_owner=is_owner, ) async def accept_subscription(self, item_id: UUID) -> dict: user_id = self.require_user_id() try: inbox = await self._inbox_repository.get_pending_calendar_invite( item_id, user_id ) if inbox is None: raise HTTPException( status_code=404, detail="No pending invitation found" ) content = inbox.content or {} permission = content.get("permission", 1) existing = await self._repository.get_subscription(item_id, user_id) if existing: await self._repository.update_subscription_status( item_id, user_id, SubscriptionStatus.ACTIVE ) else: await self._repository.create_subscription( { "item_id": item_id, "subscriber_id": user_id, "permission": permission, "status": SubscriptionStatus.ACTIVE, "created_by": inbox.sender_id, } ) inbox.status = InboxMessageStatus.ACCEPTED await self._session.commit() return {"message": "Subscription accepted"} except HTTPException: raise except Exception: await self._session.rollback() logger.exception("Failed to accept subscription") raise HTTPException(status_code=503, detail="Failed to accept subscription") async def reject_subscription(self, item_id: UUID) -> dict: user_id = self.require_user_id() try: inbox = await self._inbox_repository.get_pending_calendar_invite( item_id, user_id ) if inbox is None: raise HTTPException( status_code=404, detail="No pending invitation found" ) existing = await self._repository.get_subscription(item_id, user_id) if existing: await self._repository.update_subscription_status( item_id, user_id, SubscriptionStatus.UNSUBSCRIBED ) inbox.status = InboxMessageStatus.REJECTED await self._session.commit() return {"message": "Subscription rejected"} except HTTPException: raise except Exception: await self._session.rollback() logger.exception("Failed to reject subscription") raise HTTPException(status_code=503, detail="Failed to reject subscription") async def _notify_subscribers( self, item_id: UUID, title: str, action_type: Literal["updated", "deleted"], ): user_id = self.require_user_id() subscriptions = await self._repository.get_subscriptions_by_item_id(item_id) for sub in subscriptions: if sub.subscriber_id == user_id: continue content = { "type": action_type, "title": title, "action": action_type, } message = InboxMessage( recipient_id=sub.subscriber_id, sender_id=user_id, message_type=InboxMessageType.CALENDAR, schedule_item_id=item_id, content=content, created_by=user_id, ) self._session.add(message) if subscriptions: await self._session.commit() def _to_utc(self, dt: datetime | None) -> datetime | None: if dt is None: return None if dt.tzinfo is None: raise HTTPException( status_code=400, detail="datetime must include timezone" ) return dt.astimezone(timezone.utc) def _to_utc_required(self, dt: datetime) -> datetime: normalized = self._to_utc(dt) if normalized is None: raise HTTPException(status_code=400, detail="datetime is required") return normalized