UMBRA/backend/app/routers/event_invitations.py
Kyle Pope 8652c9f2ce Implement event invitation feature (invite, RSVP, per-occurrence override, leave)
Full-stack implementation of event invitations allowing users to invite connected
contacts to calendar events. Invitees can respond Going/Tentative/Declined, with
per-occurrence overrides for recurring series. Invited events appear on the invitee's
calendar with a Users icon indicator. LeaveEventDialog replaces delete for invited events.

Backend: Migration 054 (2 tables + notification types), EventInvitation model with
lazy="raise", service layer, dual-router (events + event-invitations), cascade on
disconnect, events/dashboard queries extended with OR for invited events.

Frontend: Types, useEventInvitations hook, InviteeSection (view list + RSVP buttons +
invite search), LeaveEventDialog, event invite toast with 3 response buttons, calendar
eventContent render with Users icon for invited events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 02:47:27 +08:00

232 lines
7.7 KiB
Python

"""
Event invitation endpoints — invite users to events, respond, override per-occurrence, leave.
Two routers:
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
- router: mounted at /api/event-invitations for respond/override/delete/pending
"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.event_invitation import EventInvitation
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.event_invitation import (
EventInvitationCreate,
EventInvitationRespond,
EventInvitationOverrideCreate,
)
from app.services.calendar_sharing import get_user_permission
from app.services.event_invitation import (
send_event_invitations,
respond_to_invitation,
override_occurrence_status,
dismiss_invitation,
dismiss_invitation_by_owner,
get_event_invitations,
get_pending_invitations,
)
# Mounted at /api/events — event-scoped invitation endpoints
events_router = APIRouter()
# Mounted at /api/event-invitations — invitation-scoped endpoints
router = APIRouter()
async def _get_event_with_access_check(
db: AsyncSession, event_id: int, user_id: int
) -> CalendarEvent:
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check calendar access
perm = await get_user_permission(db, event.calendar_id, user_id)
if perm is not None:
return event
# Check if invitee (also check parent for recurring children)
event_ids_to_check = [event_id]
if event.parent_event_id:
event_ids_to_check.append(event.parent_event_id)
inv_result = await db.execute(
select(EventInvitation.id).where(
EventInvitation.event_id.in_(event_ids_to_check),
EventInvitation.user_id == user_id,
)
)
if inv_result.first() is not None:
return event
raise HTTPException(status_code=404, detail="Event not found")
# ── Event-scoped endpoints (mounted at /api/events) ──
@events_router.post("/{event_id}/invitations", status_code=201)
async def invite_to_event(
body: EventInvitationCreate,
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Permission check: owner or create_modify+
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")
if perm not in ("owner", "create_modify", "full_access"):
raise HTTPException(status_code=403, detail="Insufficient permission")
# For recurring child events, invite to the parent (series)
target_event_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await send_event_invitations(
db=db,
event_id=target_event_id,
user_ids=body.user_ids,
invited_by=current_user.id,
)
await db.commit()
return {"invited": len(invitations), "event_id": target_event_id}
@events_router.get("/{event_id}/invitations")
async def list_event_invitations(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all invitees and their statuses for an event."""
event = await _get_event_with_access_check(db, event_id, current_user.id)
# For recurring children, also fetch parent's invitations
target_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await get_event_invitations(db, target_id)
return invitations
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
@router.get("/pending")
async def my_pending_invitations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all pending event invitations for the current user."""
return await get_pending_invitations(db, current_user.id)
@router.put("/{invitation_id}/respond")
async def respond_invitation(
body: EventInvitationRespond,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept, tentative, or decline an event invitation."""
invitation = await respond_to_invitation(
db=db,
invitation_id=invitation_id,
user_id=current_user.id,
status=body.status,
)
# Build response before commit (ORM objects expire after commit)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"status": invitation.status,
"responded_at": invitation.responded_at,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/respond/{occurrence_id}")
async def override_occurrence(
body: EventInvitationOverrideCreate,
invitation_id: int = Path(ge=1, le=2147483647),
occurrence_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Override invitation status for a specific occurrence of a recurring event."""
override = await override_occurrence_status(
db=db,
invitation_id=invitation_id,
occurrence_id=occurrence_id,
user_id=current_user.id,
status=body.status,
)
response_data = {
"invitation_id": override.invitation_id,
"occurrence_id": override.occurrence_id,
"status": override.status,
}
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),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Leave an event (invitee) or revoke an invitation (event owner).
Invitees can only delete their own invitations.
Event owners can delete any invitation for their events.
"""
inv_result = await db.execute(
select(EventInvitation).where(EventInvitation.id == invitation_id)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.user_id == current_user.id:
# Invitee leaving
await dismiss_invitation(db, invitation_id, current_user.id)
else:
# Check if current user is the event owner
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == invitation.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 != "owner":
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
await dismiss_invitation_by_owner(db, invitation_id)
await db.commit()
return None