From 8652c9f2ce16fc6383d85bd41dce90905e19ea46 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Sun, 15 Mar 2026 02:47:27 +0800 Subject: [PATCH 01/20] 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 --- .../alembic/versions/054_event_invitations.py | 116 ++++++ backend/app/main.py | 5 +- backend/app/models/__init__.py | 3 + backend/app/models/event_invitation.py | 70 ++++ backend/app/models/notification.py | 1 + backend/app/routers/dashboard.py | 28 +- backend/app/routers/event_invitations.py | 231 +++++++++++ backend/app/routers/events.py | 73 +++- backend/app/schemas/event_invitation.py | 31 ++ backend/app/services/calendar_sharing.py | 24 ++ backend/app/services/event_invitation.py | 389 ++++++++++++++++++ frontend/nginx.conf | 7 + .../src/components/calendar/CalendarPage.tsx | 54 +-- .../components/calendar/EventDetailPanel.tsx | 97 ++++- .../components/calendar/InviteeSection.tsx | 245 +++++++++++ .../components/calendar/LeaveEventDialog.tsx | 48 +++ .../notifications/NotificationToaster.tsx | 143 ++++++- frontend/src/hooks/useEventInvitations.ts | 105 +++++ frontend/src/types/index.ts | 27 ++ 19 files changed, 1649 insertions(+), 48 deletions(-) create mode 100644 backend/alembic/versions/054_event_invitations.py create mode 100644 backend/app/models/event_invitation.py create mode 100644 backend/app/routers/event_invitations.py create mode 100644 backend/app/schemas/event_invitation.py create mode 100644 backend/app/services/event_invitation.py create mode 100644 frontend/src/components/calendar/InviteeSection.tsx create mode 100644 frontend/src/components/calendar/LeaveEventDialog.tsx create mode 100644 frontend/src/hooks/useEventInvitations.ts diff --git a/backend/alembic/versions/054_event_invitations.py b/backend/alembic/versions/054_event_invitations.py new file mode 100644 index 0000000..6cfc5e4 --- /dev/null +++ b/backend/alembic/versions/054_event_invitations.py @@ -0,0 +1,116 @@ +"""Event invitations tables and notification types. + +Revision ID: 054 +Revises: 053 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "054" +down_revision = "053" +branch_labels = None +depends_on = None + + +def upgrade(): + # ── event_invitations table ── + op.create_table( + "event_invitations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "event_id", + sa.Integer(), + sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "invited_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("responded_at", sa.DateTime(), nullable=True), + sa.UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"), + sa.CheckConstraint( + "status IN ('pending', 'accepted', 'tentative', 'declined')", + name="ck_event_invitations_status", + ), + ) + op.create_index( + "ix_event_invitations_user_status", + "event_invitations", + ["user_id", "status"], + ) + op.create_index( + "ix_event_invitations_event_id", + "event_invitations", + ["event_id"], + ) + + # ── event_invitation_overrides table ── + op.create_table( + "event_invitation_overrides", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "invitation_id", + sa.Integer(), + sa.ForeignKey("event_invitations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "occurrence_id", + sa.Integer(), + sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("status", sa.String(20), nullable=False), + sa.Column("responded_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"), + sa.CheckConstraint( + "status IN ('accepted', 'tentative', 'declined')", + name="ck_invitation_override_status", + ), + ) + op.create_index( + "ix_invitation_overrides_lookup", + "event_invitation_overrides", + ["invitation_id", "occurrence_id"], + ) + + # ── Expand notification type check constraint ── + op.drop_constraint("ck_notifications_type", "notifications", type_="check") + op.create_check_constraint( + "ck_notifications_type", + "notifications", + "type IN ('connection_request', 'connection_accepted', 'connection_rejected', " + "'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', " + "'event_invite', 'event_invite_response', " + "'info', 'warning', 'reminder', 'system')", + ) + + +def downgrade(): + op.drop_index("ix_invitation_overrides_lookup", table_name="event_invitation_overrides") + op.drop_table("event_invitation_overrides") + op.drop_index("ix_event_invitations_event_id", table_name="event_invitations") + op.drop_index("ix_event_invitations_user_status", table_name="event_invitations") + op.drop_table("event_invitations") + + # Restore original notification type constraint + op.drop_constraint("ck_notifications_type", "notifications", type_="check") + op.create_check_constraint( + "ck_notifications_type", + "notifications", + "type IN ('connection_request', 'connection_accepted', 'connection_rejected', " + "'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', " + "'info', 'warning', 'reminder', 'system')", + ) diff --git a/backend/app/main.py b/backend/app/main.py index ae8f094..6c30d36 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router +from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -22,6 +22,7 @@ from app.models import connection_request as _connection_request_model # noqa: from app.models import user_connection as _user_connection_model # noqa: F401 from app.models import calendar_member as _calendar_member_model # noqa: F401 from app.models import event_lock as _event_lock_model # noqa: F401 +from app.models import event_invitation as _event_invitation_model # noqa: F401 # --------------------------------------------------------------------------- @@ -137,6 +138,8 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"]) +app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"]) +app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 81e14b1..c6c7aa5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -20,6 +20,7 @@ from app.models.connection_request import ConnectionRequest from app.models.user_connection import UserConnection from app.models.calendar_member import CalendarMember from app.models.event_lock import EventLock +from app.models.event_invitation import EventInvitation, EventInvitationOverride __all__ = [ "Settings", @@ -44,4 +45,6 @@ __all__ = [ "UserConnection", "CalendarMember", "EventLock", + "EventInvitation", + "EventInvitationOverride", ] diff --git a/backend/app/models/event_invitation.py b/backend/app/models/event_invitation.py new file mode 100644 index 0000000..7b66bda --- /dev/null +++ b/backend/app/models/event_invitation.py @@ -0,0 +1,70 @@ +from sqlalchemy import ( + CheckConstraint, DateTime, Integer, ForeignKey, Index, + String, UniqueConstraint, func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional +from app.database import Base + + +class EventInvitation(Base): + __tablename__ = "event_invitations" + __table_args__ = ( + UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"), + CheckConstraint( + "status IN ('pending', 'accepted', 'tentative', 'declined')", + name="ck_event_invitations_status", + ), + Index("ix_event_invitations_user_status", "user_id", "status"), + Index("ix_event_invitations_event_id", "event_id"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + event_id: Mapped[int] = mapped_column( + Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + invited_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + status: Mapped[str] = mapped_column(String(20), default="pending") + invited_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now() + ) + responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, 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" + ) + overrides: Mapped[list["EventInvitationOverride"]] = relationship( + lazy="raise", cascade="all, delete-orphan" + ) + + +class EventInvitationOverride(Base): + __tablename__ = "event_invitation_overrides" + __table_args__ = ( + UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"), + CheckConstraint( + "status IN ('accepted', 'tentative', 'declined')", + name="ck_invitation_override_status", + ), + Index("ix_invitation_overrides_lookup", "invitation_id", "occurrence_id"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + invitation_id: Mapped[int] = mapped_column( + Integer, ForeignKey("event_invitations.id", ondelete="CASCADE"), nullable=False + ) + occurrence_id: Mapped[int] = mapped_column( + Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False + ) + status: Mapped[str] = mapped_column(String(20), nullable=False) + responded_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now() + ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 2f4cfc6..e0d6f47 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -8,6 +8,7 @@ from app.database import Base _NOTIFICATION_TYPES = ( "connection_request", "connection_accepted", "connection_rejected", "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", + "event_invite", "event_invite_response", "info", "warning", "reminder", "system", ) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 816ee0d..ec0de48 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -12,7 +12,7 @@ from app.models.reminder import Reminder from app.models.project import Project from app.models.user import User from app.routers.auth import get_current_user, get_current_settings -from app.services.calendar_sharing import get_accessible_calendar_ids +from app.services.calendar_sharing import get_accessible_calendar_ids, get_accessible_event_scope router = APIRouter() @@ -35,14 +35,18 @@ async def get_dashboard( today = client_date or date.today() upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) - # Fetch all accessible calendar IDs (owned + accepted shared memberships) - user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) + # Fetch all accessible calendar IDs + invited event IDs + user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db) # Today's events (exclude parent templates — they are hidden, children are shown) today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today, datetime.max.time()) events_query = select(CalendarEvent).where( - CalendarEvent.calendar_id.in_(user_calendar_ids), + or_( + CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + ), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= today_end, _not_parent_template, @@ -95,7 +99,11 @@ async def get_dashboard( # Starred events — no upper date bound so future events always appear in countdown. # _not_parent_template excludes recurring parent templates (children still show). starred_query = select(CalendarEvent).where( - CalendarEvent.calendar_id.in_(user_calendar_ids), + or_( + CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + ), CalendarEvent.is_starred == True, CalendarEvent.start_datetime > today_start, _not_parent_template, @@ -169,8 +177,8 @@ async def get_upcoming( overdue_floor = today - timedelta(days=30) overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time()) - # Fetch all accessible calendar IDs (owned + accepted shared memberships) - user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) + # Fetch all accessible calendar IDs + invited event IDs + user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db) # Build queries — include overdue todos (up to 30 days back) and snoozed reminders todos_query = select(Todo).where( @@ -182,7 +190,11 @@ async def get_upcoming( ) events_query = select(CalendarEvent).where( - CalendarEvent.calendar_id.in_(user_calendar_ids), + or_( + CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + ), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, _not_parent_template, diff --git a/backend/app/routers/event_invitations.py b/backend/app/routers/event_invitations.py new file mode 100644 index 0000000..09f8644 --- /dev/null +++ b/backend/app/routers/event_invitations.py @@ -0,0 +1,231 @@ +""" +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 diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 2782c3f..cc47e12 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,7 +1,7 @@ import json from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete +from sqlalchemy import select, delete, or_ from sqlalchemy.orm import selectinload from typing import Optional, List, Any, Literal @@ -19,14 +19,21 @@ from app.schemas.calendar_event import ( from app.routers.auth import get_current_user from app.models.user import User from app.services.recurrence import generate_occurrences -from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, require_permission +from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission +from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user +from app.models.event_invitation import EventInvitation router = APIRouter() -def _event_to_dict(event: CalendarEvent) -> dict: +def _event_to_dict( + event: CalendarEvent, + is_invited: bool = False, + invitation_status: str | None = None, + invitation_id: int | None = None, +) -> dict: """Serialize a CalendarEvent ORM object to a response dict including calendar info.""" - return { + d = { "id": event.id, "title": event.title, "description": event.description, @@ -46,7 +53,11 @@ def _event_to_dict(event: CalendarEvent) -> dict: "original_start": event.original_start, "created_at": event.created_at, "updated_at": event.updated_at, + "is_invited": is_invited, + "invitation_status": invitation_status, + "invitation_id": invitation_id, } + return d def _birthday_events_for_range( @@ -143,13 +154,20 @@ async def get_events( recurrence_rule IS NOT NULL) are excluded — their materialised children are what get displayed on the calendar. """ - # Scope events through calendar ownership + shared memberships - all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) + # Scope events through calendar ownership + shared memberships + invitations + all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db) + query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.calendar_id.in_(all_calendar_ids)) + .where( + or_( + CalendarEvent.calendar_id.in_(all_calendar_ids), + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + ) + ) ) # Exclude parent template rows — they are not directly rendered @@ -171,7 +189,36 @@ async def get_events( result = await db.execute(query) events = result.scalars().all() - response: List[dict] = [_event_to_dict(e) for e in events] + # Build invitation lookup for the current user + invited_event_id_set = set(invited_event_ids) + invitation_map: dict[int, tuple[str, int]] = {} # event_id -> (status, invitation_id) + if invited_event_ids: + inv_result = await db.execute( + select(EventInvitation.event_id, EventInvitation.status, EventInvitation.id).where( + EventInvitation.user_id == current_user.id, + EventInvitation.event_id.in_(invited_event_ids), + ) + ) + for eid, status, inv_id in inv_result.all(): + invitation_map[eid] = (status, inv_id) + + # Get per-occurrence overrides for invited events + all_event_ids = [e.id for e in events] + override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids) + + response: List[dict] = [] + for e in events: + # Determine if this event is from an invitation + parent_id = e.parent_event_id or e.id + is_invited = parent_id in invited_event_id_set + inv_status = None + inv_id = None + if is_invited and parent_id in invitation_map: + inv_status, inv_id = invitation_map[parent_id] + # Check for per-occurrence override + if e.id in override_map: + inv_status = override_map[e.id] + response.append(_event_to_dict(e, is_invited=is_invited, invitation_status=inv_status, invitation_id=inv_id)) # Fetch the user's Birthdays system calendar; only generate virtual events if visible bday_result = await db.execute( @@ -281,14 +328,20 @@ async def get_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) + all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db) + invited_set = set(invited_event_ids) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(all_calendar_ids), + or_( + CalendarEvent.calendar_id.in_(all_calendar_ids), + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + ), ) ) event = result.scalar_one_or_none() diff --git a/backend/app/schemas/event_invitation.py b/backend/app/schemas/event_invitation.py new file mode 100644 index 0000000..6b120ca --- /dev/null +++ b/backend/app/schemas/event_invitation.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, ConfigDict, Field +from typing import Literal, Optional +from datetime import datetime + + +class EventInvitationCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + user_ids: list[int] = Field(..., min_length=1, max_length=20) + + +class EventInvitationRespond(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["accepted", "tentative", "declined"] + + +class EventInvitationOverrideCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["accepted", "tentative", "declined"] + + +class EventInvitationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + event_id: int + user_id: int + invited_by: Optional[int] + status: str + invited_at: datetime + responded_at: Optional[datetime] + invitee_name: Optional[str] = None + invitee_umbral_name: Optional[str] = None diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index da1d71a..8b2a112 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.calendar import Calendar from app.models.calendar_member import CalendarMember from app.models.event_lock import EventLock +from app.models.event_invitation import EventInvitation logger = logging.getLogger(__name__) @@ -34,6 +35,25 @@ async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[in return [r[0] for r in result.all()] +async def get_accessible_event_scope( + user_id: int, db: AsyncSession +) -> tuple[list[int], list[int]]: + """ + Returns (calendar_ids, invited_parent_event_ids). + calendar_ids: all calendars the user can access (owned + accepted shared). + invited_parent_event_ids: event IDs where the user has a non-declined invitation. + """ + cal_ids = await get_accessible_calendar_ids(user_id, db) + invited_result = await db.execute( + select(EventInvitation.event_id).where( + EventInvitation.user_id == user_id, + EventInvitation.status != "declined", + ) + ) + invited_event_ids = [r[0] for r in invited_result.all()] + return cal_ids, invited_event_ids + + async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None: """ Returns "owner" if the user owns the calendar, the permission string @@ -220,6 +240,10 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int {"user_id": user_a_id, "cal_ids": b_cal_ids}, ) + # Clean up event invitations between the two users + from app.services.event_invitation import cascade_event_invitations_on_disconnect + await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id) + # AC-5: Single aggregation query instead of N per-calendar checks all_cal_ids = a_cal_ids + b_cal_ids if all_cal_ids: diff --git a/backend/app/services/event_invitation.py b/backend/app/services/event_invitation.py new file mode 100644 index 0000000..c0ba7ac --- /dev/null +++ b/backend/app/services/event_invitation.py @@ -0,0 +1,389 @@ +""" +Event invitation service — send, respond, override, dismiss invitations. + +All functions accept an AsyncSession and do NOT commit — callers manage transactions. +""" +import logging +from datetime import datetime + +from fastapi import HTTPException +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.calendar_event import CalendarEvent +from app.models.event_invitation import EventInvitation, EventInvitationOverride +from app.models.user_connection import UserConnection +from app.models.settings import Settings +from app.models.user import User +from app.services.notification import create_notification + +logger = logging.getLogger(__name__) + + +async def validate_connections( + db: AsyncSession, inviter_id: int, user_ids: list[int] +) -> None: + """Verify bidirectional connections exist for all invitees. Raises 404 on failure.""" + if not user_ids: + return + result = await db.execute( + select(UserConnection.connected_user_id).where( + UserConnection.user_id == inviter_id, + UserConnection.connected_user_id.in_(user_ids), + ) + ) + connected_ids = {r[0] for r in result.all()} + missing = set(user_ids) - connected_ids + if missing: + raise HTTPException(status_code=404, detail="One or more users not found in your connections") + + +async def send_event_invitations( + db: AsyncSession, + event_id: int, + user_ids: list[int], + invited_by: int, +) -> list[EventInvitation]: + """ + Bulk-insert invitations for an event. Skips self-invites and existing invitations. + Creates in-app notifications for each invitee. + """ + # Remove self from list + user_ids = [uid for uid in user_ids if uid != invited_by] + if not user_ids: + raise HTTPException(status_code=400, detail="Cannot invite yourself") + + # Validate connections + await validate_connections(db, invited_by, user_ids) + + # Check existing invitations to skip duplicates + existing_result = await db.execute( + select(EventInvitation.user_id).where( + EventInvitation.event_id == event_id, + EventInvitation.user_id.in_(user_ids), + ) + ) + existing_ids = {r[0] for r in existing_result.all()} + + # Cap: max 20 pending invitations per event + count_result = await db.execute( + select(EventInvitation.id).where(EventInvitation.event_id == event_id) + ) + current_count = len(count_result.all()) + new_ids = [uid for uid in user_ids if uid not in existing_ids] + if current_count + len(new_ids) > 20: + raise HTTPException(status_code=400, detail="Maximum 20 invitations per event") + + if not new_ids: + return [] + + # Fetch event title for notifications + event_result = await db.execute( + select(CalendarEvent.title, CalendarEvent.start_datetime).where( + CalendarEvent.id == event_id + ) + ) + event_row = event_result.one_or_none() + event_title = event_row[0] if event_row else "an event" + event_start = event_row[1] if event_row else None + + # Fetch inviter's name + inviter_settings = await db.execute( + select(Settings.preferred_name).where(Settings.user_id == invited_by) + ) + inviter_name_row = inviter_settings.one_or_none() + inviter_name = inviter_name_row[0] if inviter_name_row and inviter_name_row[0] else "Someone" + + invitations = [] + for uid in new_ids: + inv = EventInvitation( + event_id=event_id, + user_id=uid, + invited_by=invited_by, + status="pending", + ) + db.add(inv) + invitations.append(inv) + + # Create notification + start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else "" + await create_notification( + db=db, + user_id=uid, + type="event_invite", + title="Event Invitation", + message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""), + data={"event_id": event_id, "event_title": event_title}, + source_type="event_invitation", + source_id=event_id, + ) + + return invitations + + +async def respond_to_invitation( + db: AsyncSession, + invitation_id: int, + user_id: int, + status: str, +) -> EventInvitation: + """Update invitation status. Returns the updated invitation.""" + result = await db.execute( + select(EventInvitation) + .options(selectinload(EventInvitation.event)) + .where( + EventInvitation.id == invitation_id, + EventInvitation.user_id == user_id, + ) + ) + invitation = result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + # Build response data before modifying + event_title = invitation.event.title + old_status = invitation.status + + invitation.status = status + invitation.responded_at = datetime.now() + + # Notify the inviter + if invitation.invited_by: + status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"} + # Fetch responder name + responder_settings = await db.execute( + select(Settings.preferred_name).where(Settings.user_id == user_id) + ) + responder_row = responder_settings.one_or_none() + responder_name = responder_row[0] if responder_row and responder_row[0] else "Someone" + + await create_notification( + db=db, + user_id=invitation.invited_by, + type="event_invite_response", + title="Event RSVP", + message=f"{responder_name} is {status_label.get(status, status)} for {event_title}", + data={"event_id": invitation.event_id, "status": status}, + source_type="event_invitation", + source_id=invitation.event_id, + ) + + return invitation + + +async def override_occurrence_status( + db: AsyncSession, + invitation_id: int, + occurrence_id: int, + user_id: int, + status: str, +) -> EventInvitationOverride: + """Create or update a per-occurrence status override.""" + # Verify invitation belongs to user + inv_result = await db.execute( + select(EventInvitation).where( + EventInvitation.id == invitation_id, + EventInvitation.user_id == user_id, + ) + ) + invitation = inv_result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + # Verify occurrence belongs to the invited event's series + occ_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == occurrence_id) + ) + occurrence = occ_result.scalar_one_or_none() + if not occurrence: + raise HTTPException(status_code=404, detail="Occurrence not found") + + # Occurrence must be the event itself OR a child of the invited event + if occurrence.id != invitation.event_id and occurrence.parent_event_id != invitation.event_id: + raise HTTPException(status_code=400, detail="Occurrence does not belong to this event series") + + # Upsert override + existing = await db.execute( + select(EventInvitationOverride).where( + EventInvitationOverride.invitation_id == invitation_id, + EventInvitationOverride.occurrence_id == occurrence_id, + ) + ) + override = existing.scalar_one_or_none() + if override: + override.status = status + override.responded_at = datetime.now() + else: + override = EventInvitationOverride( + invitation_id=invitation_id, + occurrence_id=occurrence_id, + status=status, + responded_at=datetime.now(), + ) + db.add(override) + + return override + + +async def dismiss_invitation( + db: AsyncSession, + invitation_id: int, + user_id: int, +) -> None: + """Delete an invitation (invitee leaving or owner revoking).""" + result = await db.execute( + delete(EventInvitation).where( + EventInvitation.id == invitation_id, + EventInvitation.user_id == user_id, + ) + ) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Invitation not found") + + +async def dismiss_invitation_by_owner( + db: AsyncSession, + invitation_id: int, +) -> None: + """Delete an invitation by the event owner (revoking).""" + result = await db.execute( + delete(EventInvitation).where(EventInvitation.id == invitation_id) + ) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Invitation not found") + + +async def get_event_invitations( + db: AsyncSession, + event_id: int, +) -> list[dict]: + """Get all invitations for an event with invitee names.""" + result = await db.execute( + select( + EventInvitation, + Settings.preferred_name, + User.umbral_name, + ) + .join(User, EventInvitation.user_id == User.id) + .outerjoin(Settings, Settings.user_id == User.id) + .where(EventInvitation.event_id == event_id) + .order_by(EventInvitation.invited_at.asc()) + ) + rows = result.all() + return [ + { + "id": inv.id, + "event_id": inv.event_id, + "user_id": inv.user_id, + "invited_by": inv.invited_by, + "status": inv.status, + "invited_at": inv.invited_at, + "responded_at": inv.responded_at, + "invitee_name": preferred_name or umbral_name or "Unknown", + "invitee_umbral_name": umbral_name or "Unknown", + } + for inv, preferred_name, umbral_name in rows + ] + + +async def get_invited_event_ids( + db: AsyncSession, + user_id: int, +) -> list[int]: + """Return event IDs where user has a non-declined invitation.""" + result = await db.execute( + select(EventInvitation.event_id).where( + EventInvitation.user_id == user_id, + EventInvitation.status != "declined", + ) + ) + return [r[0] for r in result.all()] + + +async def get_pending_invitations( + db: AsyncSession, + user_id: int, +) -> list[dict]: + """Return pending invitations for the current user.""" + result = await db.execute( + select( + EventInvitation, + CalendarEvent.title, + CalendarEvent.start_datetime, + Settings.preferred_name, + ) + .join(CalendarEvent, EventInvitation.event_id == CalendarEvent.id) + .outerjoin( + User, EventInvitation.invited_by == User.id + ) + .outerjoin( + Settings, Settings.user_id == User.id + ) + .where( + EventInvitation.user_id == user_id, + EventInvitation.status == "pending", + ) + .order_by(EventInvitation.invited_at.desc()) + ) + rows = result.all() + return [ + { + "id": inv.id, + "event_id": inv.event_id, + "event_title": title, + "event_start": start_dt, + "invited_by_name": inviter_name or "Someone", + "invited_at": inv.invited_at, + "status": inv.status, + } + for inv, title, start_dt, inviter_name in rows + ] + + +async def get_invitation_overrides_for_user( + db: AsyncSession, + user_id: int, + event_ids: list[int], +) -> dict[int, str]: + """ + For a list of occurrence event IDs, return a map of occurrence_id -> override status. + Used to annotate event listings with per-occurrence invitation status. + """ + if not event_ids: + return {} + + result = await db.execute( + select( + EventInvitationOverride.occurrence_id, + EventInvitationOverride.status, + ) + .join(EventInvitation, EventInvitationOverride.invitation_id == EventInvitation.id) + .where( + EventInvitation.user_id == user_id, + EventInvitationOverride.occurrence_id.in_(event_ids), + ) + ) + return {r[0]: r[1] for r in result.all()} + + +async def cascade_event_invitations_on_disconnect( + db: AsyncSession, + user_a_id: int, + user_b_id: int, +) -> None: + """Delete event invitations between two users when connection is severed.""" + # Delete invitations where A invited B + await db.execute( + delete(EventInvitation).where( + EventInvitation.invited_by == user_a_id, + EventInvitation.user_id == user_b_id, + ) + ) + # Delete invitations where B invited A + await db.execute( + delete(EventInvitation).where( + EventInvitation.invited_by == user_b_id, + EventInvitation.user_id == user_a_id, + ) + ) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 85d709c..fb98f21 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -111,6 +111,13 @@ server { include /etc/nginx/proxy-params.conf; } + # Event invite — rate-limited to prevent invite spam (reuse cal_invite_limit zone) + location ~ /api/events/\d+/invitations$ { + limit_req zone=cal_invite_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # Calendar sync — rate-limited to prevent excessive polling location /api/shared-calendars/sync { limit_req zone=cal_sync_limit burst=5 nodelay; diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index c58c3ec..fd229d5 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -10,7 +10,7 @@ import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import enAuLocale from '@fullcalendar/core/locales/en-au'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core'; -import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react'; +import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat, Users } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import axios from 'axios'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; @@ -361,22 +361,26 @@ export default function CalendarPage() { } }; - const calendarEvents = filteredEvents.map((event) => ({ - id: String(event.id), - title: event.title, - start: event.start_datetime, - end: event.end_datetime || undefined, - allDay: event.all_day, - color: 'transparent', - editable: permissionMap.get(event.calendar_id) !== 'read_only', - extendedProps: { - is_virtual: event.is_virtual, - is_recurring: event.is_recurring, - parent_event_id: event.parent_event_id, - calendar_id: event.calendar_id, - calendarColor: event.calendar_color || 'hsl(var(--accent-color))', - }, - })); + const calendarEvents = filteredEvents + // Exclude declined invited events from calendar display + .filter((event) => !(event.is_invited && event.invitation_status === 'declined')) + .map((event) => ({ + id: String(event.id), + title: event.title, + start: event.start_datetime, + end: event.end_datetime || undefined, + allDay: event.all_day, + color: 'transparent', + editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only', + extendedProps: { + is_virtual: event.is_virtual, + is_recurring: event.is_recurring, + parent_event_id: event.parent_event_id, + calendar_id: event.calendar_id, + calendarColor: event.calendar_color || 'hsl(var(--accent-color))', + is_invited: event.is_invited, + }, + })); const handleEventClick = (info: EventClickArg) => { const event = events.find((e) => String(e.id) === info.event.id); @@ -516,17 +520,21 @@ export default function CalendarPage() { const isMonth = arg.view.type === 'dayGridMonth'; const isAllDay = arg.event.allDay; const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id; + const isInvited = arg.event.extendedProps.is_invited; - const repeatIcon = isRecurring ? ( - - ) : null; + const icons = ( + <> + {isInvited && } + {isRecurring && } + + ); if (isMonth) { if (isAllDay) { return (
{arg.event.title} - {repeatIcon} + {icons}
); } @@ -538,7 +546,7 @@ export default function CalendarPage() { style={{ borderColor: 'var(--event-color)' }} /> {arg.event.title} - {repeatIcon} + {icons} {arg.timeText} ); @@ -549,7 +557,7 @@ export default function CalendarPage() {
{arg.event.title} - {repeatIcon} + {icons}
{arg.timeText}
diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 8362a1f..0bb174f 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, parseISO } from 'date-fns'; import { - X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, + X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut, } from 'lucide-react'; import axios from 'axios'; import api, { getErrorMessage } from '@/lib/api'; @@ -11,9 +11,12 @@ import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarP import { useCalendars } from '@/hooks/useCalendars'; import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useEventLock } from '@/hooks/useEventLock'; +import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations'; import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import EventLockBanner from './EventLockBanner'; +import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection'; +import LeaveEventDialog from './LeaveEventDialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; @@ -251,6 +254,20 @@ export default function EventDetailPanel({ const [lockInfo, setLockInfo] = useState(null); + // Event invitation hooks + const eventNumericId = event && typeof event.id === 'number' ? event.id : null; + const parentEventId = event?.parent_event_id ?? eventNumericId; + const { + invitees, isLoadingInvitees, invite, isInviting, respond: respondInvitation, + isResponding, override: overrideInvitation, leave: leaveInvitation, isLeaving, + } = useEventInvitations(parentEventId); + const { connections } = useConnectedUsersSearch(); + const [showLeaveDialog, setShowLeaveDialog] = useState(false); + + const isInvitedEvent = !!event?.is_invited; + const myInvitationStatus = event?.invitation_status ?? null; + const myInvitationId = event?.invitation_id ?? null; + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating @@ -579,7 +596,8 @@ export default function EventDetailPanel({ <> {!event?.is_virtual && ( <> - {canEdit && ( + {/* Edit button — only for own events or shared with edit permission */} + {canEdit && !isInvitedEvent && ( )} - {canDelete && ( + {/* Leave button for invited events */} + {isInvitedEvent && myInvitationId && ( + + )} + {/* Delete button for own events */} + {canDelete && !isInvitedEvent && ( confirmingDelete ? ( + ))} + + )} + {search.trim() && searchResults.length === 0 && ( +
+

