Compare commits

...

21 Commits

Author SHA1 Message Date
cbb62ea7aa Merge feature/event-invitations: full event invitation system
Adds event invitations with RSVP, per-occurrence overrides for recurring
events, display calendar assignment, can_modify toggle for granting
invitees edit access, active-invitee icon on owner's calendar, and
in-app notification integration. Three QA reviews and two penetration
tests passed. Includes field allowlist for invited editors, connection
validation, 20-invitation cap, and can_modify reset on decline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:33:06 +08:00
925c9caf91 Fix QA and pentest findings for event invitations
C-01: Use func.count() for invitation cap instead of loading all rows
C-02: Remove unused display_calendar_id from EventInvitationResponse
F-01: Add field allowlist for invited editors (blocks is_starred,
      recurrence_rule, calendar_id mutations)
W-02: Memoize existingInviteeIds Set in EventDetailPanel
W-03: Block per-occurrence overrides on declined/pending invitations
S-01: Make can_modify non-optional in EventInvitation TypeScript type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:28:01 +08:00
2f45220c5d Show shared-invitee icon on owner's calendar for events with active guests
Adds has_active_invitees flag to the events GET response. The Users icon
now appears on the owner's calendar view when an event has accepted or
tentative invitees, giving visual feedback that the event is actively
shared. Single batch query with set lookup — no N+1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:14:44 +08:00
c66fd159ea Restore 5s calendar polling for near-real-time shared event sync
Reverts the AW-3 optimization that increased polling from 5s to 30s.
The faster interval is needed for shared calendar edits and invited
editor changes to appear promptly on other users' views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:09:34 +08:00
f35798c757 Add per-invitee can_modify toggle for event edit access
Allows event owners to grant individual invitees edit permission via a
toggle in the invitee list. Invited editors can modify event details
(title, description, time, location) but cannot change calendars, manage
invitees, delete events, or bulk-edit recurring series (scope restricted
to "this" only). The can_modify flag resets on decline to prevent silent
re-grant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:59:36 +08:00
8b39c961b6 Remove unused get_accessible_calendar_ids import from dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:43:35 +08:00
0401a71fce Fix CompoundSelect chaining: use standalone union_all()
SQLAlchemy 2.0's select().union_all() returns a CompoundSelect which
cannot chain another .union_all(). Use the standalone union_all()
function to combine all three queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:39:40 +08:00
8f087ccebf Bump InviteSearch onBlur timeout from 150ms to 200ms
Safer margin for click-through on slower devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:30:09 +08:00
f54ab5079e Fix QA review findings: C-01, C-02, W-01, W-02, W-04, S-01, S-02, S-03
C-01: Remove nginx rate limit on event invitations endpoint — was
      blocking GET (invitee list) on rapid event switching. Backend
      already caps at 20 invitations per event with connection validation.

C-02: respondingRef uses string prefixes (conn-, cal-, event-) instead
      of fragile numeric offsets (+100000/+200000) to prevent collisions.

W-01: get_accessible_event_scope combined into single UNION ALL query
      (3 DB round-trips → 1) for calendar IDs + invitation IDs.

W-02: Dashboard and upcoming endpoints now include is_invited,
      invitation_status, and display_calendar_id on event items.

W-04: LeaveEventDialog closes on error (.finally) instead of staying
      open when mutation rejects.

S-01: Migration 055 FK constraint gets explicit name for consistency.

S-02: InviteSearch dropdown dismisses on blur (150ms delay for clicks).

S-03: Display calendar picker shows only owned calendars, not shared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:27:01 +08:00
25830bb99e Fix calendar event color not updating after display calendar change
eventDidMount only fires once when FullCalendar first mounts a DOM element.
When event data refetches with a new calendarColor, the existing DOM element
is reused and --event-color CSS variable stays stale.

Fix: renderEventContent now uses a ref callback (syncColor) to walk up to
the parent .umbra-event element and update --event-color on every render,
ensuring background, hover, and dot colors reflect the current calendar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:13:34 +08:00
aa1ff50788 Fix display calendar: text cutoff (py-1) and force refetch on update
- Add py-1 to Select to prevent text clipping at h-8 height
- Use refetchQueries instead of invalidateQueries for calendar-events
  after display calendar update to ensure immediate visual refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:14:09 +08:00
d00d6d6d49 Add migration 055: display_calendar_id on event_invitations
Adds nullable FK to calendars, index, and backfills accepted/tentative
invitations with each user's default calendar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:04:50 +08:00
a68ec0e23e Add display calendar support: model, router, service, types, visibility filter
Previously unstaged changes required for the display calendar feature:
- EventInvitation model: display_calendar_id column
- Event invitations router: display-calendar PUT endpoint
- Event invitation service: display calendar update logic
- CalendarPage: respect display_calendar_id in visibility filter
- Types: display_calendar_id on CalendarEvent interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:03:22 +08:00
29c2cbbec8 Fix post-review findings: stale calendar leak, aria-label, color dot, loading state
- Add access check to display calendar batch query (Security L-01)
- Add aria-label, color dot, disabled-during-mutation, h-8 height (UI W-01/W-02/W-03/S-01)
- Add display_calendar_id to EventInvitationResponse schema (Code W-02)
- Invalidate event-invitations cache on display calendar update (Code S-03)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:01:46 +08:00
68a609ee50 Mask calendar name/color for invited events (pen test F-01)
Invitees no longer see the event owner's calendar name/color,
preventing minor information disclosure (CWE-200).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:04:13 +08:00
df857a5719 Fix QA findings: flush before notify, dedup RSVP, sa_false, validation
- C-02: flush invitations before creating notifications so invitation_id
  is available in notification data; eliminates extra pending fetch
- C-03: skip RSVP notification when status hasn't changed
- C-01: add defensive comments on update/delete endpoints
- W-01: add ge=1, le=2147483647 per-element validation on user_ids
- W-04: deduplicate invited_event_ids query via get_invited_event_ids()
- W-06: replace Python False with sa_false() in or_() clauses
- Frontend: extract resolveInvitationId helper, prefer data.invitation_id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:01:15 +08:00
496666ec5a Fix 'calendar no longer available' for invited events
The shared-calendar removal guard checks allCalendarIds, which only
contains the user's own + shared calendars. Invited events belong to
the inviter's calendar, triggering a false positive. Skip the check
for invited events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:41:20 +08:00
bafda61958 Fix invited events hidden by calendar visibility filter
Invited events belong to the inviter's calendar, which doesn't exist
in the invitee's calendar list. The visibleCalendarIds filter was
removing them. Now invited events bypass this filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:36:55 +08:00
0f378ad386 Add event invite actions to notification center + toast on login
- NotificationsPage: Going/Maybe/Decline buttons for event_invite notifications
- NotificationsPage: event_invite icon mapping, eager-refetch, click-to-calendar nav
- NotificationToaster: toast actionable unread notifications on first load (max 3)
  so users see pending invites/requests when they sign in

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:00:27 +08:00
a41b48f016 Fix TS build: remove unused isLoadingInvitees var and Select import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:16:53 +08:00
8652c9f2ce 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>
2026-03-15 02:47:27 +08:00
21 changed files with 2348 additions and 133 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

