Implement event invitation feature (invite, RSVP, per-occurrence override, leave)
Full-stack implementation of event invitations allowing users to invite connected contacts to calendar events. Invitees can respond Going/Tentative/Declined, with per-occurrence overrides for recurring series. Invited events appear on the invitee's calendar with a Users icon indicator. LeaveEventDialog replaces delete for invited events. Backend: Migration 054 (2 tables + notification types), EventInvitation model with lazy="raise", service layer, dual-router (events + event-invitations), cascade on disconnect, events/dashboard queries extended with OR for invited events. Frontend: Types, useEventInvitations hook, InviteeSection (view list + RSVP buttons + invite search), LeaveEventDialog, event invite toast with 3 response buttons, calendar eventContent render with Users icon for invited events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bdfd8448b1
commit
8652c9f2ce
116
backend/alembic/versions/054_event_invitations.py
Normal file
116
backend/alembic/versions/054_event_invitations.py
Normal 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')",
|
||||||
|
)
|
||||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router
|
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
|
||||||
from app.jobs.notifications import run_notification_dispatch
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# Import models so Alembic's autogenerate can discover them
|
||||||
@ -22,6 +22,7 @@ from app.models import connection_request as _connection_request_model # noqa:
|
|||||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
from app.models import user_connection as _user_connection_model # noqa: F401
|
||||||
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
||||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||||
|
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -137,6 +138,8 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
|||||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||||
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
||||||
|
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
|
||||||
|
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from app.models.connection_request import ConnectionRequest
|
|||||||
from app.models.user_connection import UserConnection
|
from app.models.user_connection import UserConnection
|
||||||
from app.models.calendar_member import CalendarMember
|
from app.models.calendar_member import CalendarMember
|
||||||
from app.models.event_lock import EventLock
|
from app.models.event_lock import EventLock
|
||||||
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -44,4 +45,6 @@ __all__ = [
|
|||||||
"UserConnection",
|
"UserConnection",
|
||||||
"CalendarMember",
|
"CalendarMember",
|
||||||
"EventLock",
|
"EventLock",
|
||||||
|
"EventInvitation",
|
||||||
|
"EventInvitationOverride",
|
||||||
]
|
]
|
||||||
|
|||||||
70
backend/app/models/event_invitation.py
Normal file
70
backend/app/models/event_invitation.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
from sqlalchemy import (
|
||||||
|
CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||||
|
String, UniqueConstraint, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitation(Base):
|
||||||
|
__tablename__ = "event_invitations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('pending', 'accepted', 'tentative', 'declined')",
|
||||||
|
name="ck_event_invitations_status",
|
||||||
|
),
|
||||||
|
Index("ix_event_invitations_user_status", "user_id", "status"),
|
||||||
|
Index("ix_event_invitations_event_id", "event_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
event_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
invited_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||||
|
invited_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=func.now(), server_default=func.now()
|
||||||
|
)
|
||||||
|
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
|
||||||
|
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||||
|
inviter: Mapped[Optional["User"]] = relationship(
|
||||||
|
foreign_keys=[invited_by], lazy="raise"
|
||||||
|
)
|
||||||
|
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
|
||||||
|
lazy="raise", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationOverride(Base):
|
||||||
|
__tablename__ = "event_invitation_overrides"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('accepted', 'tentative', 'declined')",
|
||||||
|
name="ck_invitation_override_status",
|
||||||
|
),
|
||||||
|
Index("ix_invitation_overrides_lookup", "invitation_id", "occurrence_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
invitation_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("event_invitations.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
occurrence_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
responded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=func.now(), server_default=func.now()
|
||||||
|
)
|
||||||
@ -8,6 +8,7 @@ from app.database import Base
|
|||||||
_NOTIFICATION_TYPES = (
|
_NOTIFICATION_TYPES = (
|
||||||
"connection_request", "connection_accepted", "connection_rejected",
|
"connection_request", "connection_accepted", "connection_rejected",
|
||||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||||
|
"event_invite", "event_invite_response",
|
||||||
"info", "warning", "reminder", "system",
|
"info", "warning", "reminder", "system",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from app.models.reminder import Reminder
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_user, get_current_settings
|
from app.routers.auth import get_current_user, get_current_settings
|
||||||
from app.services.calendar_sharing import get_accessible_calendar_ids
|
from app.services.calendar_sharing import get_accessible_calendar_ids, get_accessible_event_scope
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -35,14 +35,18 @@ async def get_dashboard(
|
|||||||
today = client_date or date.today()
|
today = client_date or date.today()
|
||||||
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
||||||
|
|
||||||
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
|
# Fetch all accessible calendar IDs + invited event IDs
|
||||||
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
|
||||||
# Today's events (exclude parent templates — they are hidden, children are shown)
|
# Today's events (exclude parent templates — they are hidden, children are shown)
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
today_end = datetime.combine(today, datetime.max.time())
|
today_end = datetime.combine(today, datetime.max.time())
|
||||||
events_query = select(CalendarEvent).where(
|
events_query = select(CalendarEvent).where(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
or_(
|
||||||
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||||
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
),
|
||||||
CalendarEvent.start_datetime >= today_start,
|
CalendarEvent.start_datetime >= today_start,
|
||||||
CalendarEvent.start_datetime <= today_end,
|
CalendarEvent.start_datetime <= today_end,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
@ -95,7 +99,11 @@ async def get_dashboard(
|
|||||||
# Starred events — no upper date bound so future events always appear in countdown.
|
# Starred events — no upper date bound so future events always appear in countdown.
|
||||||
# _not_parent_template excludes recurring parent templates (children still show).
|
# _not_parent_template excludes recurring parent templates (children still show).
|
||||||
starred_query = select(CalendarEvent).where(
|
starred_query = select(CalendarEvent).where(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
or_(
|
||||||
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||||
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
),
|
||||||
CalendarEvent.is_starred == True,
|
CalendarEvent.is_starred == True,
|
||||||
CalendarEvent.start_datetime > today_start,
|
CalendarEvent.start_datetime > today_start,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
@ -169,8 +177,8 @@ async def get_upcoming(
|
|||||||
overdue_floor = today - timedelta(days=30)
|
overdue_floor = today - timedelta(days=30)
|
||||||
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
||||||
|
|
||||||
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
|
# Fetch all accessible calendar IDs + invited event IDs
|
||||||
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
|
||||||
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
||||||
todos_query = select(Todo).where(
|
todos_query = select(Todo).where(
|
||||||
@ -182,7 +190,11 @@ async def get_upcoming(
|
|||||||
)
|
)
|
||||||
|
|
||||||
events_query = select(CalendarEvent).where(
|
events_query = select(CalendarEvent).where(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
or_(
|
||||||
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||||
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
),
|
||||||
CalendarEvent.start_datetime >= today_start,
|
CalendarEvent.start_datetime >= today_start,
|
||||||
CalendarEvent.start_datetime <= cutoff_datetime,
|
CalendarEvent.start_datetime <= cutoff_datetime,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
|
|||||||
231
backend/app/routers/event_invitations.py
Normal file
231
backend/app/routers/event_invitations.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
Event invitation endpoints — invite users to events, respond, override per-occurrence, leave.
|
||||||
|
|
||||||
|
Two routers:
|
||||||
|
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
|
||||||
|
- router: mounted at /api/event-invitations for respond/override/delete/pending
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.models.event_invitation import EventInvitation
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.schemas.event_invitation import (
|
||||||
|
EventInvitationCreate,
|
||||||
|
EventInvitationRespond,
|
||||||
|
EventInvitationOverrideCreate,
|
||||||
|
)
|
||||||
|
from app.services.calendar_sharing import get_user_permission
|
||||||
|
from app.services.event_invitation import (
|
||||||
|
send_event_invitations,
|
||||||
|
respond_to_invitation,
|
||||||
|
override_occurrence_status,
|
||||||
|
dismiss_invitation,
|
||||||
|
dismiss_invitation_by_owner,
|
||||||
|
get_event_invitations,
|
||||||
|
get_pending_invitations,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mounted at /api/events — event-scoped invitation endpoints
|
||||||
|
events_router = APIRouter()
|
||||||
|
|
||||||
|
# Mounted at /api/event-invitations — invitation-scoped endpoints
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_event_with_access_check(
|
||||||
|
db: AsyncSession, event_id: int, user_id: int
|
||||||
|
) -> CalendarEvent:
|
||||||
|
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||||
|
)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
# Check calendar access
|
||||||
|
perm = await get_user_permission(db, event.calendar_id, user_id)
|
||||||
|
if perm is not None:
|
||||||
|
return event
|
||||||
|
|
||||||
|
# Check if invitee (also check parent for recurring children)
|
||||||
|
event_ids_to_check = [event_id]
|
||||||
|
if event.parent_event_id:
|
||||||
|
event_ids_to_check.append(event.parent_event_id)
|
||||||
|
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation.id).where(
|
||||||
|
EventInvitation.event_id.in_(event_ids_to_check),
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if inv_result.first() is not None:
|
||||||
|
return event
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event-scoped endpoints (mounted at /api/events) ──
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.post("/{event_id}/invitations", status_code=201)
|
||||||
|
async def invite_to_event(
|
||||||
|
body: EventInvitationCreate,
|
||||||
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||||
|
)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
# Permission check: owner or create_modify+
|
||||||
|
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||||
|
if perm is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if perm not in ("owner", "create_modify", "full_access"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||||
|
|
||||||
|
# For recurring child events, invite to the parent (series)
|
||||||
|
target_event_id = event.parent_event_id if event.parent_event_id else event_id
|
||||||
|
|
||||||
|
invitations = await send_event_invitations(
|
||||||
|
db=db,
|
||||||
|
event_id=target_event_id,
|
||||||
|
user_ids=body.user_ids,
|
||||||
|
invited_by=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"invited": len(invitations), "event_id": target_event_id}
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/{event_id}/invitations")
|
||||||
|
async def list_event_invitations(
|
||||||
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all invitees and their statuses for an event."""
|
||||||
|
event = await _get_event_with_access_check(db, event_id, current_user.id)
|
||||||
|
|
||||||
|
# For recurring children, also fetch parent's invitations
|
||||||
|
target_id = event.parent_event_id if event.parent_event_id else event_id
|
||||||
|
invitations = await get_event_invitations(db, target_id)
|
||||||
|
return invitations
|
||||||
|
|
||||||
|
|
||||||
|
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def my_pending_invitations(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get all pending event invitations for the current user."""
|
||||||
|
return await get_pending_invitations(db, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/respond")
|
||||||
|
async def respond_invitation(
|
||||||
|
body: EventInvitationRespond,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Accept, tentative, or decline an event invitation."""
|
||||||
|
invitation = await respond_to_invitation(
|
||||||
|
db=db,
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
status=body.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response before commit (ORM objects expire after commit)
|
||||||
|
response_data = {
|
||||||
|
"id": invitation.id,
|
||||||
|
"event_id": invitation.event_id,
|
||||||
|
"status": invitation.status,
|
||||||
|
"responded_at": invitation.responded_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/respond/{occurrence_id}")
|
||||||
|
async def override_occurrence(
|
||||||
|
body: EventInvitationOverrideCreate,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
occurrence_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Override invitation status for a specific occurrence of a recurring event."""
|
||||||
|
override = await override_occurrence_status(
|
||||||
|
db=db,
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
occurrence_id=occurrence_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
status=body.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"invitation_id": override.invitation_id,
|
||||||
|
"occurrence_id": override.occurrence_id,
|
||||||
|
"status": override.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{invitation_id}", status_code=204)
|
||||||
|
async def leave_or_revoke_invitation(
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Leave an event (invitee) or revoke an invitation (event owner).
|
||||||
|
Invitees can only delete their own invitations.
|
||||||
|
Event owners can delete any invitation for their events.
|
||||||
|
"""
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation).where(EventInvitation.id == invitation_id)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
if invitation.user_id == current_user.id:
|
||||||
|
# Invitee leaving
|
||||||
|
await dismiss_invitation(db, invitation_id, current_user.id)
|
||||||
|
else:
|
||||||
|
# Check if current user is the event owner
|
||||||
|
event_result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == invitation.event_id)
|
||||||
|
)
|
||||||
|
event = event_result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||||
|
if perm != "owner":
|
||||||
|
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
|
||||||
|
|
||||||
|
await dismiss_invitation_by_owner(db, invitation_id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return None
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional, List, Any, Literal
|
from typing import Optional, List, Any, Literal
|
||||||
|
|
||||||
@ -19,14 +19,21 @@ from app.schemas.calendar_event import (
|
|||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.recurrence import generate_occurrences
|
from app.services.recurrence import generate_occurrences
|
||||||
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, require_permission
|
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
|
||||||
|
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
|
||||||
|
from app.models.event_invitation import EventInvitation
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _event_to_dict(event: CalendarEvent) -> dict:
|
def _event_to_dict(
|
||||||
|
event: CalendarEvent,
|
||||||
|
is_invited: bool = False,
|
||||||
|
invitation_status: str | None = None,
|
||||||
|
invitation_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
||||||
return {
|
d = {
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
"title": event.title,
|
"title": event.title,
|
||||||
"description": event.description,
|
"description": event.description,
|
||||||
@ -46,7 +53,11 @@ def _event_to_dict(event: CalendarEvent) -> dict:
|
|||||||
"original_start": event.original_start,
|
"original_start": event.original_start,
|
||||||
"created_at": event.created_at,
|
"created_at": event.created_at,
|
||||||
"updated_at": event.updated_at,
|
"updated_at": event.updated_at,
|
||||||
|
"is_invited": is_invited,
|
||||||
|
"invitation_status": invitation_status,
|
||||||
|
"invitation_id": invitation_id,
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _birthday_events_for_range(
|
def _birthday_events_for_range(
|
||||||
@ -143,13 +154,20 @@ async def get_events(
|
|||||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||||
are what get displayed on the calendar.
|
are what get displayed on the calendar.
|
||||||
"""
|
"""
|
||||||
# Scope events through calendar ownership + shared memberships
|
# Scope events through calendar ownership + shared memberships + invitations
|
||||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
|
.where(
|
||||||
|
or_(
|
||||||
|
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||||
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exclude parent template rows — they are not directly rendered
|
# Exclude parent template rows — they are not directly rendered
|
||||||
@ -171,7 +189,36 @@ async def get_events(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
events = result.scalars().all()
|
events = result.scalars().all()
|
||||||
|
|
||||||
response: List[dict] = [_event_to_dict(e) for e in events]
|
# Build invitation lookup for the current user
|
||||||
|
invited_event_id_set = set(invited_event_ids)
|
||||||
|
invitation_map: dict[int, tuple[str, int]] = {} # event_id -> (status, invitation_id)
|
||||||
|
if invited_event_ids:
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.id).where(
|
||||||
|
EventInvitation.user_id == current_user.id,
|
||||||
|
EventInvitation.event_id.in_(invited_event_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for eid, status, inv_id in inv_result.all():
|
||||||
|
invitation_map[eid] = (status, inv_id)
|
||||||
|
|
||||||
|
# Get per-occurrence overrides for invited events
|
||||||
|
all_event_ids = [e.id for e in events]
|
||||||
|
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
|
||||||
|
|
||||||
|
response: List[dict] = []
|
||||||
|
for e in events:
|
||||||
|
# Determine if this event is from an invitation
|
||||||
|
parent_id = e.parent_event_id or e.id
|
||||||
|
is_invited = parent_id in invited_event_id_set
|
||||||
|
inv_status = None
|
||||||
|
inv_id = None
|
||||||
|
if is_invited and parent_id in invitation_map:
|
||||||
|
inv_status, inv_id = invitation_map[parent_id]
|
||||||
|
# Check for per-occurrence override
|
||||||
|
if e.id in override_map:
|
||||||
|
inv_status = override_map[e.id]
|
||||||
|
response.append(_event_to_dict(e, is_invited=is_invited, invitation_status=inv_status, invitation_id=inv_id))
|
||||||
|
|
||||||
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
||||||
bday_result = await db.execute(
|
bday_result = await db.execute(
|
||||||
@ -281,14 +328,20 @@ async def get_event(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
invited_set = set(invited_event_ids)
|
||||||
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
.where(
|
.where(
|
||||||
CalendarEvent.id == event_id,
|
CalendarEvent.id == event_id,
|
||||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
or_(
|
||||||
|
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||||
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
|
|||||||
31
backend/app/schemas/event_invitation.py
Normal file
31
backend/app/schemas/event_invitation.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from typing import Literal, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
user_ids: list[int] = Field(..., min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationRespond(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
status: Literal["accepted", "tentative", "declined"]
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationOverrideCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
status: Literal["accepted", "tentative", "declined"]
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
event_id: int
|
||||||
|
user_id: int
|
||||||
|
invited_by: Optional[int]
|
||||||
|
status: str
|
||||||
|
invited_at: datetime
|
||||||
|
responded_at: Optional[datetime]
|
||||||
|
invitee_name: Optional[str] = None
|
||||||
|
invitee_umbral_name: Optional[str] = None
|
||||||
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
from app.models.calendar_member import CalendarMember
|
from app.models.calendar_member import CalendarMember
|
||||||
from app.models.event_lock import EventLock
|
from app.models.event_lock import EventLock
|
||||||
|
from app.models.event_invitation import EventInvitation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -34,6 +35,25 @@ async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[in
|
|||||||
return [r[0] for r in result.all()]
|
return [r[0] for r in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_accessible_event_scope(
|
||||||
|
user_id: int, db: AsyncSession
|
||||||
|
) -> tuple[list[int], list[int]]:
|
||||||
|
"""
|
||||||
|
Returns (calendar_ids, invited_parent_event_ids).
|
||||||
|
calendar_ids: all calendars the user can access (owned + accepted shared).
|
||||||
|
invited_parent_event_ids: event IDs where the user has a non-declined invitation.
|
||||||
|
"""
|
||||||
|
cal_ids = await get_accessible_calendar_ids(user_id, db)
|
||||||
|
invited_result = await db.execute(
|
||||||
|
select(EventInvitation.event_id).where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitation.status != "declined",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invited_event_ids = [r[0] for r in invited_result.all()]
|
||||||
|
return cal_ids, invited_event_ids
|
||||||
|
|
||||||
|
|
||||||
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
|
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
|
||||||
"""
|
"""
|
||||||
Returns "owner" if the user owns the calendar, the permission string
|
Returns "owner" if the user owns the calendar, the permission string
|
||||||
@ -220,6 +240,10 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
|
|||||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up event invitations between the two users
|
||||||
|
from app.services.event_invitation import cascade_event_invitations_on_disconnect
|
||||||
|
await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id)
|
||||||
|
|
||||||
# AC-5: Single aggregation query instead of N per-calendar checks
|
# AC-5: Single aggregation query instead of N per-calendar checks
|
||||||
all_cal_ids = a_cal_ids + b_cal_ids
|
all_cal_ids = a_cal_ids + b_cal_ids
|
||||||
if all_cal_ids:
|
if all_cal_ids:
|
||||||
|
|||||||
389
backend/app/services/event_invitation.py
Normal file
389
backend/app/services/event_invitation.py
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
"""
|
||||||
|
Event invitation service — send, respond, override, dismiss invitations.
|
||||||
|
|
||||||
|
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
|
from app.models.user_connection import UserConnection
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.notification import create_notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_connections(
|
||||||
|
db: AsyncSession, inviter_id: int, user_ids: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""Verify bidirectional connections exist for all invitees. Raises 404 on failure."""
|
||||||
|
if not user_ids:
|
||||||
|
return
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection.connected_user_id).where(
|
||||||
|
UserConnection.user_id == inviter_id,
|
||||||
|
UserConnection.connected_user_id.in_(user_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connected_ids = {r[0] for r in result.all()}
|
||||||
|
missing = set(user_ids) - connected_ids
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=404, detail="One or more users not found in your connections")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_event_invitations(
|
||||||
|
db: AsyncSession,
|
||||||
|
event_id: int,
|
||||||
|
user_ids: list[int],
|
||||||
|
invited_by: int,
|
||||||
|
) -> list[EventInvitation]:
|
||||||
|
"""
|
||||||
|
Bulk-insert invitations for an event. Skips self-invites and existing invitations.
|
||||||
|
Creates in-app notifications for each invitee.
|
||||||
|
"""
|
||||||
|
# Remove self from list
|
||||||
|
user_ids = [uid for uid in user_ids if uid != invited_by]
|
||||||
|
if not user_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot invite yourself")
|
||||||
|
|
||||||
|
# Validate connections
|
||||||
|
await validate_connections(db, invited_by, user_ids)
|
||||||
|
|
||||||
|
# Check existing invitations to skip duplicates
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(EventInvitation.user_id).where(
|
||||||
|
EventInvitation.event_id == event_id,
|
||||||
|
EventInvitation.user_id.in_(user_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_ids = {r[0] for r in existing_result.all()}
|
||||||
|
|
||||||
|
# Cap: max 20 pending invitations per event
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(EventInvitation.id).where(EventInvitation.event_id == event_id)
|
||||||
|
)
|
||||||
|
current_count = len(count_result.all())
|
||||||
|
new_ids = [uid for uid in user_ids if uid not in existing_ids]
|
||||||
|
if current_count + len(new_ids) > 20:
|
||||||
|
raise HTTPException(status_code=400, detail="Maximum 20 invitations per event")
|
||||||
|
|
||||||
|
if not new_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Fetch event title for notifications
|
||||||
|
event_result = await db.execute(
|
||||||
|
select(CalendarEvent.title, CalendarEvent.start_datetime).where(
|
||||||
|
CalendarEvent.id == event_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
event_row = event_result.one_or_none()
|
||||||
|
event_title = event_row[0] if event_row else "an event"
|
||||||
|
event_start = event_row[1] if event_row else None
|
||||||
|
|
||||||
|
# Fetch inviter's name
|
||||||
|
inviter_settings = await db.execute(
|
||||||
|
select(Settings.preferred_name).where(Settings.user_id == invited_by)
|
||||||
|
)
|
||||||
|
inviter_name_row = inviter_settings.one_or_none()
|
||||||
|
inviter_name = inviter_name_row[0] if inviter_name_row and inviter_name_row[0] else "Someone"
|
||||||
|
|
||||||
|
invitations = []
|
||||||
|
for uid in new_ids:
|
||||||
|
inv = EventInvitation(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=uid,
|
||||||
|
invited_by=invited_by,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
invitations.append(inv)
|
||||||
|
|
||||||
|
# Create notification
|
||||||
|
start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else ""
|
||||||
|
await create_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=uid,
|
||||||
|
type="event_invite",
|
||||||
|
title="Event Invitation",
|
||||||
|
message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""),
|
||||||
|
data={"event_id": event_id, "event_title": event_title},
|
||||||
|
source_type="event_invitation",
|
||||||
|
source_id=event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return invitations
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_invitation(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
) -> EventInvitation:
|
||||||
|
"""Update invitation status. Returns the updated invitation."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventInvitation)
|
||||||
|
.options(selectinload(EventInvitation.event))
|
||||||
|
.where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
# Build response data before modifying
|
||||||
|
event_title = invitation.event.title
|
||||||
|
old_status = invitation.status
|
||||||
|
|
||||||
|
invitation.status = status
|
||||||
|
invitation.responded_at = datetime.now()
|
||||||
|
|
||||||
|
# Notify the inviter
|
||||||
|
if invitation.invited_by:
|
||||||
|
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
|
||||||
|
# Fetch responder name
|
||||||
|
responder_settings = await db.execute(
|
||||||
|
select(Settings.preferred_name).where(Settings.user_id == user_id)
|
||||||
|
)
|
||||||
|
responder_row = responder_settings.one_or_none()
|
||||||
|
responder_name = responder_row[0] if responder_row and responder_row[0] else "Someone"
|
||||||
|
|
||||||
|
await create_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=invitation.invited_by,
|
||||||
|
type="event_invite_response",
|
||||||
|
title="Event RSVP",
|
||||||
|
message=f"{responder_name} is {status_label.get(status, status)} for {event_title}",
|
||||||
|
data={"event_id": invitation.event_id, "status": status},
|
||||||
|
source_type="event_invitation",
|
||||||
|
source_id=invitation.event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return invitation
|
||||||
|
|
||||||
|
|
||||||
|
async def override_occurrence_status(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
occurrence_id: int,
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
) -> EventInvitationOverride:
|
||||||
|
"""Create or update a per-occurrence status override."""
|
||||||
|
# Verify invitation belongs to user
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation).where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
# Verify occurrence belongs to the invited event's series
|
||||||
|
occ_result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == occurrence_id)
|
||||||
|
)
|
||||||
|
occurrence = occ_result.scalar_one_or_none()
|
||||||
|
if not occurrence:
|
||||||
|
raise HTTPException(status_code=404, detail="Occurrence not found")
|
||||||
|
|
||||||
|
# Occurrence must be the event itself OR a child of the invited event
|
||||||
|
if occurrence.id != invitation.event_id and occurrence.parent_event_id != invitation.event_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Occurrence does not belong to this event series")
|
||||||
|
|
||||||
|
# Upsert override
|
||||||
|
existing = await db.execute(
|
||||||
|
select(EventInvitationOverride).where(
|
||||||
|
EventInvitationOverride.invitation_id == invitation_id,
|
||||||
|
EventInvitationOverride.occurrence_id == occurrence_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override = existing.scalar_one_or_none()
|
||||||
|
if override:
|
||||||
|
override.status = status
|
||||||
|
override.responded_at = datetime.now()
|
||||||
|
else:
|
||||||
|
override = EventInvitationOverride(
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
occurrence_id=occurrence_id,
|
||||||
|
status=status,
|
||||||
|
responded_at=datetime.now(),
|
||||||
|
)
|
||||||
|
db.add(override)
|
||||||
|
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss_invitation(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an invitation (invitee leaving or owner revoking)."""
|
||||||
|
result = await db.execute(
|
||||||
|
delete(EventInvitation).where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss_invitation_by_owner(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an invitation by the event owner (revoking)."""
|
||||||
|
result = await db.execute(
|
||||||
|
delete(EventInvitation).where(EventInvitation.id == invitation_id)
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_invitations(
|
||||||
|
db: AsyncSession,
|
||||||
|
event_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get all invitations for an event with invitee names."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitation,
|
||||||
|
Settings.preferred_name,
|
||||||
|
User.umbral_name,
|
||||||
|
)
|
||||||
|
.join(User, EventInvitation.user_id == User.id)
|
||||||
|
.outerjoin(Settings, Settings.user_id == User.id)
|
||||||
|
.where(EventInvitation.event_id == event_id)
|
||||||
|
.order_by(EventInvitation.invited_at.asc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": inv.id,
|
||||||
|
"event_id": inv.event_id,
|
||||||
|
"user_id": inv.user_id,
|
||||||
|
"invited_by": inv.invited_by,
|
||||||
|
"status": inv.status,
|
||||||
|
"invited_at": inv.invited_at,
|
||||||
|
"responded_at": inv.responded_at,
|
||||||
|
"invitee_name": preferred_name or umbral_name or "Unknown",
|
||||||
|
"invitee_umbral_name": umbral_name or "Unknown",
|
||||||
|
}
|
||||||
|
for inv, preferred_name, umbral_name in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invited_event_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return event IDs where user has a non-declined invitation."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventInvitation.event_id).where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitation.status != "declined",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [r[0] for r in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_invitations(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return pending invitations for the current user."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitation,
|
||||||
|
CalendarEvent.title,
|
||||||
|
CalendarEvent.start_datetime,
|
||||||
|
Settings.preferred_name,
|
||||||
|
)
|
||||||
|
.join(CalendarEvent, EventInvitation.event_id == CalendarEvent.id)
|
||||||
|
.outerjoin(
|
||||||
|
User, EventInvitation.invited_by == User.id
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Settings, Settings.user_id == User.id
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitation.status == "pending",
|
||||||
|
)
|
||||||
|
.order_by(EventInvitation.invited_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": inv.id,
|
||||||
|
"event_id": inv.event_id,
|
||||||
|
"event_title": title,
|
||||||
|
"event_start": start_dt,
|
||||||
|
"invited_by_name": inviter_name or "Someone",
|
||||||
|
"invited_at": inv.invited_at,
|
||||||
|
"status": inv.status,
|
||||||
|
}
|
||||||
|
for inv, title, start_dt, inviter_name in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invitation_overrides_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
event_ids: list[int],
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
For a list of occurrence event IDs, return a map of occurrence_id -> override status.
|
||||||
|
Used to annotate event listings with per-occurrence invitation status.
|
||||||
|
"""
|
||||||
|
if not event_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitationOverride.occurrence_id,
|
||||||
|
EventInvitationOverride.status,
|
||||||
|
)
|
||||||
|
.join(EventInvitation, EventInvitationOverride.invitation_id == EventInvitation.id)
|
||||||
|
.where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitationOverride.occurrence_id.in_(event_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {r[0]: r[1] for r in result.all()}
|
||||||
|
|
||||||
|
|
||||||
|
async def cascade_event_invitations_on_disconnect(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_a_id: int,
|
||||||
|
user_b_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Delete event invitations between two users when connection is severed."""
|
||||||
|
# Delete invitations where A invited B
|
||||||
|
await db.execute(
|
||||||
|
delete(EventInvitation).where(
|
||||||
|
EventInvitation.invited_by == user_a_id,
|
||||||
|
EventInvitation.user_id == user_b_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Delete invitations where B invited A
|
||||||
|
await db.execute(
|
||||||
|
delete(EventInvitation).where(
|
||||||
|
EventInvitation.invited_by == user_b_id,
|
||||||
|
EventInvitation.user_id == user_a_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -111,6 +111,13 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Event invite — rate-limited to prevent invite spam (reuse cal_invite_limit zone)
|
||||||
|
location ~ /api/events/\d+/invitations$ {
|
||||||
|
limit_req zone=cal_invite_limit burst=3 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
# Calendar sync — rate-limited to prevent excessive polling
|
# Calendar sync — rate-limited to prevent excessive polling
|
||||||
location /api/shared-calendars/sync {
|
location /api/shared-calendars/sync {
|
||||||
limit_req zone=cal_sync_limit burst=5 nodelay;
|
limit_req zone=cal_sync_limit burst=5 nodelay;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
|
|||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import enAuLocale from '@fullcalendar/core/locales/en-au';
|
import enAuLocale from '@fullcalendar/core/locales/en-au';
|
||||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
|
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
|
||||||
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat, Users } from 'lucide-react';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
|
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
|
||||||
@ -361,22 +361,26 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarEvents = filteredEvents.map((event) => ({
|
const calendarEvents = filteredEvents
|
||||||
id: String(event.id),
|
// Exclude declined invited events from calendar display
|
||||||
title: event.title,
|
.filter((event) => !(event.is_invited && event.invitation_status === 'declined'))
|
||||||
start: event.start_datetime,
|
.map((event) => ({
|
||||||
end: event.end_datetime || undefined,
|
id: String(event.id),
|
||||||
allDay: event.all_day,
|
title: event.title,
|
||||||
color: 'transparent',
|
start: event.start_datetime,
|
||||||
editable: permissionMap.get(event.calendar_id) !== 'read_only',
|
end: event.end_datetime || undefined,
|
||||||
extendedProps: {
|
allDay: event.all_day,
|
||||||
is_virtual: event.is_virtual,
|
color: 'transparent',
|
||||||
is_recurring: event.is_recurring,
|
editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only',
|
||||||
parent_event_id: event.parent_event_id,
|
extendedProps: {
|
||||||
calendar_id: event.calendar_id,
|
is_virtual: event.is_virtual,
|
||||||
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
|
is_recurring: event.is_recurring,
|
||||||
},
|
parent_event_id: event.parent_event_id,
|
||||||
}));
|
calendar_id: event.calendar_id,
|
||||||
|
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||||
|
is_invited: event.is_invited,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const handleEventClick = (info: EventClickArg) => {
|
const handleEventClick = (info: EventClickArg) => {
|
||||||
const event = events.find((e) => String(e.id) === info.event.id);
|
const event = events.find((e) => String(e.id) === info.event.id);
|
||||||
@ -516,17 +520,21 @@ export default function CalendarPage() {
|
|||||||
const isMonth = arg.view.type === 'dayGridMonth';
|
const isMonth = arg.view.type === 'dayGridMonth';
|
||||||
const isAllDay = arg.event.allDay;
|
const isAllDay = arg.event.allDay;
|
||||||
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
|
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
|
||||||
|
const isInvited = arg.event.extendedProps.is_invited;
|
||||||
|
|
||||||
const repeatIcon = isRecurring ? (
|
const icons = (
|
||||||
<Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />
|
<>
|
||||||
) : null;
|
{isInvited && <Users className="h-2.5 w-2.5 shrink-0 opacity-60" />}
|
||||||
|
{isRecurring && <Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (isMonth) {
|
if (isMonth) {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 truncate px-1">
|
<div className="flex items-center gap-1 truncate px-1">
|
||||||
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
||||||
{repeatIcon}
|
{icons}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -538,7 +546,7 @@ export default function CalendarPage() {
|
|||||||
style={{ borderColor: 'var(--event-color)' }}
|
style={{ borderColor: 'var(--event-color)' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
||||||
{repeatIcon}
|
{icons}
|
||||||
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
|
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -549,7 +557,7 @@ export default function CalendarPage() {
|
|||||||
<div className="flex flex-col overflow-hidden h-full">
|
<div className="flex flex-col overflow-hidden h-full">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
|
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
|
||||||
{repeatIcon}
|
{icons}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
|
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
|
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
@ -11,9 +11,12 @@ import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarP
|
|||||||
import { useCalendars } from '@/hooks/useCalendars';
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
import { useEventLock } from '@/hooks/useEventLock';
|
import { useEventLock } from '@/hooks/useEventLock';
|
||||||
|
import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations';
|
||||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||||
import CopyableField from '@/components/shared/CopyableField';
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
import EventLockBanner from './EventLockBanner';
|
import EventLockBanner from './EventLockBanner';
|
||||||
|
import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection';
|
||||||
|
import LeaveEventDialog from './LeaveEventDialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
@ -251,6 +254,20 @@ export default function EventDetailPanel({
|
|||||||
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
// Event invitation hooks
|
||||||
|
const eventNumericId = event && typeof event.id === 'number' ? event.id : null;
|
||||||
|
const parentEventId = event?.parent_event_id ?? eventNumericId;
|
||||||
|
const {
|
||||||
|
invitees, isLoadingInvitees, invite, isInviting, respond: respondInvitation,
|
||||||
|
isResponding, override: overrideInvitation, leave: leaveInvitation, isLeaving,
|
||||||
|
} = useEventInvitations(parentEventId);
|
||||||
|
const { connections } = useConnectedUsersSearch();
|
||||||
|
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
|
||||||
|
|
||||||
|
const isInvitedEvent = !!event?.is_invited;
|
||||||
|
const myInvitationStatus = event?.invitation_status ?? null;
|
||||||
|
const myInvitationId = event?.invitation_id ?? null;
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(isCreating);
|
const [isEditing, setIsEditing] = useState(isCreating);
|
||||||
const [editState, setEditState] = useState<EditState>(() =>
|
const [editState, setEditState] = useState<EditState>(() =>
|
||||||
isCreating
|
isCreating
|
||||||
@ -579,7 +596,8 @@ export default function EventDetailPanel({
|
|||||||
<>
|
<>
|
||||||
{!event?.is_virtual && (
|
{!event?.is_virtual && (
|
||||||
<>
|
<>
|
||||||
{canEdit && (
|
{/* Edit button — only for own events or shared with edit permission */}
|
||||||
|
{canEdit && !isInvitedEvent && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -591,7 +609,20 @@ export default function EventDetailPanel({
|
|||||||
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{/* Leave button for invited events */}
|
||||||
|
{isInvitedEvent && myInvitationId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setShowLeaveDialog(true)}
|
||||||
|
title="Leave event"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Delete button for own events */}
|
||||||
|
{canDelete && !isInvitedEvent && (
|
||||||
confirmingDelete ? (
|
confirmingDelete ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -988,6 +1019,49 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invitee section — view mode */}
|
||||||
|
{event && !event.is_virtual && (
|
||||||
|
<>
|
||||||
|
{/* RSVP buttons for invitees */}
|
||||||
|
{isInvitedEvent && myInvitationId && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
Your RSVP
|
||||||
|
</div>
|
||||||
|
<RsvpButtons
|
||||||
|
currentStatus={myInvitationStatus || 'pending'}
|
||||||
|
onRespond={(status) => {
|
||||||
|
if (event.parent_event_id && eventNumericId) {
|
||||||
|
overrideInvitation({ invitationId: myInvitationId, occurrenceId: eventNumericId, status });
|
||||||
|
} else {
|
||||||
|
respondInvitation({ invitationId: myInvitationId, status });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isResponding={isResponding}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invitee list */}
|
||||||
|
{invitees.length > 0 && (
|
||||||
|
<InviteeList
|
||||||
|
invitees={invitees}
|
||||||
|
isRecurringChild={!!event.parent_event_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invite search for event owner/editor */}
|
||||||
|
{!isInvitedEvent && canEdit && (
|
||||||
|
<InviteSearch
|
||||||
|
connections={connections}
|
||||||
|
existingInviteeIds={new Set(invitees.map((i) => i.user_id))}
|
||||||
|
onInvite={(userIds) => invite(userIds)}
|
||||||
|
isInviting={isInviting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Updated at */}
|
{/* Updated at */}
|
||||||
{event && !event.is_virtual && (
|
{event && !event.is_virtual && (
|
||||||
<div className="pt-2 border-t border-border">
|
<div className="pt-2 border-t border-border">
|
||||||
@ -996,6 +1070,23 @@ export default function EventDetailPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Leave event dialog */}
|
||||||
|
{event && isInvitedEvent && myInvitationId && (
|
||||||
|
<LeaveEventDialog
|
||||||
|
open={showLeaveDialog}
|
||||||
|
onClose={() => setShowLeaveDialog(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
leaveInvitation(myInvitationId).then(() => {
|
||||||
|
setShowLeaveDialog(false);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
eventTitle={event.title}
|
||||||
|
isRecurring={!!(event.is_recurring || event.parent_event_id)}
|
||||||
|
isLeaving={isLeaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
245
frontend/src/components/calendar/InviteeSection.tsx
Normal file
245
frontend/src/components/calendar/InviteeSection.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Users, UserPlus, Search, X } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import type { EventInvitation, Connection } from '@/types';
|
||||||
|
|
||||||
|
// ── Status display helpers ──
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
accepted: { label: 'Going', dotClass: 'bg-green-400', textClass: 'text-green-400' },
|
||||||
|
tentative: { label: 'Tentative', dotClass: 'bg-amber-400', textClass: 'text-amber-400' },
|
||||||
|
declined: { label: 'Declined', dotClass: 'bg-red-400', textClass: 'text-red-400' },
|
||||||
|
pending: { label: 'Pending', dotClass: 'bg-neutral-500', textClass: 'text-muted-foreground' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.pending;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-[7px] h-[7px] rounded-full ${config.dotClass}`} />
|
||||||
|
<span className={`text-xs ${config.textClass}`}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarCircle({ name }: { name: string }) {
|
||||||
|
const letter = name?.charAt(0)?.toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View Mode: InviteeList ──
|
||||||
|
|
||||||
|
interface InviteeListProps {
|
||||||
|
invitees: EventInvitation[];
|
||||||
|
isRecurringChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
|
||||||
|
if (invitees.length === 0) return null;
|
||||||
|
|
||||||
|
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
|
||||||
|
const countLabel = goingCount > 0 ? `${goingCount} going` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Invitees
|
||||||
|
</div>
|
||||||
|
{countLabel && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">{countLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{invitees.map((inv) => (
|
||||||
|
<div key={inv.id} className="flex items-center gap-2 py-1">
|
||||||
|
<AvatarCircle name={inv.invitee_name} />
|
||||||
|
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span>
|
||||||
|
<StatusBadge status={inv.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isRecurringChild && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Status shown for this occurrence
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Mode: InviteSearch ──
|
||||||
|
|
||||||
|
interface InviteSearchProps {
|
||||||
|
connections: Connection[];
|
||||||
|
existingInviteeIds: Set<number>;
|
||||||
|
onInvite: (userIds: number[]) => void;
|
||||||
|
isInviting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteSearch({ connections, existingInviteeIds, onInvite, isInviting }: InviteSearchProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
if (!search.trim()) return [];
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return connections
|
||||||
|
.filter((c) =>
|
||||||
|
!existingInviteeIds.has(c.connected_user_id) &&
|
||||||
|
!selectedIds.includes(c.connected_user_id) &&
|
||||||
|
(
|
||||||
|
(c.connected_preferred_name?.toLowerCase().includes(q)) ||
|
||||||
|
c.connected_umbral_name.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.slice(0, 6);
|
||||||
|
}, [search, connections, existingInviteeIds, selectedIds]);
|
||||||
|
|
||||||
|
const selectedConnections = connections.filter((c) => selectedIds.includes(c.connected_user_id));
|
||||||
|
|
||||||
|
const handleAdd = (userId: number) => {
|
||||||
|
setSelectedIds((prev) => [...prev, userId]);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (userId: number) => {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => id !== userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
onInvite(selectedIds);
|
||||||
|
setSelectedIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<UserPlus className="h-3 w-3" />
|
||||||
|
Invite People
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search connections..."
|
||||||
|
className="h-8 pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
{search.trim() && searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||||
|
{searchResults.map((conn) => (
|
||||||
|
<button
|
||||||
|
key={conn.connected_user_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAdd(conn.connected_user_id)}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm truncate block">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
|
||||||
|
{conn.connected_preferred_name && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">@{conn.connected_umbral_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{search.trim() && searchResults.length === 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">No connections found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected invitees */}
|
||||||
|
{selectedConnections.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedConnections.map((conn) => (
|
||||||
|
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
|
||||||
|
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
|
||||||
|
<span className="text-sm flex-1 truncate">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(conn.connected_user_id)}
|
||||||
|
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isInviting}
|
||||||
|
className="w-full mt-1"
|
||||||
|
>
|
||||||
|
{isInviting ? 'Sending...' : `Send ${selectedIds.length === 1 ? 'Invite' : `${selectedIds.length} Invites`}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSVP Buttons (for invitee view) ──
|
||||||
|
|
||||||
|
interface RsvpButtonsProps {
|
||||||
|
currentStatus: string;
|
||||||
|
onRespond: (status: 'accepted' | 'tentative' | 'declined') => void;
|
||||||
|
isResponding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RsvpButtons({ currentStatus, onRespond, isResponding }: RsvpButtonsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRespond('accepted')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
currentStatus === 'accepted'
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'text-muted-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Going
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRespond('tentative')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
currentStatus === 'tentative'
|
||||||
|
? 'bg-amber-500/20 text-amber-400'
|
||||||
|
: 'text-muted-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Maybe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRespond('declined')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
currentStatus === 'declined'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: 'text-muted-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/calendar/LeaveEventDialog.tsx
Normal file
48
frontend/src/components/calendar/LeaveEventDialog.tsx
Normal 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 “{eventTitle}”. You won’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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react';
|
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { AppNotification } from '@/types';
|
import type { AppNotification } from '@/types';
|
||||||
|
|
||||||
export default function NotificationToaster() {
|
export default function NotificationToaster() {
|
||||||
@ -88,6 +88,38 @@ export default function NotificationToaster() {
|
|||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const handleEventInviteRespond = useCallback(
|
||||||
|
async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => {
|
||||||
|
if (respondingRef.current.has(invitationId + 200000)) return;
|
||||||
|
respondingRef.current.add(invitationId + 200000);
|
||||||
|
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' };
|
||||||
|
const loadingId = toast.loading(`${statusLabel[status]}…`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/event-invitations/${invitationId}/respond`, { status });
|
||||||
|
toast.dismiss(loadingId);
|
||||||
|
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
|
||||||
|
toast.success(`Marked as ${successLabel[status]}`);
|
||||||
|
markReadRef.current([notificationId]).catch(() => {});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.dismiss(loadingId);
|
||||||
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
toast.success('Already responded');
|
||||||
|
markReadRef.current([notificationId]).catch(() => {});
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
respondingRef.current.delete(invitationId + 200000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Track unread count changes to force-refetch the list
|
// Track unread count changes to force-refetch the list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||||
@ -126,6 +158,10 @@ export default function NotificationToaster() {
|
|||||||
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
||||||
}
|
}
|
||||||
|
if (newNotifications.some((n) => n.type === 'event_invite')) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
}
|
||||||
|
|
||||||
// Show toasts
|
// Show toasts
|
||||||
newNotifications.forEach((notification) => {
|
newNotifications.forEach((notification) => {
|
||||||
@ -133,6 +169,8 @@ export default function NotificationToaster() {
|
|||||||
showConnectionRequestToast(notification);
|
showConnectionRequestToast(notification);
|
||||||
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
||||||
showCalendarInviteToast(notification);
|
showCalendarInviteToast(notification);
|
||||||
|
} else if (notification.type === 'event_invite' && notification.data) {
|
||||||
|
showEventInviteToast(notification);
|
||||||
} else {
|
} else {
|
||||||
toast(notification.title || 'New Notification', {
|
toast(notification.title || 'New Notification', {
|
||||||
description: notification.message || undefined,
|
description: notification.message || undefined,
|
||||||
@ -141,7 +179,7 @@ export default function NotificationToaster() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
|
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
|
||||||
|
|
||||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||||
const requestId = notification.source_id!;
|
const requestId = notification.source_id!;
|
||||||
@ -222,5 +260,104 @@ export default function NotificationToaster() {
|
|||||||
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
|
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const showEventInviteToast = (notification: AppNotification) => {
|
||||||
|
const data = notification.data as Record<string, unknown>;
|
||||||
|
const eventId = data?.event_id as number;
|
||||||
|
// Use source_id as a stable ID for dedup (it's the event_id)
|
||||||
|
const inviteKey = `event-invite-${notification.id}`;
|
||||||
|
|
||||||
|
// We need the invitation ID to respond — fetch pending invitations
|
||||||
|
// For now, use a simplified approach: the toast will query pending invitations
|
||||||
|
toast.custom(
|
||||||
|
(id) => (
|
||||||
|
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
|
||||||
|
<Calendar className="h-4 w-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">Event Invitation</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{notification.message || 'You were invited to an event'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
// Fetch the invitation ID from pending invitations
|
||||||
|
try {
|
||||||
|
const { data: pending } = await api.get('/event-invitations/pending');
|
||||||
|
const inv = (pending as Array<{ id: number; event_id: number }>).find(
|
||||||
|
(p) => p.event_id === eventId
|
||||||
|
);
|
||||||
|
if (inv) {
|
||||||
|
handleEventInviteRespond(inv.id, 'accepted', id, notification.id);
|
||||||
|
} else {
|
||||||
|
toast.dismiss(id);
|
||||||
|
markReadRef.current([notification.id]).catch(() => {});
|
||||||
|
toast.success('Already responded');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.error('Failed to respond');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const { data: pending } = await api.get('/event-invitations/pending');
|
||||||
|
const inv = (pending as Array<{ id: number; event_id: number }>).find(
|
||||||
|
(p) => p.event_id === eventId
|
||||||
|
);
|
||||||
|
if (inv) {
|
||||||
|
handleEventInviteRespond(inv.id, 'tentative', id, notification.id);
|
||||||
|
} else {
|
||||||
|
toast.dismiss(id);
|
||||||
|
markReadRef.current([notification.id]).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.dismiss(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-amber-500/15 text-amber-400 hover:bg-amber-500/25 transition-colors"
|
||||||
|
>
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
Tentative
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const { data: pending } = await api.get('/event-invitations/pending');
|
||||||
|
const inv = (pending as Array<{ id: number; event_id: number }>).find(
|
||||||
|
(p) => p.event_id === eventId
|
||||||
|
);
|
||||||
|
if (inv) {
|
||||||
|
handleEventInviteRespond(inv.id, 'declined', id, notification.id);
|
||||||
|
} else {
|
||||||
|
toast.dismiss(id);
|
||||||
|
markReadRef.current([notification.id]).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.dismiss(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ id: inviteKey, duration: 30000 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
105
frontend/src/hooks/useEventInvitations.ts
Normal file
105
frontend/src/hooks/useEventInvitations.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { EventInvitation, Connection } from '@/types';
|
||||||
|
|
||||||
|
export function useEventInvitations(eventId: number | null) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const inviteesQuery = useQuery({
|
||||||
|
queryKey: ['event-invitations', eventId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<EventInvitation[]>(`/events/${eventId}/invitations`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteMutation = useMutation({
|
||||||
|
mutationFn: async (userIds: number[]) => {
|
||||||
|
const { data } = await api.post(`/events/${eventId}/invitations`, { user_ids: userIds });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
toast.success('Invitation sent');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to send invitation'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondMutation = useMutation({
|
||||||
|
mutationFn: async ({ invitationId, status }: { invitationId: number; status: 'accepted' | 'tentative' | 'declined' }) => {
|
||||||
|
const { data } = await api.put(`/event-invitations/${invitationId}/respond`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to respond to invitation'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const overrideMutation = useMutation({
|
||||||
|
mutationFn: async ({ invitationId, occurrenceId, status }: { invitationId: number; occurrenceId: number; status: 'accepted' | 'tentative' | 'declined' }) => {
|
||||||
|
const { data } = await api.put(`/event-invitations/${invitationId}/respond/${occurrenceId}`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to update status'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveMutation = useMutation({
|
||||||
|
mutationFn: async (invitationId: number) => {
|
||||||
|
await api.delete(`/event-invitations/${invitationId}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
|
toast.success('Left event');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to leave event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitees: inviteesQuery.data ?? [],
|
||||||
|
isLoadingInvitees: inviteesQuery.isLoading,
|
||||||
|
invite: inviteMutation.mutateAsync,
|
||||||
|
isInviting: inviteMutation.isPending,
|
||||||
|
respond: respondMutation.mutateAsync,
|
||||||
|
isResponding: respondMutation.isPending,
|
||||||
|
override: overrideMutation.mutateAsync,
|
||||||
|
leave: leaveMutation.mutateAsync,
|
||||||
|
isLeaving: leaveMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnectedUsersSearch() {
|
||||||
|
const connectionsQuery = useQuery({
|
||||||
|
queryKey: ['connections'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Connection[]>('/connections');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections: connectionsQuery.data ?? [],
|
||||||
|
isLoading: connectionsQuery.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -112,6 +112,9 @@ export interface CalendarEvent {
|
|||||||
parent_event_id?: number | null;
|
parent_event_id?: number | null;
|
||||||
is_recurring?: boolean;
|
is_recurring?: boolean;
|
||||||
original_start?: string | null;
|
original_start?: string | null;
|
||||||
|
is_invited?: boolean;
|
||||||
|
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
||||||
|
invitation_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -486,6 +489,30 @@ export interface CalendarInvite {
|
|||||||
invited_at: string;
|
invited_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event Invitations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EventInvitation {
|
||||||
|
id: number;
|
||||||
|
event_id: number;
|
||||||
|
user_id: number;
|
||||||
|
invited_by: number | null;
|
||||||
|
status: 'pending' | 'accepted' | 'tentative' | 'declined';
|
||||||
|
invited_at: string;
|
||||||
|
responded_at: string | null;
|
||||||
|
invitee_name: string;
|
||||||
|
invitee_umbral_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingEventInvitation {
|
||||||
|
id: number;
|
||||||
|
event_id: number;
|
||||||
|
event_title: string;
|
||||||
|
event_start: string;
|
||||||
|
invited_by_name: string;
|
||||||
|
invited_at: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventLockInfo {
|
export interface EventLockInfo {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
locked_by_name: string | null;
|
locked_by_name: string | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user