from __future__ import annotations import json from typing import TYPE_CHECKING, Protocol 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 from models.schedule_items import ScheduleItem from v1.auth.gateway import SupabaseAuthGateway 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 def __init__( self, repository: ScheduleItemRepository, session: AsyncSession, current_user: CurrentUser | None, auth_gateway: AuthByEmailGateway | None = None, ) -> None: super().__init__(current_user=current_user) self._repository = repository self._session = session self._auth_gateway = auth_gateway or SupabaseAuthGateway() 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() if request.end_at and request.end_at <= request.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": request.start_at, "end_at": request.end_at, "timezone": request.timezone, "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._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_item_id(item_id, user_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") return self._to_response(item) 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 and update_data["metadata"] is not None: update_data["metadata"] = update_data["metadata"].model_dump() # Validate time range next_start = update_data.get("start_at", existing.start_at) next_end = update_data.get("end_at", existing.end_at) if next_end is not None and 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._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") 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() if request.end_at <= request.start_at: raise HTTPException(status_code=400, detail="end_at must be after start_at") try: items = await self._repository.list_by_date_range( user_id, request.start_at, request.end_at ) except SQLAlchemyError: logger.exception("Failed to list schedule items") raise HTTPException( status_code=503, detail="Schedule item store unavailable" ) return [self._to_response(item) for item in items] async def list_paginated( self, *, page: int, page_size: int, ) -> 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, ) 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") if item.owner_id != user_id: raise HTTPException( status_code=403, detail="Only owner can share this schedule item", ) target_user = await self._auth_gateway.get_user_by_email(request.email) recipient_id = UUID(target_user.id) message = InboxMessage( recipient_id=recipient_id, sender_id=user_id, message_type=InboxMessageType.CALENDAR, schedule_item_id=item.id, content=json.dumps({"permission": request._permission_value()}), 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) -> ScheduleItemResponse: return ScheduleItemResponse( id=item.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(item.status.value), source_type=ScheduleItemSourceType(item.source_type.value), created_at=item.created_at, updated_at=item.updated_at, )