Implement event invitation feature (invite, RSVP, per-occurrence override, leave)

Full-stack implementation of event invitations allowing users to invite connected
contacts to calendar events. Invitees can respond Going/Tentative/Declined, with
per-occurrence overrides for recurring series. Invited events appear on the invitee's
calendar with a Users icon indicator. LeaveEventDialog replaces delete for invited events.

Backend: Migration 054 (2 tables + notification types), EventInvitation model with
lazy="raise", service layer, dual-router (events + event-invitations), cascade on
disconnect, events/dashboard queries extended with OR for invited events.

Frontend: Types, useEventInvitations hook, InviteeSection (view list + RSVP buttons +
invite search), LeaveEventDialog, event invite toast with 3 response buttons, calendar
eventContent render with Users icon for invited events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-15 02:47:27 +08:00
parent bdfd8448b1
commit 8652c9f2ce
19 changed files with 1649 additions and 48 deletions

View File

@ -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')",
)

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings from app.config import settings
from app.database import engine 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 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 from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them # 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 user_connection as _user_connection_model # noqa: F401
from app.models import calendar_member as _calendar_member_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_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(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) 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(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("/") @app.get("/")

View File

@ -20,6 +20,7 @@ from app.models.connection_request import ConnectionRequest
from app.models.user_connection import UserConnection from app.models.user_connection import UserConnection
from app.models.calendar_member import CalendarMember from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock from app.models.event_lock import EventLock
from app.models.event_invitation import EventInvitation, EventInvitationOverride
__all__ = [ __all__ = [
"Settings", "Settings",
@ -44,4 +45,6 @@ __all__ = [
"UserConnection", "UserConnection",
"CalendarMember", "CalendarMember",
"EventLock", "EventLock",
"EventInvitation",
"EventInvitationOverride",
] ]

View File

@ -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()
)

View File

@ -8,6 +8,7 @@ from app.database import Base
_NOTIFICATION_TYPES = ( _NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected", "connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"event_invite", "event_invite_response",
"info", "warning", "reminder", "system", "info", "warning", "reminder", "system",
) )

View File

