""" Shared calendars router — invites, membership, locks, sync. All endpoints live under /api/shared-calendars. """ import logging from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from sqlalchemy import delete, func, select, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent from app.models.calendar_member import CalendarMember from app.models.event_lock import EventLock from app.models.settings import Settings from app.models.user import User from app.models.user_connection import UserConnection from app.routers.auth import get_current_user from app.schemas.shared_calendar import ( CalendarInviteResponse, CalendarMemberResponse, InviteMemberRequest, LockStatusResponse, RespondInviteRequest, SyncResponse, UpdateLocalColorRequest, UpdateMemberRequest, ) from app.services.audit import get_client_ip, log_audit_event from app.services.calendar_sharing import ( PERMISSION_RANK, acquire_lock, get_user_permission, release_lock, require_permission, ) from app.services.notification import create_notification router = APIRouter() logger = logging.getLogger(__name__) PENDING_INVITE_CAP = 10 # -- Helpers --------------------------------------------------------------- async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None: result = await db.execute(select(Settings).where(Settings.user_id == user_id)) return result.scalar_one_or_none() def _build_member_response(member: CalendarMember) -> dict: return { "id": member.id, "calendar_id": member.calendar_id, "user_id": member.user_id, "umbral_name": member.user.umbral_name if member.user else "", "preferred_name": None, "permission": member.permission, "can_add_others": member.can_add_others, "local_color": member.local_color, "status": member.status, "invited_at": member.invited_at, "accepted_at": member.accepted_at, } # -- GET / — List accepted memberships ------------------------------------ @router.get("/") async def list_shared_calendars( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List calendars the current user has accepted membership in.""" result = await db.execute( select(CalendarMember) .where( CalendarMember.user_id == current_user.id, CalendarMember.status == "accepted", ) .options(selectinload(CalendarMember.calendar)) .order_by(CalendarMember.accepted_at.desc()) ) members = result.scalars().all() return [ { "id": m.id, "calendar_id": m.calendar_id, "calendar_name": m.calendar.name if m.calendar else "", "calendar_color": m.calendar.color if m.calendar else "", "local_color": m.local_color, "permission": m.permission, "can_add_others": m.can_add_others, "is_owner": False, } for m in members ] # -- POST /{cal_id}/invite — Invite via connection_id --------------------- @router.post("/{cal_id}/invite", status_code=201) async def invite_member( body: InviteMemberRequest, request: Request, cal_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Invite a connected user to a shared calendar.""" cal_result = await db.execute( select(Calendar).where(Calendar.id == cal_id) ) calendar = cal_result.scalar_one_or_none() if not calendar: raise HTTPException(status_code=404, detail="Calendar not found") is_owner = calendar.user_id == current_user.id inviter_perm = "owner" if is_owner else None if not is_owner: member_result = await db.execute( select(CalendarMember).where( CalendarMember.calendar_id == cal_id, CalendarMember.user_id == current_user.id, CalendarMember.status == "accepted", ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=404, detail="Calendar not found") if not member.can_add_others: raise HTTPException(status_code=403, detail="You do not have permission to invite others") if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0): raise HTTPException(status_code=403, detail="Read-only members cannot invite others") inviter_perm = member.permission # Permission ceiling if inviter_perm != "owner": if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0): raise HTTPException( status_code=403, detail="Cannot grant a permission level higher than your own", ) # Resolve connection_id -> connected user conn_result = await db.execute( select(UserConnection).where( UserConnection.id == body.connection_id, UserConnection.user_id == current_user.id, ) ) connection = conn_result.scalar_one_or_none() if not connection: raise HTTPException(status_code=404, detail="Connection not found") target_user_id = connection.connected_user_id if target_user_id == calendar.user_id: raise HTTPException(status_code=400, detail="Cannot invite the calendar owner") target_result = await db.execute( select(User).where(User.id == target_user_id) ) target = target_result.scalar_one_or_none() if not target or not target.is_active: raise HTTPException(status_code=404, detail="Target user not found or inactive") existing = await db.execute( select(CalendarMember).where( CalendarMember.calendar_id == cal_id, CalendarMember.user_id == target_user_id, ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="User already invited or is a member") pending_count = await db.scalar( select(func.count()) .select_from(CalendarMember) .where( CalendarMember.calendar_id == cal_id, CalendarMember.status == "pending", ) ) or 0 if pending_count >= PENDING_INVITE_CAP: raise HTTPException(status_code=429, detail="Too many pending invites for this calendar") if not calendar.is_shared: calendar.is_shared = True new_member = CalendarMember( calendar_id=cal_id, user_id=target_user_id, invited_by=current_user.id, permission=body.permission, can_add_others=body.can_add_others, status="pending", ) db.add(new_member) await db.flush() inviter_settings = await _get_settings_for_user(db, current_user.id) inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name cal_name = calendar.name await create_notification( db, user_id=target_user_id, type="calendar_invite", title="Calendar Invite", message=f"{inviter_display} invited you to '{cal_name}'", data={"calendar_id": cal_id, "calendar_name": cal_name}, source_type="calendar_invite", source_id=new_member.id, ) await log_audit_event( db, action="calendar.invite_sent", actor_id=current_user.id, target_id=target_user_id, detail={ "calendar_id": cal_id, "calendar_name": cal_name, "permission": body.permission, }, ip=get_client_ip(request), ) response = { "message": "Invite sent", "member_id": new_member.id, "calendar_id": cal_id, } await db.commit() return response # -- PUT /invites/{id}/respond — Accept or reject ------------------------- @router.put("/invites/{invite_id}/respond") async def respond_to_invite( body: RespondInviteRequest, request: Request, invite_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Accept or reject a calendar invite.""" result = await db.execute( select(CalendarMember) .where( CalendarMember.id == invite_id, CalendarMember.user_id == current_user.id, CalendarMember.status == "pending", ) .options(selectinload(CalendarMember.calendar)) ) invite = result.scalar_one_or_none() if not invite: raise HTTPException(status_code=404, detail="Invite not found or already resolved") calendar_name = invite.calendar.name if invite.calendar else "Unknown" calendar_owner_id = invite.calendar.user_id if invite.calendar else None inviter_id = invite.invited_by if body.action == "accept": invite.status = "accepted" invite.accepted_at = datetime.now() notify_user_id = inviter_id or calendar_owner_id if notify_user_id: user_settings = await _get_settings_for_user(db, current_user.id) display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name await create_notification( db, user_id=notify_user_id, type="calendar_invite_accepted", title="Invite Accepted", message=f"{display} accepted your invite to '{calendar_name}'", data={"calendar_id": invite.calendar_id}, source_type="calendar_invite", source_id=invite.id, ) await log_audit_event( db, action="calendar.invite_accepted", actor_id=current_user.id, detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name}, ip=get_client_ip(request), ) await db.commit() return {"message": "Invite accepted"} else: member_id = invite.id calendar_id = invite.calendar_id notify_user_id = inviter_id or calendar_owner_id if notify_user_id: user_settings = await _get_settings_for_user(db, current_user.id) display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name await create_notification( db, user_id=notify_user_id, type="calendar_invite_rejected", title="Invite Rejected", message=f"{display} declined your invite to '{calendar_name}'", data={"calendar_id": calendar_id}, source_type="calendar_invite", source_id=member_id, ) await log_audit_event( db, action="calendar.invite_rejected", actor_id=current_user.id, detail={"calendar_id": calendar_id, "calendar_name": calendar_name}, ip=get_client_ip(request), ) await db.delete(invite) await db.commit() return {"message": "Invite rejected"} # -- GET /invites/incoming — Pending invites ------------------------------- @router.get("/invites/incoming", response_model=list[CalendarInviteResponse]) async def get_incoming_invites( page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List pending calendar invites for the current user.""" offset = (page - 1) * per_page result = await db.execute( select(CalendarMember) .where( CalendarMember.user_id == current_user.id, CalendarMember.status == "pending", ) .options( selectinload(CalendarMember.calendar), selectinload(CalendarMember.inviter), ) .order_by(CalendarMember.invited_at.desc()) .offset(offset) .limit(per_page) ) invites = result.scalars().all() # Batch-fetch owner names to avoid N+1 owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar}) if owner_ids: owner_result = await db.execute( select(User.id, User.umbral_name).where(User.id.in_(owner_ids)) ) owner_names = {row.id: row.umbral_name for row in owner_result.all()} else: owner_names = {} responses = [] for inv in invites: owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else "" responses.append(CalendarInviteResponse( id=inv.id, calendar_id=inv.calendar_id, calendar_name=inv.calendar.name if inv.calendar else "", calendar_color=inv.calendar.color if inv.calendar else "", owner_umbral_name=owner_name, inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "", permission=inv.permission, invited_at=inv.invited_at, )) return responses # -- GET /{cal_id}/members — Member list ----------------------------------- @router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse]) async def list_members( cal_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all members of a shared calendar. Requires membership or ownership.""" perm = await get_user_permission(db, cal_id, current_user.id) if perm is None: raise HTTPException(status_code=404, detail="Calendar not found") result = await db.execute( select(CalendarMember) .where(CalendarMember.calendar_id == cal_id) .options(selectinload(CalendarMember.user)) .order_by(CalendarMember.invited_at.asc()) ) members = result.scalars().all() user_ids = [m.user_id for m in members] if user_ids: settings_result = await db.execute( select(Settings.user_id, Settings.preferred_name) .where(Settings.user_id.in_(user_ids)) ) pref_names = {row.user_id: row.preferred_name for row in settings_result.all()} else: pref_names = {} responses = [] for m in members: resp = _build_member_response(m) resp["preferred_name"] = pref_names.get(m.user_id) responses.append(resp) return responses # -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ---------- @router.put("/{cal_id}/members/{member_id}") async def update_member( body: UpdateMemberRequest, request: Request, cal_id: int = Path(ge=1, le=2147483647), member_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update a member permission or can_add_others. Owner only.""" cal_result = await db.execute( select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id) ) if not cal_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Only the calendar owner can update members") member_result = await db.execute( select(CalendarMember).where( CalendarMember.id == member_id, CalendarMember.calendar_id == cal_id, ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=404, detail="Member not found") update_data = body.model_dump(exclude_unset=True) if not update_data: raise HTTPException(status_code=400, detail="No fields to update") for key, value in update_data.items(): setattr(member, key, value) await log_audit_event( db, action="calendar.member_updated", actor_id=current_user.id, target_id=member.user_id, detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data}, ip=get_client_ip(request), ) await db.commit() return {"message": "Member updated"} # -- DELETE /{cal_id}/members/{mid} — Remove member or leave --------------- @router.delete("/{cal_id}/members/{member_id}", status_code=204) async def remove_member( request: Request, cal_id: int = Path(ge=1, le=2147483647), member_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Remove a member or leave a shared calendar.""" member_result = await db.execute( select(CalendarMember).where( CalendarMember.id == member_id, CalendarMember.calendar_id == cal_id, ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=404, detail="Member not found") cal_result = await db.execute( select(Calendar).where(Calendar.id == cal_id) ) calendar = cal_result.scalar_one_or_none() is_self = member.user_id == current_user.id is_owner = calendar and calendar.user_id == current_user.id if not is_self and not is_owner: raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members") target_user_id = member.user_id await db.execute( delete(EventLock).where( EventLock.locked_by == target_user_id, EventLock.event_id.in_( select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id) ), ) ) await db.delete(member) remaining = await db.execute( select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) ) if not remaining.scalar_one_or_none() and calendar: calendar.is_shared = False action = "calendar.member_left" if is_self else "calendar.member_removed" await log_audit_event( db, action=action, actor_id=current_user.id, target_id=target_user_id, detail={"calendar_id": cal_id, "member_id": member_id}, ip=get_client_ip(request), ) await db.commit() return None # -- PUT /{cal_id}/members/me/color — Update local color ------------------- @router.put("/{cal_id}/members/me/color") async def update_local_color( body: UpdateLocalColorRequest, cal_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update the current user local color for a shared calendar.""" member_result = await db.execute( select(CalendarMember).where( CalendarMember.calendar_id == cal_id, CalendarMember.user_id == current_user.id, CalendarMember.status == "accepted", ) ) member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=404, detail="Membership not found") member.local_color = body.local_color await db.commit() return {"message": "Color updated"} # -- GET /sync — Sync endpoint --------------------------------------------- @router.get("/sync", response_model=SyncResponse) async def sync_shared_calendars( since: datetime = Query(...), calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Sync events and member changes since a given timestamp. Cap 500 events.""" MAX_EVENTS = 500 cal_id_list: list[int] = [] if calendar_ids: for part in calendar_ids.split(","): part = part.strip() if part.isdigit(): cal_id_list.append(int(part)) cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause owned_ids_result = await db.execute( select(Calendar.id).where(Calendar.user_id == current_user.id) ) owned_ids = {row[0] for row in owned_ids_result.all()} member_ids_result = await db.execute( select(CalendarMember.calendar_id).where( CalendarMember.user_id == current_user.id, CalendarMember.status == "accepted", ) ) member_ids = {row[0] for row in member_ids_result.all()} accessible = owned_ids | member_ids if cal_id_list: accessible = accessible & set(cal_id_list) if not accessible: return SyncResponse(events=[], member_changes=[], server_time=datetime.now()) accessible_list = list(accessible) events_result = await db.execute( select(CalendarEvent) .where( CalendarEvent.calendar_id.in_(accessible_list), CalendarEvent.updated_at >= since, ) .options(selectinload(CalendarEvent.calendar)) .order_by(CalendarEvent.updated_at.desc()) .limit(MAX_EVENTS + 1) ) events = events_result.scalars().all() truncated = len(events) > MAX_EVENTS events = events[:MAX_EVENTS] event_dicts = [] for e in events: event_dicts.append({ "id": e.id, "title": e.title, "start_datetime": e.start_datetime.isoformat() if e.start_datetime else None, "end_datetime": e.end_datetime.isoformat() if e.end_datetime else None, "all_day": e.all_day, "calendar_id": e.calendar_id, "calendar_name": e.calendar.name if e.calendar else "", "updated_at": e.updated_at.isoformat() if e.updated_at else None, "updated_by": e.updated_by, }) members_result = await db.execute( select(CalendarMember) .where( CalendarMember.calendar_id.in_(accessible_list), (CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since), ) .options(selectinload(CalendarMember.user)) ) member_changes = members_result.scalars().all() member_dicts = [] for m in member_changes: member_dicts.append({ "id": m.id, "calendar_id": m.calendar_id, "user_id": m.user_id, "umbral_name": m.user.umbral_name if m.user else "", "permission": m.permission, "status": m.status, "invited_at": m.invited_at.isoformat() if m.invited_at else None, "accepted_at": m.accepted_at.isoformat() if m.accepted_at else None, }) return SyncResponse( events=event_dicts, member_changes=member_dicts, server_time=datetime.now(), truncated=truncated, ) # -- Event Lock Endpoints -------------------------------------------------- @router.post("/events/{event_id}/lock") async def lock_event( event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Acquire a 5-minute editing lock on an event.""" event_result = await db.execute( select(CalendarEvent).where(CalendarEvent.id == event_id) ) event = event_result.scalar_one_or_none() if not event: raise HTTPException(status_code=404, detail="Event not found") await require_permission(db, event.calendar_id, current_user.id, "create_modify") lock = await acquire_lock(db, event_id, current_user.id) # Build response BEFORE commit — ORM objects expire after commit response = { "locked": True, "locked_by_name": current_user.umbral_name, "expires_at": lock.expires_at, "is_permanent": lock.is_permanent, } await db.commit() return response @router.delete("/events/{event_id}/lock", status_code=204) async def unlock_event( event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Release a lock. Only the holder or calendar owner can release.""" event_result = await db.execute( select(CalendarEvent).where(CalendarEvent.id == event_id) ) event = event_result.scalar_one_or_none() if not event: raise HTTPException(status_code=404, detail="Event not found") # SC-01: Verify caller has access to this calendar before revealing lock state perm = await get_user_permission(db, event.calendar_id, current_user.id) if perm is None: raise HTTPException(status_code=404, detail="Event not found") lock_result = await db.execute( select(EventLock).where(EventLock.event_id == event_id) ) lock = lock_result.scalar_one_or_none() if not lock: return None cal_result = await db.execute( select(Calendar).where(Calendar.id == event.calendar_id) ) calendar = cal_result.scalar_one_or_none() is_owner = calendar and calendar.user_id == current_user.id if lock.locked_by != current_user.id and not is_owner: raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release") await db.delete(lock) await db.commit() return None @router.get("/events/{event_id}/lock", response_model=LockStatusResponse) async def get_lock_status( event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Get the lock status of an event.""" event_result = await db.execute( select(CalendarEvent).where(CalendarEvent.id == event_id) ) event = event_result.scalar_one_or_none() if not event: raise HTTPException(status_code=404, detail="Event not found") perm = await get_user_permission(db, event.calendar_id, current_user.id) if perm is None: raise HTTPException(status_code=404, detail="Event not found") lock_result = await db.execute( select(EventLock) .where(EventLock.event_id == event_id) .options(selectinload(EventLock.holder)) ) lock = lock_result.scalar_one_or_none() if not lock: return LockStatusResponse(locked=False) now = datetime.now() if not lock.is_permanent and lock.expires_at and lock.expires_at < now: await db.delete(lock) await db.commit() return LockStatusResponse(locked=False) return LockStatusResponse( locked=True, locked_by_name=lock.holder.umbral_name if lock.holder else None, expires_at=lock.expires_at, is_permanent=lock.is_permanent, ) @router.post("/events/{event_id}/owner-lock") async def set_permanent_lock( event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Set a permanent lock on an event. Calendar owner only.""" event_result = await db.execute( select(CalendarEvent).where(CalendarEvent.id == event_id) ) event = event_result.scalar_one_or_none() if not event: raise HTTPException(status_code=404, detail="Event not found") cal_result = await db.execute( select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id) ) if not cal_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks") now = datetime.now() await db.execute( text(""" INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent) VALUES (:event_id, :user_id, :now, NULL, true) ON CONFLICT (event_id) DO UPDATE SET locked_by = :user_id, locked_at = :now, expires_at = NULL, is_permanent = true """), {"event_id": event_id, "user_id": current_user.id, "now": now}, ) await db.commit() return {"message": "Permanent lock set", "is_permanent": True} @router.delete("/events/{event_id}/owner-lock", status_code=204) async def remove_permanent_lock( event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Remove a permanent lock. Calendar owner only.""" event_result = await db.execute( select(CalendarEvent).where(CalendarEvent.id == event_id) ) event = event_result.scalar_one_or_none() if not event: raise HTTPException(status_code=404, detail="Event not found") cal_result = await db.execute( select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id) ) if not cal_result.scalar_one_or_none(): raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks") await db.execute( delete(EventLock).where( EventLock.event_id == event_id, EventLock.is_permanent == True, ) ) await db.commit() return None