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.database import engine
|
||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
|
||||
from app.jobs.notifications import run_notification_dispatch
|
||||
|
||||
# Import models so Alembic's autogenerate can discover them
|
||||
@ -22,6 +22,7 @@ from app.models import connection_request as _connection_request_model # noqa:
|
||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
||||
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -137,6 +138,8 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
||||
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
|
||||
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -20,6 +20,7 @@ from app.models.connection_request import ConnectionRequest
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -44,4 +45,6 @@ __all__ = [
|
||||
"UserConnection",
|
||||
"CalendarMember",
|
||||
"EventLock",
|
||||
"EventInvitation",
|
||||
"EventInvitationOverride",
|
||||
]
|
||||
|
||||
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 = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ from app.models.reminder import Reminder
|
||||
from app.models.project import Project
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user, get_current_settings
|
||||
from app.services.calendar_sharing import get_accessible_calendar_ids
|
||||
from app.services.calendar_sharing import get_accessible_calendar_ids, get_accessible_event_scope
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -35,14 +35,18 @@ async def get_dashboard(
|
||||
today = client_date or date.today()
|
||||
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
||||
|
||||
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
|
||||
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
# Fetch all accessible calendar IDs + invited event IDs
|
||||
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
|
||||
# Today's events (exclude parent templates — they are hidden, children are shown)
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
events_query = select(CalendarEvent).where(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
),
|
||||
CalendarEvent.start_datetime >= today_start,
|
||||
CalendarEvent.start_datetime <= today_end,
|
||||
_not_parent_template,
|
||||
@ -95,7 +99,11 @@ async def get_dashboard(
|
||||
# Starred events — no upper date bound so future events always appear in countdown.
|
||||
# _not_parent_template excludes recurring parent templates (children still show).
|
||||
starred_query = select(CalendarEvent).where(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
),
|
||||
CalendarEvent.is_starred == True,
|
||||
CalendarEvent.start_datetime > today_start,
|
||||
_not_parent_template,
|
||||
@ -169,8 +177,8 @@ async def get_upcoming(
|
||||
overdue_floor = today - timedelta(days=30)
|
||||
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
||||
|
||||
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
|
||||
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
# Fetch all accessible calendar IDs + invited event IDs
|
||||
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
|
||||
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
||||
todos_query = select(Todo).where(
|
||||
@ -182,7 +190,11 @@ async def get_upcoming(
|
||||
)
|
||||
|
||||
events_query = select(CalendarEvent).where(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
),
|
||||
CalendarEvent.start_datetime >= today_start,
|
||||
CalendarEvent.start_datetime <= cutoff_datetime,
|
||||
_not_parent_template,
|
||||
|
||||
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
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy import select, delete, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Any, Literal
|
||||
|
||||
@ -19,14 +19,21 @@ from app.schemas.calendar_event import (
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.recurrence import generate_occurrences
|
||||
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, require_permission
|
||||
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
|
||||
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
|
||||
from app.models.event_invitation import EventInvitation
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _event_to_dict(event: CalendarEvent) -> dict:
|
||||
def _event_to_dict(
|
||||
event: CalendarEvent,
|
||||
is_invited: bool = False,
|
||||
invitation_status: str | None = None,
|
||||
invitation_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
||||
return {
|
||||
d = {
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"description": event.description,
|
||||
@ -46,7 +53,11 @@ def _event_to_dict(event: CalendarEvent) -> dict:
|
||||
"original_start": event.original_start,
|
||||
"created_at": event.created_at,
|
||||
"updated_at": event.updated_at,
|
||||
"is_invited": is_invited,
|
||||
"invitation_status": invitation_status,
|
||||
"invitation_id": invitation_id,
|
||||
}
|
||||
return d
|
||||
|
||||
|
||||
def _birthday_events_for_range(
|
||||
@ -143,13 +154,20 @@ async def get_events(
|
||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||
are what get displayed on the calendar.
|
||||
"""
|
||||
# Scope events through calendar ownership + shared memberships
|
||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
# Scope events through calendar ownership + shared memberships + invitations
|
||||
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
|
||||
|
||||
query = (
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
|
||||
.where(
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Exclude parent template rows — they are not directly rendered
|
||||
@ -171,7 +189,36 @@ async def get_events(
|
||||
result = await db.execute(query)
|
||||
events = result.scalars().all()
|
||||
|
||||
response: List[dict] = [_event_to_dict(e) for e in events]
|
||||
# Build invitation lookup for the current user
|
||||
invited_event_id_set = set(invited_event_ids)
|
||||
invitation_map: dict[int, tuple[str, int]] = {} # event_id -> (status, invitation_id)
|
||||
if invited_event_ids:
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.id).where(
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.event_id.in_(invited_event_ids),
|
||||
)
|
||||
)
|
||||
for eid, status, inv_id in inv_result.all():
|
||||
invitation_map[eid] = (status, inv_id)
|
||||
|
||||
# Get per-occurrence overrides for invited events
|
||||
all_event_ids = [e.id for e in events]
|
||||
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
|
||||
|
||||
response: List[dict] = []
|
||||
for e in events:
|
||||
# Determine if this event is from an invitation
|
||||
parent_id = e.parent_event_id or e.id
|
||||
is_invited = parent_id in invited_event_id_set
|
||||
inv_status = None
|
||||
inv_id = None
|
||||
if is_invited and parent_id in invitation_map:
|
||||
inv_status, inv_id = invitation_map[parent_id]
|
||||
# Check for per-occurrence override
|
||||
if e.id in override_map:
|
||||
inv_status = override_map[e.id]
|
||||
response.append(_event_to_dict(e, is_invited=is_invited, invitation_status=inv_status, invitation_id=inv_id))
|
||||
|
||||
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
||||
bday_result = await db.execute(
|
||||
@ -281,14 +328,20 @@ async def get_event(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
invited_set = set(invited_event_ids)
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else False,
|
||||
),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
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_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.event_invitation import EventInvitation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -34,6 +35,25 @@ async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[in
|
||||
return [r[0] for r in result.all()]
|
||||
|
||||
|
||||
async def get_accessible_event_scope(
|
||||
user_id: int, db: AsyncSession
|
||||
) -> tuple[list[int], list[int]]:
|
||||
"""
|
||||
Returns (calendar_ids, invited_parent_event_ids).
|
||||
calendar_ids: all calendars the user can access (owned + accepted shared).
|
||||
invited_parent_event_ids: event IDs where the user has a non-declined invitation.
|
||||
"""
|
||||
cal_ids = await get_accessible_calendar_ids(user_id, db)
|
||||
invited_result = await db.execute(
|
||||
select(EventInvitation.event_id).where(
|
||||
EventInvitation.user_id == user_id,
|
||||
EventInvitation.status != "declined",
|
||||
)
|
||||
)
|
||||
invited_event_ids = [r[0] for r in invited_result.all()]
|
||||
return cal_ids, invited_event_ids
|
||||
|
||||
|
||||
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
|
||||
"""
|
||||
Returns "owner" if the user owns the calendar, the permission string
|
||||
@ -220,6 +240,10 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
|
||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||
)
|
||||
|
||||
# Clean up event invitations between the two users
|
||||
from app.services.event_invitation import cascade_event_invitations_on_disconnect
|
||||
await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id)
|
||||
|
||||
# AC-5: Single aggregation query instead of N per-calendar checks
|
||||
all_cal_ids = a_cal_ids + b_cal_ids
|
||||
if all_cal_ids:
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
# Event invite — rate-limited to prevent invite spam (reuse cal_invite_limit zone)
|
||||
location ~ /api/events/\d+/invitations$ {
|
||||
limit_req zone=cal_invite_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Calendar sync — rate-limited to prevent excessive polling
|
||||
location /api/shared-calendars/sync {
|
||||
limit_req zone=cal_sync_limit burst=5 nodelay;
|
||||
|
||||
@ -10,7 +10,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import enAuLocale from '@fullcalendar/core/locales/en-au';
|
||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
|
||||
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat, Users } from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import axios from 'axios';
|
||||
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
|
||||
@ -361,22 +361,26 @@ export default function CalendarPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const calendarEvents = filteredEvents.map((event) => ({
|
||||
id: String(event.id),
|
||||
title: event.title,
|
||||
start: event.start_datetime,
|
||||
end: event.end_datetime || undefined,
|
||||
allDay: event.all_day,
|
||||
color: 'transparent',
|
||||
editable: permissionMap.get(event.calendar_id) !== 'read_only',
|
||||
extendedProps: {
|
||||
is_virtual: event.is_virtual,
|
||||
is_recurring: event.is_recurring,
|
||||
parent_event_id: event.parent_event_id,
|
||||
calendar_id: event.calendar_id,
|
||||
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||
},
|
||||
}));
|
||||
const calendarEvents = filteredEvents
|
||||
// Exclude declined invited events from calendar display
|
||||
.filter((event) => !(event.is_invited && event.invitation_status === 'declined'))
|
||||
.map((event) => ({
|
||||
id: String(event.id),
|
||||
title: event.title,
|
||||
start: event.start_datetime,
|
||||
end: event.end_datetime || undefined,
|
||||
allDay: event.all_day,
|
||||
color: 'transparent',
|
||||
editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only',
|
||||
extendedProps: {
|
||||
is_virtual: event.is_virtual,
|
||||
is_recurring: event.is_recurring,
|
||||
parent_event_id: event.parent_event_id,
|
||||
calendar_id: event.calendar_id,
|
||||
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||
is_invited: event.is_invited,
|
||||
},
|
||||
}));
|
||||
|
||||
const handleEventClick = (info: EventClickArg) => {
|
||||
const event = events.find((e) => String(e.id) === info.event.id);
|
||||
@ -516,17 +520,21 @@ export default function CalendarPage() {
|
||||
const isMonth = arg.view.type === 'dayGridMonth';
|
||||
const isAllDay = arg.event.allDay;
|
||||
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
|
||||
const isInvited = arg.event.extendedProps.is_invited;
|
||||
|
||||
const repeatIcon = isRecurring ? (
|
||||
<Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />
|
||||
) : null;
|
||||
const icons = (
|
||||
<>
|
||||
{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 (isAllDay) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 truncate px-1">
|
||||
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
||||
{repeatIcon}
|
||||
{icons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -538,7 +546,7 @@ export default function CalendarPage() {
|
||||
style={{ borderColor: 'var(--event-color)' }}
|
||||
/>
|
||||
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
||||
{repeatIcon}
|
||||
{icons}
|
||||
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
|
||||
</div>
|
||||
);
|
||||
@ -549,7 +557,7 @@ export default function CalendarPage() {
|
||||
<div className="flex flex-col overflow-hidden h-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
|
||||
{repeatIcon}
|
||||
{icons}
|
||||
</div>
|
||||
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import {
|
||||
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
|
||||
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
@ -11,9 +11,12 @@ import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarP
|
||||
import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { useEventLock } from '@/hooks/useEventLock';
|
||||
import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations';
|
||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import CopyableField from '@/components/shared/CopyableField';
|
||||
import EventLockBanner from './EventLockBanner';
|
||||
import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection';
|
||||
import LeaveEventDialog from './LeaveEventDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@ -251,6 +254,20 @@ export default function EventDetailPanel({
|
||||
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
||||
|
||||
|
||||
// Event invitation hooks
|
||||
const eventNumericId = event && typeof event.id === 'number' ? event.id : null;
|
||||
const parentEventId = event?.parent_event_id ?? eventNumericId;
|
||||
const {
|
||||
invitees, 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 [editState, setEditState] = useState<EditState>(() =>
|
||||
isCreating
|
||||
@ -579,7 +596,8 @@ export default function EventDetailPanel({
|
||||
<>
|
||||
{!event?.is_virtual && (
|
||||
<>
|
||||
{canEdit && (
|
||||
{/* Edit button — only for own events or shared with edit permission */}
|
||||
{canEdit && !isInvitedEvent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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" />}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
{/* Leave button for invited events */}
|
||||
{isInvitedEvent && myInvitationId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setShowLeaveDialog(true)}
|
||||
title="Leave event"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Delete button for own events */}
|
||||
{canDelete && !isInvitedEvent && (
|
||||
confirmingDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -988,6 +1019,49 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invitee section — view mode */}
|
||||
{event && !event.is_virtual && (
|
||||
<>
|
||||
{/* RSVP buttons for invitees */}
|
||||
{isInvitedEvent && myInvitationId && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Your RSVP
|
||||
</div>
|
||||
<RsvpButtons
|
||||
currentStatus={myInvitationStatus || 'pending'}
|
||||
onRespond={(status) => {
|
||||
if (event.parent_event_id && eventNumericId) {
|
||||
overrideInvitation({ invitationId: myInvitationId, occurrenceId: eventNumericId, status });
|
||||
} else {
|
||||
respondInvitation({ invitationId: myInvitationId, status });
|
||||
}
|
||||
}}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invitee list */}
|
||||
{invitees.length > 0 && (
|
||||
<InviteeList
|
||||
invitees={invitees}
|
||||
isRecurringChild={!!event.parent_event_id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{event && !event.is_virtual && (
|
||||
<div className="pt-2 border-t border-border">
|
||||
@ -996,6 +1070,23 @@ export default function EventDetailPanel({
|
||||
</span>
|
||||
</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>
|
||||
|
||||
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 { toast } from 'sonner';
|
||||
import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react';
|
||||
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { AppNotification } from '@/types';
|
||||
|
||||
export default function NotificationToaster() {
|
||||
@ -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
|
||||
useEffect(() => {
|
||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||
@ -126,6 +158,10 @@ export default function NotificationToaster() {
|
||||
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
||||
}
|
||||
if (newNotifications.some((n) => n.type === 'event_invite')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||
}
|
||||
|
||||
// Show toasts
|
||||
newNotifications.forEach((notification) => {
|
||||
@ -133,6 +169,8 @@ export default function NotificationToaster() {
|
||||
showConnectionRequestToast(notification);
|
||||
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
||||
showCalendarInviteToast(notification);
|
||||
} else if (notification.type === 'event_invite' && notification.data) {
|
||||
showEventInviteToast(notification);
|
||||
} else {
|
||||
toast(notification.title || 'New Notification', {
|
||||
description: notification.message || undefined,
|
||||
@ -141,7 +179,7 @@ export default function NotificationToaster() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
|
||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
|
||||
|
||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||
const requestId = notification.source_id!;
|
||||
@ -222,5 +260,104 @@ export default function NotificationToaster() {
|
||||
{ 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;
|
||||
}
|
||||
|
||||
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;
|
||||
is_recurring?: boolean;
|
||||
original_start?: string | null;
|
||||
is_invited?: boolean;
|
||||
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
||||
invitation_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -486,6 +489,30 @@ export interface CalendarInvite {
|
||||
invited_at: string;
|
||||
}
|
||||
|
||||
// ── Event Invitations ─────────────────────────────────────────────
|
||||
|
||||
export interface EventInvitation {
|
||||
id: number;
|
||||
event_id: number;
|
||||
user_id: number;
|
||||
invited_by: number | null;
|
||||
status: 'pending' | 'accepted' | 'tentative' | 'declined';
|
||||
invited_at: string;
|
||||
responded_at: string | null;
|
||||
invitee_name: string;
|
||||
invitee_umbral_name: string;
|
||||
}
|
||||
|
||||
export interface PendingEventInvitation {
|
||||
id: number;
|
||||
event_id: number;
|
||||
event_title: string;
|
||||
event_start: string;
|
||||
invited_by_name: string;
|
||||
invited_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface EventLockInfo {
|
||||
locked: boolean;
|
||||
locked_by_name: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user