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