No connections found

+
+ )} + + + {/* Selected invitees */} + {selectedConnections.length > 0 && ( +
+ {selectedConnections.map((conn) => ( +
+ + {conn.connected_preferred_name || conn.connected_umbral_name} + +
+ ))} + +
+ )} + + ); +} + +// ── RSVP Buttons (for invitee view) ── + +interface RsvpButtonsProps { + currentStatus: string; + onRespond: (status: 'accepted' | 'tentative' | 'declined') => void; + isResponding: boolean; +} + +export function RsvpButtons({ currentStatus, onRespond, isResponding }: RsvpButtonsProps) { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/components/calendar/LeaveEventDialog.tsx b/frontend/src/components/calendar/LeaveEventDialog.tsx new file mode 100644 index 0000000..33e371a --- /dev/null +++ b/frontend/src/components/calendar/LeaveEventDialog.tsx @@ -0,0 +1,48 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface LeaveEventDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + eventTitle: string; + isRecurring: boolean; + isLeaving: boolean; +} + +export default function LeaveEventDialog({ + open, + onClose, + onConfirm, + eventTitle, + isRecurring, + isLeaving, +}: LeaveEventDialogProps) { + return ( + !v && onClose()}> + + + Leave Event + +
+

+ This will remove you from “{eventTitle}”. You won’t see it on your calendar anymore. +

