Compare commits

..

No commits in common. "cbb62ea7aa4fe0988dc6f3803dc4333167278724" and "bdfd8448b1b110a0472488850f405d3c05e4250f" have entirely different histories.

21 changed files with 133 additions and 2348 deletions

View File

@ -1,116 +0,0 @@
"""Event invitations tables and notification types.
Revision ID: 054
Revises: 053
"""
from alembic import op
import sqlalchemy as sa
revision = "054"
down_revision = "053"
branch_labels = None
depends_on = None
def upgrade():
# ── event_invitations table ──
op.create_table(
"event_invitations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"event_id",
sa.Integer(),
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"invited_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("responded_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'tentative', 'declined')",
name="ck_event_invitations_status",
),
)
op.create_index(
"ix_event_invitations_user_status",
"event_invitations",
["user_id", "status"],
)
op.create_index(
"ix_event_invitations_event_id",
"event_invitations",
["event_id"],
)
# ── event_invitation_overrides table ──
op.create_table(
"event_invitation_overrides",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"invitation_id",
sa.Integer(),
sa.ForeignKey("event_invitations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"occurrence_id",
sa.Integer(),
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("responded_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
sa.CheckConstraint(
"status IN ('accepted', 'tentative', 'declined')",
name="ck_invitation_override_status",
),
)
op.create_index(
"ix_invitation_overrides_lookup",
"event_invitation_overrides",
["invitation_id", "occurrence_id"],
)
# ── Expand notification type check constraint ──
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint(
"ck_notifications_type",
"notifications",
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
"'event_invite', 'event_invite_response', "
"'info', 'warning', 'reminder', 'system')",
)
def downgrade():
op.drop_index("ix_invitation_overrides_lookup", table_name="event_invitation_overrides")
op.drop_table("event_invitation_overrides")
op.drop_index("ix_event_invitations_event_id", table_name="event_invitations")
op.drop_index("ix_event_invitations_user_status", table_name="event_invitations")
op.drop_table("event_invitations")
# Restore original notification type constraint
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint(
"ck_notifications_type",
"notifications",
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
"'info', 'warning', 'reminder', 'system')",
)

View File

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

View File

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

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -22,7 +22,6 @@ 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
# ---------------------------------------------------------------------------
@ -138,8 +137,6 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
@app.get("/")

View File

@ -20,7 +20,6 @@ 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",
@ -45,6 +44,4 @@ __all__ = [
"UserConnection",
"CalendarMember",
"EventLock",
"EventInvitation",
"EventInvitationOverride",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
import { Check, X, Bell, UserPlus, Calendar } 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 api, { getErrorMessage } from '@/lib/api';
import { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types';
export default function NotificationToaster() {
@ -18,7 +18,7 @@ export default function NotificationToaster() {
const initializedRef = useRef(false);
const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<string>>(new Set());
const respondingRef = useRef<Set<number>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondInviteRef = useRef(respondInvite);
const respondRef = useRef(respond);
@ -30,9 +30,8 @@ export default function NotificationToaster() {
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
const key = `conn-${requestId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
if (respondingRef.current.has(requestId)) return;
respondingRef.current.add(requestId);
// Immediately dismiss the custom toast and show a loading indicator
toast.dismiss(toastId);
@ -55,7 +54,7 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond to request'));
}
} finally {
respondingRef.current.delete(key);
respondingRef.current.delete(requestId);
}
},
[],
@ -64,9 +63,8 @@ export default function NotificationToaster() {
const handleCalendarInviteRespond = useCallback(
async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
const key = `cal-${inviteId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
if (respondingRef.current.has(inviteId + 100000)) return;
respondingRef.current.add(inviteId + 100000);
toast.dismiss(toastId);
const loadingId = toast.loading(
@ -85,44 +83,11 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
}
} finally {
respondingRef.current.delete(key);
respondingRef.current.delete(inviteId + 100000);
}
},
[],
);
const handleEventInviteRespond = useCallback(
async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => {
const key = `event-${invitationId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
toast.dismiss(toastId);
const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' };
const loadingId = toast.loading(`${statusLabel[status]}`);
try {
await api.put(`/event-invitations/${invitationId}/respond`, { status });
toast.dismiss(loadingId);
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
toast.success(`Marked as ${successLabel[status]}`);
markReadRef.current([notificationId]).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
respondingRef.current.delete(key);
}
},
[],
);
// Track unread count changes to force-refetch the list
useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -135,28 +100,10 @@ export default function NotificationToaster() {
useEffect(() => {
if (!notifications.length) return;
// On first load, record the max ID — but still toast actionable unread items
// On first load, record the max ID without toasting
if (!initializedRef.current) {
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
initializedRef.current = true;
// Toast actionable unread notifications on login so the user can act immediately
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']);
const actionable = notifications.filter(
(n) => !n.is_read && actionableTypes.has(n.type),
);
if (actionable.length === 0) return;
// Show at most 3 toasts on first load to avoid flooding
const toShow = actionable.slice(0, 3);
toShow.forEach((notification) => {
if (notification.type === 'connection_request' && notification.source_id) {
showConnectionRequestToast(notification);
} else if (notification.type === 'calendar_invite' && notification.source_id) {
showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification);
}
});
return;
}
@ -179,10 +126,6 @@ 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) => {
@ -190,8 +133,6 @@ 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,
@ -200,7 +141,7 @@ export default function NotificationToaster() {
});
}
});
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!;
@ -281,84 +222,5 @@ export default function NotificationToaster() {
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
);
};
const resolveInvitationId = async (notification: AppNotification): Promise<number | null> => {
const data = notification.data as Record<string, unknown> | undefined;
// Prefer invitation_id from notification data (populated after flush fix)
if (data?.invitation_id) return data.invitation_id as number;
// Fallback: fetch pending invitations to resolve by event_id
const eventId = data?.event_id as number | undefined;
if (!eventId) return null;
try {
const { data: pending } = await api.get('/event-invitations/pending');
const inv = (pending as Array<{ id: number; event_id: number }>).find(
(p) => p.event_id === eventId,
);
return inv?.id ?? null;
} catch {
return null;
}
};
const handleEventToastClick = async (
notification: AppNotification,
status: 'accepted' | 'tentative' | 'declined',
toastId: string | number,
) => {
const invId = await resolveInvitationId(notification);
if (invId) {
handleEventInviteRespond(invId, status, toastId, notification.id);
} else {
toast.dismiss(toastId);
markReadRef.current([notification.id]).catch(() => {});
toast.success('Already responded');
}
};
const showEventInviteToast = (notification: AppNotification) => {
const inviteKey = `event-invite-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<Calendar className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Event Invitation</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were invited to an event'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleEventToastClick(notification, 'accepted', id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleEventToastClick(notification, 'tentative', id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-amber-500/15 text-amber-400 hover:bg-amber-500/25 transition-colors"
>
<Clock className="h-3.5 w-3.5" />
Tentative
</button>
<button
onClick={() => handleEventToastClick(notification, 'declined', id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: inviteKey, duration: 30000 },
);
};
return null;
}

View File

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

View File

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

View File

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