From 8652c9f2ce16fc6383d85bd41dce90905e19ea46 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Sun, 15 Mar 2026 02:47:27 +0800 Subject: [PATCH] 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;