@ -0,0 +1,51 @@
"""Add display_calendar_id to event_invitations.
Allows invitees to assign invited events to their own calendars
for personal organization, color, and visibility control.
Revision ID: 055
Revises: 054
"""
from alembic import op
import sqlalchemy as sa
revision = "055"
down_revision = "054"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"event_invitations",
sa.Column(
"display_calendar_id",
sa.Integer(),
sa.ForeignKey("calendars.id", ondelete="SET NULL", name="fk_event_invitations_display_calendar_id"),
nullable=True,
),
)
op.create_index(
"ix_event_invitations_display_calendar",
"event_invitations",
["display_calendar_id"],
)
# Backfill accepted/tentative invitations with each user's default calendar
op.execute("""
UPDATE event_invitations
SET display_calendar_id = (
SELECT c.id FROM calendars c
WHERE c.user_id = event_invitations.user_id
AND c.is_default = true
LIMIT 1
)
WHERE status IN ('accepted', 'tentative')
AND display_calendar_id IS NULL
""")
def downgrade() -> None:
op.drop_index("ix_event_invitations_display_calendar", table_name="event_invitations")
op.drop_column("event_invitations", "display_calendar_id")

View File

@ -0,0 +1,26 @@
"""add can_modify to event_invitations
Revision ID: 056
Revises: 055
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "056"
down_revision = "055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"event_invitations",
sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False),
)
def downgrade() -> None:
op.drop_column("event_invitations", "can_modify")

View File

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

View File

@ -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",
]

View File

@ -0,0 +1,77 @@
from sqlalchemy import (
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
String, UniqueConstraint, false as sa_false, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class EventInvitation(Base):
__tablename__ = "event_invitations"
__table_args__ = (
UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
CheckConstraint(
"status IN ('pending', 'accepted', 'tentative', 'declined')",
name="ck_event_invitations_status",
),
Index("ix_event_invitations_user_status", "user_id", "status"),
Index("ix_event_invitations_event_id", "event_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
event_id: Mapped[int] = mapped_column(
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
invited_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(String(20), default="pending")
invited_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
display_calendar_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
)
can_modify: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=sa_false()
)
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
inviter: Mapped[Optional["User"]] = relationship(
foreign_keys=[invited_by], lazy="raise"
)
display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise")
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
lazy="raise", cascade="all, delete-orphan"
)
class EventInvitationOverride(Base):
__tablename__ = "event_invitation_overrides"
__table_args__ = (
UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
CheckConstraint(
"status IN ('accepted', 'tentative', 'declined')",
name="ck_invitation_override_status",
),
Index("ix_invitation_overrides_lookup", "invitation_id", "occurrence_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
invitation_id: Mapped[int] = mapped_column(
Integer, ForeignKey("event_invitations.id", ondelete="CASCADE"), nullable=False
)
occurrence_id: Mapped[int] = mapped_column(
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False)
responded_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)

View File

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

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, case
from sqlalchemy import false as sa_false, select, func, or_, case
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
@ -12,7 +12,8 @@ from app.models.reminder import Reminder
from app.models.project import Project
from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings
from app.services.calendar_sharing import get_accessible_calendar_ids
from app.models.event_invitation import EventInvitation
from app.services.calendar_sharing import get_accessible_event_scope
router = APIRouter()
@ -35,14 +36,18 @@ async def get_dashboard(
today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
# Fetch all accessible calendar IDs + invited event IDs
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
# Today's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
or_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= today_end,
_not_parent_template,
@ -50,6 +55,22 @@ async def get_dashboard(
events_result = await db.execute(events_query)
todays_events = events_result.scalars().all()
# Build invitation lookup for today's events
invited_event_id_set = set(invited_event_ids)
today_inv_map: dict[int, tuple[str, int | None]] = {}
today_event_ids = [e.id for e in todays_events]
parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set]
inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set)
if inv_lookup_ids:
inv_result = await db.execute(
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(inv_lookup_ids),
)
)
for eid, status, disp_cal_id in inv_result.all():
today_inv_map[eid] = (status, disp_cal_id)
# Upcoming todos (not completed, with due date from today through upcoming_days)
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
@ -95,7 +116,11 @@ async def get_dashboard(
# Starred events — no upper date bound so future events always appear in countdown.
# _not_parent_template excludes recurring parent templates (children still show).
starred_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
or_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > today_start,
_not_parent_template,
@ -121,7 +146,10 @@ async def get_dashboard(
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred
"is_starred": event.is_starred,
"is_invited": (event.parent_event_id or event.id) in invited_event_id_set,
"invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0],
"display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1],
}
for event in todays_events
],
@ -169,8 +197,8 @@ async def get_upcoming(
overdue_floor = today - timedelta(days=30)
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
# Fetch all accessible calendar IDs + invited event IDs
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
todos_query = select(Todo).where(
@ -182,7 +210,11 @@ async def get_upcoming(
)
events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
or_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime,
_not_parent_template,
@ -206,6 +238,20 @@ async def get_upcoming(
reminders_result = await db.execute(reminders_query)
reminders = reminders_result.scalars().all()
# Build invitation lookup for upcoming events
invited_event_id_set_up = set(invited_event_ids)
upcoming_inv_map: dict[int, tuple[str, int | None]] = {}
up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up)
if up_parent_ids:
up_inv_result = await db.execute(
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(up_parent_ids),
)
)
for eid, status, disp_cal_id in up_inv_result.all():
upcoming_inv_map[eid] = (status, disp_cal_id)
# Combine into unified list
upcoming_items: List[Dict[str, Any]] = []
@ -223,6 +269,8 @@ async def get_upcoming(
for event in events:
end_dt = event.end_datetime
parent_id = event.parent_event_id or event.id
is_inv = parent_id in invited_event_id_set_up
upcoming_items.append({
"type": "event",
"id": event.id,
@ -233,6 +281,9 @@ async def get_upcoming(
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred,
"is_invited": is_inv,
"invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None,
"display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None,
})
for reminder in reminders:

View File

@ -0,0 +1,307 @@
"""
Event invitation endpoints invite users to events, respond, override per-occurrence, leave.
Two routers:
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
- router: mounted at /api/event-invitations for respond/override/delete/pending
"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.event_invitation import EventInvitation
from app.models.user import User
from app.routers.auth import get_current_user
from sqlalchemy.orm import selectinload
from app.schemas.event_invitation import (
EventInvitationCreate,
EventInvitationRespond,
EventInvitationOverrideCreate,
UpdateCanModify,
UpdateDisplayCalendar,
)
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
from app.services.event_invitation import (
send_event_invitations,
respond_to_invitation,
override_occurrence_status,
dismiss_invitation,
dismiss_invitation_by_owner,
get_event_invitations,
get_pending_invitations,
)
# Mounted at /api/events — event-scoped invitation endpoints
events_router = APIRouter()
# Mounted at /api/event-invitations — invitation-scoped endpoints
router = APIRouter()
async def _get_event_with_access_check(
db: AsyncSession, event_id: int, user_id: int
) -> CalendarEvent:
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check calendar access
perm = await get_user_permission(db, event.calendar_id, user_id)
if perm is not None:
return event
# Check if invitee (also check parent for recurring children)
event_ids_to_check = [event_id]
if event.parent_event_id:
event_ids_to_check.append(event.parent_event_id)
inv_result = await db.execute(
select(EventInvitation.id).where(
EventInvitation.event_id.in_(event_ids_to_check),
EventInvitation.user_id == user_id,
)
)
if inv_result.first() is not None:
return event
raise HTTPException(status_code=404, detail="Event not found")
# ── Event-scoped endpoints (mounted at /api/events) ──
@events_router.post("/{event_id}/invitations", status_code=201)
async def invite_to_event(
body: EventInvitationCreate,
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Permission check: owner or create_modify+
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
if perm not in ("owner", "create_modify", "full_access"):
raise HTTPException(status_code=403, detail="Insufficient permission")
# For recurring child events, invite to the parent (series)
target_event_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await send_event_invitations(
db=db,
event_id=target_event_id,
user_ids=body.user_ids,
invited_by=current_user.id,
)
await db.commit()
return {"invited": len(invitations), "event_id": target_event_id}
@events_router.get("/{event_id}/invitations")
async def list_event_invitations(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all invitees and their statuses for an event."""
event = await _get_event_with_access_check(db, event_id, current_user.id)
# For recurring children, also fetch parent's invitations
target_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await get_event_invitations(db, target_id)
return invitations
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
@router.get("/pending")
async def my_pending_invitations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all pending event invitations for the current user."""
return await get_pending_invitations(db, current_user.id)
@router.put("/{invitation_id}/respond")
async def respond_invitation(
body: EventInvitationRespond,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept, tentative, or decline an event invitation."""
invitation = await respond_to_invitation(
db=db,
invitation_id=invitation_id,
user_id=current_user.id,
status=body.status,
)
# Build response before commit (ORM objects expire after commit)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"status": invitation.status,
"responded_at": invitation.responded_at,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/respond/{occurrence_id}")
async def override_occurrence(
body: EventInvitationOverrideCreate,
invitation_id: int = Path(ge=1, le=2147483647),
occurrence_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Override invitation status for a specific occurrence of a recurring event."""
override = await override_occurrence_status(
db=db,
invitation_id=invitation_id,
occurrence_id=occurrence_id,
user_id=current_user.id,
status=body.status,
)
response_data = {
"invitation_id": override.invitation_id,
"occurrence_id": override.occurrence_id,
"status": override.status,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/display-calendar")
async def update_display_calendar(
body: UpdateDisplayCalendar,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the display calendar for an accepted/tentative invitation."""
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == current_user.id,
)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.status not in ("accepted", "tentative"):
raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations")
# Verify calendar is accessible to this user
accessible_ids = await get_accessible_calendar_ids(current_user.id, db)
if body.calendar_id not in accessible_ids:
raise HTTPException(status_code=404, detail="Calendar not found")
invitation.display_calendar_id = body.calendar_id
# Extract response before commit (ORM expiry rule)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"display_calendar_id": invitation.display_calendar_id,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/can-modify")
async def update_can_modify(
body: UpdateCanModify,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle can_modify on an invitation. Owner-only."""
inv_result = await db.execute(
select(EventInvitation)
.options(selectinload(EventInvitation.event))
.where(EventInvitation.id == invitation_id)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
# Only the calendar owner can toggle can_modify (W-03)
perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id)
if perm != "owner":
raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access")
invitation.can_modify = body.can_modify
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"can_modify": invitation.can_modify,
}
await db.commit()
return response_data
@router.delete("/{invitation_id}", status_code=204)
async def leave_or_revoke_invitation(
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Leave an event (invitee) or revoke an invitation (event owner).
Invitees can only delete their own invitations.
Event owners can delete any invitation for their events.
"""
inv_result = await db.execute(
select(EventInvitation).where(EventInvitation.id == invitation_id)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.user_id == current_user.id:
# Invitee leaving
await dismiss_invitation(db, invitation_id, current_user.id)
else:
# Check if current user is the event owner
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == invitation.event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm != "owner":
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
await dismiss_invitation_by_owner(db, invitation_id)
await db.commit()
return None

View File

@ -1,7 +1,7 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy import false as sa_false, select, delete, or_
from sqlalchemy.orm import selectinload
from typing import Optional, List, Any, Literal
@ -19,14 +19,38 @@ from app.schemas.calendar_event import (
from app.routers.auth import get_current_user
from app.models.user import User
from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, require_permission
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
from app.models.event_invitation import EventInvitation
router = APIRouter()
def _event_to_dict(event: CalendarEvent) -> dict:
def _event_to_dict(
event: CalendarEvent,
is_invited: bool = False,
invitation_status: str | None = None,
invitation_id: int | None = None,
display_calendar_id: int | None = None,
display_calendar_name: str | None = None,
display_calendar_color: str | None = None,
can_modify: bool = False,
has_active_invitees: bool = False,
) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
return {
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
if is_invited:
if display_calendar_name:
cal_name = display_calendar_name
cal_color = display_calendar_color or "#6B7280"
else:
cal_name = "Invited"
cal_color = "#6B7280"
else:
cal_name = event.calendar.name if event.calendar else ""
cal_color = event.calendar.color if event.calendar else ""
d = {
"id": event.id,
"title": event.title,
"description": event.description,
@ -38,15 +62,22 @@ def _event_to_dict(event: CalendarEvent) -> dict:
"recurrence_rule": event.recurrence_rule,
"is_starred": event.is_starred,
"calendar_id": event.calendar_id,
"calendar_name": event.calendar.name if event.calendar else "",
"calendar_color": event.calendar.color if event.calendar else "",
"calendar_name": cal_name,
"calendar_color": cal_color,
"is_virtual": False,
"parent_event_id": event.parent_event_id,
"is_recurring": event.is_recurring,
"original_start": event.original_start,
"created_at": event.created_at,
"updated_at": event.updated_at,
"is_invited": is_invited,
"invitation_status": invitation_status,
"invitation_id": invitation_id,
"display_calendar_id": display_calendar_id,
"can_modify": can_modify,
"has_active_invitees": has_active_invitees,
}
return d
def _birthday_events_for_range(
@ -143,13 +174,20 @@ async def get_events(
recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar.
"""
# Scope events through calendar ownership + shared memberships
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
# Scope events through calendar ownership + shared memberships + invitations
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
.where(
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
)
)
)
# Exclude parent template rows — they are not directly rendered
@ -171,7 +209,88 @@ async def get_events(
result = await db.execute(query)
events = result.scalars().all()
response: List[dict] = [_event_to_dict(e) for e in events]
# Build invitation lookup for the current user
invited_event_id_set = set(invited_event_ids)
invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
if invited_event_ids:
inv_result = await db.execute(
select(
EventInvitation.event_id,
EventInvitation.status,
EventInvitation.id,
EventInvitation.display_calendar_id,
EventInvitation.can_modify,
).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(invited_event_ids),
)
)
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
# Batch-fetch display calendars for invited events
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
display_cal_map: dict[int, dict] = {} # cal_id -> {name, color}
if display_cal_ids:
cal_result = await db.execute(
select(Calendar.id, Calendar.name, Calendar.color).where(
Calendar.id.in_(display_cal_ids),
Calendar.id.in_(all_calendar_ids),
)
)
for cal_id, cal_name, cal_color in cal_result.all():
display_cal_map[cal_id] = {"name": cal_name, "color": cal_color}
# Get per-occurrence overrides for invited events
all_event_ids = [e.id for e in events]
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
# Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon)
active_invitee_set: set[int] = set()
if all_event_ids:
active_inv_result = await db.execute(
select(EventInvitation.event_id).where(
EventInvitation.event_id.in_(all_event_ids),
EventInvitation.status.in_(["accepted", "tentative"]),
).distinct()
)
active_invitee_set = {r[0] for r in active_inv_result.all()}
# Also mark parent events: if a parent has active invitees, all its children should show the icon
parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set}
if parent_ids:
active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set)
response: List[dict] = []
for e in events:
# Determine if this event is from an invitation
parent_id = e.parent_event_id or e.id
is_invited = parent_id in invited_event_id_set
inv_status = None
inv_id = None
disp_cal_id = None
disp_cal_name = None
disp_cal_color = None
inv_can_modify = False
if is_invited and parent_id in invitation_map:
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
# Check for per-occurrence override
if e.id in override_map:
inv_status = override_map[e.id]
# Resolve display calendar info
if disp_cal_id and disp_cal_id in display_cal_map:
disp_cal_name = display_cal_map[disp_cal_id]["name"]
disp_cal_color = display_cal_map[disp_cal_id]["color"]
response.append(_event_to_dict(
e,
is_invited=is_invited,
invitation_status=inv_status,
invitation_id=inv_id,
display_calendar_id=disp_cal_id,
display_calendar_name=disp_cal_name,
display_calendar_color=disp_cal_color,
can_modify=inv_can_modify,
has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set),
))
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute(
@ -281,14 +400,20 @@ async def get_event(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
invited_set = set(invited_event_ids)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(all_calendar_ids),
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
)
)
event = result.scalar_one_or_none()
@ -306,7 +431,11 @@ async def update_event(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to edit them
# UNLESS they have can_modify=True (checked in fallback path below).
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
is_invited_editor = False
result = await db.execute(
select(CalendarEvent)
@ -319,14 +448,62 @@ async def update_event(
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Fallback: check if user has can_modify invitation for this event
# Must check both event_id (direct) and parent_event_id (recurring child)
# because invitations are stored against the parent event
target_event_result = await db.execute(
select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id)
)
target_row = target_event_result.one_or_none()
if not target_row:
raise HTTPException(status_code=404, detail="Calendar event not found")
candidate_ids = [event_id]
if target_row[0] is not None:
candidate_ids.append(target_row[0])
# Shared calendar: require create_modify+ and check lock
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.event_id.in_(candidate_ids),
EventInvitation.user_id == current_user.id,
EventInvitation.can_modify == True,
EventInvitation.status.in_(["accepted", "tentative"]),
)
)
inv = inv_result.scalar_one_or_none()
if not inv:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Load the event directly (bypassing calendar filter)
event_result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
is_invited_editor = True
update_data = event_update.model_dump(exclude_unset=True)
if is_invited_editor:
# Invited editor restrictions — enforce BEFORE any data mutation
# Field allowlist: invited editors can only modify event content, not structure
INVITED_EDITOR_ALLOWED = {"title", "description", "start_datetime", "end_datetime", "all_day", "color", "edit_scope", "location_id"}
disallowed = set(update_data.keys()) - INVITED_EDITOR_ALLOWED
if disallowed:
raise HTTPException(status_code=403, detail="Invited editors cannot modify: " + ", ".join(sorted(disallowed)))
scope_peek = update_data.get("edit_scope")
# Block all bulk-scope edits on recurring events (C-01/F-01)
if event.is_recurring and scope_peek != "this":
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences")
else:
# Standard calendar-access path: require create_modify+ permission
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
# Lock check applies to both paths (uses owner's calendar_id)
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
# Extract scope before applying fields to the model
scope: Optional[str] = update_data.pop("edit_scope", None)
@ -335,23 +512,24 @@ async def update_event(
if rule_obj is not None:
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
# Only verify ownership when the calendar is actually changing — members submitting
# an unchanged calendar_id must not be rejected just because they aren't the owner.
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
if not is_invited_editor:
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
# Only verify ownership when the calendar is actually changing — members submitting
# an unchanged calendar_id must not be rejected just because they aren't the owner.
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
# M-01: Block non-owners from moving events off shared calendars
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
source_cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
source_cal = source_cal_result.scalar_one_or_none()
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Only the calendar owner can move events between calendars",
# M-01: Block non-owners from moving events off shared calendars
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
source_cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
source_cal = source_cal_result.scalar_one_or_none()
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Only the calendar owner can move events between calendars",
)
start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime)
@ -451,6 +629,9 @@ async def delete_event(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to delete them.
# Invitees use DELETE /api/event-invitations/{id} to leave instead.
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
result = await db.execute(

View File

@ -0,0 +1,43 @@
from typing import Annotated, Literal, Optional
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class EventInvitationCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(..., min_length=1, max_length=20)
class EventInvitationRespond(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["accepted", "tentative", "declined"]
class EventInvitationOverrideCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["accepted", "tentative", "declined"]
class UpdateDisplayCalendar(BaseModel):
model_config = ConfigDict(extra="forbid")
calendar_id: Annotated[int, Field(ge=1, le=2147483647)]
class UpdateCanModify(BaseModel):
model_config = ConfigDict(extra="forbid")
can_modify: bool
class EventInvitationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event_id: int
user_id: int
invited_by: Optional[int]
status: str
invited_at: datetime
responded_at: Optional[datetime]
invitee_name: Optional[str] = None
invitee_umbral_name: Optional[str] = None
can_modify: bool = False

View File

@ -7,7 +7,7 @@ import logging
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import delete, select, text, update
from sqlalchemy import delete, literal_column, select, text, union_all, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar
@ -34,6 +34,42 @@ async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[in
return [r[0] for r in result.all()]
async def get_accessible_event_scope(
user_id: int, db: AsyncSession
) -> tuple[list[int], list[int]]:
"""
Returns (calendar_ids, invited_parent_event_ids) in a single DB round-trip.
calendar_ids: all calendars the user can access (owned + accepted shared).
invited_parent_event_ids: event IDs where the user has a non-declined invitation.
"""
from app.models.event_invitation import EventInvitation
result = await db.execute(
union_all(
select(literal_column("'c'").label("kind"), Calendar.id.label("val"))
.where(Calendar.user_id == user_id),
select(literal_column("'c'"), CalendarMember.calendar_id)
.where(
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
),
select(literal_column("'i'"), EventInvitation.event_id)
.where(
EventInvitation.user_id == user_id,
EventInvitation.status != "declined",
),
)
)
cal_ids: list[int] = []
inv_ids: list[int] = []
for kind, val in result.all():
if kind == "c":
cal_ids.append(val)
else:
inv_ids.append(val)
return cal_ids, inv_ids
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
"""
Returns "owner" if the user owns the calendar, the permission string
@ -220,6 +256,10 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
{"user_id": user_a_id, "cal_ids": b_cal_ids},
)
# Clean up event invitations between the two users
from app.services.event_invitation import cascade_event_invitations_on_disconnect
await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id)
# AC-5: Single aggregation query instead of N per-calendar checks
all_cal_ids = a_cal_ids + b_cal_ids
if all_cal_ids:

View File

@ -0,0 +1,421 @@
"""
Event invitation service send, respond, override, dismiss invitations.
All functions accept an AsyncSession and do NOT commit callers manage transactions.
"""
import logging
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.models.event_invitation import EventInvitation, EventInvitationOverride
from app.models.user_connection import UserConnection
from app.models.settings import Settings
from app.models.user import User
from app.services.notification import create_notification
logger = logging.getLogger(__name__)
async def validate_connections(
db: AsyncSession, inviter_id: int, user_ids: list[int]
) -> None:
"""Verify bidirectional connections exist for all invitees. Raises 404 on failure."""
if not user_ids:
return
result = await db.execute(
select(UserConnection.connected_user_id).where(
UserConnection.user_id == inviter_id,
UserConnection.connected_user_id.in_(user_ids),
)
)
connected_ids = {r[0] for r in result.all()}
missing = set(user_ids) - connected_ids
if missing:
raise HTTPException(status_code=404, detail="One or more users not found in your connections")
async def send_event_invitations(
db: AsyncSession,
event_id: int,
user_ids: list[int],
invited_by: int,
) -> list[EventInvitation]:
"""
Bulk-insert invitations for an event. Skips self-invites and existing invitations.
Creates in-app notifications for each invitee.
"""
# Remove self from list
user_ids = [uid for uid in user_ids if uid != invited_by]
if not user_ids:
raise HTTPException(status_code=400, detail="Cannot invite yourself")
# Validate connections
await validate_connections(db, invited_by, user_ids)
# Check existing invitations to skip duplicates
existing_result = await db.execute(
select(EventInvitation.user_id).where(
EventInvitation.event_id == event_id,
EventInvitation.user_id.in_(user_ids),
)
)
existing_ids = {r[0] for r in existing_result.all()}
# Cap: max 20 invitations per event
count_result = await db.execute(
select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id)
)
current_count = count_result.scalar_one()
new_ids = [uid for uid in user_ids if uid not in existing_ids]
if current_count + len(new_ids) > 20:
raise HTTPException(status_code=400, detail="Maximum 20 invitations per event")
if not new_ids:
return []
# Fetch event title for notifications
event_result = await db.execute(
select(CalendarEvent.title, CalendarEvent.start_datetime).where(
CalendarEvent.id == event_id
)
)
event_row = event_result.one_or_none()
event_title = event_row[0] if event_row else "an event"
event_start = event_row[1] if event_row else None
# Fetch inviter's name
inviter_settings = await db.execute(
select(Settings.preferred_name).where(Settings.user_id == invited_by)
)
inviter_name_row = inviter_settings.one_or_none()
inviter_name = inviter_name_row[0] if inviter_name_row and inviter_name_row[0] else "Someone"
invitations = []
for uid in new_ids:
inv = EventInvitation(
event_id=event_id,
user_id=uid,
invited_by=invited_by,
status="pending",
)
db.add(inv)
invitations.append(inv)
# Flush to populate invitation IDs before creating notifications
await db.flush()
for inv in invitations:
start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else ""
await create_notification(
db=db,
user_id=inv.user_id,
type="event_invite",
title="Event Invitation",
message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""),
data={"event_id": event_id, "event_title": event_title, "invitation_id": inv.id},
source_type="event_invitation",
source_id=event_id,
)
return invitations
async def respond_to_invitation(
db: AsyncSession,
invitation_id: int,
user_id: int,
status: str,
) -> EventInvitation:
"""Update invitation status. Returns the updated invitation."""
result = await db.execute(
select(EventInvitation)
.options(selectinload(EventInvitation.event))
.where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == user_id,
)
)
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
# Build response data before modifying
event_title = invitation.event.title
old_status = invitation.status
invitation.status = status
invitation.responded_at = datetime.now()
# Clear can_modify on decline (F-02: prevent silent re-grant)
if status == "declined":
invitation.can_modify = False
# Auto-assign display calendar on accept/tentative (atomic: only if not already set)
if status in ("accepted", "tentative"):
default_cal = await db.execute(
select(Calendar.id).where(
Calendar.user_id == user_id,
Calendar.is_default == True,
).limit(1)
)
default_cal_id = default_cal.scalar_one_or_none()
if default_cal_id and invitation.display_calendar_id is None:
# Atomic: only set if still NULL (race-safe)
await db.execute(
update(EventInvitation)
.where(
EventInvitation.id == invitation_id,
EventInvitation.display_calendar_id == None,
)
.values(display_calendar_id=default_cal_id)
)
invitation.display_calendar_id = default_cal_id
# Notify the inviter only if status actually changed (prevents duplicate notifications)
if invitation.invited_by and old_status != status:
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
# Fetch responder name
responder_settings = await db.execute(
select(Settings.preferred_name).where(Settings.user_id == user_id)
)
responder_row = responder_settings.one_or_none()
responder_name = responder_row[0] if responder_row and responder_row[0] else "Someone"
await create_notification(
db=db,
user_id=invitation.invited_by,
type="event_invite_response",
title="Event RSVP",
message=f"{responder_name} is {status_label.get(status, status)} for {event_title}",
data={"event_id": invitation.event_id, "status": status},
source_type="event_invitation",
source_id=invitation.event_id,
)
return invitation
async def override_occurrence_status(
db: AsyncSession,
invitation_id: int,
occurrence_id: int,
user_id: int,
status: str,
) -> EventInvitationOverride:
"""Create or update a per-occurrence status override."""
# Verify invitation belongs to user
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == user_id,
)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.status not in ("accepted", "tentative"):
raise HTTPException(status_code=400, detail="Must accept or tentatively accept the invitation first")
# Verify occurrence belongs to the invited event's series
occ_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == occurrence_id)
)
occurrence = occ_result.scalar_one_or_none()
if not occurrence:
raise HTTPException(status_code=404, detail="Occurrence not found")
# Occurrence must be the event itself OR a child of the invited event
if occurrence.id != invitation.event_id and occurrence.parent_event_id != invitation.event_id:
raise HTTPException(status_code=400, detail="Occurrence does not belong to this event series")
# Upsert override
existing = await db.execute(
select(EventInvitationOverride).where(
EventInvitationOverride.invitation_id == invitation_id,
EventInvitationOverride.occurrence_id == occurrence_id,
)
)
override = existing.scalar_one_or_none()
if override:
override.status = status
override.responded_at = datetime.now()
else:
override = EventInvitationOverride(
invitation_id=invitation_id,
occurrence_id=occurrence_id,
status=status,
responded_at=datetime.now(),
)
db.add(override)
return override
async def dismiss_invitation(
db: AsyncSession,
invitation_id: int,
user_id: int,
) -> None:
"""Delete an invitation (invitee leaving or owner revoking)."""
result = await db.execute(
delete(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == user_id,
)
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Invitation not found")
async def dismiss_invitation_by_owner(
db: AsyncSession,
invitation_id: int,
) -> None:
"""Delete an invitation by the event owner (revoking)."""
result = await db.execute(
delete(EventInvitation).where(EventInvitation.id == invitation_id)
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Invitation not found")
async def get_event_invitations(
db: AsyncSession,
event_id: int,
) -> list[dict]:
"""Get all invitations for an event with invitee names."""
result = await db.execute(
select(
EventInvitation,
Settings.preferred_name,
User.umbral_name,
)
.join(User, EventInvitation.user_id == User.id)
.outerjoin(Settings, Settings.user_id == User.id)
.where(EventInvitation.event_id == event_id)
.order_by(EventInvitation.invited_at.asc())
)
rows = result.all()
return [
{
"id": inv.id,
"event_id": inv.event_id,
"user_id": inv.user_id,
"invited_by": inv.invited_by,
"status": inv.status,
"invited_at": inv.invited_at,
"responded_at": inv.responded_at,
"invitee_name": preferred_name or umbral_name or "Unknown",
"invitee_umbral_name": umbral_name or "Unknown",
"can_modify": inv.can_modify,
}
for inv, preferred_name, umbral_name in rows
]
async def get_invited_event_ids(
db: AsyncSession,
user_id: int,
) -> list[int]:
"""Return event IDs where user has a non-declined invitation."""
result = await db.execute(
select(EventInvitation.event_id).where(
EventInvitation.user_id == user_id,
EventInvitation.status != "declined",
)
)
return [r[0] for r in result.all()]
async def get_pending_invitations(
db: AsyncSession,
user_id: int,
) -> list[dict]:
"""Return pending invitations for the current user."""
result = await db.execute(
select(
EventInvitation,
CalendarEvent.title,
CalendarEvent.start_datetime,
Settings.preferred_name,
)
.join(CalendarEvent, EventInvitation.event_id == CalendarEvent.id)
.outerjoin(
User, EventInvitation.invited_by == User.id
)
.outerjoin(
Settings, Settings.user_id == User.id
)
.where(
EventInvitation.user_id == user_id,
EventInvitation.status == "pending",
)
.order_by(EventInvitation.invited_at.desc())
)
rows = result.all()
return [
{
"id": inv.id,
"event_id": inv.event_id,
"event_title": title,
"event_start": start_dt,
"invited_by_name": inviter_name or "Someone",
"invited_at": inv.invited_at,
"status": inv.status,
}
for inv, title, start_dt, inviter_name in rows
]
async def get_invitation_overrides_for_user(
db: AsyncSession,
user_id: int,
event_ids: list[int],
) -> dict[int, str]:
"""
For a list of occurrence event IDs, return a map of occurrence_id -> override status.
Used to annotate event listings with per-occurrence invitation status.
"""
if not event_ids:
return {}
result = await db.execute(
select(
EventInvitationOverride.occurrence_id,
EventInvitationOverride.status,
)
.join(EventInvitation, EventInvitationOverride.invitation_id == EventInvitation.id)
.where(
EventInvitation.user_id == user_id,
EventInvitationOverride.occurrence_id.in_(event_ids),
)
)
return {r[0]: r[1] for r in result.all()}
async def cascade_event_invitations_on_disconnect(
db: AsyncSession,
user_a_id: int,
user_b_id: int,
) -> None:
"""Delete event invitations between two users when connection is severed."""
# Delete invitations where A invited B
await db.execute(
delete(EventInvitation).where(
EventInvitation.invited_by == user_a_id,
EventInvitation.user_id == user_b_id,
)
)
# Delete invitations where B invited A
await db.execute(
delete(EventInvitation).where(
EventInvitation.invited_by == user_b_id,
EventInvitation.user_id == user_a_id,
)
)

View File

@ -10,7 +10,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import enAuLocale from '@fullcalendar/core/locales/en-au';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react';
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat, Users } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios';
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
@ -229,9 +229,8 @@ export default function CalendarPage() {
});
return data;
},
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
refetchInterval: 30_000,
staleTime: 30_000,
refetchInterval: 5_000,
staleTime: 5_000,
});
const selectedEvent = useMemo(
@ -242,9 +241,10 @@ export default function CalendarPage() {
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false;
// Close panel if shared calendar was removed while viewing
// Close panel if shared calendar was removed while viewing (skip for invited events)
useEffect(() => {
if (!selectedEvent || allCalendarIds.size === 0) return;
if (selectedEvent.is_invited) return;
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
handlePanelClose();
toast.info('This calendar is no longer available');
@ -331,7 +331,16 @@ export default function CalendarPage() {
const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
// Invited events: if display_calendar_id is set, respect that calendar's visibility;
// otherwise (pending) always show
return events.filter((e) => {
if (e.is_invited) {
return e.display_calendar_id
? visibleCalendarIds.has(e.display_calendar_id)
: true;
}
return visibleCalendarIds.has(e.calendar_id);
});
}, [events, visibleCalendarIds, calendars.length]);
const searchResults = useMemo(() => {
@ -361,22 +370,28 @@ export default function CalendarPage() {
}
};
const calendarEvents = filteredEvents.map((event) => ({
id: String(event.id),
title: event.title,
start: event.start_datetime,
end: event.end_datetime || undefined,
allDay: event.all_day,
color: 'transparent',
editable: permissionMap.get(event.calendar_id) !== 'read_only',
extendedProps: {
is_virtual: event.is_virtual,
is_recurring: event.is_recurring,
parent_event_id: event.parent_event_id,
calendar_id: event.calendar_id,
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
},
}));
const calendarEvents = filteredEvents
// Exclude declined invited events from calendar display
.filter((event) => !(event.is_invited && event.invitation_status === 'declined'))
.map((event) => ({
id: String(event.id),
title: event.title,
start: event.start_datetime,
end: event.end_datetime || undefined,
allDay: event.all_day,
color: 'transparent',
editable: (event.is_invited && !!event.can_modify) || (!event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only'),
extendedProps: {
is_virtual: event.is_virtual,
is_recurring: event.is_recurring,
parent_event_id: event.parent_event_id,
calendar_id: event.calendar_id,
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
is_invited: event.is_invited,
can_modify: event.can_modify,
has_active_invitees: event.has_active_invitees,
},
}));
const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id);
@ -516,29 +531,44 @@ export default function CalendarPage() {
const isMonth = arg.view.type === 'dayGridMonth';
const isAllDay = arg.event.allDay;
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
const isInvited = arg.event.extendedProps.is_invited;
const hasActiveInvitees = arg.event.extendedProps.has_active_invitees;
const calColor = arg.event.extendedProps.calendarColor as string;
const repeatIcon = isRecurring ? (
<Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />
) : null;
// Sync --event-color on the parent FC element so CSS rules (background, hover)
// pick up color changes without requiring a full remount (eventDidMount only fires once).
const syncColor = (el: HTMLElement | null) => {
if (el && calColor) {
const fcEl = el.closest('.umbra-event');
if (fcEl) (fcEl as HTMLElement).style.setProperty('--event-color', calColor);
}
};
const icons = (
<>
{(isInvited || hasActiveInvitees) && <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 (isAllDay) {
return (
<div className="flex items-center gap-1 truncate px-1">
<div ref={syncColor} className="flex items-center gap-1 truncate px-1">
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
{repeatIcon}
{icons}
</div>
);
}
// Timed events in month: dot + title + time right-aligned
return (
<div className="flex items-center gap-1.5 truncate w-full">
<div ref={syncColor} className="flex items-center gap-1.5 truncate w-full">
<span
className="fc-daygrid-event-dot"
style={{ borderColor: 'var(--event-color)' }}
style={{ borderColor: calColor || 'var(--event-color)' }}
/>
<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>
</div>
);
@ -546,10 +576,10 @@ export default function CalendarPage() {
// Week/day view — title on top, time underneath
return (
<div className="flex flex-col overflow-hidden h-full">
<div ref={syncColor} className="flex flex-col overflow-hidden h-full">
<div className="flex items-center gap-1">
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
{repeatIcon}
{icons}
</div>
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
</div>

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut,
} from 'lucide-react';
import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api';
@ -11,9 +11,12 @@ import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarP
import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useEventLock } from '@/hooks/useEventLock';
import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import EventLockBanner from './EventLockBanner';
import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection';
import LeaveEventDialog from './LeaveEventDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
@ -234,6 +237,7 @@ export default function EventDetailPanel({
.filter((m) => m.permission === 'create_modify' || m.permission === 'full_access')
.map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })),
];
const ownedCalendars = calendars.filter((c) => !c.is_system);
const defaultCalendar = calendars.find((c) => c.is_default);
const { data: locations = [] } = useQuery({
@ -251,6 +255,24 @@ export default function EventDetailPanel({
const [lockInfo, setLockInfo] = useState<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, invite, isInviting, respond: respondInvitation,
isResponding, override: overrideInvitation, updateDisplayCalendar,
isUpdatingDisplayCalendar, leave: leaveInvitation, isLeaving,
toggleCanModify, togglingInvitationId,
} = useEventInvitations(parentEventId);
const { connections } = useConnectedUsersSearch();
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const isInvitedEvent = !!event?.is_invited;
const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify;
const existingInviteeIds = useMemo(() => new Set(invitees.map((i) => i.user_id)), [invitees]);
const myInvitationStatus = event?.invitation_status ?? null;
const myInvitationId = event?.invitation_id ?? null;
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating
@ -294,7 +316,7 @@ export default function EventDetailPanel({
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Permission helpers
const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
const canEdit = canModifyAsInvitee || !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
// Reset state when event changes
@ -345,10 +367,13 @@ export default function EventDetailPanel({
end_datetime: endDt,
all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
};
// Invited editors cannot change calendars — omit calendar_id from payload
if (!canModifyAsInvitee) {
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
}
if (event && !isCreating) {
if (editScope) payload.edit_scope = editScope;
@ -418,7 +443,14 @@ export default function EventDetailPanel({
}
if (isRecurring) {
setScopeStep('edit');
// Invited editors can only edit "this" occurrence — skip scope step
if (canModifyAsInvitee) {
setEditScope('this');
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
} else {
setScopeStep('edit');
}
} else {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
@ -579,7 +611,8 @@ export default function EventDetailPanel({
<>
{!event?.is_virtual && (
<>
{canEdit && (
{/* Edit button — own events, shared with edit permission, or can_modify invitees */}
{canEdit && (!isInvitedEvent || canModifyAsInvitee) && (
<Button
variant="ghost"
size="icon"
@ -591,7 +624,20 @@ export default function EventDetailPanel({
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</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 ? (
<Button
variant="ghost"
@ -743,20 +789,22 @@ export default function EventDetailPanel({
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
id="panel-calendar"
value={editState.calendar_id}
onChange={(e) => updateField('calendar_id', e.target.value)}
className="text-xs"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
{!canModifyAsInvitee && (
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
id="panel-calendar"
value={editState.calendar_id}
onChange={(e) => updateField('calendar_id', e.target.value)}
className="text-xs"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
)}
<div className="space-y-1">
<Label htmlFor="panel-location">Location</Label>
<LocationPicker
@ -789,22 +837,24 @@ export default function EventDetailPanel({
</div>
</div>
{/* Recurrence */}
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
id="panel-recurrence"
value={editState.recurrence_type}
onChange={(e) => updateField('recurrence_type', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
{!canModifyAsInvitee && (
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
id="panel-recurrence"
value={editState.recurrence_type}
onChange={(e) => updateField('recurrence_type', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
)}
{editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1">
@ -898,19 +948,47 @@ export default function EventDetailPanel({
<>
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Calendar */}
{/* Calendar — for invited events with accepted/tentative, show picker */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Calendar className="h-3 w-3" />
Calendar
</div>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event?.calendar_name}</span>
</div>
{isInvitedEvent && myInvitationId && (myInvitationStatus === 'accepted' || myInvitationStatus === 'tentative') ? (
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || '#6B7280' }}
/>
<Select
aria-label="Display calendar"
value={event?.display_calendar_id?.toString() || ''}
onChange={(e) => {
const calId = parseInt(e.target.value);
if (calId && myInvitationId) {
updateDisplayCalendar({ invitationId: myInvitationId, calendarId: calId });
}
}}
className="text-xs h-8 py-1"
disabled={isUpdatingDisplayCalendar}
>
{!event?.display_calendar_id && (
<option value="" disabled>Assign to calendar...</option>
)}
{ownedCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
) : (
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event?.calendar_name}</span>
</div>
)}
</div>
{/* Starred */}
@ -988,6 +1066,54 @@ export default function EventDetailPanel({
</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}
isOwner={myPermission === 'owner'}
onToggleCanModify={(invitationId, canModify) =>
toggleCanModify({ invitationId, canModify })
}
togglingInvitationId={togglingInvitationId}
/>
)}
{/* Invite search for event owner/editor */}
{!isInvitedEvent && canEdit && (
<InviteSearch
connections={connections}
existingInviteeIds={existingInviteeIds}
onInvite={(userIds) => invite(userIds)}
isInviting={isInviting}
/>
)}
</>
)}
{/* Updated at */}
{event && !event.is_virtual && (
<div className="pt-2 border-t border-border">
@ -996,6 +1122,23 @@ export default function EventDetailPanel({
</span>
</div>
)}
{/* Leave event dialog */}
{event && isInvitedEvent && myInvitationId && (
<LeaveEventDialog
open={showLeaveDialog}
onClose={() => setShowLeaveDialog(false)}
onConfirm={() => {
leaveInvitation(myInvitationId)
.then(() => onClose())
.catch(() => {})
.finally(() => setShowLeaveDialog(false));
}}
eventTitle={event.title}
isRecurring={!!(event.is_recurring || event.parent_event_id)}
isLeaving={isLeaving}
/>
)}
</>
)}
</div>

View File

@ -0,0 +1,264 @@
import { useState, useMemo } from 'react';
import { Users, UserPlus, Search, X, Pencil, PencilOff } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { EventInvitation, Connection } from '@/types';
// ── Status display helpers ──
const STATUS_CONFIG = {
accepted: { label: 'Going', dotClass: 'bg-green-400', textClass: 'text-green-400' },
tentative: { label: 'Tentative', dotClass: 'bg-amber-400', textClass: 'text-amber-400' },
declined: { label: 'Declined', dotClass: 'bg-red-400', textClass: 'text-red-400' },
pending: { label: 'Pending', dotClass: 'bg-neutral-500', textClass: 'text-muted-foreground' },
} as const;
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.pending;
return (
<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;
isOwner?: boolean;
onToggleCanModify?: (invitationId: number, canModify: boolean) => void;
togglingInvitationId?: number | null;
}
export function InviteeList({ invitees, isRecurringChild, isOwner, onToggleCanModify, togglingInvitationId }: InviteeListProps) {
if (invitees.length === 0) return null;
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
const countLabel = goingCount > 0 ? `${goingCount} going` : null;
return (
<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>
{isOwner && onToggleCanModify && (
<button
type="button"
onClick={() => onToggleCanModify(inv.id, !inv.can_modify)}
disabled={togglingInvitationId === inv.id}
title={inv.can_modify ? 'Remove edit access' : 'Allow editing'}
className={`p-1 rounded transition-colors ${
inv.can_modify
? 'text-accent bg-accent/10'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={inv.can_modify ? { color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' } : undefined}
>
{inv.can_modify ? <Pencil className="h-3 w-3" /> : <PencilOff className="h-3 w-3" />}
</button>
)}
<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)}
onBlur={() => setTimeout(() => setSearch(''), 200)}
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 { toast } from 'sonner';
import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react';
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import api, { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types';
export default function NotificationToaster() {
@ -18,7 +18,7 @@ export default function NotificationToaster() {
const initializedRef = useRef(false);
const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<number>>(new Set());
const respondingRef = useRef<Set<string>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondInviteRef = useRef(respondInvite);
const respondRef = useRef(respond);
@ -30,8 +30,9 @@ export default function NotificationToaster() {
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
if (respondingRef.current.has(requestId)) return;
respondingRef.current.add(requestId);
const key = `conn-${requestId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
// Immediately dismiss the custom toast and show a loading indicator
toast.dismiss(toastId);
@ -54,7 +55,7 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond to request'));
}
} finally {
respondingRef.current.delete(requestId);
respondingRef.current.delete(key);
}
},
[],
@ -63,8 +64,9 @@ export default function NotificationToaster() {
const handleCalendarInviteRespond = useCallback(
async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
if (respondingRef.current.has(inviteId + 100000)) return;
respondingRef.current.add(inviteId + 100000);
const key = `cal-${inviteId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
toast.dismiss(toastId);
const loadingId = toast.loading(
@ -83,11 +85,44 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
}
} finally {
respondingRef.current.delete(inviteId + 100000);
respondingRef.current.delete(key);
}
},
[],
);
const handleEventInviteRespond = useCallback(
async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => {
const key = `event-${invitationId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
toast.dismiss(toastId);
const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' };
const loadingId = toast.loading(`${statusLabel[status]}`);
try {
await api.put(`/event-invitations/${invitationId}/respond`, { status });
toast.dismiss(loadingId);
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
toast.success(`Marked as ${successLabel[status]}`);
markReadRef.current([notificationId]).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
respondingRef.current.delete(key);
}
},
[],
);
// Track unread count changes to force-refetch the list
useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -100,10 +135,28 @@ export default function NotificationToaster() {
useEffect(() => {
if (!notifications.length) return;
// On first load, record the max ID without toasting
// On first load, record the max ID — but still toast actionable unread items
if (!initializedRef.current) {
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
initializedRef.current = true;
// Toast actionable unread notifications on login so the user can act immediately
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']);
const actionable = notifications.filter(
(n) => !n.is_read && actionableTypes.has(n.type),
);
if (actionable.length === 0) return;
// Show at most 3 toasts on first load to avoid flooding
const toShow = actionable.slice(0, 3);
toShow.forEach((notification) => {
if (notification.type === 'connection_request' && notification.source_id) {
showConnectionRequestToast(notification);
} else if (notification.type === 'calendar_invite' && notification.source_id) {
showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification);
}
});
return;
}
@ -126,6 +179,10 @@ export default function NotificationToaster() {
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
}
if (newNotifications.some((n) => n.type === 'event_invite')) {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
}
// Show toasts
newNotifications.forEach((notification) => {
@ -133,6 +190,8 @@ export default function NotificationToaster() {
showConnectionRequestToast(notification);
} else if (notification.type === 'calendar_invite' && notification.source_id) {
showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification);
} else {
toast(notification.title || 'New Notification', {
description: notification.message || undefined,
@ -141,7 +200,7 @@ export default function NotificationToaster() {
});
}
});
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!;
@ -222,5 +281,84 @@ export default function NotificationToaster() {
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
);
};
const resolveInvitationId = async (notification: AppNotification): Promise<number | null> => {
const data = notification.data as Record<string, unknown> | undefined;
// Prefer invitation_id from notification data (populated after flush fix)
if (data?.invitation_id) return data.invitation_id as number;
// Fallback: fetch pending invitations to resolve by event_id
const eventId = data?.event_id as number | undefined;
if (!eventId) return null;
try {
const { data: pending } = await api.get('/event-invitations/pending');
const inv = (pending as Array<{ id: number; event_id: number }>).find(
(p) => p.event_id === eventId,
);
return inv?.id ?? null;
} catch {
return null;
}
};
const handleEventToastClick = async (
notification: AppNotification,
status: 'accepted' | 'tentative' | 'declined',
toastId: string | number,
) => {
const invId = await resolveInvitationId(notification);
if (invId) {
handleEventInviteRespond(invId, status, toastId, notification.id);
} else {
toast.dismiss(toastId);
markReadRef.current([notification.id]).catch(() => {});
toast.success('Already responded');
}
};
const showEventInviteToast = (notification: AppNotification) => {
const inviteKey = `event-invite-${notification.id}`;
toast.custom(
(id) => (
<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={() => handleEventToastClick(notification, 'accepted', id)}
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={() => handleEventToastClick(notification, 'tentative', 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={() => handleEventToastClick(notification, 'declined', 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;
}

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications';
@ -10,7 +10,7 @@ import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import api, { getErrorMessage } from '@/lib/api';
import { ListSkeleton } from '@/components/ui/skeleton';
import type { AppNotification } from '@/types';
@ -20,6 +20,8 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
calendar_invite: { icon: Calendar, color: 'text-purple-400' },
calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' },
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
event_invite: { icon: Calendar, color: 'text-purple-400' },
event_invite_response: { icon: Calendar, color: 'text-green-400' },
info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' },
};
@ -41,6 +43,7 @@ export default function NotificationsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all');
const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null);
// Build a set of pending connection request IDs for quick lookup
const pendingInviteIds = useMemo(
@ -60,6 +63,10 @@ export default function NotificationsPage() {
if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) {
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
}
// Refresh event invitations
if (notifications.some((n) => n.type === 'event_invite' && !n.is_read)) {
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
}
const hasMissing = notifications.some(
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
);
@ -141,6 +148,52 @@ export default function NotificationsPage() {
}
}
};
const handleEventInviteRespond = async (
notification: AppNotification,
status: 'accepted' | 'tentative' | 'declined',
) => {
const data = notification.data as Record<string, unknown> | undefined;
const eventId = data?.event_id as number | undefined;
if (!eventId) return;
setRespondingEventInvite(notification.id);
try {
// Prefer invitation_id from notification data; fallback to pending fetch
let invitationId = data?.invitation_id as number | undefined;
if (!invitationId) {
const { data: pending } = await api.get('/event-invitations/pending');
const inv = (pending as Array<{ id: number; event_id: number }>).find(
(p) => p.event_id === eventId,
);
invitationId = inv?.id;
}
if (invitationId) {
await api.put(`/event-invitations/${invitationId}/respond`, { status });
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
toast.success(`Marked as ${successLabel[status]}`);
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
} else {
toast.success('Already responded');
}
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
setRespondingEventInvite(null);
}
};
const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline
if (
@ -150,6 +203,10 @@ export default function NotificationsPage() {
) {
return;
}
// Don't navigate for unread event invites — let user act inline
if (notification.type === 'event_invite' && !notification.is_read) {
return;
}
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
@ -157,6 +214,10 @@ export default function NotificationsPage() {
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
navigate('/people');
}
// Navigate to Calendar for event-related notifications
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
navigate('/calendar');
}
};
return (
@ -311,6 +372,42 @@ export default function NotificationsPage() {
</Button>
</div>
)}
{/* Event invite actions (inline) */}
{notification.type === 'event_invite' &&
!notification.is_read && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleEventInviteRespond(notification, 'accepted'); }}
disabled={respondingEventInvite === notification.id}
className="gap-1 h-7 text-xs"
>
{respondingEventInvite === notification.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Going
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEventInviteRespond(notification, 'tentative'); }}
disabled={respondingEventInvite === notification.id}
className="h-7 text-xs gap-1 text-amber-400 hover:text-amber-300"
>
<Clock className="h-3 w-3" />
Maybe
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEventInviteRespond(notification, 'declined'); }}
disabled={respondingEventInvite === notification.id}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums">

View File

@ -0,0 +1,144 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { EventInvitation, Connection } from '@/types';
export function useEventInvitations(eventId: number | null) {
const queryClient = useQueryClient();
const inviteesQuery = useQuery({
queryKey: ['event-invitations', eventId],
queryFn: async () => {
const { data } = await api.get<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 updateDisplayCalendarMutation = useMutation({
mutationFn: async ({ invitationId, calendarId }: { invitationId: number; calendarId: number }) => {
const { data } = await api.put(`/event-invitations/${invitationId}/display-calendar`, { calendar_id: calendarId });
return data;
},
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
toast.success('Display calendar updated');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update display calendar'));
},
});
const [togglingInvitationId, setTogglingInvitationId] = useState<number | null>(null);
const toggleCanModifyMutation = useMutation({
mutationFn: async ({ invitationId, canModify }: { invitationId: number; canModify: boolean }) => {
setTogglingInvitationId(invitationId);
const { data } = await api.put(`/event-invitations/${invitationId}/can-modify`, { can_modify: canModify });
return data;
},
onSuccess: () => {
setTogglingInvitationId(null);
queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
},
onError: (error) => {
setTogglingInvitationId(null);
toast.error(getErrorMessage(error, 'Failed to update edit access'));
},
});
const leaveMutation = useMutation({
mutationFn: async (invitationId: number) => {
await api.delete(`/event-invitations/${invitationId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Left event');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to leave event'));
},
});
return {
invitees: inviteesQuery.data ?? [],
isLoadingInvitees: inviteesQuery.isLoading,
invite: inviteMutation.mutateAsync,
isInviting: inviteMutation.isPending,
respond: respondMutation.mutateAsync,
isResponding: respondMutation.isPending,
override: overrideMutation.mutateAsync,
updateDisplayCalendar: updateDisplayCalendarMutation.mutateAsync,
isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending,
leave: leaveMutation.mutateAsync,
isLeaving: leaveMutation.isPending,
toggleCanModify: toggleCanModifyMutation.mutateAsync,
isTogglingCanModify: toggleCanModifyMutation.isPending,
togglingInvitationId,
};
}
export function useConnectedUsersSearch() {
const connectionsQuery = useQuery({
queryKey: ['connections'],
queryFn: async () => {
const { data } = await api.get<Connection[]>('/connections');
return data;
},
staleTime: 30_000,
});
return {
connections: connectionsQuery.data ?? [],
isLoading: connectionsQuery.isLoading,
};
}

View File

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