Compare commits
21 Commits
bdfd8448b1
...
cbb62ea7aa
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb62ea7aa | |||
| 925c9caf91 | |||
| 2f45220c5d | |||
| c66fd159ea | |||
| f35798c757 | |||
| 8b39c961b6 | |||
| 0401a71fce | |||
| 8f087ccebf | |||
| f54ab5079e | |||
| 25830bb99e | |||
| aa1ff50788 | |||
| d00d6d6d49 | |||
| a68ec0e23e | |||
| 29c2cbbec8 | |||
| 68a609ee50 | |||
| df857a5719 | |||
| 496666ec5a | |||
| bafda61958 | |||
| 0f378ad386 | |||
| a41b48f016 | |||
| 8652c9f2ce |
116
backend/alembic/versions/054_event_invitations.py
Normal file
116
backend/alembic/versions/054_event_invitations.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""Event invitations tables and notification types.
|
||||||
|
|
||||||
|
Revision ID: 054
|
||||||
|
Revises: 053
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "054"
|
||||||
|
down_revision = "053"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ── event_invitations table ──
|
||||||
|
op.create_table(
|
||||||
|
"event_invitations",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"event_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"invited_by",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("responded_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('pending', 'accepted', 'tentative', 'declined')",
|
||||||
|
name="ck_event_invitations_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_event_invitations_user_status",
|
||||||
|
"event_invitations",
|
||||||
|
["user_id", "status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_event_invitations_event_id",
|
||||||
|
"event_invitations",
|
||||||
|
["event_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── event_invitation_overrides table ──
|
||||||
|
op.create_table(
|
||||||
|
"event_invitation_overrides",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"invitation_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("event_invitations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"occurrence_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("responded_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('accepted', 'tentative', 'declined')",
|
||||||
|
name="ck_invitation_override_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_invitation_overrides_lookup",
|
||||||
|
"event_invitation_overrides",
|
||||||
|
["invitation_id", "occurrence_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Expand notification type check constraint ──
|
||||||
|
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_notifications_type",
|
||||||
|
"notifications",
|
||||||
|
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
|
||||||
|
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
|
||||||
|
"'event_invite', 'event_invite_response', "
|
||||||
|
"'info', 'warning', 'reminder', 'system')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_invitation_overrides_lookup", table_name="event_invitation_overrides")
|
||||||
|
op.drop_table("event_invitation_overrides")
|
||||||
|
op.drop_index("ix_event_invitations_event_id", table_name="event_invitations")
|
||||||
|
op.drop_index("ix_event_invitations_user_status", table_name="event_invitations")
|
||||||
|
op.drop_table("event_invitations")
|
||||||
|
|
||||||
|
# Restore original notification type constraint
|
||||||
|
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_notifications_type",
|
||||||
|
"notifications",
|
||||||
|
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
|
||||||
|
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
|
||||||
|
"'info', 'warning', 'reminder', 'system')",
|
||||||
|
)
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
"""Add display_calendar_id to event_invitations.
|
||||||
|
|
||||||
|
Allows invitees to assign invited events to their own calendars
|
||||||
|
for personal organization, color, and visibility control.
|
||||||
|
|
||||||
|
Revision ID: 055
|
||||||
|
Revises: 054
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "055"
|
||||||
|
down_revision = "054"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"event_invitations",
|
||||||
|
sa.Column(
|
||||||
|
"display_calendar_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("calendars.id", ondelete="SET NULL", name="fk_event_invitations_display_calendar_id"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_event_invitations_display_calendar",
|
||||||
|
"event_invitations",
|
||||||
|
["display_calendar_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill accepted/tentative invitations with each user's default calendar
|
||||||
|
op.execute("""
|
||||||
|
UPDATE event_invitations
|
||||||
|
SET display_calendar_id = (
|
||||||
|
SELECT c.id FROM calendars c
|
||||||
|
WHERE c.user_id = event_invitations.user_id
|
||||||
|
AND c.is_default = true
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE status IN ('accepted', 'tentative')
|
||||||
|
AND display_calendar_id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_event_invitations_display_calendar", table_name="event_invitations")
|
||||||
|
op.drop_column("event_invitations", "display_calendar_id")
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
"""add can_modify to event_invitations
|
||||||
|
|
||||||
|
Revision ID: 056
|
||||||
|
Revises: 055
|
||||||
|
Create Date: 2025-01-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "056"
|
||||||
|
down_revision = "055"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"event_invitations",
|
||||||
|
sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("event_invitations", "can_modify")
|
||||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router
|
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
|
||||||
from app.jobs.notifications import run_notification_dispatch
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# Import models so Alembic's autogenerate can discover them
|
||||||
@ -22,6 +22,7 @@ from app.models import connection_request as _connection_request_model # noqa:
|
|||||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
from app.models import user_connection as _user_connection_model # noqa: F401
|
||||||
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
||||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||||
|
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -137,6 +138,8 @@ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
|||||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||||
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
||||||
|
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
|
||||||
|
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from app.models.connection_request import ConnectionRequest
|
|||||||
from app.models.user_connection import UserConnection
|
from app.models.user_connection import UserConnection
|
||||||
from app.models.calendar_member import CalendarMember
|
from app.models.calendar_member import CalendarMember
|
||||||
from app.models.event_lock import EventLock
|
from app.models.event_lock import EventLock
|
||||||
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -44,4 +45,6 @@ __all__ = [
|
|||||||
"UserConnection",
|
"UserConnection",
|
||||||
"CalendarMember",
|
"CalendarMember",
|
||||||
"EventLock",
|
"EventLock",
|
||||||
|
"EventInvitation",
|
||||||
|
"EventInvitationOverride",
|
||||||
]
|
]
|
||||||
|
|||||||
77
backend/app/models/event_invitation.py
Normal file
77
backend/app/models/event_invitation.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from sqlalchemy import (
|
||||||
|
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||||
|
String, UniqueConstraint, false as sa_false, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitation(Base):
|
||||||
|
__tablename__ = "event_invitations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('pending', 'accepted', 'tentative', 'declined')",
|
||||||
|
name="ck_event_invitations_status",
|
||||||
|
),
|
||||||
|
Index("ix_event_invitations_user_status", "user_id", "status"),
|
||||||
|
Index("ix_event_invitations_event_id", "event_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
event_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
invited_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||||
|
invited_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=func.now(), server_default=func.now()
|
||||||
|
)
|
||||||
|
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
display_calendar_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
can_modify: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default=sa_false()
|
||||||
|
)
|
||||||
|
|
||||||
|
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
|
||||||
|
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||||
|
inviter: Mapped[Optional["User"]] = relationship(
|
||||||
|
foreign_keys=[invited_by], lazy="raise"
|
||||||
|
)
|
||||||
|
display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise")
|
||||||
|
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
|
||||||
|
lazy="raise", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationOverride(Base):
|
||||||
|
__tablename__ = "event_invitation_overrides"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('accepted', 'tentative', 'declined')",
|
||||||
|
name="ck_invitation_override_status",
|
||||||
|
),
|
||||||
|
Index("ix_invitation_overrides_lookup", "invitation_id", "occurrence_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
invitation_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("event_invitations.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
occurrence_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
responded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=func.now(), server_default=func.now()
|
||||||
|
)
|
||||||
@ -8,6 +8,7 @@ from app.database import Base
|
|||||||
_NOTIFICATION_TYPES = (
|
_NOTIFICATION_TYPES = (
|
||||||
"connection_request", "connection_accepted", "connection_rejected",
|
"connection_request", "connection_accepted", "connection_rejected",
|
||||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||||
|
"event_invite", "event_invite_response",
|
||||||
"info", "warning", "reminder", "system",
|
"info", "warning", "reminder", "system",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_, case
|
from sqlalchemy import false as sa_false, select, func, or_, case
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
@ -12,7 +12,8 @@ from app.models.reminder import Reminder
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_user, get_current_settings
|
from app.routers.auth import get_current_user, get_current_settings
|
||||||
from app.services.calendar_sharing import get_accessible_calendar_ids
|
from app.models.event_invitation import EventInvitation
|
||||||
|
from app.services.calendar_sharing import get_accessible_event_scope
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -35,14 +36,18 @@ async def get_dashboard(
|
|||||||
today = client_date or date.today()
|
today = client_date or date.today()
|
||||||
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
||||||
|
|
||||||
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
|
# Fetch all accessible calendar IDs + invited event IDs
|
||||||
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
|
||||||
# Today's events (exclude parent templates — they are hidden, children are shown)
|
# Today's events (exclude parent templates — they are hidden, children are shown)
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
today_end = datetime.combine(today, datetime.max.time())
|
today_end = datetime.combine(today, datetime.max.time())
|
||||||
events_query = select(CalendarEvent).where(
|
events_query = select(CalendarEvent).where(
|
||||||
|
or_(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
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_start,
|
||||||
CalendarEvent.start_datetime <= today_end,
|
CalendarEvent.start_datetime <= today_end,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
@ -50,6 +55,22 @@ async def get_dashboard(
|
|||||||
events_result = await db.execute(events_query)
|
events_result = await db.execute(events_query)
|
||||||
todays_events = events_result.scalars().all()
|
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)
|
# Upcoming todos (not completed, with due date from today through upcoming_days)
|
||||||
todos_query = select(Todo).where(
|
todos_query = select(Todo).where(
|
||||||
Todo.user_id == current_user.id,
|
Todo.user_id == current_user.id,
|
||||||
@ -95,7 +116,11 @@ async def get_dashboard(
|
|||||||
# Starred events — no upper date bound so future events always appear in countdown.
|
# Starred events — no upper date bound so future events always appear in countdown.
|
||||||
# _not_parent_template excludes recurring parent templates (children still show).
|
# _not_parent_template excludes recurring parent templates (children still show).
|
||||||
starred_query = select(CalendarEvent).where(
|
starred_query = select(CalendarEvent).where(
|
||||||
|
or_(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
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.is_starred == True,
|
||||||
CalendarEvent.start_datetime > today_start,
|
CalendarEvent.start_datetime > today_start,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
@ -121,7 +146,10 @@ async def get_dashboard(
|
|||||||
"end_datetime": event.end_datetime,
|
"end_datetime": event.end_datetime,
|
||||||
"all_day": event.all_day,
|
"all_day": event.all_day,
|
||||||
"color": event.color,
|
"color": event.color,
|
||||||
"is_starred": event.is_starred
|
"is_starred": event.is_starred,
|
||||||
|
"is_invited": (event.parent_event_id or event.id) in invited_event_id_set,
|
||||||
|
"invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0],
|
||||||
|
"display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1],
|
||||||
}
|
}
|
||||||
for event in todays_events
|
for event in todays_events
|
||||||
],
|
],
|
||||||
@ -169,8 +197,8 @@ async def get_upcoming(
|
|||||||
overdue_floor = today - timedelta(days=30)
|
overdue_floor = today - timedelta(days=30)
|
||||||
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
||||||
|
|
||||||
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
|
# Fetch all accessible calendar IDs + invited event IDs
|
||||||
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
|
||||||
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
||||||
todos_query = select(Todo).where(
|
todos_query = select(Todo).where(
|
||||||
@ -182,7 +210,11 @@ async def get_upcoming(
|
|||||||
)
|
)
|
||||||
|
|
||||||
events_query = select(CalendarEvent).where(
|
events_query = select(CalendarEvent).where(
|
||||||
|
or_(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
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_start,
|
||||||
CalendarEvent.start_datetime <= cutoff_datetime,
|
CalendarEvent.start_datetime <= cutoff_datetime,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
@ -206,6 +238,20 @@ async def get_upcoming(
|
|||||||
reminders_result = await db.execute(reminders_query)
|
reminders_result = await db.execute(reminders_query)
|
||||||
reminders = reminders_result.scalars().all()
|
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
|
# Combine into unified list
|
||||||
upcoming_items: List[Dict[str, Any]] = []
|
upcoming_items: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
@ -223,6 +269,8 @@ async def get_upcoming(
|
|||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
end_dt = event.end_datetime
|
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({
|
upcoming_items.append({
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
@ -233,6 +281,9 @@ async def get_upcoming(
|
|||||||
"all_day": event.all_day,
|
"all_day": event.all_day,
|
||||||
"color": event.color,
|
"color": event.color,
|
||||||
"is_starred": event.is_starred,
|
"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:
|
for reminder in reminders:
|
||||||
|
|||||||
307
backend/app/routers/event_invitations.py
Normal file
307
backend/app/routers/event_invitations.py
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
Event invitation endpoints — invite users to events, respond, override per-occurrence, leave.
|
||||||
|
|
||||||
|
Two routers:
|
||||||
|
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
|
||||||
|
- router: mounted at /api/event-invitations for respond/override/delete/pending
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.models.event_invitation import EventInvitation
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from app.schemas.event_invitation import (
|
||||||
|
EventInvitationCreate,
|
||||||
|
EventInvitationRespond,
|
||||||
|
EventInvitationOverrideCreate,
|
||||||
|
UpdateCanModify,
|
||||||
|
UpdateDisplayCalendar,
|
||||||
|
)
|
||||||
|
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
|
||||||
|
from app.services.event_invitation import (
|
||||||
|
send_event_invitations,
|
||||||
|
respond_to_invitation,
|
||||||
|
override_occurrence_status,
|
||||||
|
dismiss_invitation,
|
||||||
|
dismiss_invitation_by_owner,
|
||||||
|
get_event_invitations,
|
||||||
|
get_pending_invitations,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mounted at /api/events — event-scoped invitation endpoints
|
||||||
|
events_router = APIRouter()
|
||||||
|
|
||||||
|
# Mounted at /api/event-invitations — invitation-scoped endpoints
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_event_with_access_check(
|
||||||
|
db: AsyncSession, event_id: int, user_id: int
|
||||||
|
) -> CalendarEvent:
|
||||||
|
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||||
|
)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
# Check calendar access
|
||||||
|
perm = await get_user_permission(db, event.calendar_id, user_id)
|
||||||
|
if perm is not None:
|
||||||
|
return event
|
||||||
|
|
||||||
|
# Check if invitee (also check parent for recurring children)
|
||||||
|
event_ids_to_check = [event_id]
|
||||||
|
if event.parent_event_id:
|
||||||
|
event_ids_to_check.append(event.parent_event_id)
|
||||||
|
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation.id).where(
|
||||||
|
EventInvitation.event_id.in_(event_ids_to_check),
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if inv_result.first() is not None:
|
||||||
|
return event
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event-scoped endpoints (mounted at /api/events) ──
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.post("/{event_id}/invitations", status_code=201)
|
||||||
|
async def invite_to_event(
|
||||||
|
body: EventInvitationCreate,
|
||||||
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||||
|
)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
# Permission check: owner or create_modify+
|
||||||
|
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||||
|
if perm is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if perm not in ("owner", "create_modify", "full_access"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||||
|
|
||||||
|
# For recurring child events, invite to the parent (series)
|
||||||
|
target_event_id = event.parent_event_id if event.parent_event_id else event_id
|
||||||
|
|
||||||
|
invitations = await send_event_invitations(
|
||||||
|
db=db,
|
||||||
|
event_id=target_event_id,
|
||||||
|
user_ids=body.user_ids,
|
||||||
|
invited_by=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"invited": len(invitations), "event_id": target_event_id}
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/{event_id}/invitations")
|
||||||
|
async def list_event_invitations(
|
||||||
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all invitees and their statuses for an event."""
|
||||||
|
event = await _get_event_with_access_check(db, event_id, current_user.id)
|
||||||
|
|
||||||
|
# For recurring children, also fetch parent's invitations
|
||||||
|
target_id = event.parent_event_id if event.parent_event_id else event_id
|
||||||
|
invitations = await get_event_invitations(db, target_id)
|
||||||
|
return invitations
|
||||||
|
|
||||||
|
|
||||||
|
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def my_pending_invitations(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get all pending event invitations for the current user."""
|
||||||
|
return await get_pending_invitations(db, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/respond")
|
||||||
|
async def respond_invitation(
|
||||||
|
body: EventInvitationRespond,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Accept, tentative, or decline an event invitation."""
|
||||||
|
invitation = await respond_to_invitation(
|
||||||
|
db=db,
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
status=body.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response before commit (ORM objects expire after commit)
|
||||||
|
response_data = {
|
||||||
|
"id": invitation.id,
|
||||||
|
"event_id": invitation.event_id,
|
||||||
|
"status": invitation.status,
|
||||||
|
"responded_at": invitation.responded_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/respond/{occurrence_id}")
|
||||||
|
async def override_occurrence(
|
||||||
|
body: EventInvitationOverrideCreate,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
occurrence_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Override invitation status for a specific occurrence of a recurring event."""
|
||||||
|
override = await override_occurrence_status(
|
||||||
|
db=db,
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
occurrence_id=occurrence_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
status=body.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"invitation_id": override.invitation_id,
|
||||||
|
"occurrence_id": override.occurrence_id,
|
||||||
|
"status": override.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/display-calendar")
|
||||||
|
async def update_display_calendar(
|
||||||
|
body: UpdateDisplayCalendar,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Change the display calendar for an accepted/tentative invitation."""
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation).where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
if invitation.status not in ("accepted", "tentative"):
|
||||||
|
raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations")
|
||||||
|
|
||||||
|
# Verify calendar is accessible to this user
|
||||||
|
accessible_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||||
|
if body.calendar_id not in accessible_ids:
|
||||||
|
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||||
|
|
||||||
|
invitation.display_calendar_id = body.calendar_id
|
||||||
|
|
||||||
|
# Extract response before commit (ORM expiry rule)
|
||||||
|
response_data = {
|
||||||
|
"id": invitation.id,
|
||||||
|
"event_id": invitation.event_id,
|
||||||
|
"display_calendar_id": invitation.display_calendar_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{invitation_id}/can-modify")
|
||||||
|
async def update_can_modify(
|
||||||
|
body: UpdateCanModify,
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Toggle can_modify on an invitation. Owner-only."""
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation)
|
||||||
|
.options(selectinload(EventInvitation.event))
|
||||||
|
.where(EventInvitation.id == invitation_id)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
# Only the calendar owner can toggle can_modify (W-03)
|
||||||
|
perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id)
|
||||||
|
if perm != "owner":
|
||||||
|
raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access")
|
||||||
|
|
||||||
|
invitation.can_modify = body.can_modify
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"id": invitation.id,
|
||||||
|
"event_id": invitation.event_id,
|
||||||
|
"can_modify": invitation.can_modify,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{invitation_id}", status_code=204)
|
||||||
|
async def leave_or_revoke_invitation(
|
||||||
|
invitation_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Leave an event (invitee) or revoke an invitation (event owner).
|
||||||
|
Invitees can only delete their own invitations.
|
||||||
|
Event owners can delete any invitation for their events.
|
||||||
|
"""
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation).where(EventInvitation.id == invitation_id)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
if invitation.user_id == current_user.id:
|
||||||
|
# Invitee leaving
|
||||||
|
await dismiss_invitation(db, invitation_id, current_user.id)
|
||||||
|
else:
|
||||||
|
# Check if current user is the event owner
|
||||||
|
event_result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == invitation.event_id)
|
||||||
|
)
|
||||||
|
event = event_result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
|
||||||
|
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||||
|
if perm != "owner":
|
||||||
|
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
|
||||||
|
|
||||||
|
await dismiss_invitation_by_owner(db, invitation_id)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return None
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import false as sa_false, select, delete, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional, List, Any, Literal
|
from typing import Optional, List, Any, Literal
|
||||||
|
|
||||||
@ -19,14 +19,38 @@ from app.schemas.calendar_event import (
|
|||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.recurrence import generate_occurrences
|
from app.services.recurrence import generate_occurrences
|
||||||
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, require_permission
|
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
|
||||||
|
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
|
||||||
|
from app.models.event_invitation import EventInvitation
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _event_to_dict(event: CalendarEvent) -> dict:
|
def _event_to_dict(
|
||||||
|
event: CalendarEvent,
|
||||||
|
is_invited: bool = False,
|
||||||
|
invitation_status: str | None = None,
|
||||||
|
invitation_id: int | None = None,
|
||||||
|
display_calendar_id: int | None = None,
|
||||||
|
display_calendar_name: str | None = None,
|
||||||
|
display_calendar_color: str | None = None,
|
||||||
|
can_modify: bool = False,
|
||||||
|
has_active_invitees: bool = False,
|
||||||
|
) -> dict:
|
||||||
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
||||||
return {
|
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
|
||||||
|
if is_invited:
|
||||||
|
if display_calendar_name:
|
||||||
|
cal_name = display_calendar_name
|
||||||
|
cal_color = display_calendar_color or "#6B7280"
|
||||||
|
else:
|
||||||
|
cal_name = "Invited"
|
||||||
|
cal_color = "#6B7280"
|
||||||
|
else:
|
||||||
|
cal_name = event.calendar.name if event.calendar else ""
|
||||||
|
cal_color = event.calendar.color if event.calendar else ""
|
||||||
|
|
||||||
|
d = {
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
"title": event.title,
|
"title": event.title,
|
||||||
"description": event.description,
|
"description": event.description,
|
||||||
@ -38,15 +62,22 @@ def _event_to_dict(event: CalendarEvent) -> dict:
|
|||||||
"recurrence_rule": event.recurrence_rule,
|
"recurrence_rule": event.recurrence_rule,
|
||||||
"is_starred": event.is_starred,
|
"is_starred": event.is_starred,
|
||||||
"calendar_id": event.calendar_id,
|
"calendar_id": event.calendar_id,
|
||||||
"calendar_name": event.calendar.name if event.calendar else "",
|
"calendar_name": cal_name,
|
||||||
"calendar_color": event.calendar.color if event.calendar else "",
|
"calendar_color": cal_color,
|
||||||
"is_virtual": False,
|
"is_virtual": False,
|
||||||
"parent_event_id": event.parent_event_id,
|
"parent_event_id": event.parent_event_id,
|
||||||
"is_recurring": event.is_recurring,
|
"is_recurring": event.is_recurring,
|
||||||
"original_start": event.original_start,
|
"original_start": event.original_start,
|
||||||
"created_at": event.created_at,
|
"created_at": event.created_at,
|
||||||
"updated_at": event.updated_at,
|
"updated_at": event.updated_at,
|
||||||
|
"is_invited": is_invited,
|
||||||
|
"invitation_status": invitation_status,
|
||||||
|
"invitation_id": invitation_id,
|
||||||
|
"display_calendar_id": display_calendar_id,
|
||||||
|
"can_modify": can_modify,
|
||||||
|
"has_active_invitees": has_active_invitees,
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _birthday_events_for_range(
|
def _birthday_events_for_range(
|
||||||
@ -143,13 +174,20 @@ async def get_events(
|
|||||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||||
are what get displayed on the calendar.
|
are what get displayed on the calendar.
|
||||||
"""
|
"""
|
||||||
# Scope events through calendar ownership + shared memberships
|
# Scope events through calendar ownership + shared memberships + invitations
|
||||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
|
.where(
|
||||||
|
or_(
|
||||||
|
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||||
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||||
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exclude parent template rows — they are not directly rendered
|
# Exclude parent template rows — they are not directly rendered
|
||||||
@ -171,7 +209,88 @@ async def get_events(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
events = result.scalars().all()
|
events = result.scalars().all()
|
||||||
|
|
||||||
response: List[dict] = [_event_to_dict(e) for e in events]
|
# Build invitation lookup for the current user
|
||||||
|
invited_event_id_set = set(invited_event_ids)
|
||||||
|
invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
|
||||||
|
if invited_event_ids:
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitation.event_id,
|
||||||
|
EventInvitation.status,
|
||||||
|
EventInvitation.id,
|
||||||
|
EventInvitation.display_calendar_id,
|
||||||
|
EventInvitation.can_modify,
|
||||||
|
).where(
|
||||||
|
EventInvitation.user_id == current_user.id,
|
||||||
|
EventInvitation.event_id.in_(invited_event_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
|
||||||
|
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
|
||||||
|
|
||||||
|
# Batch-fetch display calendars for invited events
|
||||||
|
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
|
||||||
|
display_cal_map: dict[int, dict] = {} # cal_id -> {name, color}
|
||||||
|
if display_cal_ids:
|
||||||
|
cal_result = await db.execute(
|
||||||
|
select(Calendar.id, Calendar.name, Calendar.color).where(
|
||||||
|
Calendar.id.in_(display_cal_ids),
|
||||||
|
Calendar.id.in_(all_calendar_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for cal_id, cal_name, cal_color in cal_result.all():
|
||||||
|
display_cal_map[cal_id] = {"name": cal_name, "color": cal_color}
|
||||||
|
|
||||||
|
# Get per-occurrence overrides for invited events
|
||||||
|
all_event_ids = [e.id for e in events]
|
||||||
|
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
|
||||||
|
|
||||||
|
# Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon)
|
||||||
|
active_invitee_set: set[int] = set()
|
||||||
|
if all_event_ids:
|
||||||
|
active_inv_result = await db.execute(
|
||||||
|
select(EventInvitation.event_id).where(
|
||||||
|
EventInvitation.event_id.in_(all_event_ids),
|
||||||
|
EventInvitation.status.in_(["accepted", "tentative"]),
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
active_invitee_set = {r[0] for r in active_inv_result.all()}
|
||||||
|
# Also mark parent events: if a parent has active invitees, all its children should show the icon
|
||||||
|
parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set}
|
||||||
|
if parent_ids:
|
||||||
|
active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set)
|
||||||
|
|
||||||
|
response: List[dict] = []
|
||||||
|
for e in events:
|
||||||
|
# Determine if this event is from an invitation
|
||||||
|
parent_id = e.parent_event_id or e.id
|
||||||
|
is_invited = parent_id in invited_event_id_set
|
||||||
|
inv_status = None
|
||||||
|
inv_id = None
|
||||||
|
disp_cal_id = None
|
||||||
|
disp_cal_name = None
|
||||||
|
disp_cal_color = None
|
||||||
|
inv_can_modify = False
|
||||||
|
if is_invited and parent_id in invitation_map:
|
||||||
|
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
|
||||||
|
# Check for per-occurrence override
|
||||||
|
if e.id in override_map:
|
||||||
|
inv_status = override_map[e.id]
|
||||||
|
# Resolve display calendar info
|
||||||
|
if disp_cal_id and disp_cal_id in display_cal_map:
|
||||||
|
disp_cal_name = display_cal_map[disp_cal_id]["name"]
|
||||||
|
disp_cal_color = display_cal_map[disp_cal_id]["color"]
|
||||||
|
response.append(_event_to_dict(
|
||||||
|
e,
|
||||||
|
is_invited=is_invited,
|
||||||
|
invitation_status=inv_status,
|
||||||
|
invitation_id=inv_id,
|
||||||
|
display_calendar_id=disp_cal_id,
|
||||||
|
display_calendar_name=disp_cal_name,
|
||||||
|
display_calendar_color=disp_cal_color,
|
||||||
|
can_modify=inv_can_modify,
|
||||||
|
has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set),
|
||||||
|
))
|
||||||
|
|
||||||
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
||||||
bday_result = await db.execute(
|
bday_result = await db.execute(
|
||||||
@ -281,14 +400,20 @@ async def get_event(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||||
|
invited_set = set(invited_event_ids)
|
||||||
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
.where(
|
.where(
|
||||||
CalendarEvent.id == event_id,
|
CalendarEvent.id == event_id,
|
||||||
|
or_(
|
||||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
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()
|
event = result.scalar_one_or_none()
|
||||||
@ -306,7 +431,11 @@ async def update_event(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
# 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)
|
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||||
|
is_invited_editor = False
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
@ -319,14 +448,62 @@ async def update_event(
|
|||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not event:
|
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")
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||||
|
|
||||||
# Shared calendar: require create_modify+ and check lock
|
# Load the event directly (bypassing calendar filter)
|
||||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
event_result = await db.execute(
|
||||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
select(CalendarEvent)
|
||||||
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
|
.where(CalendarEvent.id == event_id)
|
||||||
|
)
|
||||||
|
event = event_result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||||
|
is_invited_editor = True
|
||||||
|
|
||||||
update_data = event_update.model_dump(exclude_unset=True)
|
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
|
# Extract scope before applying fields to the model
|
||||||
scope: Optional[str] = update_data.pop("edit_scope", None)
|
scope: Optional[str] = update_data.pop("edit_scope", None)
|
||||||
|
|
||||||
@ -335,6 +512,7 @@ async def update_event(
|
|||||||
if rule_obj is not None:
|
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
|
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
|
# 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
|
# 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.
|
# an unchanged calendar_id must not be rejected just because they aren't the owner.
|
||||||
@ -451,6 +629,9 @@ async def delete_event(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
# 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)
|
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
43
backend/app/schemas/event_invitation.py
Normal file
43
backend/app/schemas/event_invitation.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from typing import Annotated, Literal, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(..., min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationRespond(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
status: Literal["accepted", "tentative", "declined"]
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationOverrideCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
status: Literal["accepted", "tentative", "declined"]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDisplayCalendar(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
calendar_id: Annotated[int, Field(ge=1, le=2147483647)]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCanModify(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
can_modify: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvitationResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
event_id: int
|
||||||
|
user_id: int
|
||||||
|
invited_by: Optional[int]
|
||||||
|
status: str
|
||||||
|
invited_at: datetime
|
||||||
|
responded_at: Optional[datetime]
|
||||||
|
invitee_name: Optional[str] = None
|
||||||
|
invitee_umbral_name: Optional[str] = None
|
||||||
|
can_modify: bool = False
|
||||||
@ -7,7 +7,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete, select, text, update
|
from sqlalchemy import delete, literal_column, select, text, union_all, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
@ -34,6 +34,42 @@ async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[in
|
|||||||
return [r[0] for r in result.all()]
|
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:
|
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
|
||||||
"""
|
"""
|
||||||
Returns "owner" if the user owns the calendar, the permission string
|
Returns "owner" if the user owns the calendar, the permission string
|
||||||
@ -220,6 +256,10 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
|
|||||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up event invitations between the two users
|
||||||
|
from app.services.event_invitation import cascade_event_invitations_on_disconnect
|
||||||
|
await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id)
|
||||||
|
|
||||||
# AC-5: Single aggregation query instead of N per-calendar checks
|
# AC-5: Single aggregation query instead of N per-calendar checks
|
||||||
all_cal_ids = a_cal_ids + b_cal_ids
|
all_cal_ids = a_cal_ids + b_cal_ids
|
||||||
if all_cal_ids:
|
if all_cal_ids:
|
||||||
|
|||||||
421
backend/app/services/event_invitation.py
Normal file
421
backend/app/services/event_invitation.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
"""
|
||||||
|
Event invitation service — send, respond, override, dismiss invitations.
|
||||||
|
|
||||||
|
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.calendar import Calendar
|
||||||
|
from app.models.calendar_event import CalendarEvent
|
||||||
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
|
from app.models.user_connection import UserConnection
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.notification import create_notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_connections(
|
||||||
|
db: AsyncSession, inviter_id: int, user_ids: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""Verify bidirectional connections exist for all invitees. Raises 404 on failure."""
|
||||||
|
if not user_ids:
|
||||||
|
return
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection.connected_user_id).where(
|
||||||
|
UserConnection.user_id == inviter_id,
|
||||||
|
UserConnection.connected_user_id.in_(user_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connected_ids = {r[0] for r in result.all()}
|
||||||
|
missing = set(user_ids) - connected_ids
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=404, detail="One or more users not found in your connections")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_event_invitations(
|
||||||
|
db: AsyncSession,
|
||||||
|
event_id: int,
|
||||||
|
user_ids: list[int],
|
||||||
|
invited_by: int,
|
||||||
|
) -> list[EventInvitation]:
|
||||||
|
"""
|
||||||
|
Bulk-insert invitations for an event. Skips self-invites and existing invitations.
|
||||||
|
Creates in-app notifications for each invitee.
|
||||||
|
"""
|
||||||
|
# Remove self from list
|
||||||
|
user_ids = [uid for uid in user_ids if uid != invited_by]
|
||||||
|
if not user_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot invite yourself")
|
||||||
|
|
||||||
|
# Validate connections
|
||||||
|
await validate_connections(db, invited_by, user_ids)
|
||||||
|
|
||||||
|
# Check existing invitations to skip duplicates
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(EventInvitation.user_id).where(
|
||||||
|
EventInvitation.event_id == event_id,
|
||||||
|
EventInvitation.user_id.in_(user_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_ids = {r[0] for r in existing_result.all()}
|
||||||
|
|
||||||
|
# Cap: max 20 invitations per event
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id)
|
||||||
|
)
|
||||||
|
current_count = count_result.scalar_one()
|
||||||
|
new_ids = [uid for uid in user_ids if uid not in existing_ids]
|
||||||
|
if current_count + len(new_ids) > 20:
|
||||||
|
raise HTTPException(status_code=400, detail="Maximum 20 invitations per event")
|
||||||
|
|
||||||
|
if not new_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Fetch event title for notifications
|
||||||
|
event_result = await db.execute(
|
||||||
|
select(CalendarEvent.title, CalendarEvent.start_datetime).where(
|
||||||
|
CalendarEvent.id == event_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
event_row = event_result.one_or_none()
|
||||||
|
event_title = event_row[0] if event_row else "an event"
|
||||||
|
event_start = event_row[1] if event_row else None
|
||||||
|
|
||||||
|
# Fetch inviter's name
|
||||||
|
inviter_settings = await db.execute(
|
||||||
|
select(Settings.preferred_name).where(Settings.user_id == invited_by)
|
||||||
|
)
|
||||||
|
inviter_name_row = inviter_settings.one_or_none()
|
||||||
|
inviter_name = inviter_name_row[0] if inviter_name_row and inviter_name_row[0] else "Someone"
|
||||||
|
|
||||||
|
invitations = []
|
||||||
|
for uid in new_ids:
|
||||||
|
inv = EventInvitation(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=uid,
|
||||||
|
invited_by=invited_by,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
invitations.append(inv)
|
||||||
|
|
||||||
|
# Flush to populate invitation IDs before creating notifications
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for inv in invitations:
|
||||||
|
start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else ""
|
||||||
|
await create_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=inv.user_id,
|
||||||
|
type="event_invite",
|
||||||
|
title="Event Invitation",
|
||||||
|
message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""),
|
||||||
|
data={"event_id": event_id, "event_title": event_title, "invitation_id": inv.id},
|
||||||
|
source_type="event_invitation",
|
||||||
|
source_id=event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return invitations
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_invitation(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
) -> EventInvitation:
|
||||||
|
"""Update invitation status. Returns the updated invitation."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventInvitation)
|
||||||
|
.options(selectinload(EventInvitation.event))
|
||||||
|
.where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
# Build response data before modifying
|
||||||
|
event_title = invitation.event.title
|
||||||
|
old_status = invitation.status
|
||||||
|
|
||||||
|
invitation.status = status
|
||||||
|
invitation.responded_at = datetime.now()
|
||||||
|
|
||||||
|
# Clear can_modify on decline (F-02: prevent silent re-grant)
|
||||||
|
if status == "declined":
|
||||||
|
invitation.can_modify = False
|
||||||
|
|
||||||
|
# Auto-assign display calendar on accept/tentative (atomic: only if not already set)
|
||||||
|
if status in ("accepted", "tentative"):
|
||||||
|
default_cal = await db.execute(
|
||||||
|
select(Calendar.id).where(
|
||||||
|
Calendar.user_id == user_id,
|
||||||
|
Calendar.is_default == True,
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
default_cal_id = default_cal.scalar_one_or_none()
|
||||||
|
if default_cal_id and invitation.display_calendar_id is None:
|
||||||
|
# Atomic: only set if still NULL (race-safe)
|
||||||
|
await db.execute(
|
||||||
|
update(EventInvitation)
|
||||||
|
.where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.display_calendar_id == None,
|
||||||
|
)
|
||||||
|
.values(display_calendar_id=default_cal_id)
|
||||||
|
)
|
||||||
|
invitation.display_calendar_id = default_cal_id
|
||||||
|
|
||||||
|
# Notify the inviter only if status actually changed (prevents duplicate notifications)
|
||||||
|
if invitation.invited_by and old_status != status:
|
||||||
|
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
|
||||||
|
# Fetch responder name
|
||||||
|
responder_settings = await db.execute(
|
||||||
|
select(Settings.preferred_name).where(Settings.user_id == user_id)
|
||||||
|
)
|
||||||
|
responder_row = responder_settings.one_or_none()
|
||||||
|
responder_name = responder_row[0] if responder_row and responder_row[0] else "Someone"
|
||||||
|
|
||||||
|
await create_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=invitation.invited_by,
|
||||||
|
type="event_invite_response",
|
||||||
|
title="Event RSVP",
|
||||||
|
message=f"{responder_name} is {status_label.get(status, status)} for {event_title}",
|
||||||
|
data={"event_id": invitation.event_id, "status": status},
|
||||||
|
source_type="event_invitation",
|
||||||
|
source_id=invitation.event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return invitation
|
||||||
|
|
||||||
|
|
||||||
|
async def override_occurrence_status(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
occurrence_id: int,
|
||||||
|
user_id: int,
|
||||||
|
status: str,
|
||||||
|
) -> EventInvitationOverride:
|
||||||
|
"""Create or update a per-occurrence status override."""
|
||||||
|
# Verify invitation belongs to user
|
||||||
|
inv_result = await db.execute(
|
||||||
|
select(EventInvitation).where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = inv_result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
if invitation.status not in ("accepted", "tentative"):
|
||||||
|
raise HTTPException(status_code=400, detail="Must accept or tentatively accept the invitation first")
|
||||||
|
|
||||||
|
# Verify occurrence belongs to the invited event's series
|
||||||
|
occ_result = await db.execute(
|
||||||
|
select(CalendarEvent).where(CalendarEvent.id == occurrence_id)
|
||||||
|
)
|
||||||
|
occurrence = occ_result.scalar_one_or_none()
|
||||||
|
if not occurrence:
|
||||||
|
raise HTTPException(status_code=404, detail="Occurrence not found")
|
||||||
|
|
||||||
|
# Occurrence must be the event itself OR a child of the invited event
|
||||||
|
if occurrence.id != invitation.event_id and occurrence.parent_event_id != invitation.event_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Occurrence does not belong to this event series")
|
||||||
|
|
||||||
|
# Upsert override
|
||||||
|
existing = await db.execute(
|
||||||
|
select(EventInvitationOverride).where(
|
||||||
|
EventInvitationOverride.invitation_id == invitation_id,
|
||||||
|
EventInvitationOverride.occurrence_id == occurrence_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override = existing.scalar_one_or_none()
|
||||||
|
if override:
|
||||||
|
override.status = status
|
||||||
|
override.responded_at = datetime.now()
|
||||||
|
else:
|
||||||
|
override = EventInvitationOverride(
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
occurrence_id=occurrence_id,
|
||||||
|
status=status,
|
||||||
|
responded_at=datetime.now(),
|
||||||
|
)
|
||||||
|
db.add(override)
|
||||||
|
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss_invitation(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an invitation (invitee leaving or owner revoking)."""
|
||||||
|
result = await db.execute(
|
||||||
|
delete(EventInvitation).where(
|
||||||
|
EventInvitation.id == invitation_id,
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss_invitation_by_owner(
|
||||||
|
db: AsyncSession,
|
||||||
|
invitation_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an invitation by the event owner (revoking)."""
|
||||||
|
result = await db.execute(
|
||||||
|
delete(EventInvitation).where(EventInvitation.id == invitation_id)
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_event_invitations(
|
||||||
|
db: AsyncSession,
|
||||||
|
event_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get all invitations for an event with invitee names."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitation,
|
||||||
|
Settings.preferred_name,
|
||||||
|
User.umbral_name,
|
||||||
|
)
|
||||||
|
.join(User, EventInvitation.user_id == User.id)
|
||||||
|
.outerjoin(Settings, Settings.user_id == User.id)
|
||||||
|
.where(EventInvitation.event_id == event_id)
|
||||||
|
.order_by(EventInvitation.invited_at.asc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": inv.id,
|
||||||
|
"event_id": inv.event_id,
|
||||||
|
"user_id": inv.user_id,
|
||||||
|
"invited_by": inv.invited_by,
|
||||||
|
"status": inv.status,
|
||||||
|
"invited_at": inv.invited_at,
|
||||||
|
"responded_at": inv.responded_at,
|
||||||
|
"invitee_name": preferred_name or umbral_name or "Unknown",
|
||||||
|
"invitee_umbral_name": umbral_name or "Unknown",
|
||||||
|
"can_modify": inv.can_modify,
|
||||||
|
}
|
||||||
|
for inv, preferred_name, umbral_name in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invited_event_ids(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return event IDs where user has a non-declined invitation."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EventInvitation.event_id).where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitation.status != "declined",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [r[0] for r in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_invitations(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return pending invitations for the current user."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitation,
|
||||||
|
CalendarEvent.title,
|
||||||
|
CalendarEvent.start_datetime,
|
||||||
|
Settings.preferred_name,
|
||||||
|
)
|
||||||
|
.join(CalendarEvent, EventInvitation.event_id == CalendarEvent.id)
|
||||||
|
.outerjoin(
|
||||||
|
User, EventInvitation.invited_by == User.id
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
Settings, Settings.user_id == User.id
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitation.status == "pending",
|
||||||
|
)
|
||||||
|
.order_by(EventInvitation.invited_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": inv.id,
|
||||||
|
"event_id": inv.event_id,
|
||||||
|
"event_title": title,
|
||||||
|
"event_start": start_dt,
|
||||||
|
"invited_by_name": inviter_name or "Someone",
|
||||||
|
"invited_at": inv.invited_at,
|
||||||
|
"status": inv.status,
|
||||||
|
}
|
||||||
|
for inv, title, start_dt, inviter_name in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invitation_overrides_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
event_ids: list[int],
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
For a list of occurrence event IDs, return a map of occurrence_id -> override status.
|
||||||
|
Used to annotate event listings with per-occurrence invitation status.
|
||||||
|
"""
|
||||||
|
if not event_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
EventInvitationOverride.occurrence_id,
|
||||||
|
EventInvitationOverride.status,
|
||||||
|
)
|
||||||
|
.join(EventInvitation, EventInvitationOverride.invitation_id == EventInvitation.id)
|
||||||
|
.where(
|
||||||
|
EventInvitation.user_id == user_id,
|
||||||
|
EventInvitationOverride.occurrence_id.in_(event_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {r[0]: r[1] for r in result.all()}
|
||||||
|
|
||||||
|
|
||||||
|
async def cascade_event_invitations_on_disconnect(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_a_id: int,
|
||||||
|
user_b_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Delete event invitations between two users when connection is severed."""
|
||||||
|
# Delete invitations where A invited B
|
||||||
|
await db.execute(
|
||||||
|
delete(EventInvitation).where(
|
||||||
|
EventInvitation.invited_by == user_a_id,
|
||||||
|
EventInvitation.user_id == user_b_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Delete invitations where B invited A
|
||||||
|
await db.execute(
|
||||||
|
delete(EventInvitation).where(
|
||||||
|
EventInvitation.invited_by == user_b_id,
|
||||||
|
EventInvitation.user_id == user_a_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -10,7 +10,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
|
|||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import enAuLocale from '@fullcalendar/core/locales/en-au';
|
import enAuLocale from '@fullcalendar/core/locales/en-au';
|
||||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
|
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
|
||||||
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat, Users } from 'lucide-react';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
|
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
|
||||||
@ -229,9 +229,8 @@ export default function CalendarPage() {
|
|||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
refetchInterval: 5_000,
|
||||||
refetchInterval: 30_000,
|
staleTime: 5_000,
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedEvent = useMemo(
|
const selectedEvent = useMemo(
|
||||||
@ -242,9 +241,10 @@ export default function CalendarPage() {
|
|||||||
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
|
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
|
||||||
const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false;
|
const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false;
|
||||||
|
|
||||||
// Close panel if shared calendar was removed while viewing
|
// Close panel if shared calendar was removed while viewing (skip for invited events)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEvent || allCalendarIds.size === 0) return;
|
if (!selectedEvent || allCalendarIds.size === 0) return;
|
||||||
|
if (selectedEvent.is_invited) return;
|
||||||
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
|
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
|
||||||
handlePanelClose();
|
handlePanelClose();
|
||||||
toast.info('This calendar is no longer available');
|
toast.info('This calendar is no longer available');
|
||||||
@ -331,7 +331,16 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
if (calendars.length === 0) return events;
|
if (calendars.length === 0) return events;
|
||||||
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
// Invited events: if display_calendar_id is set, respect that calendar's visibility;
|
||||||
|
// otherwise (pending) always show
|
||||||
|
return events.filter((e) => {
|
||||||
|
if (e.is_invited) {
|
||||||
|
return e.display_calendar_id
|
||||||
|
? visibleCalendarIds.has(e.display_calendar_id)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
return visibleCalendarIds.has(e.calendar_id);
|
||||||
|
});
|
||||||
}, [events, visibleCalendarIds, calendars.length]);
|
}, [events, visibleCalendarIds, calendars.length]);
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
@ -361,20 +370,26 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarEvents = filteredEvents.map((event) => ({
|
const calendarEvents = filteredEvents
|
||||||
|
// Exclude declined invited events from calendar display
|
||||||
|
.filter((event) => !(event.is_invited && event.invitation_status === 'declined'))
|
||||||
|
.map((event) => ({
|
||||||
id: String(event.id),
|
id: String(event.id),
|
||||||
title: event.title,
|
title: event.title,
|
||||||
start: event.start_datetime,
|
start: event.start_datetime,
|
||||||
end: event.end_datetime || undefined,
|
end: event.end_datetime || undefined,
|
||||||
allDay: event.all_day,
|
allDay: event.all_day,
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
editable: permissionMap.get(event.calendar_id) !== 'read_only',
|
editable: (event.is_invited && !!event.can_modify) || (!event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only'),
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
is_virtual: event.is_virtual,
|
is_virtual: event.is_virtual,
|
||||||
is_recurring: event.is_recurring,
|
is_recurring: event.is_recurring,
|
||||||
parent_event_id: event.parent_event_id,
|
parent_event_id: event.parent_event_id,
|
||||||
calendar_id: event.calendar_id,
|
calendar_id: event.calendar_id,
|
||||||
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
|
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,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -516,29 +531,44 @@ export default function CalendarPage() {
|
|||||||
const isMonth = arg.view.type === 'dayGridMonth';
|
const isMonth = arg.view.type === 'dayGridMonth';
|
||||||
const isAllDay = arg.event.allDay;
|
const isAllDay = arg.event.allDay;
|
||||||
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
|
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
|
||||||
|
const isInvited = arg.event.extendedProps.is_invited;
|
||||||
|
const hasActiveInvitees = arg.event.extendedProps.has_active_invitees;
|
||||||
|
const calColor = arg.event.extendedProps.calendarColor as string;
|
||||||
|
|
||||||
const repeatIcon = isRecurring ? (
|
// Sync --event-color on the parent FC element so CSS rules (background, hover)
|
||||||
<Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />
|
// pick up color changes without requiring a full remount (eventDidMount only fires once).
|
||||||
) : null;
|
const syncColor = (el: HTMLElement | null) => {
|
||||||
|
if (el && calColor) {
|
||||||
|
const fcEl = el.closest('.umbra-event');
|
||||||
|
if (fcEl) (fcEl as HTMLElement).style.setProperty('--event-color', calColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = (
|
||||||
|
<>
|
||||||
|
{(isInvited || hasActiveInvitees) && <Users className="h-2.5 w-2.5 shrink-0 opacity-60" />}
|
||||||
|
{isRecurring && <Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (isMonth) {
|
if (isMonth) {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 truncate px-1">
|
<div ref={syncColor} className="flex items-center gap-1 truncate px-1">
|
||||||
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
||||||
{repeatIcon}
|
{icons}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Timed events in month: dot + title + time right-aligned
|
// Timed events in month: dot + title + time right-aligned
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 truncate w-full">
|
<div ref={syncColor} className="flex items-center gap-1.5 truncate w-full">
|
||||||
<span
|
<span
|
||||||
className="fc-daygrid-event-dot"
|
className="fc-daygrid-event-dot"
|
||||||
style={{ borderColor: 'var(--event-color)' }}
|
style={{ borderColor: calColor || 'var(--event-color)' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
|
||||||
{repeatIcon}
|
{icons}
|
||||||
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
|
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -546,10 +576,10 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
// Week/day view — title on top, time underneath
|
// Week/day view — title on top, time underneath
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col overflow-hidden h-full">
|
<div ref={syncColor} className="flex flex-col overflow-hidden h-full">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
|
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
|
||||||
{repeatIcon}
|
{icons}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
|
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
|
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
@ -11,9 +11,12 @@ import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarP
|
|||||||
import { useCalendars } from '@/hooks/useCalendars';
|
import { useCalendars } from '@/hooks/useCalendars';
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
import { useEventLock } from '@/hooks/useEventLock';
|
import { useEventLock } from '@/hooks/useEventLock';
|
||||||
|
import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations';
|
||||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||||
import CopyableField from '@/components/shared/CopyableField';
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
import EventLockBanner from './EventLockBanner';
|
import EventLockBanner from './EventLockBanner';
|
||||||
|
import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection';
|
||||||
|
import LeaveEventDialog from './LeaveEventDialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
@ -234,6 +237,7 @@ export default function EventDetailPanel({
|
|||||||
.filter((m) => m.permission === 'create_modify' || m.permission === 'full_access')
|
.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 })),
|
.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 defaultCalendar = calendars.find((c) => c.is_default);
|
||||||
|
|
||||||
const { data: locations = [] } = useQuery({
|
const { data: locations = [] } = useQuery({
|
||||||
@ -251,6 +255,24 @@ export default function EventDetailPanel({
|
|||||||
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
// Event invitation hooks
|
||||||
|
const eventNumericId = event && typeof event.id === 'number' ? event.id : null;
|
||||||
|
const parentEventId = event?.parent_event_id ?? eventNumericId;
|
||||||
|
const {
|
||||||
|
invitees, 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 [isEditing, setIsEditing] = useState(isCreating);
|
||||||
const [editState, setEditState] = useState<EditState>(() =>
|
const [editState, setEditState] = useState<EditState>(() =>
|
||||||
isCreating
|
isCreating
|
||||||
@ -294,7 +316,7 @@ export default function EventDetailPanel({
|
|||||||
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
||||||
|
|
||||||
// Permission helpers
|
// Permission helpers
|
||||||
const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
|
const canEdit = canModifyAsInvitee || !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
|
||||||
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
|
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
|
||||||
|
|
||||||
// Reset state when event changes
|
// Reset state when event changes
|
||||||
@ -345,10 +367,13 @@ export default function EventDetailPanel({
|
|||||||
end_datetime: endDt,
|
end_datetime: endDt,
|
||||||
all_day: data.all_day,
|
all_day: data.all_day,
|
||||||
location_id: data.location_id ? parseInt(data.location_id) : null,
|
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,
|
is_starred: data.is_starred,
|
||||||
recurrence_rule: rule,
|
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 (event && !isCreating) {
|
||||||
if (editScope) payload.edit_scope = editScope;
|
if (editScope) payload.edit_scope = editScope;
|
||||||
@ -418,7 +443,14 @@ export default function EventDetailPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRecurring) {
|
if (isRecurring) {
|
||||||
|
// Invited editors can only edit "this" occurrence — skip scope step
|
||||||
|
if (canModifyAsInvitee) {
|
||||||
|
setEditScope('this');
|
||||||
|
if (event) setEditState(buildEditStateFromEvent(event));
|
||||||
|
setIsEditing(true);
|
||||||
|
} else {
|
||||||
setScopeStep('edit');
|
setScopeStep('edit');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (event) setEditState(buildEditStateFromEvent(event));
|
if (event) setEditState(buildEditStateFromEvent(event));
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@ -579,7 +611,8 @@ export default function EventDetailPanel({
|
|||||||
<>
|
<>
|
||||||
{!event?.is_virtual && (
|
{!event?.is_virtual && (
|
||||||
<>
|
<>
|
||||||
{canEdit && (
|
{/* Edit button — own events, shared with edit permission, or can_modify invitees */}
|
||||||
|
{canEdit && (!isInvitedEvent || canModifyAsInvitee) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -591,7 +624,20 @@ export default function EventDetailPanel({
|
|||||||
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{/* Leave button for invited events */}
|
||||||
|
{isInvitedEvent && myInvitationId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setShowLeaveDialog(true)}
|
||||||
|
title="Leave event"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Delete button for own events */}
|
||||||
|
{canDelete && !isInvitedEvent && (
|
||||||
confirmingDelete ? (
|
confirmingDelete ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -743,7 +789,8 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||||
|
{!canModifyAsInvitee && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-calendar">Calendar</Label>
|
<Label htmlFor="panel-calendar">Calendar</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -757,6 +804,7 @@ export default function EventDetailPanel({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-location">Location</Label>
|
<Label htmlFor="panel-location">Location</Label>
|
||||||
<LocationPicker
|
<LocationPicker
|
||||||
@ -789,7 +837,8 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurrence */}
|
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
||||||
|
{!canModifyAsInvitee && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -805,6 +854,7 @@ export default function EventDetailPanel({
|
|||||||
<option value="monthly_date">Monthly (date)</option>
|
<option value="monthly_date">Monthly (date)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editState.recurrence_type === 'every_n_days' && (
|
{editState.recurrence_type === 'every_n_days' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -898,12 +948,39 @@ export default function EventDetailPanel({
|
|||||||
<>
|
<>
|
||||||
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
|
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Calendar */}
|
{/* Calendar — for invited events with accepted/tentative, show picker */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
Calendar
|
Calendar
|
||||||
</div>
|
</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="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full shrink-0"
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
@ -911,6 +988,7 @@ export default function EventDetailPanel({
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm">{event?.calendar_name}</span>
|
<span className="text-sm">{event?.calendar_name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Starred */}
|
{/* Starred */}
|
||||||
@ -988,6 +1066,54 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invitee section — view mode */}
|
||||||
|
{event && !event.is_virtual && (
|
||||||
|
<>
|
||||||
|
{/* RSVP buttons for invitees */}
|
||||||
|
{isInvitedEvent && myInvitationId && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
Your RSVP
|
||||||
|
</div>
|
||||||
|
<RsvpButtons
|
||||||
|
currentStatus={myInvitationStatus || 'pending'}
|
||||||
|
onRespond={(status) => {
|
||||||
|
if (event.parent_event_id && eventNumericId) {
|
||||||
|
overrideInvitation({ invitationId: myInvitationId, occurrenceId: eventNumericId, status });
|
||||||
|
} else {
|
||||||
|
respondInvitation({ invitationId: myInvitationId, status });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isResponding={isResponding}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invitee list */}
|
||||||
|
{invitees.length > 0 && (
|
||||||
|
<InviteeList
|
||||||
|
invitees={invitees}
|
||||||
|
isRecurringChild={!!event.parent_event_id}
|
||||||
|
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 */}
|
{/* Updated at */}
|
||||||
{event && !event.is_virtual && (
|
{event && !event.is_virtual && (
|
||||||
<div className="pt-2 border-t border-border">
|
<div className="pt-2 border-t border-border">
|
||||||
@ -996,6 +1122,23 @@ export default function EventDetailPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Leave event dialog */}
|
||||||
|
{event && isInvitedEvent && myInvitationId && (
|
||||||
|
<LeaveEventDialog
|
||||||
|
open={showLeaveDialog}
|
||||||
|
onClose={() => setShowLeaveDialog(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
leaveInvitation(myInvitationId)
|
||||||
|
.then(() => onClose())
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setShowLeaveDialog(false));
|
||||||
|
}}
|
||||||
|
eventTitle={event.title}
|
||||||
|
isRecurring={!!(event.is_recurring || event.parent_event_id)}
|
||||||
|
isLeaving={isLeaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
264
frontend/src/components/calendar/InviteeSection.tsx
Normal file
264
frontend/src/components/calendar/InviteeSection.tsx
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Users, UserPlus, Search, X, Pencil, PencilOff } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { EventInvitation, Connection } from '@/types';
|
||||||
|
|
||||||
|
// ── Status display helpers ──
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
accepted: { label: 'Going', dotClass: 'bg-green-400', textClass: 'text-green-400' },
|
||||||
|
tentative: { label: 'Tentative', dotClass: 'bg-amber-400', textClass: 'text-amber-400' },
|
||||||
|
declined: { label: 'Declined', dotClass: 'bg-red-400', textClass: 'text-red-400' },
|
||||||
|
pending: { label: 'Pending', dotClass: 'bg-neutral-500', textClass: 'text-muted-foreground' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.pending;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-[7px] h-[7px] rounded-full ${config.dotClass}`} />
|
||||||
|
<span className={`text-xs ${config.textClass}`}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarCircle({ name }: { name: string }) {
|
||||||
|
const letter = name?.charAt(0)?.toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View Mode: InviteeList ──
|
||||||
|
|
||||||
|
interface InviteeListProps {
|
||||||
|
invitees: EventInvitation[];
|
||||||
|
isRecurringChild?: boolean;
|
||||||
|
isOwner?: boolean;
|
||||||
|
onToggleCanModify?: (invitationId: number, canModify: boolean) => void;
|
||||||
|
togglingInvitationId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteeList({ invitees, isRecurringChild, isOwner, onToggleCanModify, togglingInvitationId }: InviteeListProps) {
|
||||||
|
if (invitees.length === 0) return null;
|
||||||
|
|
||||||
|
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
|
||||||
|
const countLabel = goingCount > 0 ? `${goingCount} going` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Invitees
|
||||||
|
</div>
|
||||||
|
{countLabel && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">{countLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{invitees.map((inv) => (
|
||||||
|
<div key={inv.id} className="flex items-center gap-2 py-1">
|
||||||
|
<AvatarCircle name={inv.invitee_name} />
|
||||||
|
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span>
|
||||||
|
{isOwner && onToggleCanModify && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleCanModify(inv.id, !inv.can_modify)}
|
||||||
|
disabled={togglingInvitationId === inv.id}
|
||||||
|
title={inv.can_modify ? 'Remove edit access' : 'Allow editing'}
|
||||||
|
className={`p-1 rounded transition-colors ${
|
||||||
|
inv.can_modify
|
||||||
|
? 'text-accent bg-accent/10'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
style={inv.can_modify ? { color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' } : undefined}
|
||||||
|
>
|
||||||
|
{inv.can_modify ? <Pencil className="h-3 w-3" /> : <PencilOff className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<StatusBadge status={inv.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isRecurringChild && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Status shown for this occurrence
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Mode: InviteSearch ──
|
||||||
|
|
||||||
|
interface InviteSearchProps {
|
||||||
|
connections: Connection[];
|
||||||
|
existingInviteeIds: Set<number>;
|
||||||
|
onInvite: (userIds: number[]) => void;
|
||||||
|
isInviting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteSearch({ connections, existingInviteeIds, onInvite, isInviting }: InviteSearchProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
if (!search.trim()) return [];
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return connections
|
||||||
|
.filter((c) =>
|
||||||
|
!existingInviteeIds.has(c.connected_user_id) &&
|
||||||
|
!selectedIds.includes(c.connected_user_id) &&
|
||||||
|
(
|
||||||
|
(c.connected_preferred_name?.toLowerCase().includes(q)) ||
|
||||||
|
c.connected_umbral_name.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.slice(0, 6);
|
||||||
|
}, [search, connections, existingInviteeIds, selectedIds]);
|
||||||
|
|
||||||
|
const selectedConnections = connections.filter((c) => selectedIds.includes(c.connected_user_id));
|
||||||
|
|
||||||
|
const handleAdd = (userId: number) => {
|
||||||
|
setSelectedIds((prev) => [...prev, userId]);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (userId: number) => {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => id !== userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
onInvite(selectedIds);
|
||||||
|
setSelectedIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<UserPlus className="h-3 w-3" />
|
||||||
|
Invite People
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onBlur={() => setTimeout(() => setSearch(''), 200)}
|
||||||
|
placeholder="Search connections..."
|
||||||
|
className="h-8 pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
{search.trim() && searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||||
|
{searchResults.map((conn) => (
|
||||||
|
<button
|
||||||
|
key={conn.connected_user_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAdd(conn.connected_user_id)}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm truncate block">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
|
||||||
|
{conn.connected_preferred_name && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">@{conn.connected_umbral_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{search.trim() && searchResults.length === 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">No connections found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected invitees */}
|
||||||
|
{selectedConnections.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedConnections.map((conn) => (
|
||||||
|
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
|
||||||
|
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
|
||||||
|
<span className="text-sm flex-1 truncate">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(conn.connected_user_id)}
|
||||||
|
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isInviting}
|
||||||
|
className="w-full mt-1"
|
||||||
|
>
|
||||||
|
{isInviting ? 'Sending...' : `Send ${selectedIds.length === 1 ? 'Invite' : `${selectedIds.length} Invites`}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSVP Buttons (for invitee view) ──
|
||||||
|
|
||||||
|
interface RsvpButtonsProps {
|
||||||
|
currentStatus: string;
|
||||||
|
onRespond: (status: 'accepted' | 'tentative' | 'declined') => void;
|
||||||
|
isResponding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RsvpButtons({ currentStatus, onRespond, isResponding }: RsvpButtonsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRespond('accepted')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
currentStatus === 'accepted'
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'text-muted-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Going
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRespond('tentative')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
currentStatus === 'tentative'
|
||||||
|
? 'bg-amber-500/20 text-amber-400'
|
||||||
|
: 'text-muted-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Maybe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRespond('declined')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
currentStatus === 'declined'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: 'text-muted-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/calendar/LeaveEventDialog.tsx
Normal file
48
frontend/src/components/calendar/LeaveEventDialog.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface LeaveEventDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
eventTitle: string;
|
||||||
|
isRecurring: boolean;
|
||||||
|
isLeaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeaveEventDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
eventTitle,
|
||||||
|
isRecurring,
|
||||||
|
isLeaving,
|
||||||
|
}: LeaveEventDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Leave Event</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
This will remove you from “{eventTitle}”. You won’t see it on your calendar anymore.
|
||||||
|
</p>
|
||||||
|
{isRecurring && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
For recurring events: removed from all future occurrences.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLeaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm} disabled={isLeaving}>
|
||||||
|
{isLeaving ? 'Leaving...' : 'Leave Event'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react';
|
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { AppNotification } from '@/types';
|
import type { AppNotification } from '@/types';
|
||||||
|
|
||||||
export default function NotificationToaster() {
|
export default function NotificationToaster() {
|
||||||
@ -18,7 +18,7 @@ export default function NotificationToaster() {
|
|||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
const prevUnreadRef = useRef(0);
|
const prevUnreadRef = useRef(0);
|
||||||
// Track in-flight request IDs so repeated clicks are blocked
|
// Track in-flight request IDs so repeated clicks are blocked
|
||||||
const respondingRef = useRef<Set<number>>(new Set());
|
const respondingRef = useRef<Set<string>>(new Set());
|
||||||
// Always call the latest respond — Sonner toasts capture closures at creation time
|
// Always call the latest respond — Sonner toasts capture closures at creation time
|
||||||
const respondInviteRef = useRef(respondInvite);
|
const respondInviteRef = useRef(respondInvite);
|
||||||
const respondRef = useRef(respond);
|
const respondRef = useRef(respond);
|
||||||
@ -30,8 +30,9 @@ export default function NotificationToaster() {
|
|||||||
const handleConnectionRespond = useCallback(
|
const handleConnectionRespond = useCallback(
|
||||||
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
||||||
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
|
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
|
||||||
if (respondingRef.current.has(requestId)) return;
|
const key = `conn-${requestId}`;
|
||||||
respondingRef.current.add(requestId);
|
if (respondingRef.current.has(key)) return;
|
||||||
|
respondingRef.current.add(key);
|
||||||
|
|
||||||
// Immediately dismiss the custom toast and show a loading indicator
|
// Immediately dismiss the custom toast and show a loading indicator
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
@ -54,7 +55,7 @@ export default function NotificationToaster() {
|
|||||||
toast.error(getErrorMessage(err, 'Failed to respond to request'));
|
toast.error(getErrorMessage(err, 'Failed to respond to request'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
respondingRef.current.delete(requestId);
|
respondingRef.current.delete(key);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@ -63,8 +64,9 @@ export default function NotificationToaster() {
|
|||||||
|
|
||||||
const handleCalendarInviteRespond = useCallback(
|
const handleCalendarInviteRespond = useCallback(
|
||||||
async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
||||||
if (respondingRef.current.has(inviteId + 100000)) return;
|
const key = `cal-${inviteId}`;
|
||||||
respondingRef.current.add(inviteId + 100000);
|
if (respondingRef.current.has(key)) return;
|
||||||
|
respondingRef.current.add(key);
|
||||||
|
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
const loadingId = toast.loading(
|
const loadingId = toast.loading(
|
||||||
@ -83,11 +85,44 @@ export default function NotificationToaster() {
|
|||||||
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
|
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
respondingRef.current.delete(inviteId + 100000);
|
respondingRef.current.delete(key);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const handleEventInviteRespond = useCallback(
|
||||||
|
async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => {
|
||||||
|
const key = `event-${invitationId}`;
|
||||||
|
if (respondingRef.current.has(key)) return;
|
||||||
|
respondingRef.current.add(key);
|
||||||
|
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' };
|
||||||
|
const loadingId = toast.loading(`${statusLabel[status]}…`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/event-invitations/${invitationId}/respond`, { status });
|
||||||
|
toast.dismiss(loadingId);
|
||||||
|
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
|
||||||
|
toast.success(`Marked as ${successLabel[status]}`);
|
||||||
|
markReadRef.current([notificationId]).catch(() => {});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.dismiss(loadingId);
|
||||||
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
toast.success('Already responded');
|
||||||
|
markReadRef.current([notificationId]).catch(() => {});
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
respondingRef.current.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Track unread count changes to force-refetch the list
|
// Track unread count changes to force-refetch the list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||||
@ -100,10 +135,28 @@ export default function NotificationToaster() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notifications.length) return;
|
if (!notifications.length) return;
|
||||||
|
|
||||||
// On first load, record the max ID without toasting
|
// On first load, record the max ID — but still toast actionable unread items
|
||||||
if (!initializedRef.current) {
|
if (!initializedRef.current) {
|
||||||
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
|
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
|
||||||
initializedRef.current = true;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +179,10 @@ export default function NotificationToaster() {
|
|||||||
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
||||||
}
|
}
|
||||||
|
if (newNotifications.some((n) => n.type === 'event_invite')) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
}
|
||||||
|
|
||||||
// Show toasts
|
// Show toasts
|
||||||
newNotifications.forEach((notification) => {
|
newNotifications.forEach((notification) => {
|
||||||
@ -133,6 +190,8 @@ export default function NotificationToaster() {
|
|||||||
showConnectionRequestToast(notification);
|
showConnectionRequestToast(notification);
|
||||||
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
||||||
showCalendarInviteToast(notification);
|
showCalendarInviteToast(notification);
|
||||||
|
} else if (notification.type === 'event_invite' && notification.data) {
|
||||||
|
showEventInviteToast(notification);
|
||||||
} else {
|
} else {
|
||||||
toast(notification.title || 'New Notification', {
|
toast(notification.title || 'New Notification', {
|
||||||
description: notification.message || undefined,
|
description: notification.message || undefined,
|
||||||
@ -141,7 +200,7 @@ export default function NotificationToaster() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
|
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
|
||||||
|
|
||||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||||
const requestId = notification.source_id!;
|
const requestId = notification.source_id!;
|
||||||
@ -222,5 +281,84 @@ export default function NotificationToaster() {
|
|||||||
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
|
{ 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react';
|
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
@ -10,7 +10,7 @@ import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
import type { AppNotification } from '@/types';
|
import type { AppNotification } from '@/types';
|
||||||
|
|
||||||
@ -20,6 +20,8 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
|||||||
calendar_invite: { icon: Calendar, color: 'text-purple-400' },
|
calendar_invite: { icon: Calendar, color: 'text-purple-400' },
|
||||||
calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' },
|
calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' },
|
||||||
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
|
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' },
|
info: { icon: Info, color: 'text-blue-400' },
|
||||||
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
||||||
};
|
};
|
||||||
@ -41,6 +43,7 @@ export default function NotificationsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null);
|
||||||
|
|
||||||
// Build a set of pending connection request IDs for quick lookup
|
// Build a set of pending connection request IDs for quick lookup
|
||||||
const pendingInviteIds = useMemo(
|
const pendingInviteIds = useMemo(
|
||||||
@ -60,6 +63,10 @@ export default function NotificationsPage() {
|
|||||||
if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) {
|
if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
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(
|
const hasMissing = notifications.some(
|
||||||
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
|
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
|
||||||
);
|
);
|
||||||
@ -141,6 +148,52 @@ export default function NotificationsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleEventInviteRespond = async (
|
||||||
|
notification: AppNotification,
|
||||||
|
status: 'accepted' | 'tentative' | 'declined',
|
||||||
|
) => {
|
||||||
|
const data = notification.data as Record<string, unknown> | undefined;
|
||||||
|
const eventId = data?.event_id as number | undefined;
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
setRespondingEventInvite(notification.id);
|
||||||
|
try {
|
||||||
|
// Prefer invitation_id from notification data; fallback to pending fetch
|
||||||
|
let invitationId = data?.invitation_id as number | undefined;
|
||||||
|
if (!invitationId) {
|
||||||
|
const { data: pending } = await api.get('/event-invitations/pending');
|
||||||
|
const inv = (pending as Array<{ id: number; event_id: number }>).find(
|
||||||
|
(p) => p.event_id === eventId,
|
||||||
|
);
|
||||||
|
invitationId = inv?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationId) {
|
||||||
|
await api.put(`/event-invitations/${invitationId}/respond`, { status });
|
||||||
|
const successLabel = { accepted: 'Going', tentative: 'Tentative', declined: 'Declined' };
|
||||||
|
toast.success(`Marked as ${successLabel[status]}`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
} else {
|
||||||
|
toast.success('Already responded');
|
||||||
|
}
|
||||||
|
if (!notification.is_read) {
|
||||||
|
await markRead([notification.id]).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
toast.success('Already responded');
|
||||||
|
if (!notification.is_read) {
|
||||||
|
await markRead([notification.id]).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRespondingEventInvite(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleNotificationClick = async (notification: AppNotification) => {
|
const handleNotificationClick = async (notification: AppNotification) => {
|
||||||
// Don't navigate for pending connection requests — let user act inline
|
// Don't navigate for pending connection requests — let user act inline
|
||||||
if (
|
if (
|
||||||
@ -150,6 +203,10 @@ export default function NotificationsPage() {
|
|||||||
) {
|
) {
|
||||||
return;
|
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) {
|
if (!notification.is_read) {
|
||||||
await markRead([notification.id]).catch(() => {});
|
await markRead([notification.id]).catch(() => {});
|
||||||
}
|
}
|
||||||
@ -157,6 +214,10 @@ export default function NotificationsPage() {
|
|||||||
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
|
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
|
||||||
navigate('/people');
|
navigate('/people');
|
||||||
}
|
}
|
||||||
|
// Navigate to Calendar for event-related notifications
|
||||||
|
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
|
||||||
|
navigate('/calendar');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -311,6 +372,42 @@ export default function NotificationsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Timestamp + actions */}
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
|||||||
144
frontend/src/hooks/useEventInvitations.ts
Normal file
144
frontend/src/hooks/useEventInvitations.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { EventInvitation, Connection } from '@/types';
|
||||||
|
|
||||||
|
export function useEventInvitations(eventId: number | null) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const inviteesQuery = useQuery({
|
||||||
|
queryKey: ['event-invitations', eventId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<EventInvitation[]>(`/events/${eventId}/invitations`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteMutation = useMutation({
|
||||||
|
mutationFn: async (userIds: number[]) => {
|
||||||
|
const { data } = await api.post(`/events/${eventId}/invitations`, { user_ids: userIds });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
toast.success('Invitation sent');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to send invitation'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondMutation = useMutation({
|
||||||
|
mutationFn: async ({ invitationId, status }: { invitationId: number; status: 'accepted' | 'tentative' | 'declined' }) => {
|
||||||
|
const { data } = await api.put(`/event-invitations/${invitationId}/respond`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to respond to invitation'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const overrideMutation = useMutation({
|
||||||
|
mutationFn: async ({ invitationId, occurrenceId, status }: { invitationId: number; occurrenceId: number; status: 'accepted' | 'tentative' | 'declined' }) => {
|
||||||
|
const { data } = await api.put(`/event-invitations/${invitationId}/respond/${occurrenceId}`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to update status'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDisplayCalendarMutation = useMutation({
|
||||||
|
mutationFn: async ({ invitationId, calendarId }: { invitationId: number; calendarId: number }) => {
|
||||||
|
const { data } = await api.put(`/event-invitations/${invitationId}/display-calendar`, { calendar_id: calendarId });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
toast.success('Display calendar updated');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to update display calendar'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [togglingInvitationId, setTogglingInvitationId] = useState<number | null>(null);
|
||||||
|
const toggleCanModifyMutation = useMutation({
|
||||||
|
mutationFn: async ({ invitationId, canModify }: { invitationId: number; canModify: boolean }) => {
|
||||||
|
setTogglingInvitationId(invitationId);
|
||||||
|
const { data } = await api.put(`/event-invitations/${invitationId}/can-modify`, { can_modify: canModify });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setTogglingInvitationId(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setTogglingInvitationId(null);
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to update edit access'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveMutation = useMutation({
|
||||||
|
mutationFn: async (invitationId: number) => {
|
||||||
|
await api.delete(`/event-invitations/${invitationId}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
|
toast.success('Left event');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to leave event'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitees: inviteesQuery.data ?? [],
|
||||||
|
isLoadingInvitees: inviteesQuery.isLoading,
|
||||||
|
invite: inviteMutation.mutateAsync,
|
||||||
|
isInviting: inviteMutation.isPending,
|
||||||
|
respond: respondMutation.mutateAsync,
|
||||||
|
isResponding: respondMutation.isPending,
|
||||||
|
override: overrideMutation.mutateAsync,
|
||||||
|
updateDisplayCalendar: updateDisplayCalendarMutation.mutateAsync,
|
||||||
|
isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending,
|
||||||
|
leave: leaveMutation.mutateAsync,
|
||||||
|
isLeaving: leaveMutation.isPending,
|
||||||
|
toggleCanModify: toggleCanModifyMutation.mutateAsync,
|
||||||
|
isTogglingCanModify: toggleCanModifyMutation.isPending,
|
||||||
|
togglingInvitationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnectedUsersSearch() {
|
||||||
|
const connectionsQuery = useQuery({
|
||||||
|
queryKey: ['connections'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Connection[]>('/connections');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections: connectionsQuery.data ?? [],
|
||||||
|
isLoading: connectionsQuery.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -112,6 +112,12 @@ export interface CalendarEvent {
|
|||||||
parent_event_id?: number | null;
|
parent_event_id?: number | null;
|
||||||
is_recurring?: boolean;
|
is_recurring?: boolean;
|
||||||
original_start?: string | null;
|
original_start?: string | null;
|
||||||
|
is_invited?: boolean;
|
||||||
|
invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
|
||||||
|
invitation_id?: number | null;
|
||||||
|
display_calendar_id?: number | null;
|
||||||
|
can_modify?: boolean;
|
||||||
|
has_active_invitees?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -486,6 +492,31 @@ export interface CalendarInvite {
|
|||||||
invited_at: string;
|
invited_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event Invitations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EventInvitation {
|
||||||
|
id: number;
|
||||||
|
event_id: number;
|
||||||
|
user_id: number;
|
||||||
|
invited_by: number | null;
|
||||||
|
status: 'pending' | 'accepted' | 'tentative' | 'declined';
|
||||||
|
invited_at: string;
|
||||||
|
responded_at: string | null;
|
||||||
|
invitee_name: string;
|
||||||
|
invitee_umbral_name: string;
|
||||||
|
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 {
|
export interface EventLockInfo {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
locked_by_name: string | null;
|
locked_by_name: string | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user