Compare commits
No commits in common. "cbb62ea7aa4fe0988dc6f3803dc4333167278724" and "bdfd8448b1b110a0472488850f405d3c05e4250f" have entirely different histories.
cbb62ea7aa
...
bdfd8448b1
@ -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')",
|
||||
)
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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("/")
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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()
|
||||
)
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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.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.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.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:
|
||||
|
||||
@ -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
|
||||
@ -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(),
|
||||
),
|
||||
)
|
||||
)
|
||||
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])
|
||||
|
||||
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,7 +335,6 @@ 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.
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
@ -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,26 +361,20 @@ 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) => ({
|
||||
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: (event.is_invited && !!event.can_modify) || (!event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only'),
|
||||
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))',
|
||||
is_invited: event.is_invited,
|
||||
can_modify: event.can_modify,
|
||||
has_active_invitees: event.has_active_invitees,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
}
|
||||
} 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,8 +743,7 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-calendar">Calendar</Label>
|
||||
<Select
|
||||
@ -804,7 +757,6 @@ export default function EventDetailPanel({
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-location">Location</Label>
|
||||
<LocationPicker
|
||||
@ -837,8 +789,7 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
||||
{!canModifyAsInvitee && (
|
||||
{/* Recurrence */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
@ -854,7 +805,6 @@ export default function EventDetailPanel({
|
||||
<option value="monthly_date">Monthly (date)</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editState.recurrence_type === 'every_n_days' && (
|
||||
<div className="space-y-1">
|
||||
@ -948,39 +898,12 @@ 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"
|
||||
@ -988,7 +911,6 @@ export default function EventDetailPanel({
|
||||
/>
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 “{eventTitle}”. You won’t see it on your calendar anymore.
|
||||
</p>
|
||||
{isRecurring && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For recurring events: removed from all future occurrences.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLeaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={isLeaving}>
|
||||
{isLeaving ? 'Leaving...' : 'Leave Event'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Check, X, Bell, UserPlus, Calendar, 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;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user