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/alembic/versions/055_add_display_calendar_to_event_invitations.py b/backend/alembic/versions/055_add_display_calendar_to_event_invitations.py new file mode 100644 index 0000000..69753a2 --- /dev/null +++ b/backend/alembic/versions/055_add_display_calendar_to_event_invitations.py @@ -0,0 +1,51 @@ +"""Add display_calendar_id to event_invitations. + +Allows invitees to assign invited events to their own calendars +for personal organization, color, and visibility control. + +Revision ID: 055 +Revises: 054 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "055" +down_revision = "054" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "event_invitations", + sa.Column( + "display_calendar_id", + sa.Integer(), + sa.ForeignKey("calendars.id", ondelete="SET NULL", name="fk_event_invitations_display_calendar_id"), + nullable=True, + ), + ) + op.create_index( + "ix_event_invitations_display_calendar", + "event_invitations", + ["display_calendar_id"], + ) + + # Backfill accepted/tentative invitations with each user's default calendar + op.execute(""" + UPDATE event_invitations + SET display_calendar_id = ( + SELECT c.id FROM calendars c + WHERE c.user_id = event_invitations.user_id + AND c.is_default = true + LIMIT 1 + ) + WHERE status IN ('accepted', 'tentative') + AND display_calendar_id IS NULL + """) + + +def downgrade() -> None: + op.drop_index("ix_event_invitations_display_calendar", table_name="event_invitations") + op.drop_column("event_invitations", "display_calendar_id") diff --git a/backend/alembic/versions/056_add_can_modify_to_event_invitations.py b/backend/alembic/versions/056_add_can_modify_to_event_invitations.py new file mode 100644 index 0000000..bf54dcf --- /dev/null +++ b/backend/alembic/versions/056_add_can_modify_to_event_invitations.py @@ -0,0 +1,26 @@ +"""add can_modify to event_invitations + +Revision ID: 056 +Revises: 055 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "056" +down_revision = "055" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "event_invitations", + sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("event_invitations", "can_modify") 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..cbd53d4 --- /dev/null +++ b/backend/app/models/event_invitation.py @@ -0,0 +1,77 @@ +from sqlalchemy import ( + Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index, + String, UniqueConstraint, false as sa_false, 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) + display_calendar_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True + ) + can_modify: Mapped[bool] = mapped_column( + Boolean, default=False, server_default=sa_false() + ) + + event: Mapped["CalendarEvent"] = relationship(lazy="raise") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") + inviter: Mapped[Optional["User"]] = relationship( + foreign_keys=[invited_by], lazy="raise" + ) + display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise") + overrides: Mapped[list["EventInvitationOverride"]] = relationship( + lazy="raise", cascade="all, delete-orphan" + ) + + +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..7581ee6 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 @@ -12,7 +12,8 @@ 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.models.event_invitation import EventInvitation +from app.services.calendar_sharing import get_accessible_event_scope router = APIRouter() @@ -35,14 +36,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 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, _not_parent_template, @@ -50,6 +55,22 @@ async def get_dashboard( events_result = await db.execute(events_query) todays_events = events_result.scalars().all() + # Build invitation lookup for today's events + invited_event_id_set = set(invited_event_ids) + today_inv_map: dict[int, tuple[str, int | None]] = {} + today_event_ids = [e.id for e in todays_events] + parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set] + inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set) + if inv_lookup_ids: + inv_result = await db.execute( + select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where( + EventInvitation.user_id == current_user.id, + EventInvitation.event_id.in_(inv_lookup_ids), + ) + ) + for eid, status, disp_cal_id in inv_result.all(): + today_inv_map[eid] = (status, disp_cal_id) + # Upcoming todos (not completed, with due date from today through upcoming_days) todos_query = select(Todo).where( Todo.user_id == current_user.id, @@ -95,7 +116,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 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, _not_parent_template, @@ -121,7 +146,10 @@ async def get_dashboard( "end_datetime": event.end_datetime, "all_day": event.all_day, "color": event.color, - "is_starred": event.is_starred + "is_starred": event.is_starred, + "is_invited": (event.parent_event_id or event.id) in invited_event_id_set, + "invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0], + "display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1], } for event in todays_events ], @@ -169,8 +197,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 +210,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 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, _not_parent_template, @@ -206,6 +238,20 @@ async def get_upcoming( reminders_result = await db.execute(reminders_query) reminders = reminders_result.scalars().all() + # Build invitation lookup for upcoming events + invited_event_id_set_up = set(invited_event_ids) + upcoming_inv_map: dict[int, tuple[str, int | None]] = {} + up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up) + if up_parent_ids: + up_inv_result = await db.execute( + select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where( + EventInvitation.user_id == current_user.id, + EventInvitation.event_id.in_(up_parent_ids), + ) + ) + for eid, status, disp_cal_id in up_inv_result.all(): + upcoming_inv_map[eid] = (status, disp_cal_id) + # Combine into unified list upcoming_items: List[Dict[str, Any]] = [] @@ -223,6 +269,8 @@ async def get_upcoming( for event in events: end_dt = event.end_datetime + parent_id = event.parent_event_id or event.id + is_inv = parent_id in invited_event_id_set_up upcoming_items.append({ "type": "event", "id": event.id, @@ -233,6 +281,9 @@ async def get_upcoming( "all_day": event.all_day, "color": event.color, "is_starred": event.is_starred, + "is_invited": is_inv, + "invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None, + "display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None, }) for reminder in reminders: diff --git a/backend/app/routers/event_invitations.py b/backend/app/routers/event_invitations.py new file mode 100644 index 0000000..5067b06 --- /dev/null +++ b/backend/app/routers/event_invitations.py @@ -0,0 +1,307 @@ +""" +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 sqlalchemy.orm import selectinload +from app.schemas.event_invitation import ( + EventInvitationCreate, + EventInvitationRespond, + EventInvitationOverrideCreate, + UpdateCanModify, + UpdateDisplayCalendar, +) +from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission +from app.services.event_invitation import ( + send_event_invitations, + respond_to_invitation, + 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.put("/{invitation_id}/display-calendar") +async def update_display_calendar( + body: UpdateDisplayCalendar, + invitation_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Change the display calendar for an accepted/tentative invitation.""" + inv_result = await db.execute( + select(EventInvitation).where( + EventInvitation.id == invitation_id, + EventInvitation.user_id == current_user.id, + ) + ) + invitation = inv_result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status not in ("accepted", "tentative"): + raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations") + + # Verify calendar is accessible to this user + accessible_ids = await get_accessible_calendar_ids(current_user.id, db) + if body.calendar_id not in accessible_ids: + raise HTTPException(status_code=404, detail="Calendar not found") + + invitation.display_calendar_id = body.calendar_id + + # Extract response before commit (ORM expiry rule) + response_data = { + "id": invitation.id, + "event_id": invitation.event_id, + "display_calendar_id": invitation.display_calendar_id, + } + + await db.commit() + return response_data + + +@router.put("/{invitation_id}/can-modify") +async def update_can_modify( + body: UpdateCanModify, + invitation_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Toggle can_modify on an invitation. Owner-only.""" + inv_result = await db.execute( + select(EventInvitation) + .options(selectinload(EventInvitation.event)) + .where(EventInvitation.id == invitation_id) + ) + invitation = inv_result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + # Only the calendar owner can toggle can_modify (W-03) + perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id) + if perm != "owner": + raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access") + + invitation.can_modify = body.can_modify + + response_data = { + "id": invitation.id, + "event_id": invitation.event_id, + "can_modify": invitation.can_modify, + } + + 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..b89ec9f 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 false as sa_false, select, delete, or_ from sqlalchemy.orm import selectinload from typing import Optional, List, Any, Literal @@ -19,14 +19,38 @@ 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, + display_calendar_id: int | None = None, + 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.""" - return { + # For invited events: use display calendar if set, otherwise fallback to "Invited"/gray + if is_invited: + if display_calendar_name: + cal_name = display_calendar_name + cal_color = display_calendar_color or "#6B7280" + else: + cal_name = "Invited" + cal_color = "#6B7280" + else: + cal_name = event.calendar.name if event.calendar else "" + cal_color = event.calendar.color if event.calendar else "" + + d = { "id": event.id, "title": event.title, "description": event.description, @@ -38,15 +62,22 @@ def _event_to_dict(event: CalendarEvent) -> dict: "recurrence_rule": event.recurrence_rule, "is_starred": event.is_starred, "calendar_id": event.calendar_id, - "calendar_name": event.calendar.name if event.calendar else "", - "calendar_color": event.calendar.color if event.calendar else "", + "calendar_name": cal_name, + "calendar_color": cal_color, "is_virtual": False, "parent_event_id": event.parent_event_id, "is_recurring": event.is_recurring, "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, + "display_calendar_id": display_calendar_id, + "can_modify": can_modify, + "has_active_invitees": has_active_invitees, } + return d def _birthday_events_for_range( @@ -143,13 +174,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 sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), + ) + ) ) # Exclude parent template rows — they are not directly rendered @@ -171,7 +209,88 @@ 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, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify) + if invited_event_ids: + inv_result = await db.execute( + select( + EventInvitation.event_id, + EventInvitation.status, + EventInvitation.id, + EventInvitation.display_calendar_id, + EventInvitation.can_modify, + ).where( + EventInvitation.user_id == current_user.id, + EventInvitation.event_id.in_(invited_event_ids), + ) + ) + for eid, status, inv_id, disp_cal_id, cm in inv_result.all(): + invitation_map[eid] = (status, inv_id, disp_cal_id, cm) + + # Batch-fetch display calendars for invited events + display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None} + display_cal_map: dict[int, dict] = {} # cal_id -> {name, color} + if display_cal_ids: + cal_result = await db.execute( + select(Calendar.id, Calendar.name, Calendar.color).where( + Calendar.id.in_(display_cal_ids), + Calendar.id.in_(all_calendar_ids), + ) + ) + for cal_id, cal_name, cal_color in cal_result.all(): + display_cal_map[cal_id] = {"name": cal_name, "color": cal_color} + + # 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) + + # 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 + parent_id = e.parent_event_id or e.id + is_invited = parent_id in invited_event_id_set + inv_status = None + inv_id = None + disp_cal_id = None + disp_cal_name = None + disp_cal_color = None + inv_can_modify = False + if is_invited and parent_id in invitation_map: + inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id] + # Check for per-occurrence override + if e.id in override_map: + inv_status = override_map[e.id] + # Resolve display calendar info + if disp_cal_id and disp_cal_id in display_cal_map: + disp_cal_name = display_cal_map[disp_cal_id]["name"] + disp_cal_color = display_cal_map[disp_cal_id]["color"] + response.append(_event_to_dict( + e, + is_invited=is_invited, + invitation_status=inv_status, + invitation_id=inv_id, + display_calendar_id=disp_cal_id, + 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 bday_result = await db.execute( @@ -281,14 +400,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 sa_false(), + CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), + ), ) ) event = result.scalar_one_or_none() @@ -306,7 +431,11 @@ 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 + # UNLESS they have can_modify=True (checked in fallback path below). all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) + is_invited_editor = False result = await db.execute( select(CalendarEvent) @@ -319,14 +448,62 @@ async def update_event( event = result.scalar_one_or_none() if not event: - raise HTTPException(status_code=404, detail="Calendar event not found") + # Fallback: check if user has can_modify invitation for this event + # Must check both event_id (direct) and parent_event_id (recurring child) + # because invitations are stored against the parent event + target_event_result = await db.execute( + select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id) + ) + target_row = target_event_result.one_or_none() + if not target_row: + raise HTTPException(status_code=404, detail="Calendar event not found") + candidate_ids = [event_id] + if target_row[0] is not None: + candidate_ids.append(target_row[0]) - # Shared calendar: require create_modify+ and check lock - await require_permission(db, event.calendar_id, current_user.id, "create_modify") - await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + inv_result = await db.execute( + select(EventInvitation).where( + EventInvitation.event_id.in_(candidate_ids), + EventInvitation.user_id == current_user.id, + EventInvitation.can_modify == True, + EventInvitation.status.in_(["accepted", "tentative"]), + ) + ) + inv = inv_result.scalar_one_or_none() + if not inv: + raise HTTPException(status_code=404, detail="Calendar event not found") + + # Load the event directly (bypassing calendar filter) + event_result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Calendar event not found") + is_invited_editor = True update_data = event_update.model_dump(exclude_unset=True) + 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") + else: + # Standard calendar-access path: require create_modify+ permission + await require_permission(db, event.calendar_id, current_user.id, "create_modify") + + # Lock check applies to both paths (uses owner's calendar_id) + await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + # Extract scope before applying fields to the model scope: Optional[str] = update_data.pop("edit_scope", None) @@ -335,23 +512,24 @@ async def update_event( if rule_obj is not None: update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None - # SEC-04: if calendar_id is being changed, verify the target belongs to the user - # Only verify ownership when the calendar is actually changing — members submitting - # an unchanged calendar_id must not be rejected just because they aren't the owner. - if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id: - await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) + if not is_invited_editor: + # SEC-04: if calendar_id is being changed, verify the target belongs to the user + # Only verify ownership when the calendar is actually changing — members submitting + # an unchanged calendar_id must not be rejected just because they aren't the owner. + if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id: + await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) - # M-01: Block non-owners from moving events off shared calendars - if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: - source_cal_result = await db.execute( - select(Calendar).where(Calendar.id == event.calendar_id) - ) - source_cal = source_cal_result.scalar_one_or_none() - if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id: - raise HTTPException( - status_code=403, - detail="Only the calendar owner can move events between calendars", + # M-01: Block non-owners from moving events off shared calendars + if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: + source_cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id) ) + source_cal = source_cal_result.scalar_one_or_none() + if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id: + raise HTTPException( + status_code=403, + detail="Only the calendar owner can move events between calendars", + ) start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) @@ -451,6 +629,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 new file mode 100644 index 0000000..024704a --- /dev/null +++ b/backend/app/schemas/event_invitation.py @@ -0,0 +1,43 @@ +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[Annotated[int, Field(ge=1, le=2147483647)]] = 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 UpdateDisplayCalendar(BaseModel): + model_config = ConfigDict(extra="forbid") + calendar_id: Annotated[int, Field(ge=1, le=2147483647)] + + +class UpdateCanModify(BaseModel): + model_config = ConfigDict(extra="forbid") + can_modify: bool + + +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 + can_modify: bool = False diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index da1d71a..fea6df9 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -7,7 +7,7 @@ import logging from datetime import datetime, timedelta from fastapi import HTTPException -from sqlalchemy import delete, select, text, update +from sqlalchemy import delete, literal_column, select, text, union_all, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.calendar import Calendar @@ -34,6 +34,42 @@ 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) in a single DB round-trip. + 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.models.event_invitation import EventInvitation + + result = await db.execute( + union_all( + select(literal_column("'c'").label("kind"), Calendar.id.label("val")) + .where(Calendar.user_id == user_id), + select(literal_column("'c'"), CalendarMember.calendar_id) + .where( + CalendarMember.user_id == user_id, + CalendarMember.status == "accepted", + ), + select(literal_column("'i'"), EventInvitation.event_id) + .where( + EventInvitation.user_id == user_id, + EventInvitation.status != "declined", + ), + ) + ) + cal_ids: list[int] = [] + inv_ids: list[int] = [] + for kind, val in result.all(): + if kind == "c": + cal_ids.append(val) + else: + inv_ids.append(val) + return cal_ids, inv_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 +256,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..d6b6c02 --- /dev/null +++ b/backend/app/services/event_invitation.py @@ -0,0 +1,421 @@ +""" +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, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.calendar import Calendar +from app.models.calendar_event import CalendarEvent +from app.models.event_invitation import EventInvitation, EventInvitationOverride +from app.models.user_connection import UserConnection +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 invitations per event + count_result = await db.execute( + select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id) + ) + 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") + + 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) + + # 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=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, "invitation_id": inv.id}, + 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() + + # Clear can_modify on decline (F-02: prevent silent re-grant) + if status == "declined": + invitation.can_modify = False + + # Auto-assign display calendar on accept/tentative (atomic: only if not already set) + if status in ("accepted", "tentative"): + default_cal = await db.execute( + select(Calendar.id).where( + Calendar.user_id == user_id, + Calendar.is_default == True, + ).limit(1) + ) + default_cal_id = default_cal.scalar_one_or_none() + if default_cal_id and invitation.display_calendar_id is None: + # Atomic: only set if still NULL (race-safe) + await db.execute( + update(EventInvitation) + .where( + EventInvitation.id == invitation_id, + EventInvitation.display_calendar_id == None, + ) + .values(display_calendar_id=default_cal_id) + ) + invitation.display_calendar_id = default_cal_id + + # Notify the inviter only if status actually changed (prevents duplicate notifications) + if invitation.invited_by and old_status != status: + status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"} + # 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") + 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( + 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", + "can_modify": inv.can_modify, + } + 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/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index c58c3ec..dc58d13 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'; @@ -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( @@ -242,9 +241,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'); @@ -331,7 +331,16 @@ export default function CalendarPage() { const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; - return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); + // Invited events: if display_calendar_id is set, respect that calendar's visibility; + // otherwise (pending) always show + return events.filter((e) => { + if (e.is_invited) { + return e.display_calendar_id + ? visibleCalendarIds.has(e.display_calendar_id) + : true; + } + return visibleCalendarIds.has(e.calendar_id); + }); }, [events, visibleCalendarIds, calendars.length]); const searchResults = useMemo(() => { @@ -361,22 +370,28 @@ 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 && !!event.can_modify) || (!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, + can_modify: event.can_modify, + has_active_invitees: event.has_active_invitees, + }, + })); const handleEventClick = (info: EventClickArg) => { const event = events.find((e) => String(e.id) === info.event.id); @@ -516,29 +531,44 @@ 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 hasActiveInvitees = arg.event.extendedProps.has_active_invitees; + const calColor = arg.event.extendedProps.calendarColor as string; - const repeatIcon = isRecurring ? ( - - ) : null; + // Sync --event-color on the parent FC element so CSS rules (background, hover) + // pick up color changes without requiring a full remount (eventDidMount only fires once). + const syncColor = (el: HTMLElement | null) => { + if (el && calColor) { + const fcEl = el.closest('.umbra-event'); + if (fcEl) (fcEl as HTMLElement).style.setProperty('--event-color', calColor); + } + }; + + const icons = ( + <> + {(isInvited || hasActiveInvitees) && } + {isRecurring && } + + ); if (isMonth) { if (isAllDay) { return ( -
+
{arg.event.title} - {repeatIcon} + {icons}
); } // Timed events in month: dot + title + time right-aligned return ( -
+
{arg.event.title} - {repeatIcon} + {icons} {arg.timeText}
); @@ -546,10 +576,10 @@ export default function CalendarPage() { // Week/day view — title on top, time underneath return ( -
+
{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..b8e08e8 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -1,9 +1,9 @@ -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'; 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'; @@ -234,6 +237,7 @@ export default function EventDetailPanel({ .filter((m) => m.permission === 'create_modify' || m.permission === 'full_access') .map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })), ]; + const ownedCalendars = calendars.filter((c) => !c.is_system); const defaultCalendar = calendars.find((c) => c.is_default); const { data: locations = [] } = useQuery({ @@ -251,6 +255,24 @@ 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, invite, isInviting, respond: respondInvitation, + isResponding, override: overrideInvitation, updateDisplayCalendar, + isUpdatingDisplayCalendar, leave: leaveInvitation, isLeaving, + toggleCanModify, togglingInvitationId, + } = useEventInvitations(parentEventId); + const { connections } = useConnectedUsersSearch(); + const [showLeaveDialog, setShowLeaveDialog] = useState(false); + + 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; + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating @@ -294,7 +316,7 @@ export default function EventDetailPanel({ const isRecurring = !!(event?.is_recurring || event?.parent_event_id); // Permission helpers - const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access'; + const canEdit = canModifyAsInvitee || !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access'; const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access'; // Reset state when event changes @@ -345,10 +367,13 @@ export default function EventDetailPanel({ end_datetime: endDt, all_day: data.all_day, location_id: data.location_id ? parseInt(data.location_id) : null, - calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, is_starred: data.is_starred, recurrence_rule: rule, }; + // Invited editors cannot change calendars — omit calendar_id from payload + if (!canModifyAsInvitee) { + payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null; + } if (event && !isCreating) { if (editScope) payload.edit_scope = editScope; @@ -418,7 +443,14 @@ export default function EventDetailPanel({ } if (isRecurring) { - setScopeStep('edit'); + // Invited editors can only edit "this" occurrence — skip scope step + if (canModifyAsInvitee) { + setEditScope('this'); + if (event) setEditState(buildEditStateFromEvent(event)); + setIsEditing(true); + } else { + setScopeStep('edit'); + } } else { if (event) setEditState(buildEditStateFromEvent(event)); setIsEditing(true); @@ -579,7 +611,8 @@ export default function EventDetailPanel({ <> {!event?.is_virtual && ( <> - {canEdit && ( + {/* Edit button — own events, shared with edit permission, or can_modify invitees */} + {canEdit && (!isInvitedEvent || canModifyAsInvitee) && ( )} - {canDelete && ( + {/* Leave button for invited events */} + {isInvitedEvent && myInvitationId && ( + + )} + {/* Delete button for own events */} + {canDelete && !isInvitedEvent && ( confirmingDelete ? (
-
-
- - -
+
+ {!canModifyAsInvitee && ( +
+ + +
+ )}
- {/* Recurrence */} -
- - -
+ {/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */} + {!canModifyAsInvitee && ( +
+ + +
+ )} {editState.recurrence_type === 'every_n_days' && (
@@ -898,19 +948,47 @@ export default function EventDetailPanel({ <> {/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
- {/* Calendar */} + {/* Calendar — for invited events with accepted/tentative, show picker */}
Calendar
-
-
- {event?.calendar_name} -
+ {isInvitedEvent && myInvitationId && (myInvitationStatus === 'accepted' || myInvitationStatus === 'tentative') ? ( +
+
+ +
+ ) : ( +
+
+ {event?.calendar_name} +
+ )}
{/* Starred */} @@ -988,6 +1066,54 @@ export default function EventDetailPanel({
)} + {/* Invitee section — view mode */} + {event && !event.is_virtual && ( + <> + {/* RSVP buttons for invitees */} + {isInvitedEvent && myInvitationId && ( +
+
+ Your RSVP +
+ { + if (event.parent_event_id && eventNumericId) { + overrideInvitation({ invitationId: myInvitationId, occurrenceId: eventNumericId, status }); + } else { + respondInvitation({ invitationId: myInvitationId, status }); + } + }} + isResponding={isResponding} + /> +
+ )} + + {/* Invitee list */} + {invitees.length > 0 && ( + + toggleCanModify({ invitationId, canModify }) + } + togglingInvitationId={togglingInvitationId} + /> + )} + + {/* Invite search for event owner/editor */} + {!isInvitedEvent && canEdit && ( + invite(userIds)} + isInviting={isInviting} + /> + )} + + )} + {/* Updated at */} {event && !event.is_virtual && (
@@ -996,6 +1122,23 @@ export default function EventDetailPanel({
)} + + {/* Leave event dialog */} + {event && isInvitedEvent && myInvitationId && ( + setShowLeaveDialog(false)} + onConfirm={() => { + leaveInvitation(myInvitationId) + .then(() => onClose()) + .catch(() => {}) + .finally(() => setShowLeaveDialog(false)); + }} + eventTitle={event.title} + isRecurring={!!(event.is_recurring || event.parent_event_id)} + isLeaving={isLeaving} + /> + )} )}
diff --git a/frontend/src/components/calendar/InviteeSection.tsx b/frontend/src/components/calendar/InviteeSection.tsx new file mode 100644 index 0000000..6233467 --- /dev/null +++ b/frontend/src/components/calendar/InviteeSection.tsx @@ -0,0 +1,264 @@ +import { useState, useMemo } from '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'; + +// ── Status display helpers ── + +const STATUS_CONFIG = { + accepted: { label: 'Going', dotClass: 'bg-green-400', textClass: 'text-green-400' }, + tentative: { label: 'Tentative', dotClass: 'bg-amber-400', textClass: 'text-amber-400' }, + declined: { label: 'Declined', dotClass: 'bg-red-400', textClass: 'text-red-400' }, + pending: { label: 'Pending', dotClass: 'bg-neutral-500', textClass: 'text-muted-foreground' }, +} as const; + +function StatusBadge({ status }: { status: string }) { + const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.pending; + return ( +
+ + {config.label} +
+ ); +} + +function AvatarCircle({ name }: { name: string }) { + const letter = name?.charAt(0)?.toUpperCase() || '?'; + return ( +
+ {letter} +
+ ); +} + +// ── View Mode: InviteeList ── + +interface InviteeListProps { + invitees: EventInvitation[]; + isRecurringChild?: boolean; + isOwner?: boolean; + onToggleCanModify?: (invitationId: number, canModify: boolean) => void; + togglingInvitationId?: number | null; +} + +export function InviteeList({ invitees, isRecurringChild, isOwner, onToggleCanModify, togglingInvitationId }: InviteeListProps) { + if (invitees.length === 0) return null; + + const goingCount = invitees.filter((i) => i.status === 'accepted').length; + const countLabel = goingCount > 0 ? `${goingCount} going` : null; + + return ( +
+
+
+ + Invitees +
+ {countLabel && ( + {countLabel} + )} +
+
+ {invitees.map((inv) => ( +
+ + {inv.invitee_name} + {isOwner && onToggleCanModify && ( + + )} + +
+ ))} +
+ {isRecurringChild && ( +

+ Status shown for this occurrence +

+ )} +
+ ); +} + +// ── Edit Mode: InviteSearch ── + +interface InviteSearchProps { + connections: Connection[]; + existingInviteeIds: Set; + onInvite: (userIds: number[]) => void; + isInviting: boolean; +} + +export function InviteSearch({ connections, existingInviteeIds, onInvite, isInviting }: InviteSearchProps) { + const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + + const searchResults = useMemo(() => { + if (!search.trim()) return []; + const q = search.toLowerCase(); + return connections + .filter((c) => + !existingInviteeIds.has(c.connected_user_id) && + !selectedIds.includes(c.connected_user_id) && + ( + (c.connected_preferred_name?.toLowerCase().includes(q)) || + c.connected_umbral_name.toLowerCase().includes(q) + ) + ) + .slice(0, 6); + }, [search, connections, existingInviteeIds, selectedIds]); + + const selectedConnections = connections.filter((c) => selectedIds.includes(c.connected_user_id)); + + const handleAdd = (userId: number) => { + setSelectedIds((prev) => [...prev, userId]); + setSearch(''); + }; + + const handleRemove = (userId: number) => { + setSelectedIds((prev) => prev.filter((id) => id !== userId)); + }; + + const handleSend = () => { + if (selectedIds.length === 0) return; + onInvite(selectedIds); + setSelectedIds([]); + }; + + return ( +
+
+ + Invite People +
+ +
+ + setSearch(e.target.value)} + onBlur={() => setTimeout(() => setSearch(''), 200)} + placeholder="Search connections..." + className="h-8 pl-8 text-xs" + /> + {search.trim() && searchResults.length > 0 && ( +
+ {searchResults.map((conn) => ( + + ))} +
+ )} + {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..64e9300 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() { @@ -18,7 +18,7 @@ export default function NotificationToaster() { const initializedRef = useRef(false); const prevUnreadRef = useRef(0); // Track in-flight request IDs so repeated clicks are blocked - const respondingRef = useRef>(new Set()); + const respondingRef = useRef>(new Set()); // Always call the latest respond — Sonner toasts capture closures at creation time const respondInviteRef = useRef(respondInvite); const respondRef = useRef(respond); @@ -30,8 +30,9 @@ export default function NotificationToaster() { const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => { // Guard against double-clicks (Sonner toasts are static, no disabled prop) - if (respondingRef.current.has(requestId)) return; - respondingRef.current.add(requestId); + const key = `conn-${requestId}`; + if (respondingRef.current.has(key)) return; + respondingRef.current.add(key); // Immediately dismiss the custom toast and show a loading indicator toast.dismiss(toastId); @@ -54,7 +55,7 @@ export default function NotificationToaster() { toast.error(getErrorMessage(err, 'Failed to respond to request')); } } finally { - respondingRef.current.delete(requestId); + respondingRef.current.delete(key); } }, [], @@ -63,8 +64,9 @@ export default function NotificationToaster() { const handleCalendarInviteRespond = useCallback( async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => { - if (respondingRef.current.has(inviteId + 100000)) return; - respondingRef.current.add(inviteId + 100000); + const key = `cal-${inviteId}`; + if (respondingRef.current.has(key)) return; + respondingRef.current.add(key); toast.dismiss(toastId); const loadingId = toast.loading( @@ -83,11 +85,44 @@ export default function NotificationToaster() { toast.error(getErrorMessage(err, 'Failed to respond to invite')); } } finally { - respondingRef.current.delete(inviteId + 100000); + respondingRef.current.delete(key); } }, [], ); + const handleEventInviteRespond = useCallback( + async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => { + const key = `event-${invitationId}`; + if (respondingRef.current.has(key)) return; + respondingRef.current.add(key); + + 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(key); + } + }, + [], + ); + // Track unread count changes to force-refetch the list useEffect(() => { if (unreadCount > prevUnreadRef.current && initializedRef.current) { @@ -100,10 +135,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; } @@ -126,6 +179,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 +190,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 +200,7 @@ export default function NotificationToaster() { }); } }); - }, [notifications, handleConnectionRespond, handleCalendarInviteRespond]); + }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]); const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; @@ -222,5 +281,84 @@ 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 inviteKey = `event-invite-${notification.id}`; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Event Invitation

+

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

+
+ + + +
+
+
+
+ ), + { id: inviteKey, duration: 30000 }, + ); + }; + return null; } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 7ad45f9..a27c8da 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,52 @@ 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 { + // Prefer invitation_id from notification data; fallback to pending fetch + let invitationId = data?.invitation_id as number | undefined; + if (!invitationId) { + 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, + ); + invitationId = inv?.id; + } + + if (invitationId) { + await api.put(`/event-invitations/${invitationId}/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 +203,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 +214,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 +372,42 @@ export default function NotificationsPage() {
)} + + {/* Event invite actions (inline) */} + {notification.type === 'event_invite' && + !notification.is_read && ( +
+ + + +
+ )} + {/* Timestamp + actions */}
diff --git a/frontend/src/hooks/useEventInvitations.ts b/frontend/src/hooks/useEventInvitations.ts new file mode 100644 index 0000000..1c260d8 --- /dev/null +++ b/frontend/src/hooks/useEventInvitations.ts @@ -0,0 +1,144 @@ +import { useState } from 'react'; +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 updateDisplayCalendarMutation = useMutation({ + mutationFn: async ({ invitationId, calendarId }: { invitationId: number; calendarId: number }) => { + const { data } = await api.put(`/event-invitations/${invitationId}/display-calendar`, { calendar_id: calendarId }); + return data; + }, + onSuccess: () => { + queryClient.refetchQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); + toast.success('Display calendar updated'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to update display calendar')); + }, + }); + + 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}`); + }, + 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, + updateDisplayCalendar: updateDisplayCalendarMutation.mutateAsync, + isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending, + leave: leaveMutation.mutateAsync, + isLeaving: leaveMutation.isPending, + toggleCanModify: toggleCanModifyMutation.mutateAsync, + isTogglingCanModify: toggleCanModifyMutation.isPending, + togglingInvitationId, + }; +} + +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..2442629 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -112,6 +112,12 @@ 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; + display_calendar_id?: number | null; + can_modify?: boolean; + has_active_invitees?: boolean; created_at: string; updated_at: string; } @@ -486,6 +492,31 @@ 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; + can_modify: boolean; +} + +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;