@ -12,7 +12,7 @@ from app.models.reminder import Reminder
from app.models.project import Project from app.models.project import Project
from app.models.user import User from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings 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() router = APIRouter()
@ -35,14 +35,18 @@ async def get_dashboard(
today = client_date or date.today() today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Fetch all accessible calendar IDs (owned + accepted shared memberships) # Fetch all accessible calendar IDs + invited event IDs
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) 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's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time()) today_end = datetime.combine(today, datetime.max.time())
events_query = select(CalendarEvent).where( 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_start,
CalendarEvent.start_datetime <= today_end, CalendarEvent.start_datetime <= today_end,
_not_parent_template, _not_parent_template,
@ -95,7 +99,11 @@ async def get_dashboard(
# Starred events — no upper date bound so future events always appear in countdown. # Starred events — no upper date bound so future events always appear in countdown.
# _not_parent_template excludes recurring parent templates (children still show). # _not_parent_template excludes recurring parent templates (children still show).
starred_query = select(CalendarEvent).where( 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.is_starred == True,
CalendarEvent.start_datetime > today_start, CalendarEvent.start_datetime > today_start,
_not_parent_template, _not_parent_template,
@ -169,8 +177,8 @@ async def get_upcoming(
overdue_floor = today - timedelta(days=30) overdue_floor = today - timedelta(days=30)
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time()) overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
# Fetch all accessible calendar IDs (owned + accepted shared memberships) # Fetch all accessible calendar IDs + invited event IDs
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) 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 # Build queries — include overdue todos (up to 30 days back) and snoozed reminders
todos_query = select(Todo).where( todos_query = select(Todo).where(
@ -182,7 +190,11 @@ async def get_upcoming(
) )
events_query = select(CalendarEvent).where( 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_start,
CalendarEvent.start_datetime <= cutoff_datetime, CalendarEvent.start_datetime <= cutoff_datetime,
_not_parent_template, _not_parent_template,

View File

@ -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

View File

@ -1,7 +1,7 @@
import json import json
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete from sqlalchemy import select, delete, or_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import Optional, List, Any, Literal 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.routers.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.services.recurrence import generate_occurrences 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() 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.""" """Serialize a CalendarEvent ORM object to a response dict including calendar info."""
return { d = {
"id": event.id, "id": event.id,
"title": event.title, "title": event.title,
"description": event.description, "description": event.description,
@ -46,7 +53,11 @@ def _event_to_dict(event: CalendarEvent) -> dict:
"original_start": event.original_start, "original_start": event.original_start,
"created_at": event.created_at, "created_at": event.created_at,
"updated_at": event.updated_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( def _birthday_events_for_range(
@ -143,13 +154,20 @@ async def get_events(
recurrence_rule IS NOT NULL) are excluded their materialised children recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar. are what get displayed on the calendar.
""" """
# Scope events through calendar ownership + shared memberships # Scope events through calendar ownership + shared memberships + invitations
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)
query = ( query = (
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .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 # Exclude parent template rows — they are not directly rendered
@ -171,7 +189,36 @@ async def get_events(
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() 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 # Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute( bday_result = await db.execute(
@ -281,14 +328,20 @@ async def get_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), 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( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .options(selectinload(CalendarEvent.calendar))
.where( .where(
CalendarEvent.id == event_id, 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() event = result.scalar_one_or_none()

View File

@ -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

View File

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar from app.models.calendar import Calendar
from app.models.calendar_member import CalendarMember from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock from app.models.event_lock import EventLock
from app.models.event_invitation import EventInvitation
logger = logging.getLogger(__name__) 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()] 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: 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 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}, {"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 # AC-5: Single aggregation query instead of N per-calendar checks
all_cal_ids = a_cal_ids + b_cal_ids all_cal_ids = a_cal_ids + b_cal_ids
if all_cal_ids: if all_cal_ids:

View File

@ -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,
)
)

View File

@ -111,6 +111,13 @@ server {
include /etc/nginx/proxy-params.conf; 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 # Calendar sync rate-limited to prevent excessive polling
location /api/shared-calendars/sync { location /api/shared-calendars/sync {
limit_req zone=cal_sync_limit burst=5 nodelay; limit_req zone=cal_sync_limit burst=5 nodelay;

View File

@ -10,7 +10,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import enAuLocale from '@fullcalendar/core/locales/en-au'; import enAuLocale from '@fullcalendar/core/locales/en-au';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core'; 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 api, { getErrorMessage } from '@/lib/api';
import axios from 'axios'; import axios from 'axios';
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
@ -361,22 +361,26 @@ export default function CalendarPage() {
} }
}; };
const calendarEvents = filteredEvents.map((event) => ({ const calendarEvents = filteredEvents
id: String(event.id), // Exclude declined invited events from calendar display
title: event.title, .filter((event) => !(event.is_invited && event.invitation_status === 'declined'))
start: event.start_datetime, .map((event) => ({
end: event.end_datetime || undefined, id: String(event.id),
allDay: event.all_day, title: event.title,
color: 'transparent', start: event.start_datetime,
editable: permissionMap.get(event.calendar_id) !== 'read_only', end: event.end_datetime || undefined,
extendedProps: { allDay: event.all_day,
is_virtual: event.is_virtual, color: 'transparent',
is_recurring: event.is_recurring, editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only',
parent_event_id: event.parent_event_id, extendedProps: {
calendar_id: event.calendar_id, is_virtual: event.is_virtual,
calendarColor: event.calendar_color || 'hsl(var(--accent-color))', 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 handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id); 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 isMonth = arg.view.type === 'dayGridMonth';
const isAllDay = arg.event.allDay; const isAllDay = arg.event.allDay;
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id; const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
const isInvited = arg.event.extendedProps.is_invited;
const repeatIcon = isRecurring ? ( const icons = (
<Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" /> <>
) : null; {isInvited && <Users className="h-2.5 w-2.5 shrink-0 opacity-60" />}
{isRecurring && <Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />}
</>
);
if (isMonth) { if (isMonth) {
if (isAllDay) { if (isAllDay) {
return ( return (
<div className="flex items-center gap-1 truncate px-1"> <div className="flex items-center gap-1 truncate px-1">
<span className="text-[11px] font-medium truncate">{arg.event.title}</span> <span className="text-[11px] font-medium truncate">{arg.event.title}</span>
{repeatIcon} {icons}
</div> </div>
); );
} }
@ -538,7 +546,7 @@ export default function CalendarPage() {
style={{ borderColor: 'var(--event-color)' }} style={{ borderColor: 'var(--event-color)' }}
/> />
<span className="text-[11px] font-medium truncate">{arg.event.title}</span> <span className="text-[11px] font-medium truncate">{arg.event.title}</span>
{repeatIcon} {icons}
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span> <span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
</div> </div>
); );
@ -549,7 +557,7 @@ export default function CalendarPage() {
<div className="flex flex-col overflow-hidden h-full"> <div className="flex flex-col overflow-hidden h-full">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[12px] font-medium truncate">{arg.event.title}</span> <span className="text-[12px] font-medium truncate">{arg.event.title}</span>
{repeatIcon} {icons}
</div> </div>
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span> <span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
</div> </div>

View File

@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut,
} from 'lucide-react'; } from 'lucide-react';
import axios from 'axios'; import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
@ -11,9 +11,12 @@ import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarP
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useEventLock } from '@/hooks/useEventLock'; import { useEventLock } from '@/hooks/useEventLock';
import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations';
import { formatUpdatedAt } from '@/components/shared/utils'; import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField'; import CopyableField from '@/components/shared/CopyableField';
import EventLockBanner from './EventLockBanner'; import EventLockBanner from './EventLockBanner';
import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection';
import LeaveEventDialog from './LeaveEventDialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
@ -251,6 +254,20 @@ export default function EventDetailPanel({
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null); const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
// Event invitation hooks
const eventNumericId = event && typeof event.id === 'number' ? event.id : null;
const parentEventId = event?.parent_event_id ?? eventNumericId;
const {
invitees, isLoadingInvitees, invite, isInviting, respond: respondInvitation,
isResponding, override: overrideInvitation, leave: leaveInvitation, isLeaving,
} = useEventInvitations(parentEventId);
const { connections } = useConnectedUsersSearch();
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const isInvitedEvent = !!event?.is_invited;
const myInvitationStatus = event?.invitation_status ?? null;
const myInvitationId = event?.invitation_id ?? null;
const [isEditing, setIsEditing] = useState(isCreating); const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() => const [editState, setEditState] = useState<EditState>(() =>
isCreating isCreating
@ -579,7 +596,8 @@ export default function EventDetailPanel({
<> <>
{!event?.is_virtual && ( {!event?.is_virtual && (
<> <>
{canEdit && ( {/* Edit button — only for own events or shared with edit permission */}
{canEdit && !isInvitedEvent && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -591,7 +609,20 @@ export default function EventDetailPanel({
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />} {isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</Button> </Button>
)} )}
{canDelete && ( {/* Leave button for invited events */}
{isInvitedEvent && myInvitationId && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => setShowLeaveDialog(true)}
title="Leave event"
>
<LogOut className="h-3.5 w-3.5" />
</Button>
)}
{/* Delete button for own events */}
{canDelete && !isInvitedEvent && (
confirmingDelete ? ( confirmingDelete ? (
<Button <Button
variant="ghost" variant="ghost"
@ -988,6 +1019,49 @@ export default function EventDetailPanel({
</div> </div>
)} )}
{/* Invitee section — view mode */}
{event && !event.is_virtual && (
<>
{/* RSVP buttons for invitees */}
{isInvitedEvent && myInvitationId && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
Your RSVP
</div>
<RsvpButtons
currentStatus={myInvitationStatus || 'pending'}
onRespond={(status) => {
if (event.parent_event_id && eventNumericId) {
overrideInvitation({ invitationId: myInvitationId, occurrenceId: eventNumericId, status });
} else {
respondInvitation({ invitationId: myInvitationId, status });
}
}}
isResponding={isResponding}
/>
</div>
)}
{/* Invitee list */}
{invitees.length > 0 && (
<InviteeList
invitees={invitees}
isRecurringChild={!!event.parent_event_id}
/>
)}
{/* Invite search for event owner/editor */}
{!isInvitedEvent && canEdit && (
<InviteSearch
connections={connections}
existingInviteeIds={new Set(invitees.map((i) => i.user_id))}
onInvite={(userIds) => invite(userIds)}
isInviting={isInviting}
/>
)}
</>
)}
{/* Updated at */} {/* Updated at */}
{event && !event.is_virtual && ( {event && !event.is_virtual && (
<div className="pt-2 border-t border-border"> <div className="pt-2 border-t border-border">
@ -996,6 +1070,23 @@ export default function EventDetailPanel({
</span> </span>
</div> </div>
)} )}
{/* Leave event dialog */}
{event && isInvitedEvent && myInvitationId && (
<LeaveEventDialog
open={showLeaveDialog}
onClose={() => setShowLeaveDialog(false)}
onConfirm={() => {
leaveInvitation(myInvitationId).then(() => {
setShowLeaveDialog(false);
onClose();
});
}}
eventTitle={event.title}
isRecurring={!!(event.is_recurring || event.parent_event_id)}
isLeaving={isLeaving}
/>
)}
</> </>
)} )}
</div> </div>

View File

@ -0,0 +1,245 @@
import { useState, useMemo } from 'react';
import { Users, UserPlus, Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import type { EventInvitation, Connection } from '@/types';
// ── Status display helpers ──
const STATUS_CONFIG = {
accepted: { label: 'Going', dotClass: 'bg-green-400', textClass: 'text-green-400' },
tentative: { label: 'Tentative', dotClass: 'bg-amber-400', textClass: 'text-amber-400' },
declined: { label: 'Declined', dotClass: 'bg-red-400', textClass: 'text-red-400' },
pending: { label: 'Pending', dotClass: 'bg-neutral-500', textClass: 'text-muted-foreground' },
} as const;
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.pending;
return (
<div className="flex items-center gap-1.5">
<span className={`w-[7px] h-[7px] rounded-full ${config.dotClass}`} />
<span className={`text-xs ${config.textClass}`}>{config.label}</span>
</div>
);
}
function AvatarCircle({ name }: { name: string }) {
const letter = name?.charAt(0)?.toUpperCase() || '?';
return (
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
</div>
);
}
// ── View Mode: InviteeList ──
interface InviteeListProps {
invitees: EventInvitation[];
isRecurringChild?: boolean;
}
export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
if (invitees.length === 0) return null;
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
const countLabel = goingCount > 0 ? `${goingCount} going` : null;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Users className="h-3 w-3" />
Invitees
</div>
{countLabel && (
<span className="text-[11px] text-muted-foreground">{countLabel}</span>
)}
</div>
<div className="space-y-1">
{invitees.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 py-1">
<AvatarCircle name={inv.invitee_name} />
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span>
<StatusBadge status={inv.status} />
</div>
))}
</div>
{isRecurringChild && (
<p className="text-[11px] text-muted-foreground mt-1">
Status shown for this occurrence
</p>
)}
</div>
);
}
// ── Edit Mode: InviteSearch ──
interface InviteSearchProps {
connections: Connection[];
existingInviteeIds: Set<number>;
onInvite: (userIds: number[]) => void;
isInviting: boolean;
}
export function InviteSearch({ connections, existingInviteeIds, onInvite, isInviting }: InviteSearchProps) {
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const searchResults = useMemo(() => {
if (!search.trim()) return [];
const q = search.toLowerCase();
return connections
.filter((c) =>
!existingInviteeIds.has(c.connected_user_id) &&
!selectedIds.includes(c.connected_user_id) &&
(
(c.connected_preferred_name?.toLowerCase().includes(q)) ||
c.connected_umbral_name.toLowerCase().includes(q)
)
)
.slice(0, 6);
}, [search, connections, existingInviteeIds, selectedIds]);
const selectedConnections = connections.filter((c) => selectedIds.includes(c.connected_user_id));
const handleAdd = (userId: number) => {
setSelectedIds((prev) => [...prev, userId]);
setSearch('');
};
const handleRemove = (userId: number) => {
setSelectedIds((prev) => prev.filter((id) => id !== userId));
};
const handleSend = () => {
if (selectedIds.length === 0) return;
onInvite(selectedIds);
setSelectedIds([]);
};
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<UserPlus className="h-3 w-3" />
Invite People
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search connections..."
className="h-8 pl-8 text-xs"
/>
{search.trim() && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
{searchResults.map((conn) => (
<button
key={conn.connected_user_id}
type="button"
onClick={() => handleAdd(conn.connected_user_id)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
{conn.connected_preferred_name && (
<span className="text-[11px] text-muted-foreground">@{conn.connected_umbral_name}</span>
)}
</div>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
))}
</div>
)}
{search.trim() && searchResults.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No connections found</p>
</div>
)}
</div>
{/* Selected invitees */}
{selectedConnections.length > 0 && (
<div className="space-y-1">
{selectedConnections.map((conn) => (
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<span className="text-sm flex-1 truncate">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
<button
type="button"
onClick={() => handleRemove(conn.connected_user_id)}
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
<Button
size="sm"
onClick={handleSend}
disabled={isInviting}
className="w-full mt-1"
>
{isInviting ? 'Sending...' : `Send ${selectedIds.length === 1 ? 'Invite' : `${selectedIds.length} Invites`}`}
</Button>
</div>
)}
</div>
);
}
// ── RSVP Buttons (for invitee view) ──
interface RsvpButtonsProps {
currentStatus: string;
onRespond: (status: 'accepted' | 'tentative' | 'declined') => void;
isResponding: boolean;
}
export function RsvpButtons({ currentStatus, onRespond, isResponding }: RsvpButtonsProps) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onRespond('accepted')}
disabled={isResponding}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
currentStatus === 'accepted'
? 'bg-green-500/20 text-green-400'
: 'text-muted-foreground hover:bg-card-elevated'
}`}
>
Going
</button>
<button
type="button"
onClick={() => onRespond('tentative')}
disabled={isResponding}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
currentStatus === 'tentative'
? 'bg-amber-500/20 text-amber-400'
: 'text-muted-foreground hover:bg-card-elevated'
}`}
>
Maybe
</button>
<button
type="button"
onClick={() => onRespond('declined')}
disabled={isResponding}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
currentStatus === 'declined'
? 'bg-red-500/20 text-red-400'
: 'text-muted-foreground hover:bg-card-elevated'
}`}
>
Decline
</button>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface LeaveEventDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
eventTitle: string;
isRecurring: boolean;
isLeaving: boolean;
}
export default function LeaveEventDialog({
open,
onClose,
onConfirm,
eventTitle,
isRecurring,
isLeaving,
}: LeaveEventDialogProps) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Leave Event</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm text-foreground">
This will remove you from &ldquo;{eventTitle}&rdquo;. You won&rsquo;t see it on your calendar anymore.
</p>
{isRecurring && (
<p className="text-sm text-muted-foreground">
For recurring events: removed from all future occurrences.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isLeaving}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={isLeaving}>
{isLeaving ? 'Leaving...' : 'Leave Event'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,12 +1,12 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react'; import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections'; import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import axios from 'axios'; import axios from 'axios';
import { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types'; import type { AppNotification } from '@/types';
export default function NotificationToaster() { export default function NotificationToaster() {
@ -88,6 +88,38 @@ export default function NotificationToaster() {
}, },
[], [],
); );
const handleEventInviteRespond = useCallback(
async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => {
if (respondingRef.current.has(invitationId + 200000)) return;
respondingRef.current.add(invitationId + 200000);
toast.dismiss(toastId);
const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' };
const loadingId = toast.loading(`${statusLabel[status]}`);
try {
await api.put(`/event-invitations/${invitationId}/respond`, { status });
toast.dismiss(loadingId);
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
toast.success(`Marked as ${successLabel[status]}`);
markReadRef.current([notificationId]).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
respondingRef.current.delete(invitationId + 200000);
}
},
[],
);
// Track unread count changes to force-refetch the list // Track unread count changes to force-refetch the list
useEffect(() => { useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) { if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -126,6 +158,10 @@ export default function NotificationToaster() {
if (newNotifications.some((n) => n.type === 'calendar_invite')) { if (newNotifications.some((n) => n.type === 'calendar_invite')) {
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
} }
if (newNotifications.some((n) => n.type === 'event_invite')) {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
}
// Show toasts // Show toasts
newNotifications.forEach((notification) => { newNotifications.forEach((notification) => {
@ -133,6 +169,8 @@ export default function NotificationToaster() {
showConnectionRequestToast(notification); showConnectionRequestToast(notification);
} else if (notification.type === 'calendar_invite' && notification.source_id) { } else if (notification.type === 'calendar_invite' && notification.source_id) {
showCalendarInviteToast(notification); showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification);
} else { } else {
toast(notification.title || 'New Notification', { toast(notification.title || 'New Notification', {
description: notification.message || undefined, description: notification.message || undefined,
@ -141,7 +179,7 @@ export default function NotificationToaster() {
}); });
} }
}); });
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]); }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => { const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!; const requestId = notification.source_id!;
@ -222,5 +260,104 @@ export default function NotificationToaster() {
{ id: `calendar-invite-${inviteId}`, duration: 30000 }, { id: `calendar-invite-${inviteId}`, duration: 30000 },
); );
}; };
const showEventInviteToast = (notification: AppNotification) => {
const data = notification.data as Record<string, unknown>;
const eventId = data?.event_id as number;
// Use source_id as a stable ID for dedup (it's the event_id)
const inviteKey = `event-invite-${notification.id}`;
// We need the invitation ID to respond — fetch pending invitations
// For now, use a simplified approach: the toast will query pending invitations
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<Calendar className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Event Invitation</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were invited to an event'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={async () => {
// Fetch the invitation ID from pending invitations
try {
const { data: pending } = await api.get('/event-invitations/pending');
const inv = (pending as Array<{ id: number; event_id: number }>).find(
(p) => p.event_id === eventId
);
if (inv) {
handleEventInviteRespond(inv.id, 'accepted', id, notification.id);
} else {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
toast.success('Already responded');
}
} catch {
toast.dismiss(id);
toast.error('Failed to respond');
}
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={async () => {
try {
const { data: pending } = await api.get('/event-invitations/pending');
const inv = (pending as Array<{ id: number; event_id: number }>).find(
(p) => p.event_id === eventId
);
if (inv) {
handleEventInviteRespond(inv.id, 'tentative', id, notification.id);
} else {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
}
} catch {
toast.dismiss(id);
}
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-amber-500/15 text-amber-400 hover:bg-amber-500/25 transition-colors"
>
<Clock className="h-3.5 w-3.5" />
Tentative
</button>
<button
onClick={async () => {
try {
const { data: pending } = await api.get('/event-invitations/pending');
const inv = (pending as Array<{ id: number; event_id: number }>).find(
(p) => p.event_id === eventId
);
if (inv) {
handleEventInviteRespond(inv.id, 'declined', id, notification.id);
} else {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
}
} catch {
toast.dismiss(id);
}
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: inviteKey, duration: 30000 },
);
};
return null; return null;
} }

View File

@ -0,0 +1,105 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { EventInvitation, Connection } from '@/types';
export function useEventInvitations(eventId: number | null) {
const queryClient = useQueryClient();
const inviteesQuery = useQuery({
queryKey: ['event-invitations', eventId],
queryFn: async () => {
const { data } = await api.get<EventInvitation[]>(`/events/${eventId}/invitations`);
return data;
},
enabled: !!eventId,
});
const inviteMutation = useMutation({
mutationFn: async (userIds: number[]) => {
const { data } = await api.post(`/events/${eventId}/invitations`, { user_ids: userIds });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Invitation sent');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to send invitation'));
},
});
const respondMutation = useMutation({
mutationFn: async ({ invitationId, status }: { invitationId: number; status: 'accepted' | 'tentative' | 'declined' }) => {
const { data } = await api.put(`/event-invitations/${invitationId}/respond`, { status });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to respond to invitation'));
},
});
const overrideMutation = useMutation({
mutationFn: async ({ invitationId, occurrenceId, status }: { invitationId: number; occurrenceId: number; status: 'accepted' | 'tentative' | 'declined' }) => {
const { data } = await api.put(`/event-invitations/${invitationId}/respond/${occurrenceId}`, { status });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update status'));
},
});
const leaveMutation = useMutation({
mutationFn: async (invitationId: number) => {
await api.delete(`/event-invitations/${invitationId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Left event');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to leave event'));
},
});
return {
invitees: inviteesQuery.data ?? [],
isLoadingInvitees: inviteesQuery.isLoading,
invite: inviteMutation.mutateAsync,
isInviting: inviteMutation.isPending,
respond: respondMutation.mutateAsync,
isResponding: respondMutation.isPending,
override: overrideMutation.mutateAsync,
leave: leaveMutation.mutateAsync,
isLeaving: leaveMutation.isPending,
};
}
export function useConnectedUsersSearch() {
const connectionsQuery = useQuery({
queryKey: ['connections'],
queryFn: async () => {
const { data } = await api.get<Connection[]>('/connections');
return data;
},
staleTime: 30_000,
});
return {
connections: connectionsQuery.data ?? [],
isLoading: connectionsQuery.isLoading,
};
}

View File

@ -112,6 +112,9 @@ export interface CalendarEvent {
parent_event_id?: number | null; parent_event_id?: number | null;
is_recurring?: boolean; is_recurring?: boolean;
original_start?: string | null; original_start?: string | null;
is_invited?: boolean;
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
invitation_id?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -486,6 +489,30 @@ export interface CalendarInvite {
invited_at: string; invited_at: string;
} }
// ── Event Invitations ─────────────────────────────────────────────
export interface EventInvitation {
id: number;
event_id: number;
user_id: number;
invited_by: number | null;
status: 'pending' | 'accepted' | 'tentative' | 'declined';
invited_at: string;
responded_at: string | null;
invitee_name: string;
invitee_umbral_name: string;
}
export interface PendingEventInvitation {
id: number;
event_id: number;
event_title: string;
event_start: string;
invited_by_name: string;
invited_at: string;
status: string;
}
export interface EventLockInfo { export interface EventLockInfo {
locked: boolean; locked: boolean;
locked_by_name: string | null; locked_by_name: string | null;