+ {isRecurring && ( +

+ For recurring events: removed from all future occurrences. +

+ )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 783aca8..f01d395 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -1,12 +1,12 @@ import { useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; -import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react'; +import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; import { useConnections } from '@/hooks/useConnections'; import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import axios from 'axios'; -import { getErrorMessage } from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import type { AppNotification } from '@/types'; export default function NotificationToaster() { @@ -88,6 +88,38 @@ export default function NotificationToaster() { }, [], ); + const handleEventInviteRespond = useCallback( + async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => { + if (respondingRef.current.has(invitationId + 200000)) return; + respondingRef.current.add(invitationId + 200000); + + toast.dismiss(toastId); + const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' }; + const loadingId = toast.loading(`${statusLabel[status]}…`); + + try { + await api.put(`/event-invitations/${invitationId}/respond`, { status }); + toast.dismiss(loadingId); + const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' }; + toast.success(`Marked as ${successLabel[status]}`); + markReadRef.current([notificationId]).catch(() => {}); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + } catch (err) { + toast.dismiss(loadingId); + if (axios.isAxiosError(err) && err.response?.status === 409) { + toast.success('Already responded'); + markReadRef.current([notificationId]).catch(() => {}); + } else { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + } finally { + respondingRef.current.delete(invitationId + 200000); + } + }, + [], + ); + // Track unread count changes to force-refetch the list useEffect(() => { if (unreadCount > prevUnreadRef.current && initializedRef.current) { @@ -126,6 +158,10 @@ export default function NotificationToaster() { if (newNotifications.some((n) => n.type === 'calendar_invite')) { queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); } + if (newNotifications.some((n) => n.type === 'event_invite')) { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + } // Show toasts newNotifications.forEach((notification) => { @@ -133,6 +169,8 @@ export default function NotificationToaster() { showConnectionRequestToast(notification); } else if (notification.type === 'calendar_invite' && notification.source_id) { showCalendarInviteToast(notification); + } else if (notification.type === 'event_invite' && notification.data) { + showEventInviteToast(notification); } else { toast(notification.title || 'New Notification', { description: notification.message || undefined, @@ -141,7 +179,7 @@ export default function NotificationToaster() { }); } }); - }, [notifications, handleConnectionRespond, handleCalendarInviteRespond]); + }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]); const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; @@ -222,5 +260,104 @@ export default function NotificationToaster() { { id: `calendar-invite-${inviteId}`, duration: 30000 }, ); }; + const showEventInviteToast = (notification: AppNotification) => { + const data = notification.data as Record; + const eventId = data?.event_id as number; + // Use source_id as a stable ID for dedup (it's the event_id) + const inviteKey = `event-invite-${notification.id}`; + + // We need the invitation ID to respond — fetch pending invitations + // For now, use a simplified approach: the toast will query pending invitations + toast.custom( + (id) => ( +
+
+
+ +
+
+

Event Invitation

+

+ {notification.message || 'You were invited to an event'} +

+
+ + + +
+
+
+
+ ), + { id: inviteKey, duration: 30000 }, + ); + }; + return null; } diff --git a/frontend/src/hooks/useEventInvitations.ts b/frontend/src/hooks/useEventInvitations.ts new file mode 100644 index 0000000..90c4617 --- /dev/null +++ b/frontend/src/hooks/useEventInvitations.ts @@ -0,0 +1,105 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import api, { getErrorMessage } from '@/lib/api'; +import type { EventInvitation, Connection } from '@/types'; + +export function useEventInvitations(eventId: number | null) { + const queryClient = useQueryClient(); + + const inviteesQuery = useQuery({ + queryKey: ['event-invitations', eventId], + queryFn: async () => { + const { data } = await api.get(`/events/${eventId}/invitations`); + return data; + }, + enabled: !!eventId, + }); + + const inviteMutation = useMutation({ + mutationFn: async (userIds: number[]) => { + const { data } = await api.post(`/events/${eventId}/invitations`, { user_ids: userIds }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success('Invitation sent'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to send invitation')); + }, + }); + + const respondMutation = useMutation({ + mutationFn: async ({ invitationId, status }: { invitationId: number; status: 'accepted' | 'tentative' | 'declined' }) => { + const { data } = await api.put(`/event-invitations/${invitationId}/respond`, { status }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to respond to invitation')); + }, + }); + + const overrideMutation = useMutation({ + mutationFn: async ({ invitationId, occurrenceId, status }: { invitationId: number; occurrenceId: number; status: 'accepted' | 'tentative' | 'declined' }) => { + const { data } = await api.put(`/event-invitations/${invitationId}/respond/${occurrenceId}`, { status }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to update status')); + }, + }); + + const leaveMutation = useMutation({ + mutationFn: async (invitationId: number) => { + await api.delete(`/event-invitations/${invitationId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + toast.success('Left event'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to leave event')); + }, + }); + + return { + invitees: inviteesQuery.data ?? [], + isLoadingInvitees: inviteesQuery.isLoading, + invite: inviteMutation.mutateAsync, + isInviting: inviteMutation.isPending, + respond: respondMutation.mutateAsync, + isResponding: respondMutation.isPending, + override: overrideMutation.mutateAsync, + leave: leaveMutation.mutateAsync, + isLeaving: leaveMutation.isPending, + }; +} + +export function useConnectedUsersSearch() { + const connectionsQuery = useQuery({ + queryKey: ['connections'], + queryFn: async () => { + const { data } = await api.get('/connections'); + return data; + }, + staleTime: 30_000, + }); + + return { + connections: connectionsQuery.data ?? [], + isLoading: connectionsQuery.isLoading, + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 420507b..f780157 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -112,6 +112,9 @@ export interface CalendarEvent { parent_event_id?: number | null; is_recurring?: boolean; original_start?: string | null; + is_invited?: boolean; + invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null; + invitation_id?: number | null; created_at: string; updated_at: string; } @@ -486,6 +489,30 @@ export interface CalendarInvite { invited_at: string; } +// ── Event Invitations ───────────────────────────────────────────── + +export interface EventInvitation { + id: number; + event_id: number; + user_id: number; + invited_by: number | null; + status: 'pending' | 'accepted' | 'tentative' | 'declined'; + invited_at: string; + responded_at: string | null; + invitee_name: string; + invitee_umbral_name: string; +} + +export interface PendingEventInvitation { + id: number; + event_id: number; + event_title: string; + event_start: string; + invited_by_name: string; + invited_at: string; + status: string; +} + export interface EventLockInfo { locked: boolean; locked_by_name: string | null; From a41b48f016025b5fe73a1bee8dfa4ee9657d6edf Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Sun, 15 Mar 2026 22:16:53 +0800 Subject: [PATCH 02/20] Fix TS build: remove unused isLoadingInvitees var and Select import Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/EventDetailPanel.tsx | 2 +- frontend/src/components/calendar/InviteeSection.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 0bb174f..6ac2a6c 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -258,7 +258,7 @@ export default function EventDetailPanel({ const eventNumericId = event && typeof event.id === 'number' ? event.id : null; const parentEventId = event?.parent_event_id ?? eventNumericId; const { - invitees, isLoadingInvitees, invite, isInviting, respond: respondInvitation, + invitees, invite, isInviting, respond: respondInvitation, isResponding, override: overrideInvitation, leave: leaveInvitation, isLeaving, } = useEventInvitations(parentEventId); const { connections } = useConnectedUsersSearch(); diff --git a/frontend/src/components/calendar/InviteeSection.tsx b/frontend/src/components/calendar/InviteeSection.tsx index 14e6047..73dfa44 100644 --- a/frontend/src/components/calendar/InviteeSection.tsx +++ b/frontend/src/components/calendar/InviteeSection.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { Users, UserPlus, Search, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Select } from '@/components/ui/select'; import type { EventInvitation, Connection } from '@/types'; // ── Status display helpers ── From 0f378ad38679fe7652c93abaff8db4ed058782aa Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 16 Mar 2026 13:00:27 +0800 Subject: [PATCH 03/20] Add event invite actions to notification center + toast on login - NotificationsPage: Going/Maybe/Decline buttons for event_invite notifications - NotificationsPage: event_invite icon mapping, eager-refetch, click-to-calendar nav - NotificationToaster: toast actionable unread notifications on first load (max 3) so users see pending invites/requests when they sign in Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 20 +++- .../notifications/NotificationsPage.tsx | 96 ++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index f01d395..a1cab26 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -132,10 +132,28 @@ export default function NotificationToaster() { useEffect(() => { if (!notifications.length) return; - // On first load, record the max ID without toasting + // On first load, record the max ID — but still toast actionable unread items if (!initializedRef.current) { maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id)); initializedRef.current = true; + + // Toast actionable unread notifications on login so the user can act immediately + const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']); + const actionable = notifications.filter( + (n) => !n.is_read && actionableTypes.has(n.type), + ); + if (actionable.length === 0) return; + // Show at most 3 toasts on first load to avoid flooding + const toShow = actionable.slice(0, 3); + toShow.forEach((notification) => { + if (notification.type === 'connection_request' && notification.source_id) { + showConnectionRequestToast(notification); + } else if (notification.type === 'calendar_invite' && notification.source_id) { + showCalendarInviteToast(notification); + } else if (notification.type === 'event_invite' && notification.data) { + showEventInviteToast(notification); + } + }); return; } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 7ad45f9..1a161d5 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; import { useNotifications } from '@/hooks/useNotifications'; @@ -10,7 +10,7 @@ import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import axios from 'axios'; -import { getErrorMessage } from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import { ListSkeleton } from '@/components/ui/skeleton'; import type { AppNotification } from '@/types'; @@ -20,6 +20,8 @@ const typeIcons: Record = { calendar_invite: { icon: Calendar, color: 'text-purple-400' }, calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' }, calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, + event_invite: { icon: Calendar, color: 'text-purple-400' }, + event_invite_response: { icon: Calendar, color: 'text-green-400' }, info: { icon: Info, color: 'text-blue-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' }, }; @@ -41,6 +43,7 @@ export default function NotificationsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); + const [respondingEventInvite, setRespondingEventInvite] = useState(null); // Build a set of pending connection request IDs for quick lookup const pendingInviteIds = useMemo( @@ -60,6 +63,10 @@ export default function NotificationsPage() { if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) { queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); } + // Refresh event invitations + if (notifications.some((n) => n.type === 'event_invite' && !n.is_read)) { + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + } const hasMissing = notifications.some( (n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id), ); @@ -141,6 +148,47 @@ export default function NotificationsPage() { } } }; + const handleEventInviteRespond = async ( + notification: AppNotification, + status: 'accepted' | 'tentative' | 'declined', + ) => { + const data = notification.data as Record | undefined; + const eventId = data?.event_id as number | undefined; + if (!eventId) return; + + setRespondingEventInvite(notification.id); + try { + // Fetch pending invitations to resolve the invitation ID + const { data: pending } = await api.get('/event-invitations/pending'); + const inv = (pending as Array<{ id: number; event_id: number }>).find( + (p) => p.event_id === eventId, + ); + if (inv) { + await api.put(`/event-invitations/${inv.id}/respond`, { status }); + const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' }; + toast.success(`Marked as ${successLabel[status]}`); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + } else { + toast.success('Already responded'); + } + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + toast.success('Already responded'); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } else { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + } finally { + setRespondingEventInvite(null); + } + }; + const handleNotificationClick = async (notification: AppNotification) => { // Don't navigate for pending connection requests — let user act inline if ( @@ -150,6 +198,10 @@ export default function NotificationsPage() { ) { return; } + // Don't navigate for unread event invites — let user act inline + if (notification.type === 'event_invite' && !notification.is_read) { + return; + } if (!notification.is_read) { await markRead([notification.id]).catch(() => {}); } @@ -157,6 +209,10 @@ export default function NotificationsPage() { if (notification.type === 'connection_request' || notification.type === 'connection_accepted') { navigate('/people'); } + // Navigate to Calendar for event-related notifications + if (notification.type === 'event_invite' || notification.type === 'event_invite_response') { + navigate('/calendar'); + } }; return ( @@ -311,6 +367,42 @@ export default function NotificationsPage() { )} + + {/* Event invite actions (inline) */} + {notification.type === 'event_invite' && + !notification.is_read && ( +
+ + + +
+ )} + {/* Timestamp + actions */}
From bafda61958587b82e88d0b686b4ca8473daa9d57 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 16 Mar 2026 13:36:55 +0800 Subject: [PATCH 04/20] Fix invited events hidden by calendar visibility filter Invited events belong to the inviter's calendar, which doesn't exist in the invitee's calendar list. The visibleCalendarIds filter was removing them. Now invited events bypass this filter. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/CalendarPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index fd229d5..7fb18f1 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -331,7 +331,8 @@ export default function CalendarPage() { const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; - return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); + // 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)); }, [events, visibleCalendarIds, calendars.length]); const searchResults = useMemo(() => { From 496666ec5af4efa32c1f7ff318597d86b01fe436 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 16 Mar 2026 13:41:20 +0800 Subject: [PATCH 05/20] Fix 'calendar no longer available' for invited events The shared-calendar removal guard checks allCalendarIds, which only contains the user's own + shared calendars. Invited events belong to the inviter's calendar, triggering a false positive. Skip the check for invited events. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/CalendarPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 7fb18f1..8274a89 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -242,9 +242,10 @@ export default function CalendarPage() { const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false; - // Close panel if shared calendar was removed while viewing + // Close panel if shared calendar was removed while viewing (skip for invited events) useEffect(() => { if (!selectedEvent || allCalendarIds.size === 0) return; + if (selectedEvent.is_invited) return; if (!allCalendarIds.has(selectedEvent.calendar_id)) { handlePanelClose(); toast.info('This calendar is no longer available'); From df857a57193cb4beee1028a410c8dd1a434d20fa Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 16 Mar 2026 14:01:15 +0800 Subject: [PATCH 06/20] Fix QA findings: flush before notify, dedup RSVP, sa_false, validation - C-02: flush invitations before creating notifications so invitation_id is available in notification data; eliminates extra pending fetch - C-03: skip RSVP notification when status hasn't changed - C-01: add defensive comments on update/delete endpoints - W-01: add ge=1, le=2147483647 per-element validation on user_ids - W-04: deduplicate invited_event_ids query via get_invited_event_ids() - W-06: replace Python False with sa_false() in or_() clauses - Frontend: extract resolveInvitationId helper, prefer data.invitation_id Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/dashboard.py | 14 +-- backend/app/routers/events.py | 16 +++- backend/app/schemas/event_invitation.py | 7 +- backend/app/services/calendar_sharing.py | 11 +-- backend/app/services/event_invitation.py | 13 ++- .../notifications/NotificationToaster.tsx | 92 ++++++++----------- .../notifications/NotificationsPage.tsx | 19 ++-- 7 files changed, 81 insertions(+), 91 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index ec0de48..71ee255 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_, case +from sqlalchemy import false as sa_false, select, func, or_, case from datetime import datetime, date, timedelta from typing import Optional, List, Dict, Any @@ -44,8 +44,8 @@ async def get_dashboard( events_query = select(CalendarEvent).where( or_( CalendarEvent.calendar_id.in_(user_calendar_ids), - CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, - CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= today_end, @@ -101,8 +101,8 @@ async def get_dashboard( starred_query = select(CalendarEvent).where( or_( CalendarEvent.calendar_id.in_(user_calendar_ids), - CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, - CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), CalendarEvent.is_starred == True, CalendarEvent.start_datetime > today_start, @@ -192,8 +192,8 @@ async def get_upcoming( events_query = select(CalendarEvent).where( or_( CalendarEvent.calendar_id.in_(user_calendar_ids), - CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, - CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index cc47e12..a01b9fc 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,7 +1,7 @@ import json from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete, or_ +from sqlalchemy import false as sa_false, select, delete, or_ from sqlalchemy.orm import selectinload from typing import Optional, List, Any, Literal @@ -164,8 +164,8 @@ async def get_events( .where( or_( CalendarEvent.calendar_id.in_(all_calendar_ids), - CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, - CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ) ) ) @@ -339,8 +339,8 @@ async def get_event( CalendarEvent.id == event_id, or_( CalendarEvent.calendar_id.in_(all_calendar_ids), - CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False, - CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False, + CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), ) ) @@ -359,6 +359,9 @@ async def update_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + # IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope). + # Event invitees can VIEW events but must NOT be able to edit them. + # Do not add invited_event_ids to this query. all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) result = await db.execute( @@ -504,6 +507,9 @@ async def delete_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + # IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope). + # Event invitees can VIEW events but must NOT be able to delete them. + # Invitees use DELETE /api/event-invitations/{id} to leave instead. all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) result = await db.execute( diff --git a/backend/app/schemas/event_invitation.py b/backend/app/schemas/event_invitation.py index 6b120ca..0c68ce2 100644 --- a/backend/app/schemas/event_invitation.py +++ b/backend/app/schemas/event_invitation.py @@ -1,11 +1,12 @@ -from pydantic import BaseModel, ConfigDict, Field -from typing import Literal, Optional +from typing import Annotated, Literal, Optional from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field + class EventInvitationCreate(BaseModel): model_config = ConfigDict(extra="forbid") - user_ids: list[int] = Field(..., min_length=1, max_length=20) + user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(..., min_length=1, max_length=20) class EventInvitationRespond(BaseModel): diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index 8b2a112..20bdd03 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.calendar import Calendar from app.models.calendar_member import CalendarMember from app.models.event_lock import EventLock -from app.models.event_invitation import EventInvitation logger = logging.getLogger(__name__) @@ -43,14 +42,10 @@ async def get_accessible_event_scope( calendar_ids: all calendars the user can access (owned + accepted shared). invited_parent_event_ids: event IDs where the user has a non-declined invitation. """ + from app.services.event_invitation import get_invited_event_ids + cal_ids = await get_accessible_calendar_ids(user_id, db) - invited_result = await db.execute( - select(EventInvitation.event_id).where( - EventInvitation.user_id == user_id, - EventInvitation.status != "declined", - ) - ) - invited_event_ids = [r[0] for r in invited_result.all()] + invited_event_ids = await get_invited_event_ids(db, user_id) return cal_ids, invited_event_ids diff --git a/backend/app/services/event_invitation.py b/backend/app/services/event_invitation.py index c0ba7ac..e6139a2 100644 --- a/backend/app/services/event_invitation.py +++ b/backend/app/services/event_invitation.py @@ -106,15 +106,18 @@ async def send_event_invitations( db.add(inv) invitations.append(inv) - # Create notification + # Flush to populate invitation IDs before creating notifications + await db.flush() + + for inv in invitations: start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else "" await create_notification( db=db, - user_id=uid, + user_id=inv.user_id, type="event_invite", title="Event Invitation", message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""), - data={"event_id": event_id, "event_title": event_title}, + data={"event_id": event_id, "event_title": event_title, "invitation_id": inv.id}, source_type="event_invitation", source_id=event_id, ) @@ -148,8 +151,8 @@ async def respond_to_invitation( invitation.status = status invitation.responded_at = datetime.now() - # Notify the inviter - if invitation.invited_by: + # 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"} # Fetch responder name responder_settings = await db.execute( diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index a1cab26..8279b3f 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -278,14 +278,42 @@ export default function NotificationToaster() { { id: `calendar-invite-${inviteId}`, duration: 30000 }, ); }; + const resolveInvitationId = async (notification: AppNotification): Promise => { + const data = notification.data as Record | undefined; + // Prefer invitation_id from notification data (populated after flush fix) + if (data?.invitation_id) return data.invitation_id as number; + // Fallback: fetch pending invitations to resolve by event_id + const eventId = data?.event_id as number | undefined; + if (!eventId) return null; + try { + const { data: pending } = await api.get('/event-invitations/pending'); + const inv = (pending as Array<{ id: number; event_id: number }>).find( + (p) => p.event_id === eventId, + ); + return inv?.id ?? null; + } catch { + return null; + } + }; + + const handleEventToastClick = async ( + notification: AppNotification, + status: 'accepted' | 'tentative' | 'declined', + toastId: string | number, + ) => { + const invId = await resolveInvitationId(notification); + if (invId) { + handleEventInviteRespond(invId, status, toastId, notification.id); + } else { + toast.dismiss(toastId); + markReadRef.current([notification.id]).catch(() => {}); + toast.success('Already responded'); + } + }; + const showEventInviteToast = (notification: AppNotification) => { - const data = notification.data as Record; - const eventId = data?.event_id as number; - // Use source_id as a stable ID for dedup (it's the event_id) const inviteKey = `event-invite-${notification.id}`; - // We need the invitation ID to respond — fetch pending invitations - // For now, use a simplified approach: the toast will query pending invitations toast.custom( (id) => (
@@ -300,69 +328,21 @@ export default function NotificationToaster() {

-
-
- - -
+
+ {!canModifyAsInvitee && ( +
+ + +
+ )}
- {/* Recurrence */} -
- - -
+ {/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */} + {!canModifyAsInvitee && ( +
+ + +
+ )} {editState.recurrence_type === 'every_n_days' && (
@@ -1077,6 +1093,11 @@ export default function EventDetailPanel({ + toggleCanModify({ invitationId, canModify }) + } + togglingInvitationId={togglingInvitationId} /> )} diff --git a/frontend/src/components/calendar/InviteeSection.tsx b/frontend/src/components/calendar/InviteeSection.tsx index c1476a9..6233467 100644 --- a/frontend/src/components/calendar/InviteeSection.tsx +++ b/frontend/src/components/calendar/InviteeSection.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { Users, UserPlus, Search, X } from 'lucide-react'; +import { Users, UserPlus, Search, X, Pencil, PencilOff } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import type { EventInvitation, Connection } from '@/types'; @@ -37,9 +37,12 @@ function AvatarCircle({ name }: { name: string }) { interface InviteeListProps { invitees: EventInvitation[]; isRecurringChild?: boolean; + isOwner?: boolean; + onToggleCanModify?: (invitationId: number, canModify: boolean) => void; + togglingInvitationId?: number | null; } -export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) { +export function InviteeList({ invitees, isRecurringChild, isOwner, onToggleCanModify, togglingInvitationId }: InviteeListProps) { if (invitees.length === 0) return null; const goingCount = invitees.filter((i) => i.status === 'accepted').length; @@ -61,6 +64,22 @@ export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
{inv.invitee_name} + {isOwner && onToggleCanModify && ( + + )}
))} diff --git a/frontend/src/hooks/useEventInvitations.ts b/frontend/src/hooks/useEventInvitations.ts index 22fb06b..1c260d8 100644 --- a/frontend/src/hooks/useEventInvitations.ts +++ b/frontend/src/hooks/useEventInvitations.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; @@ -74,6 +75,24 @@ export function useEventInvitations(eventId: number | null) { }, }); + const [togglingInvitationId, setTogglingInvitationId] = useState(null); + const toggleCanModifyMutation = useMutation({ + mutationFn: async ({ invitationId, canModify }: { invitationId: number; canModify: boolean }) => { + setTogglingInvitationId(invitationId); + const { data } = await api.put(`/event-invitations/${invitationId}/can-modify`, { can_modify: canModify }); + return data; + }, + onSuccess: () => { + setTogglingInvitationId(null); + queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + }, + onError: (error) => { + setTogglingInvitationId(null); + toast.error(getErrorMessage(error, 'Failed to update edit access')); + }, + }); + const leaveMutation = useMutation({ mutationFn: async (invitationId: number) => { await api.delete(`/event-invitations/${invitationId}`); @@ -102,6 +121,9 @@ export function useEventInvitations(eventId: number | null) { isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending, leave: leaveMutation.mutateAsync, isLeaving: leaveMutation.isPending, + toggleCanModify: toggleCanModifyMutation.mutateAsync, + isTogglingCanModify: toggleCanModifyMutation.isPending, + togglingInvitationId, }; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f228206..357a297 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -116,6 +116,7 @@ export interface CalendarEvent { invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null; invitation_id?: number | null; display_calendar_id?: number | null; + can_modify?: boolean; created_at: string; updated_at: string; } @@ -502,6 +503,7 @@ export interface EventInvitation { responded_at: string | null; invitee_name: string; invitee_umbral_name: string; + can_modify?: boolean; } export interface PendingEventInvitation { From c66fd159eae77ce535f209d33632bb5131d7f996 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 01:09:34 +0800 Subject: [PATCH 18/20] Restore 5s calendar polling for near-real-time shared event sync Reverts the AW-3 optimization that increased polling from 5s to 30s. The faster interval is needed for shared calendar edits and invited editor changes to appear promptly on other users' views. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/calendar/CalendarPage.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index b37c481..3cec58e 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -229,9 +229,8 @@ export default function CalendarPage() { }); return data; }, - // AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min - refetchInterval: 30_000, - staleTime: 30_000, + refetchInterval: 5_000, + staleTime: 5_000, }); const selectedEvent = useMemo( From 2f45220c5da895ffbecae5980c9b7e28914f65bf Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 01:14:44 +0800 Subject: [PATCH 19/20] Show shared-invitee icon on owner's calendar for events with active guests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds has_active_invitees flag to the events GET response. The Users icon now appears on the owner's calendar view when an event has accepted or tentative invitees, giving visual feedback that the event is actively shared. Single batch query with set lookup — no N+1. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/events.py | 18 ++++++++++++++++++ .../src/components/calendar/CalendarPage.tsx | 4 +++- frontend/src/types/index.ts | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index e743f47..5a9525e 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -35,6 +35,7 @@ def _event_to_dict( display_calendar_name: str | None = None, display_calendar_color: str | None = None, can_modify: bool = False, + has_active_invitees: bool = False, ) -> dict: """Serialize a CalendarEvent ORM object to a response dict including calendar info.""" # For invited events: use display calendar if set, otherwise fallback to "Invited"/gray @@ -74,6 +75,7 @@ def _event_to_dict( "invitation_id": invitation_id, "display_calendar_id": display_calendar_id, "can_modify": can_modify, + "has_active_invitees": has_active_invitees, } return d @@ -243,6 +245,21 @@ async def get_events( all_event_ids = [e.id for e in events] override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids) + # Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon) + active_invitee_set: set[int] = set() + if all_event_ids: + active_inv_result = await db.execute( + select(EventInvitation.event_id).where( + EventInvitation.event_id.in_(all_event_ids), + EventInvitation.status.in_(["accepted", "tentative"]), + ).distinct() + ) + active_invitee_set = {r[0] for r in active_inv_result.all()} + # Also mark parent events: if a parent has active invitees, all its children should show the icon + parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set} + if parent_ids: + active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set) + response: List[dict] = [] for e in events: # Determine if this event is from an invitation @@ -272,6 +289,7 @@ async def get_events( display_calendar_name=disp_cal_name, display_calendar_color=disp_cal_color, can_modify=inv_can_modify, + has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set), )) # Fetch the user's Birthdays system calendar; only generate virtual events if visible diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 3cec58e..dc58d13 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -389,6 +389,7 @@ export default function CalendarPage() { calendarColor: event.calendar_color || 'hsl(var(--accent-color))', is_invited: event.is_invited, can_modify: event.can_modify, + has_active_invitees: event.has_active_invitees, }, })); @@ -531,6 +532,7 @@ export default function CalendarPage() { const isAllDay = arg.event.allDay; const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id; const isInvited = arg.event.extendedProps.is_invited; + const hasActiveInvitees = arg.event.extendedProps.has_active_invitees; const calColor = arg.event.extendedProps.calendarColor as string; // Sync --event-color on the parent FC element so CSS rules (background, hover) @@ -544,7 +546,7 @@ export default function CalendarPage() { const icons = ( <> - {isInvited && } + {(isInvited || hasActiveInvitees) && } {isRecurring && } ); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 357a297..0bbc8d1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -117,6 +117,7 @@ export interface CalendarEvent { invitation_id?: number | null; display_calendar_id?: number | null; can_modify?: boolean; + has_active_invitees?: boolean; created_at: string; updated_at: string; } From 925c9caf91bdc00784bfe306baa907d647e2e441 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 01:28:01 +0800 Subject: [PATCH 20/20] Fix QA and pentest findings for event invitations C-01: Use func.count() for invitation cap instead of loading all rows C-02: Remove unused display_calendar_id from EventInvitationResponse F-01: Add field allowlist for invited editors (blocks is_starred, recurrence_rule, calendar_id mutations) W-02: Memoize existingInviteeIds Set in EventDetailPanel W-03: Block per-occurrence overrides on declined/pending invitations S-01: Make can_modify non-optional in EventInvitation TypeScript type Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/events.py | 8 +++++--- backend/app/schemas/event_invitation.py | 1 - backend/app/services/event_invitation.py | 10 ++++++---- frontend/src/components/calendar/EventDetailPanel.tsx | 5 +++-- frontend/src/types/index.ts | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 5a9525e..b89ec9f 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -488,13 +488,15 @@ async def update_event( if is_invited_editor: # Invited editor restrictions — enforce BEFORE any data mutation + # Field allowlist: invited editors can only modify event content, not structure + INVITED_EDITOR_ALLOWED = {"title", "description", "start_datetime", "end_datetime", "all_day", "color", "edit_scope", "location_id"} + disallowed = set(update_data.keys()) - INVITED_EDITOR_ALLOWED + if disallowed: + raise HTTPException(status_code=403, detail="Invited editors cannot modify: " + ", ".join(sorted(disallowed))) scope_peek = update_data.get("edit_scope") # Block all bulk-scope edits on recurring events (C-01/F-01) if event.is_recurring and scope_peek != "this": raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences") - # Block calendar moves (C-02) - if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: - raise HTTPException(status_code=403, detail="Invited editors cannot move events between calendars") else: # Standard calendar-access path: require create_modify+ permission await require_permission(db, event.calendar_id, current_user.id, "create_modify") diff --git a/backend/app/schemas/event_invitation.py b/backend/app/schemas/event_invitation.py index f59bb9a..024704a 100644 --- a/backend/app/schemas/event_invitation.py +++ b/backend/app/schemas/event_invitation.py @@ -40,5 +40,4 @@ class EventInvitationResponse(BaseModel): responded_at: Optional[datetime] invitee_name: Optional[str] = None invitee_umbral_name: Optional[str] = None - display_calendar_id: Optional[int] = None can_modify: bool = False diff --git a/backend/app/services/event_invitation.py b/backend/app/services/event_invitation.py index 5e4a872..d6b6c02 100644 --- a/backend/app/services/event_invitation.py +++ b/backend/app/services/event_invitation.py @@ -7,7 +7,7 @@ import logging from datetime import datetime from fastapi import HTTPException -from sqlalchemy import delete, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -67,11 +67,11 @@ async def send_event_invitations( ) existing_ids = {r[0] for r in existing_result.all()} - # Cap: max 20 pending invitations per event + # Cap: max 20 invitations per event count_result = await db.execute( - select(EventInvitation.id).where(EventInvitation.event_id == event_id) + select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id) ) - current_count = len(count_result.all()) + current_count = count_result.scalar_one() new_ids = [uid for uid in user_ids if uid not in existing_ids] if current_count + len(new_ids) > 20: raise HTTPException(status_code=400, detail="Maximum 20 invitations per event") @@ -219,6 +219,8 @@ async def override_occurrence_status( 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="Must accept or tentatively accept the invitation first") # Verify occurrence belongs to the invited event's series occ_result = await db.execute( diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 0781656..b8e08e8 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, parseISO } from 'date-fns'; @@ -269,6 +269,7 @@ export default function EventDetailPanel({ const isInvitedEvent = !!event?.is_invited; const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify; + const existingInviteeIds = useMemo(() => new Set(invitees.map((i) => i.user_id)), [invitees]); const myInvitationStatus = event?.invitation_status ?? null; const myInvitationId = event?.invitation_id ?? null; @@ -1105,7 +1106,7 @@ export default function EventDetailPanel({ {!isInvitedEvent && canEdit && ( i.user_id))} + existingInviteeIds={existingInviteeIds} onInvite={(userIds) => invite(userIds)} isInviting={isInviting} /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0bbc8d1..2442629 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -504,7 +504,7 @@ export interface EventInvitation { responded_at: string | null; invitee_name: string; invitee_umbral_name: string; - can_modify?: boolean; + can_modify: boolean; } export interface PendingEventInvitation {