Add display calendar support: model, router, service, types, visibility filter

Previously unstaged changes required for the display calendar feature:
- EventInvitation model: display_calendar_id column
- Event invitations router: display-calendar PUT endpoint
- Event invitation service: display calendar update logic
- CalendarPage: respect display_calendar_id in visibility filter
- Types: display_calendar_id on CalendarEvent interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-16 19:03:22 +08:00
parent 29c2cbbec8
commit a68ec0e23e
5 changed files with 78 additions and 3 deletions

View File

@ -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"
)

View File

@ -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),

View File

@ -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"}

View File

@ -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(() => {

View File

@ -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;
}