diff --git a/backend/app/models/event_invitation.py b/backend/app/models/event_invitation.py index 7b66bda..54b1091 100644 --- a/backend/app/models/event_invitation.py +++ b/backend/app/models/event_invitation.py @@ -35,12 +35,16 @@ class EventInvitation(Base): DateTime, default=func.now(), server_default=func.now() ) responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + display_calendar_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True + ) event: Mapped["CalendarEvent"] = relationship(lazy="raise") user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") inviter: Mapped[Optional["User"]] = relationship( foreign_keys=[invited_by], lazy="raise" ) + display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise") overrides: Mapped[list["EventInvitationOverride"]] = relationship( lazy="raise", cascade="all, delete-orphan" ) diff --git a/backend/app/routers/event_invitations.py b/backend/app/routers/event_invitations.py index 09f8644..b306d7e 100644 --- a/backend/app/routers/event_invitations.py +++ b/backend/app/routers/event_invitations.py @@ -18,8 +18,9 @@ from app.schemas.event_invitation import ( EventInvitationCreate, EventInvitationRespond, EventInvitationOverrideCreate, + UpdateDisplayCalendar, ) -from app.services.calendar_sharing import get_user_permission +from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission from app.services.event_invitation import ( send_event_invitations, respond_to_invitation, @@ -191,6 +192,45 @@ async def override_occurrence( return response_data +@router.put("/{invitation_id}/display-calendar") +async def update_display_calendar( + body: UpdateDisplayCalendar, + invitation_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Change the display calendar for an accepted/tentative invitation.""" + inv_result = await db.execute( + select(EventInvitation).where( + EventInvitation.id == invitation_id, + EventInvitation.user_id == current_user.id, + ) + ) + invitation = inv_result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status not in ("accepted", "tentative"): + raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations") + + # Verify calendar is accessible to this user + accessible_ids = await get_accessible_calendar_ids(current_user.id, db) + if body.calendar_id not in accessible_ids: + raise HTTPException(status_code=404, detail="Calendar not found") + + invitation.display_calendar_id = body.calendar_id + + # Extract response before commit (ORM expiry rule) + response_data = { + "id": invitation.id, + "event_id": invitation.event_id, + "display_calendar_id": invitation.display_calendar_id, + } + + await db.commit() + return response_data + + @router.delete("/{invitation_id}", status_code=204) async def leave_or_revoke_invitation( invitation_id: int = Path(ge=1, le=2147483647), diff --git a/backend/app/services/event_invitation.py b/backend/app/services/event_invitation.py index e6139a2..23609db 100644 --- a/backend/app/services/event_invitation.py +++ b/backend/app/services/event_invitation.py @@ -11,6 +11,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent from app.models.event_invitation import EventInvitation, EventInvitationOverride from app.models.user_connection import UserConnection @@ -151,6 +152,27 @@ async def respond_to_invitation( invitation.status = status invitation.responded_at = datetime.now() + # Auto-assign display calendar on accept/tentative (atomic: only if not already set) + if status in ("accepted", "tentative"): + default_cal = await db.execute( + select(Calendar.id).where( + Calendar.user_id == user_id, + Calendar.is_default == True, + ).limit(1) + ) + default_cal_id = default_cal.scalar_one_or_none() + if default_cal_id and invitation.display_calendar_id is None: + # Atomic: only set if still NULL (race-safe) + await db.execute( + update(EventInvitation) + .where( + EventInvitation.id == invitation_id, + EventInvitation.display_calendar_id == None, + ) + .values(display_calendar_id=default_cal_id) + ) + invitation.display_calendar_id = default_cal_id + # Notify the inviter only if status actually changed (prevents duplicate notifications) if invitation.invited_by and old_status != status: status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"} diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 8274a89..77a9202 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -332,8 +332,16 @@ export default function CalendarPage() { const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; - // Invited events bypass calendar visibility — they don't belong to the user's calendars - return events.filter((e) => e.is_invited || visibleCalendarIds.has(e.calendar_id)); + // Invited events: if display_calendar_id is set, respect that calendar's visibility; + // otherwise (pending) always show + return events.filter((e) => { + if (e.is_invited) { + return e.display_calendar_id + ? visibleCalendarIds.has(e.display_calendar_id) + : true; + } + return visibleCalendarIds.has(e.calendar_id); + }); }, [events, visibleCalendarIds, calendars.length]); const searchResults = useMemo(() => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f780157..f228206 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -115,6 +115,7 @@ export interface CalendarEvent { is_invited?: boolean; invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null; invitation_id?: number | null; + display_calendar_id?: number | null; created_at: string; updated_at: string; }