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>
232 lines
7.7 KiB
Python
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
|