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 ? (
-
+ Status shown for this occurrence +
+ )} +No connections found
+Event Invitation
++ {notification.message || 'You were invited to an event'} +
+