From e4b45763b4a1cd42fcaa8289e9e58a531b684004 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 03:22:44 +0800 Subject: [PATCH 01/21] Phase 1: Schema and models for shared calendars Migrations 047-051: - 047: Add is_shared to calendars - 048: Create calendar_members table (permissions, status, constraints) - 049: Create event_locks table (5min TTL, permanent owner locks) - 050: Expand notification CHECK (calendar_invite types) - 051: Add updated_by to calendar_events + updated_at index New models: CalendarMember, EventLock Updated models: Calendar (is_shared, members), CalendarEvent (updated_by), Notification (3 new types) New schemas: shared_calendar.py (invite, respond, member, lock, sync) Updated schemas: calendar.py (is_shared, sharing response fields) Co-Authored-By: Claude Opus 4.6 --- .../047_add_is_shared_to_calendars.py | 23 ++++++ .../versions/048_create_calendar_members.py | 47 +++++++++++ .../versions/049_create_event_locks.py | 30 +++++++ .../050_expand_notification_types_calendar.py | 33 ++++++++ .../051_add_updated_by_to_calendar_events.py | 34 ++++++++ backend/app/models/__init__.py | 4 + backend/app/models/calendar.py | 7 +- backend/app/models/calendar_event.py | 5 ++ backend/app/models/calendar_member.py | 53 +++++++++++++ backend/app/models/event_lock.py | 31 ++++++++ backend/app/models/notification.py | 3 +- backend/app/schemas/calendar.py | 8 ++ backend/app/schemas/shared_calendar.py | 79 +++++++++++++++++++ 13 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/047_add_is_shared_to_calendars.py create mode 100644 backend/alembic/versions/048_create_calendar_members.py create mode 100644 backend/alembic/versions/049_create_event_locks.py create mode 100644 backend/alembic/versions/050_expand_notification_types_calendar.py create mode 100644 backend/alembic/versions/051_add_updated_by_to_calendar_events.py create mode 100644 backend/app/models/calendar_member.py create mode 100644 backend/app/models/event_lock.py create mode 100644 backend/app/schemas/shared_calendar.py diff --git a/backend/alembic/versions/047_add_is_shared_to_calendars.py b/backend/alembic/versions/047_add_is_shared_to_calendars.py new file mode 100644 index 0000000..81efe52 --- /dev/null +++ b/backend/alembic/versions/047_add_is_shared_to_calendars.py @@ -0,0 +1,23 @@ +"""Add is_shared to calendars + +Revision ID: 047 +Revises: 046 +""" +from alembic import op +import sqlalchemy as sa + +revision = "047" +down_revision = "046" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "calendars", + sa.Column("is_shared", sa.Boolean(), nullable=False, server_default="false"), + ) + + +def downgrade() -> None: + op.drop_column("calendars", "is_shared") diff --git a/backend/alembic/versions/048_create_calendar_members.py b/backend/alembic/versions/048_create_calendar_members.py new file mode 100644 index 0000000..18acfac --- /dev/null +++ b/backend/alembic/versions/048_create_calendar_members.py @@ -0,0 +1,47 @@ +"""Create calendar_members table + +Revision ID: 048 +Revises: 047 +""" +from alembic import op +import sqlalchemy as sa + +revision = "048" +down_revision = "047" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "calendar_members", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.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("permission", sa.String(20), nullable=False), + sa.Column("can_add_others", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("local_color", sa.String(20), 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("accepted_at", sa.DateTime(), nullable=True), + sa.UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"), + sa.CheckConstraint( + "permission IN ('read_only', 'create_modify', 'full_access')", + name="ck_calendar_members_permission", + ), + sa.CheckConstraint( + "status IN ('pending', 'accepted', 'rejected')", + name="ck_calendar_members_status", + ), + ) + op.create_index("ix_calendar_members_user_id", "calendar_members", ["user_id"]) + op.create_index("ix_calendar_members_calendar_id", "calendar_members", ["calendar_id"]) + op.create_index("ix_calendar_members_status", "calendar_members", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_calendar_members_status", table_name="calendar_members") + op.drop_index("ix_calendar_members_calendar_id", table_name="calendar_members") + op.drop_index("ix_calendar_members_user_id", table_name="calendar_members") + op.drop_table("calendar_members") diff --git a/backend/alembic/versions/049_create_event_locks.py b/backend/alembic/versions/049_create_event_locks.py new file mode 100644 index 0000000..847ba46 --- /dev/null +++ b/backend/alembic/versions/049_create_event_locks.py @@ -0,0 +1,30 @@ +"""Create event_locks table + +Revision ID: 049 +Revises: 048 +""" +from alembic import op +import sqlalchemy as sa + +revision = "049" +down_revision = "048" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "event_locks", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("event_id", sa.Integer(), sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("locked_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("locked_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("is_permanent", sa.Boolean(), nullable=False, server_default="false"), + ) + op.create_index("ix_event_locks_expires_at", "event_locks", ["expires_at"]) + + +def downgrade() -> None: + op.drop_index("ix_event_locks_expires_at", table_name="event_locks") + op.drop_table("event_locks") diff --git a/backend/alembic/versions/050_expand_notification_types_calendar.py b/backend/alembic/versions/050_expand_notification_types_calendar.py new file mode 100644 index 0000000..27df8ac --- /dev/null +++ b/backend/alembic/versions/050_expand_notification_types_calendar.py @@ -0,0 +1,33 @@ +"""Expand notification type CHECK for calendar invite types + +Revision ID: 050 +Revises: 049 +""" +from alembic import op + +revision = "050" +down_revision = "049" +branch_labels = None +depends_on = None + +_OLD_TYPES = ( + "connection_request", "connection_accepted", "connection_rejected", + "info", "warning", "reminder", "system", +) +_NEW_TYPES = _OLD_TYPES + ( + "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", +) + + +def _check_sql(types: tuple) -> str: + return f"type IN ({', '.join(repr(t) for t in types)})" + + +def upgrade() -> None: + op.drop_constraint("ck_notifications_type", "notifications", type_="check") + op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES)) + + +def downgrade() -> None: + op.drop_constraint("ck_notifications_type", "notifications", type_="check") + op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES)) diff --git a/backend/alembic/versions/051_add_updated_by_to_calendar_events.py b/backend/alembic/versions/051_add_updated_by_to_calendar_events.py new file mode 100644 index 0000000..7e7a040 --- /dev/null +++ b/backend/alembic/versions/051_add_updated_by_to_calendar_events.py @@ -0,0 +1,34 @@ +"""Add updated_by to calendar_events and ensure updated_at index + +Revision ID: 051 +Revises: 050 +""" +from alembic import op +import sqlalchemy as sa + +revision = "051" +down_revision = "050" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "calendar_events", + sa.Column( + "updated_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index( + "ix_calendar_events_updated_at", + "calendar_events", + ["updated_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_calendar_events_updated_at", table_name="calendar_events") + op.drop_column("calendar_events", "updated_by") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0b96dc8..81e14b1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,6 +18,8 @@ from app.models.audit_log import AuditLog from app.models.notification import Notification from app.models.connection_request import ConnectionRequest from app.models.user_connection import UserConnection +from app.models.calendar_member import CalendarMember +from app.models.event_lock import EventLock __all__ = [ "Settings", @@ -40,4 +42,6 @@ __all__ = [ "Notification", "ConnectionRequest", "UserConnection", + "CalendarMember", + "EventLock", ] diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index 43ab782..48c9ab2 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -1,7 +1,10 @@ from sqlalchemy import String, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime -from typing import List +from typing import List, TYPE_CHECKING + +if TYPE_CHECKING: + from app.models.calendar_member import CalendarMember from app.database import Base @@ -17,7 +20,9 @@ class Calendar(Base): is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + is_shared: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar") + members: Mapped[List["CalendarMember"]] = relationship(back_populates="calendar", cascade="all, delete-orphan") diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py index c85dfc7..2c4f256 100644 --- a/backend/app/models/calendar_event.py +++ b/backend/app/models/calendar_event.py @@ -32,6 +32,11 @@ class CalendarEvent(Base): # original_start: the originally computed occurrence datetime (children only) original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True) + updated_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/calendar_member.py b/backend/app/models/calendar_member.py new file mode 100644 index 0000000..56f647f --- /dev/null +++ b/backend/app/models/calendar_member.py @@ -0,0 +1,53 @@ +from sqlalchemy import ( + Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index, + String, UniqueConstraint, func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional +from app.database import Base + + +class CalendarMember(Base): + __tablename__ = "calendar_members" + __table_args__ = ( + UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"), + CheckConstraint( + "permission IN ('read_only', 'create_modify', 'full_access')", + name="ck_calendar_members_permission", + ), + CheckConstraint( + "status IN ('pending', 'accepted', 'rejected')", + name="ck_calendar_members_status", + ), + Index("ix_calendar_members_user_id", "user_id"), + Index("ix_calendar_members_calendar_id", "calendar_id"), + Index("ix_calendar_members_status", "status"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + calendar_id: Mapped[int] = mapped_column( + Integer, ForeignKey("calendars.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 + ) + permission: Mapped[str] = mapped_column(String(20), nullable=False) + can_add_others: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + local_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + invited_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now() + ) + accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="selectin") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="selectin") + inviter: Mapped[Optional["User"]] = relationship( + foreign_keys=[invited_by], lazy="selectin" + ) diff --git a/backend/app/models/event_lock.py b/backend/app/models/event_lock.py new file mode 100644 index 0000000..79bb05b --- /dev/null +++ b/backend/app/models/event_lock.py @@ -0,0 +1,31 @@ +from sqlalchemy import Boolean, DateTime, Integer, ForeignKey, Index, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional +from app.database import Base + + +class EventLock(Base): + __tablename__ = "event_locks" + __table_args__ = (Index("ix_event_locks_expires_at", "expires_at"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + event_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("calendar_events.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + locked_by: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + locked_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now() + ) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + is_permanent: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + + event: Mapped["CalendarEvent"] = relationship(lazy="selectin") + holder: Mapped["User"] = relationship(foreign_keys=[locked_by], lazy="selectin") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 58d71c3..2f4cfc6 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -5,10 +5,9 @@ from datetime import datetime from typing import Optional from app.database import Base -# Active: connection_request, connection_accepted -# Reserved: connection_rejected, info, warning, reminder, system _NOTIFICATION_TYPES = ( "connection_request", "connection_accepted", "connection_rejected", + "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", "info", "warning", "reminder", "system", ) diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py index e9e2753..15da21f 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -8,6 +8,7 @@ class CalendarCreate(BaseModel): name: str = Field(min_length=1, max_length=100) color: str = Field("#3b82f6", max_length=20) + is_shared: bool = False class CalendarUpdate(BaseModel): @@ -16,6 +17,7 @@ class CalendarUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) color: Optional[str] = Field(None, max_length=20) is_visible: Optional[bool] = None + is_shared: Optional[bool] = None class CalendarResponse(BaseModel): @@ -27,5 +29,11 @@ class CalendarResponse(BaseModel): is_visible: bool created_at: datetime updated_at: datetime + is_shared: bool = False + owner_umbral_name: Optional[str] = None + my_permission: Optional[str] = None + my_can_add_others: bool = False + my_local_color: Optional[str] = None + member_count: int = 0 model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/shared_calendar.py b/backend/app/schemas/shared_calendar.py new file mode 100644 index 0000000..5b5420f --- /dev/null +++ b/backend/app/schemas/shared_calendar.py @@ -0,0 +1,79 @@ +import re +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Optional, Literal +from datetime import datetime + + +class InviteMemberRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + connection_id: int = Field(ge=1, le=2147483647) + permission: Literal["read_only", "create_modify", "full_access"] + can_add_others: bool = False + + +class RespondInviteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + action: Literal["accept", "reject"] + + +class UpdateMemberRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + permission: Optional[Literal["read_only", "create_modify", "full_access"]] = None + can_add_others: Optional[bool] = None + + +class UpdateLocalColorRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + local_color: Optional[str] = Field(None, max_length=20) + + @field_validator("local_color") + @classmethod + def validate_color(cls, v: Optional[str]) -> Optional[str]: + if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v): + raise ValueError("Color must be a hex color code (#RRGGBB)") + return v + + +class ConvertToSharedRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class CalendarMemberResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + calendar_id: int + user_id: int + umbral_name: str + preferred_name: Optional[str] = None + permission: str + can_add_others: bool + local_color: Optional[str] = None + status: str + invited_at: datetime + accepted_at: Optional[datetime] = None + + +class CalendarInviteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + calendar_id: int + calendar_name: str + calendar_color: str + owner_umbral_name: str + inviter_umbral_name: str + permission: str + invited_at: datetime + + +class LockStatusResponse(BaseModel): + locked: bool + locked_by_name: Optional[str] = None + expires_at: Optional[datetime] = None + is_permanent: bool = False + + +class SyncResponse(BaseModel): + events: list[dict] + member_changes: list[dict] + server_time: datetime + truncated: bool = False From e6e81c59e70b02cf4d9d22c0ad6ab5dc656c59d8 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 04:46:17 +0800 Subject: [PATCH 02/21] Phase 2: Shared calendars backend core + QA fixes Router: invite/accept/reject flow, membership CRUD, event locking (timed + permanent), sync endpoint, local color override. Services: permission hierarchy, atomic lock acquisition, disconnect cascade. Events: shared calendar scoping, permission/lock enforcement, updated_by tracking. Admin: sharing-stats endpoint. nginx: rate limits for invite + sync. QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future), W-01 (pre-commit response build), W-02 (owned calendar short-circuit), W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch). Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 5 +- backend/app/routers/admin.py | 52 ++ backend/app/routers/connections.py | 4 + backend/app/routers/events.py | 57 +- backend/app/routers/shared_calendars.py | 849 +++++++++++++++++++++++ backend/app/services/calendar_sharing.py | 205 ++++++ frontend/nginx.conf | 16 + 7 files changed, 1178 insertions(+), 10 deletions(-) create mode 100644 backend/app/routers/shared_calendars.py create mode 100644 backend/app/services/calendar_sharing.py diff --git a/backend/app/main.py b/backend/app/main.py index 768e19c..ae8f094 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp, admin, notifications as notifications_router, connections as connections_router +from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -20,6 +20,8 @@ from app.models import audit_log as _audit_log_model # noqa: F401 from app.models import notification as _notification_model # noqa: F401 from app.models import connection_request as _connection_request_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 event_lock as _event_lock_model # noqa: F401 # --------------------------------------------------------------------------- @@ -134,6 +136,7 @@ app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) +app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"]) @app.get("/") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 56ead05..d1357f5 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.audit_log import AuditLog +from app.models.calendar import Calendar +from app.models.calendar_member import CalendarMember from app.models.backup_code import BackupCode from app.models.session import UserSession from app.models.settings import Settings @@ -618,6 +620,56 @@ async def list_user_sessions( } +# --------------------------------------------------------------------------- +# GET /users/{user_id}/sharing-stats +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}/sharing-stats") +async def get_user_sharing_stats( + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return sharing statistics for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + # Calendars owned that are shared + shared_owned = await db.scalar( + sa.select(sa.func.count()) + .select_from(Calendar) + .where(Calendar.user_id == user_id, Calendar.is_shared == True) + ) or 0 + + # Calendars the user is a member of (accepted) + member_of = await db.scalar( + sa.select(sa.func.count()) + .select_from(CalendarMember) + .where(CalendarMember.user_id == user_id, CalendarMember.status == "accepted") + ) or 0 + + # Pending invites sent by this user + pending_sent = await db.scalar( + sa.select(sa.func.count()) + .select_from(CalendarMember) + .where(CalendarMember.invited_by == user_id, CalendarMember.status == "pending") + ) or 0 + + # Pending invites received by this user + pending_received = await db.scalar( + sa.select(sa.func.count()) + .select_from(CalendarMember) + .where(CalendarMember.user_id == user_id, CalendarMember.status == "pending") + ) or 0 + + return { + "shared_calendars_owned": shared_owned, + "calendars_member_of": member_of, + "pending_invites_sent": pending_sent, + "pending_invites_received": pending_received, + } + # --------------------------------------------------------------------------- # GET /config # --------------------------------------------------------------------------- diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index 26b72bd..e3ded30 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -49,6 +49,7 @@ from app.services.connection import ( resolve_shared_profile, send_connection_ntfy, ) +from app.services.calendar_sharing import cascade_on_disconnect from app.services.notification import create_notification router = APIRouter() @@ -823,6 +824,9 @@ async def remove_connection( if reverse_conn: await db.delete(reverse_conn) + # Cascade: remove calendar memberships and event locks between these users + await cascade_on_disconnect(db, current_user.id, counterpart_id) + await log_audit_event( db, action="connection.removed", diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index c0a3c2e..df83bb0 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -18,7 +18,9 @@ from app.schemas.calendar_event import ( ) from app.routers.auth import get_current_user from app.models.user import User +from app.models.calendar_member import CalendarMember from app.services.recurrence import generate_occurrences +from app.services.calendar_sharing import check_lock_for_edit, require_permission router = APIRouter() @@ -142,13 +144,18 @@ async def get_events( recurrence_rule IS NOT NULL) are excluded — their materialised children are what get displayed on the calendar. """ - # Scope events through calendar ownership + # Scope events through calendar ownership + shared memberships user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.calendar_id.in_(user_calendar_ids)) + .where(CalendarEvent.calendar_id.in_(all_calendar_ids)) ) # Exclude parent template rows — they are not directly rendered @@ -219,8 +226,13 @@ async def create_event( if not data.get("calendar_id"): data["calendar_id"] = await _get_default_calendar_id(db, current_user.id) else: - # SEC-04: verify the target calendar belongs to the requesting user - await _verify_calendar_ownership(db, data["calendar_id"], current_user.id) + # SEC-04: verify ownership OR shared calendar permission + cal_ownership_result = await db.execute( + select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id) + ) + if not cal_ownership_result.scalar_one_or_none(): + # Not owned — check shared calendar permission + await require_permission(db, data["calendar_id"], current_user.id, "create_modify") # Serialize RecurrenceRule object to JSON string for DB storage # Exclude None values so defaults in recurrence service work correctly @@ -229,7 +241,7 @@ async def create_event( if rule_json: # Parent template: is_recurring=True, no parent_event_id - parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True) + parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id) db.add(parent) await db.flush() # assign parent.id before generating children @@ -258,7 +270,7 @@ async def create_event( return result.scalar_one() else: - new_event = CalendarEvent(**data, recurrence_rule=None) + new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id) db.add(new_event) await db.commit() @@ -277,13 +289,18 @@ async def get_event( current_user: User = Depends(get_current_user), ): user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.calendar_id.in_(all_calendar_ids), ) ) event = result.scalar_one_or_none() @@ -302,13 +319,18 @@ async def update_event( current_user: User = Depends(get_current_user), ): user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.calendar_id.in_(all_calendar_ids), ) ) event = result.scalar_one_or_none() @@ -316,6 +338,10 @@ async def update_event( if not event: raise HTTPException(status_code=404, detail="Calendar event not found") + # Shared calendar: require create_modify+ and check lock + await require_permission(db, event.calendar_id, current_user.id, "create_modify") + await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + update_data = event_update.model_dump(exclude_unset=True) # Extract scope before applying fields to the model @@ -342,6 +368,7 @@ async def update_event( # Detach from parent so it's an independent event going forward event.parent_event_id = None event.is_recurring = False + event.updated_by = current_user.id await db.commit() elif scope == "this_and_future": @@ -371,6 +398,7 @@ async def update_event( event.parent_event_id = None event.is_recurring = True event.original_start = None + event.updated_by = current_user.id # Inherit parent's recurrence_rule if none was provided in update if not event.recurrence_rule and parent_rule: @@ -386,6 +414,7 @@ async def update_event( # This IS a parent — update it and regenerate all children for key, value in update_data.items(): setattr(event, key, value) + event.updated_by = current_user.id # Delete all existing children and regenerate if event.recurrence_rule: @@ -405,6 +434,7 @@ async def update_event( # No scope — plain update (non-recurring events or full-series metadata) for key, value in update_data.items(): setattr(event, key, value) + event.updated_by = current_user.id await db.commit() result = await db.execute( @@ -427,11 +457,16 @@ async def delete_event( current_user: User = Depends(get_current_user), ): user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) result = await db.execute( select(CalendarEvent).where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.calendar_id.in_(all_calendar_ids), ) ) event = result.scalar_one_or_none() @@ -439,6 +474,10 @@ async def delete_event( if not event: raise HTTPException(status_code=404, detail="Calendar event not found") + # Shared calendar: require full_access+ and check lock + await require_permission(db, event.calendar_id, current_user.id, "full_access") + await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + if scope == "this": # Delete just this one occurrence await db.delete(event) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py new file mode 100644 index 0000000..047e68e --- /dev/null +++ b/backend/app/routers/shared_calendars.py @@ -0,0 +1,849 @@ +""" +Shared calendars router — invites, membership, locks, sync. + +All endpoints live under /api/shared-calendars. +""" +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from sqlalchemy import delete, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.calendar import Calendar +from app.models.calendar_event import CalendarEvent +from app.models.calendar_member import CalendarMember +from app.models.event_lock import EventLock +from app.models.settings import Settings +from app.models.user import User +from app.models.user_connection import UserConnection +from app.routers.auth import get_current_user +from app.schemas.shared_calendar import ( + CalendarInviteResponse, + CalendarMemberResponse, + InviteMemberRequest, + LockStatusResponse, + RespondInviteRequest, + SyncResponse, + UpdateLocalColorRequest, + UpdateMemberRequest, +) +from app.services.audit import get_client_ip, log_audit_event +from app.services.calendar_sharing import ( + PERMISSION_RANK, + acquire_lock, + get_user_permission, + release_lock, + require_permission, +) +from app.services.notification import create_notification + +router = APIRouter() +logger = logging.getLogger(__name__) + +PENDING_INVITE_CAP = 10 + + +# -- Helpers --------------------------------------------------------------- + +async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None: + result = await db.execute(select(Settings).where(Settings.user_id == user_id)) + return result.scalar_one_or_none() + + +def _build_member_response(member: CalendarMember) -> dict: + return { + "id": member.id, + "calendar_id": member.calendar_id, + "user_id": member.user_id, + "umbral_name": member.user.umbral_name if member.user else "", + "preferred_name": None, + "permission": member.permission, + "can_add_others": member.can_add_others, + "local_color": member.local_color, + "status": member.status, + "invited_at": member.invited_at, + "accepted_at": member.accepted_at, + } + + +# -- GET / — List accepted memberships ------------------------------------ + +@router.get("/") +async def list_shared_calendars( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List calendars the current user has accepted membership in.""" + result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + .options(selectinload(CalendarMember.calendar)) + .order_by(CalendarMember.accepted_at.desc()) + ) + members = result.scalars().all() + + return [ + { + "id": m.id, + "calendar_id": m.calendar_id, + "calendar_name": m.calendar.name if m.calendar else "", + "calendar_color": m.calendar.color if m.calendar else "", + "local_color": m.local_color, + "permission": m.permission, + "can_add_others": m.can_add_others, + "is_owner": False, + } + for m in members + ] + + +# -- POST /{cal_id}/invite — Invite via connection_id --------------------- + +@router.post("/{cal_id}/invite", status_code=201) +async def invite_member( + body: InviteMemberRequest, + request: Request, + cal_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Invite a connected user to a shared calendar.""" + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id) + ) + calendar = cal_result.scalar_one_or_none() + if not calendar: + raise HTTPException(status_code=404, detail="Calendar not found") + + is_owner = calendar.user_id == current_user.id + inviter_perm = "owner" if is_owner else None + + if not is_owner: + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == cal_id, + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Calendar not found") + if not member.can_add_others: + raise HTTPException(status_code=403, detail="You do not have permission to invite others") + if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0): + raise HTTPException(status_code=403, detail="Read-only members cannot invite others") + inviter_perm = member.permission + + # Permission ceiling + if inviter_perm != "owner": + if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0): + raise HTTPException( + status_code=403, + detail="Cannot grant a permission level higher than your own", + ) + + # Resolve connection_id -> connected user + conn_result = await db.execute( + select(UserConnection).where( + UserConnection.id == body.connection_id, + UserConnection.user_id == current_user.id, + ) + ) + connection = conn_result.scalar_one_or_none() + if not connection: + raise HTTPException(status_code=404, detail="Connection not found") + + target_user_id = connection.connected_user_id + + if target_user_id == calendar.user_id: + raise HTTPException(status_code=400, detail="Cannot invite the calendar owner") + + target_result = await db.execute( + select(User).where(User.id == target_user_id) + ) + target = target_result.scalar_one_or_none() + if not target or not target.is_active: + raise HTTPException(status_code=404, detail="Target user not found or inactive") + + existing = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == cal_id, + CalendarMember.user_id == target_user_id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="User already invited or is a member") + + pending_count = await db.scalar( + select(func.count()) + .select_from(CalendarMember) + .where( + CalendarMember.calendar_id == cal_id, + CalendarMember.status == "pending", + ) + ) or 0 + if pending_count >= PENDING_INVITE_CAP: + raise HTTPException(status_code=429, detail="Too many pending invites for this calendar") + + if not calendar.is_shared: + calendar.is_shared = True + + new_member = CalendarMember( + calendar_id=cal_id, + user_id=target_user_id, + invited_by=current_user.id, + permission=body.permission, + can_add_others=body.can_add_others, + status="pending", + ) + db.add(new_member) + await db.flush() + + inviter_settings = await _get_settings_for_user(db, current_user.id) + inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name + + cal_name = calendar.name + await create_notification( + db, + user_id=target_user_id, + type="calendar_invite", + title="Calendar Invite", + message=f"{inviter_display} invited you to '{cal_name}'", + data={"calendar_id": cal_id, "calendar_name": cal_name}, + source_type="calendar_invite", + source_id=new_member.id, + ) + + await log_audit_event( + db, + action="calendar.invite_sent", + actor_id=current_user.id, + target_id=target_user_id, + detail={ + "calendar_id": cal_id, + "calendar_name": cal_name, + "permission": body.permission, + }, + ip=get_client_ip(request), + ) + + response = { + "message": "Invite sent", + "member_id": new_member.id, + "calendar_id": cal_id, + } + + await db.commit() + return response + + +# -- PUT /invites/{id}/respond — Accept or reject ------------------------- + +@router.put("/invites/{invite_id}/respond") +async def respond_to_invite( + body: RespondInviteRequest, + request: Request, + invite_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Accept or reject a calendar invite.""" + result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.id == invite_id, + CalendarMember.user_id == current_user.id, + CalendarMember.status == "pending", + ) + .options(selectinload(CalendarMember.calendar)) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(status_code=404, detail="Invite not found or already resolved") + + calendar_name = invite.calendar.name if invite.calendar else "Unknown" + calendar_owner_id = invite.calendar.user_id if invite.calendar else None + inviter_id = invite.invited_by + + if body.action == "accept": + invite.status = "accepted" + invite.accepted_at = datetime.now() + + notify_user_id = inviter_id or calendar_owner_id + if notify_user_id: + user_settings = await _get_settings_for_user(db, current_user.id) + display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name + await create_notification( + db, + user_id=notify_user_id, + type="calendar_invite_accepted", + title="Invite Accepted", + message=f"{display} accepted your invite to '{calendar_name}'", + data={"calendar_id": invite.calendar_id}, + source_type="calendar_invite", + source_id=invite.id, + ) + + await log_audit_event( + db, + action="calendar.invite_accepted", + actor_id=current_user.id, + detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name}, + ip=get_client_ip(request), + ) + + await db.commit() + return {"message": "Invite accepted"} + + else: + member_id = invite.id + calendar_id = invite.calendar_id + + notify_user_id = inviter_id or calendar_owner_id + if notify_user_id: + user_settings = await _get_settings_for_user(db, current_user.id) + display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name + await create_notification( + db, + user_id=notify_user_id, + type="calendar_invite_rejected", + title="Invite Rejected", + message=f"{display} declined your invite to '{calendar_name}'", + data={"calendar_id": calendar_id}, + source_type="calendar_invite", + source_id=member_id, + ) + + await log_audit_event( + db, + action="calendar.invite_rejected", + actor_id=current_user.id, + detail={"calendar_id": calendar_id, "calendar_name": calendar_name}, + ip=get_client_ip(request), + ) + + await db.delete(invite) + await db.commit() + return {"message": "Invite rejected"} + + +# -- GET /invites/incoming — Pending invites ------------------------------- + +@router.get("/invites/incoming", response_model=list[CalendarInviteResponse]) +async def get_incoming_invites( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List pending calendar invites for the current user.""" + offset = (page - 1) * per_page + result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "pending", + ) + .options( + selectinload(CalendarMember.calendar), + selectinload(CalendarMember.inviter), + ) + .order_by(CalendarMember.invited_at.desc()) + .offset(offset) + .limit(per_page) + ) + invites = result.scalars().all() + + # Batch-fetch owner names to avoid N+1 + owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar}) + if owner_ids: + owner_result = await db.execute( + select(User.id, User.umbral_name).where(User.id.in_(owner_ids)) + ) + owner_names = {row.id: row.umbral_name for row in owner_result.all()} + else: + owner_names = {} + + responses = [] + for inv in invites: + owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else "" + + responses.append(CalendarInviteResponse( + id=inv.id, + calendar_id=inv.calendar_id, + calendar_name=inv.calendar.name if inv.calendar else "", + calendar_color=inv.calendar.color if inv.calendar else "", + owner_umbral_name=owner_name, + inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "", + permission=inv.permission, + invited_at=inv.invited_at, + )) + + return responses + + +# -- GET /{cal_id}/members — Member list ----------------------------------- + +@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse]) +async def list_members( + cal_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all members of a shared calendar. Requires membership or ownership.""" + perm = await get_user_permission(db, cal_id, current_user.id) + if perm is None: + raise HTTPException(status_code=404, detail="Calendar not found") + + result = await db.execute( + select(CalendarMember) + .where(CalendarMember.calendar_id == cal_id) + .options(selectinload(CalendarMember.user)) + .order_by(CalendarMember.invited_at.asc()) + ) + members = result.scalars().all() + + user_ids = [m.user_id for m in members] + if user_ids: + settings_result = await db.execute( + select(Settings.user_id, Settings.preferred_name) + .where(Settings.user_id.in_(user_ids)) + ) + pref_names = {row.user_id: row.preferred_name for row in settings_result.all()} + else: + pref_names = {} + + responses = [] + for m in members: + resp = _build_member_response(m) + resp["preferred_name"] = pref_names.get(m.user_id) + responses.append(resp) + + return responses + + +# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ---------- + +@router.put("/{cal_id}/members/{member_id}") +async def update_member( + body: UpdateMemberRequest, + request: Request, + cal_id: int = Path(ge=1, le=2147483647), + member_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update a member permission or can_add_others. Owner only.""" + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id) + ) + if not cal_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Only the calendar owner can update members") + + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.id == member_id, + CalendarMember.calendar_id == cal_id, + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + update_data = body.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + for key, value in update_data.items(): + setattr(member, key, value) + + await log_audit_event( + db, + action="calendar.member_updated", + actor_id=current_user.id, + target_id=member.user_id, + detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data}, + ip=get_client_ip(request), + ) + + await db.commit() + return {"message": "Member updated"} + + +# -- DELETE /{cal_id}/members/{mid} — Remove member or leave --------------- + +@router.delete("/{cal_id}/members/{member_id}", status_code=204) +async def remove_member( + request: Request, + cal_id: int = Path(ge=1, le=2147483647), + member_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a member or leave a shared calendar.""" + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.id == member_id, + CalendarMember.calendar_id == cal_id, + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id) + ) + calendar = cal_result.scalar_one_or_none() + + is_self = member.user_id == current_user.id + is_owner = calendar and calendar.user_id == current_user.id + + if not is_self and not is_owner: + raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members") + + target_user_id = member.user_id + + await db.execute( + delete(EventLock).where( + EventLock.locked_by == target_user_id, + EventLock.event_id.in_( + select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id) + ), + ) + ) + + await db.delete(member) + + remaining = await db.execute( + select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) + ) + if not remaining.scalar_one_or_none() and calendar: + calendar.is_shared = False + + action = "calendar.member_left" if is_self else "calendar.member_removed" + await log_audit_event( + db, + action=action, + actor_id=current_user.id, + target_id=target_user_id, + detail={"calendar_id": cal_id, "member_id": member_id}, + ip=get_client_ip(request), + ) + + await db.commit() + return None + + +# -- PUT /{cal_id}/members/me/color — Update local color ------------------- + +@router.put("/{cal_id}/members/me/color") +async def update_local_color( + body: UpdateLocalColorRequest, + cal_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the current user local color for a shared calendar.""" + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == cal_id, + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Membership not found") + + member.local_color = body.local_color + await db.commit() + return {"message": "Color updated"} + + +# -- GET /sync — Sync endpoint --------------------------------------------- + +@router.get("/sync", response_model=SyncResponse) +async def sync_shared_calendars( + since: datetime = Query(...), + calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Sync events and member changes since a given timestamp. Cap 500 events.""" + MAX_EVENTS = 500 + + cal_id_list: list[int] = [] + if calendar_ids: + for part in calendar_ids.split(","): + part = part.strip() + if part.isdigit(): + cal_id_list.append(int(part)) + cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause + + owned_ids_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == current_user.id) + ) + owned_ids = {row[0] for row in owned_ids_result.all()} + + member_ids_result = await db.execute( + select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + ) + member_ids = {row[0] for row in member_ids_result.all()} + + accessible = owned_ids | member_ids + if cal_id_list: + accessible = accessible & set(cal_id_list) + + if not accessible: + return SyncResponse(events=[], member_changes=[], server_time=datetime.now()) + + accessible_list = list(accessible) + + events_result = await db.execute( + select(CalendarEvent) + .where( + CalendarEvent.calendar_id.in_(accessible_list), + CalendarEvent.updated_at >= since, + ) + .options(selectinload(CalendarEvent.calendar)) + .order_by(CalendarEvent.updated_at.desc()) + .limit(MAX_EVENTS + 1) + ) + events = events_result.scalars().all() + truncated = len(events) > MAX_EVENTS + events = events[:MAX_EVENTS] + + event_dicts = [] + for e in events: + event_dicts.append({ + "id": e.id, + "title": e.title, + "start_datetime": e.start_datetime.isoformat() if e.start_datetime else None, + "end_datetime": e.end_datetime.isoformat() if e.end_datetime else None, + "all_day": e.all_day, + "calendar_id": e.calendar_id, + "calendar_name": e.calendar.name if e.calendar else "", + "updated_at": e.updated_at.isoformat() if e.updated_at else None, + "updated_by": e.updated_by, + }) + + members_result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.calendar_id.in_(accessible_list), + (CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since), + ) + .options(selectinload(CalendarMember.user)) + ) + member_changes = members_result.scalars().all() + + member_dicts = [] + for m in member_changes: + member_dicts.append({ + "id": m.id, + "calendar_id": m.calendar_id, + "user_id": m.user_id, + "umbral_name": m.user.umbral_name if m.user else "", + "permission": m.permission, + "status": m.status, + "invited_at": m.invited_at.isoformat() if m.invited_at else None, + "accepted_at": m.accepted_at.isoformat() if m.accepted_at else None, + }) + + return SyncResponse( + events=event_dicts, + member_changes=member_dicts, + server_time=datetime.now(), + truncated=truncated, + ) + + +# -- Event Lock Endpoints -------------------------------------------------- + +@router.post("/events/{event_id}/lock") +async def lock_event( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Acquire a 5-minute editing lock on an event.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + await require_permission(db, event.calendar_id, current_user.id, "create_modify") + + lock = await acquire_lock(db, event_id, current_user.id) + + # Build response BEFORE commit — ORM objects expire after commit + response = { + "locked": True, + "locked_by_name": current_user.umbral_name, + "expires_at": lock.expires_at, + "is_permanent": lock.is_permanent, + } + + await db.commit() + return response + + +@router.delete("/events/{event_id}/lock", status_code=204) +async def unlock_event( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Release a lock. Only the holder or calendar owner can release.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + lock_result = await db.execute( + select(EventLock).where(EventLock.event_id == event_id) + ) + lock = lock_result.scalar_one_or_none() + if not lock: + return None + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id) + ) + calendar = cal_result.scalar_one_or_none() + is_owner = calendar and calendar.user_id == current_user.id + + if lock.locked_by != current_user.id and not is_owner: + raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release") + + await db.delete(lock) + await db.commit() + return None + + +@router.get("/events/{event_id}/lock", response_model=LockStatusResponse) +async def get_lock_status( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get the lock status of an event.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == 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 is None: + raise HTTPException(status_code=404, detail="Event not found") + + lock_result = await db.execute( + select(EventLock) + .where(EventLock.event_id == event_id) + .options(selectinload(EventLock.holder)) + ) + lock = lock_result.scalar_one_or_none() + + if not lock: + return LockStatusResponse(locked=False) + + now = datetime.now() + if not lock.is_permanent and lock.expires_at and lock.expires_at < now: + await db.delete(lock) + await db.commit() + return LockStatusResponse(locked=False) + + return LockStatusResponse( + locked=True, + locked_by_name=lock.holder.umbral_name if lock.holder else None, + expires_at=lock.expires_at, + is_permanent=lock.is_permanent, + ) + + +@router.post("/events/{event_id}/owner-lock") +async def set_permanent_lock( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Set a permanent lock on an event. Calendar owner only.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id) + ) + if not cal_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks") + + now = datetime.now() + await db.execute( + text(""" + INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent) + VALUES (:event_id, :user_id, :now, NULL, true) + ON CONFLICT (event_id) + DO UPDATE SET + locked_by = :user_id, + locked_at = :now, + expires_at = NULL, + is_permanent = true + """), + {"event_id": event_id, "user_id": current_user.id, "now": now}, + ) + + await db.commit() + return {"message": "Permanent lock set", "is_permanent": True} + + +@router.delete("/events/{event_id}/owner-lock", status_code=204) +async def remove_permanent_lock( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a permanent lock. Calendar owner only.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id) + ) + if not cal_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks") + + await db.execute( + delete(EventLock).where( + EventLock.event_id == event_id, + EventLock.is_permanent == True, + ) + ) + + await db.commit() + return None diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py new file mode 100644 index 0000000..9be24d0 --- /dev/null +++ b/backend/app/services/calendar_sharing.py @@ -0,0 +1,205 @@ +""" +Calendar sharing service — permission checks, lock management, disconnect cascade. + +All functions accept an AsyncSession and do NOT commit — callers manage transactions. +""" +import logging +from datetime import datetime, timedelta + +from fastapi import HTTPException +from sqlalchemy import delete, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.calendar import Calendar +from app.models.calendar_member import CalendarMember +from app.models.event_lock import EventLock + +logger = logging.getLogger(__name__) + +PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3} +LOCK_DURATION_MINUTES = 5 + + +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 + if they are an accepted member, or None if they have no access. + """ + cal = await db.execute( + select(Calendar).where(Calendar.id == calendar_id) + ) + calendar = cal.scalar_one_or_none() + if not calendar: + return None + if calendar.user_id == user_id: + return "owner" + + member = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == calendar_id, + CalendarMember.user_id == user_id, + CalendarMember.status == "accepted", + ) + ) + row = member.scalar_one_or_none() + return row.permission if row else None + + +async def require_permission( + db: AsyncSession, calendar_id: int, user_id: int, min_level: str +) -> str: + """ + Raises 403 if the user lacks at least min_level permission. + Returns the actual permission string (or "owner"). + """ + perm = await get_user_permission(db, calendar_id, user_id) + if perm is None: + raise HTTPException(status_code=404, detail="Calendar not found") + if perm == "owner": + return "owner" + if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0): + raise HTTPException(status_code=403, detail="Insufficient permission on this calendar") + return perm + + +async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock: + """ + Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event. + Only succeeds if no unexpired lock exists or the existing lock is held by the same user. + Returns the lock or raises 423 Locked. + """ + now = datetime.now() + expires = now + timedelta(minutes=LOCK_DURATION_MINUTES) + + result = await db.execute( + text(""" + INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent) + VALUES (:event_id, :user_id, :now, :expires, false) + ON CONFLICT (event_id) + DO UPDATE SET + locked_by = :user_id, + locked_at = :now, + expires_at = :expires, + is_permanent = false + WHERE event_locks.expires_at < :now + OR event_locks.locked_by = :user_id + RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent + """), + {"event_id": event_id, "user_id": user_id, "now": now, "expires": expires}, + ) + row = result.first() + if not row: + raise HTTPException(status_code=423, detail="Event is locked by another user") + + lock_result = await db.execute( + select(EventLock).where(EventLock.id == row.id) + ) + return lock_result.scalar_one() + + +async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None: + """Delete the lock only if held by this user.""" + await db.execute( + delete(EventLock).where( + EventLock.event_id == event_id, + EventLock.locked_by == user_id, + ) + ) + + +async def check_lock_for_edit( + db: AsyncSession, event_id: int, user_id: int, calendar_id: int +) -> None: + """ + For shared calendars: verify no active lock by another user blocks this edit. + For personal (non-shared) calendars: no-op. + """ + cal_result = await db.execute( + select(Calendar.is_shared).where(Calendar.id == calendar_id) + ) + is_shared = cal_result.scalar_one_or_none() + if not is_shared: + return + + lock_result = await db.execute( + select(EventLock).where(EventLock.event_id == event_id) + ) + lock = lock_result.scalar_one_or_none() + if not lock: + return + now = datetime.now() + if lock.is_permanent and lock.locked_by != user_id: + raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner") + if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now): + raise HTTPException(status_code=423, detail="Event is locked by another user") + + +async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None: + """ + When a connection is severed: + 1. Delete CalendarMember rows where one user is a member of the other's calendars + 2. Delete EventLock rows held by the disconnected user on affected calendars + 3. Reset is_shared=False on calendars with no remaining members + """ + # Find calendars owned by each user + a_cal_ids_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == user_a_id) + ) + a_cal_ids = [row[0] for row in a_cal_ids_result.all()] + + b_cal_ids_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == user_b_id) + ) + b_cal_ids = [row[0] for row in b_cal_ids_result.all()] + + # Delete user_b's memberships on user_a's calendars + locks + if a_cal_ids: + await db.execute( + delete(CalendarMember).where( + CalendarMember.calendar_id.in_(a_cal_ids), + CalendarMember.user_id == user_b_id, + ) + ) + await db.execute( + text(""" + DELETE FROM event_locks + WHERE locked_by = :user_id + AND event_id IN ( + SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids) + ) + """), + {"user_id": user_b_id, "cal_ids": a_cal_ids}, + ) + + # Delete user_a's memberships on user_b's calendars + locks + if b_cal_ids: + await db.execute( + delete(CalendarMember).where( + CalendarMember.calendar_id.in_(b_cal_ids), + CalendarMember.user_id == user_a_id, + ) + ) + await db.execute( + text(""" + DELETE FROM event_locks + WHERE locked_by = :user_id + AND event_id IN ( + SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids) + ) + """), + {"user_id": user_a_id, "cal_ids": b_cal_ids}, + ) + + # Reset is_shared on calendars with no remaining members + all_cal_ids = a_cal_ids + b_cal_ids + for cal_id in all_cal_ids: + remaining = await db.execute( + select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) + ) + if not remaining.scalar_one_or_none(): + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id) + ) + cal = cal_result.scalar_one_or_none() + if cal: + cal.is_shared = False diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 52429a0..c3936cc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; # Admin API — generous for legitimate use but still guards against scraping/brute-force limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; +# Calendar sharing endpoints +limit_req_zone $binary_remote_addr zone=cal_invite_limit:10m rate=5r/m; +limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m; # Connection endpoints — prevent search enumeration and request spam limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m; @@ -99,6 +102,19 @@ server { include /etc/nginx/proxy-params.conf; } + # Calendar invite — rate-limited to prevent invite spam + location ~ /api/shared-calendars/\d+/invite { + limit_req zone=cal_invite_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Calendar sync — rate-limited to prevent excessive polling + location /api/shared-calendars/sync { + limit_req zone=cal_sync_limit burst=5 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } # Admin API — rate-limited separately from general /api traffic location /api/admin/ { limit_req zone=admin_limit burst=10 nodelay; From 4e3fd35040a9432a1ab9f8c4c8eb35dd4b2d1a6f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 04:59:13 +0800 Subject: [PATCH 03/21] Phase 3: Frontend core for shared calendars Types: CalendarPermission, SharedCalendarMembership, CalendarMemberInfo, CalendarInvite, EventLockInfo. Calendar type extended with is_shared. Hooks: useCalendars extended with shared calendar polling (5s). useSharedCalendars for member CRUD, invite responses, color updates. useConnections cascade invalidation on disconnect. New components: PermissionBadge, CalendarMemberSearch, CalendarMemberRow, CalendarMemberList, SharedCalendarSection, SharedCalendarSettings (non-owner dialog with color, members, leave). Modified: CalendarForm (sharing toggle, member management for owners), CalendarSidebar (shared calendars section with localStorage visibility), CalendarPage (shared calendar ID integration in event filtering), NotificationToaster (calendar_invite toast with accept/decline), NotificationsPage (calendar_invite inline actions + type icons). Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarForm.tsx | 106 +++++++++- .../calendar/CalendarMemberList.tsx | 55 +++++ .../components/calendar/CalendarMemberRow.tsx | 98 +++++++++ .../calendar/CalendarMemberSearch.tsx | 103 +++++++++ .../src/components/calendar/CalendarPage.tsx | 10 +- .../components/calendar/CalendarSidebar.tsx | 37 +++- .../components/calendar/PermissionBadge.tsx | 24 +++ .../calendar/SharedCalendarSection.tsx | 90 ++++++++ .../calendar/SharedCalendarSettings.tsx | 145 +++++++++++++ .../notifications/NotificationToaster.tsx | 81 +++++++- .../notifications/NotificationsPage.tsx | 63 +++++- frontend/src/hooks/useCalendars.ts | 29 ++- frontend/src/hooks/useConnections.ts | 2 + frontend/src/hooks/useSharedCalendars.ts | 196 ++++++++++++++++++ frontend/src/types/index.ts | 48 +++++ 15 files changed, 1064 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/calendar/CalendarMemberList.tsx create mode 100644 frontend/src/components/calendar/CalendarMemberRow.tsx create mode 100644 frontend/src/components/calendar/CalendarMemberSearch.tsx create mode 100644 frontend/src/components/calendar/PermissionBadge.tsx create mode 100644 frontend/src/components/calendar/SharedCalendarSection.tsx create mode 100644 frontend/src/components/calendar/SharedCalendarSettings.tsx create mode 100644 frontend/src/hooks/useSharedCalendars.ts diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 429a94a..9428093 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -1,8 +1,8 @@ import { useState, FormEvent } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; -import type { Calendar } from '@/types'; +import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types'; import { Dialog, DialogContent, @@ -14,6 +14,11 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { useConnections } from '@/hooks/useConnections'; +import { useSharedCalendars } from '@/hooks/useSharedCalendars'; +import CalendarMemberSearch from './CalendarMemberSearch'; +import CalendarMemberList from './CalendarMemberList'; interface CalendarFormProps { calendar: Calendar | null; @@ -21,20 +26,30 @@ interface CalendarFormProps { } const colorSwatches = [ - '#3b82f6', // blue - '#ef4444', // red - '#f97316', // orange - '#eab308', // yellow - '#22c55e', // green - '#8b5cf6', // purple - '#ec4899', // pink - '#06b6d4', // cyan + '#3b82f6', '#ef4444', '#f97316', '#eab308', + '#22c55e', '#8b5cf6', '#ec4899', '#06b6d4', ]; export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { const queryClient = useQueryClient(); const [name, setName] = useState(calendar?.name || ''); const [color, setColor] = useState(calendar?.color || '#3b82f6'); + const [isShared, setIsShared] = useState(calendar?.is_shared ?? false); + + const { connections } = useConnections(); + const { invite, isInviting, updateMember, removeMember } = useSharedCalendars(); + + const membersQuery = useQuery({ + queryKey: ['calendar-members', calendar?.id], + queryFn: async () => { + const { data } = await api.get( + `/shared-calendars/${calendar!.id}/members` + ); + return data; + }, + enabled: !!calendar?.is_shared, + }); + const members = membersQuery.data ?? []; const mutation = useMutation({ mutationFn: async () => { @@ -78,11 +93,41 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { mutation.mutate(); }; + const handleInvite = async (conn: Connection) => { + if (!calendar) return; + await invite({ + calendarId: calendar.id, + connectionId: conn.id, + permission: 'read_only', + canAddOthers: false, + }); + membersQuery.refetch(); + }; + + const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => { + if (!calendar) return; + await updateMember({ calendarId: calendar.id, memberId, permission }); + membersQuery.refetch(); + }; + + const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => { + if (!calendar) return; + await updateMember({ calendarId: calendar.id, memberId, canAddOthers }); + membersQuery.refetch(); + }; + + const handleRemoveMember = async (memberId: number) => { + if (!calendar) return; + await removeMember({ calendarId: calendar.id, memberId }); + membersQuery.refetch(); + }; + const canDelete = calendar && !calendar.is_default && !calendar.is_system; + const showSharing = calendar && !calendar.is_system; return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -119,6 +164,45 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { + {showSharing && ( + <> +
+ + +
+ + {isShared && ( +
+
+ + + You (Owner) + +
+ + + + +
+ )} + + )} + {canDelete && ( + + ) : ( + + )} + + ); +} diff --git a/frontend/src/components/calendar/CalendarMemberSearch.tsx b/frontend/src/components/calendar/CalendarMemberSearch.tsx new file mode 100644 index 0000000..699b52a --- /dev/null +++ b/frontend/src/components/calendar/CalendarMemberSearch.tsx @@ -0,0 +1,103 @@ +import { useState, useRef, useEffect } from 'react'; +import { Search, Loader2 } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import type { Connection, CalendarMemberInfo } from '@/types'; + +interface CalendarMemberSearchProps { + connections: Connection[]; + existingMembers: CalendarMemberInfo[]; + onSelect: (connection: Connection) => void; + isLoading?: boolean; +} + +export default function CalendarMemberSearch({ + connections, + existingMembers, + onSelect, + isLoading = false, +}: CalendarMemberSearchProps) { + const [query, setQuery] = useState(''); + const [focused, setFocused] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setFocused(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const existingUserIds = new Set(existingMembers.map((m) => m.user_id)); + + const filtered = connections.filter((c) => { + if (existingUserIds.has(c.connected_user_id)) return false; + if (!query.trim()) return true; + const q = query.toLowerCase(); + return ( + c.connected_umbral_name.toLowerCase().includes(q) || + (c.connected_preferred_name?.toLowerCase().includes(q) ?? false) + ); + }); + + const handleSelect = (connection: Connection) => { + onSelect(connection); + setQuery(''); + setFocused(false); + }; + + return ( +
+
+ {isLoading ? ( + + ) : ( + + )} + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + className="pl-8 h-9 text-sm" + /> +
+ + {focused && filtered.length > 0 && ( +
+ {filtered.map((conn) => { + const displayName = conn.connected_preferred_name || conn.connected_umbral_name; + const initial = displayName.charAt(0).toUpperCase(); + return ( + + ); + })} +
+ )} + + {focused && query.trim() && filtered.length === 0 && ( +
+

No matching connections

+
+ )} +
+ ); +} diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 8f68451..d177b0e 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -43,6 +43,7 @@ export default function CalendarPage() { const { settings } = useSettings(); const { data: calendars = [] } = useCalendars(); + const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); // Location data for event panel @@ -149,8 +150,11 @@ export default function CalendarPage() { }, [panelOpen]); const visibleCalendarIds = useMemo( - () => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), - [calendars], + () => { + const owned = calendars.filter((c) => c.is_visible).map((c) => c.id); + return new Set([...owned, ...visibleSharedIds]); + }, + [calendars, visibleSharedIds], ); const toLocalDatetime = (d: Date): string => { @@ -364,7 +368,7 @@ export default function CalendarPage() { return (
- +
{/* Custom toolbar */} diff --git a/frontend/src/components/calendar/CalendarSidebar.tsx b/frontend/src/components/calendar/CalendarSidebar.tsx index 9636064..ad794dc 100644 --- a/frontend/src/components/calendar/CalendarSidebar.tsx +++ b/frontend/src/components/calendar/CalendarSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Pencil, Trash2, FileText } from 'lucide-react'; import { toast } from 'sonner'; @@ -9,19 +9,41 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import CalendarForm from './CalendarForm'; import TemplateForm from './TemplateForm'; +import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection'; interface CalendarSidebarProps { onUseTemplate?: (template: EventTemplate) => void; + onSharedVisibilityChange?: (visibleIds: Set) => void; } -export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) { +export default function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange }: CalendarSidebarProps) { const queryClient = useQueryClient(); - const { data: calendars = [] } = useCalendars(); + const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars(); const [showForm, setShowForm] = useState(false); const [editingCalendar, setEditingCalendar] = useState(null); const [showTemplateForm, setShowTemplateForm] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); + const [sharedVisibility, setSharedVisibility] = useState>(() => loadVisibility()); + + const visibleSharedIds = new Set( + sharedCalendars + .filter((m) => sharedVisibility[m.calendar_id] !== false) + .map((m) => m.calendar_id) + ); + + useEffect(() => { + onSharedVisibilityChange?.(visibleSharedIds); + }, [sharedCalendars, sharedVisibility]); + + const handleSharedVisibilityChange = useCallback((calendarId: number, visible: boolean) => { + setSharedVisibility((prev) => { + const next = { ...prev, [calendarId]: visible }; + saveVisibility(next); + return next; + }); + }, []); + const { data: templates = [] } = useQuery({ queryKey: ['event-templates'], queryFn: async () => { @@ -84,7 +106,7 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
- {/* Calendars list */} + {/* Owned calendars list */}
{calendars.map((cal) => (
+ {/* Shared calendars section */} + + {/* Templates section */}
diff --git a/frontend/src/components/calendar/PermissionBadge.tsx b/frontend/src/components/calendar/PermissionBadge.tsx new file mode 100644 index 0000000..118cf57 --- /dev/null +++ b/frontend/src/components/calendar/PermissionBadge.tsx @@ -0,0 +1,24 @@ +import { Eye, Pencil, Shield } from 'lucide-react'; +import type { CalendarPermission } from '@/types'; + +const config: Record = { + read_only: { label: 'Read Only', icon: Eye, bg: 'bg-blue-500/10', text: 'text-blue-400' }, + create_modify: { label: 'Create/Modify', icon: Pencil, bg: 'bg-amber-500/10', text: 'text-amber-400' }, + full_access: { label: 'Full Access', icon: Shield, bg: 'bg-green-500/10', text: 'text-green-400' }, +}; + +interface PermissionBadgeProps { + permission: CalendarPermission; + showIcon?: boolean; +} + +export default function PermissionBadge({ permission, showIcon = true }: PermissionBadgeProps) { + const c = config[permission]; + const Icon = c.icon; + return ( + + {showIcon && } + {c.label} + + ); +} diff --git a/frontend/src/components/calendar/SharedCalendarSection.tsx b/frontend/src/components/calendar/SharedCalendarSection.tsx new file mode 100644 index 0000000..89997d7 --- /dev/null +++ b/frontend/src/components/calendar/SharedCalendarSection.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Pencil } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import type { SharedCalendarMembership } from '@/types'; +import SharedCalendarSettings from './SharedCalendarSettings'; + +const STORAGE_KEY = 'umbra_shared_cal_visibility'; + +function loadVisibility(): Record { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + } catch { + return {}; + } +} + +function saveVisibility(v: Record) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(v)); +} + +interface SharedCalendarSectionProps { + memberships: SharedCalendarMembership[]; + visibleSharedIds: Set; + onVisibilityChange: (calendarId: number, visible: boolean) => void; +} + +export default function SharedCalendarSection({ + memberships, + visibleSharedIds, + onVisibilityChange, +}: SharedCalendarSectionProps) { + const [settingsFor, setSettingsFor] = useState(null); + + if (memberships.length === 0) return null; + + return ( + <> +
+
+ + Shared Calendars + +
+
+ {memberships.map((m) => { + const color = m.local_color || m.calendar_color; + const isVisible = visibleSharedIds.has(m.calendar_id); + return ( +
+ onVisibilityChange(m.calendar_id, !isVisible)} + className="shrink-0" + style={{ + accentColor: color, + borderColor: isVisible ? color : undefined, + backgroundColor: isVisible ? color : undefined, + }} + /> + + {m.calendar_name} + +
+ ); + })} +
+
+ + {settingsFor && ( + setSettingsFor(null)} + /> + )} + + ); +} + +export { STORAGE_KEY, loadVisibility, saveVisibility }; diff --git a/frontend/src/components/calendar/SharedCalendarSettings.tsx b/frontend/src/components/calendar/SharedCalendarSettings.tsx new file mode 100644 index 0000000..95dc1f4 --- /dev/null +++ b/frontend/src/components/calendar/SharedCalendarSettings.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { LogOut } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import api from '@/lib/api'; +import type { SharedCalendarMembership, CalendarMemberInfo, Connection } from '@/types'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogClose, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { useSharedCalendars } from '@/hooks/useSharedCalendars'; +import { useConnections } from '@/hooks/useConnections'; +import PermissionBadge from './PermissionBadge'; +import CalendarMemberList from './CalendarMemberList'; +import CalendarMemberSearch from './CalendarMemberSearch'; + +const colorSwatches = [ + '#3b82f6', '#ef4444', '#f97316', '#eab308', + '#22c55e', '#8b5cf6', '#ec4899', '#06b6d4', +]; + +interface SharedCalendarSettingsProps { + membership: SharedCalendarMembership; + onClose: () => void; +} + +export default function SharedCalendarSettings({ membership, onClose }: SharedCalendarSettingsProps) { + const [localColor, setLocalColor] = useState(membership.local_color || membership.calendar_color); + const { updateColor, leaveCalendar, invite, isInviting } = useSharedCalendars(); + const { connections } = useConnections(); + + const membersQuery = useQuery({ + queryKey: ['calendar-members', membership.calendar_id], + queryFn: async () => { + const { data } = await api.get( + `/shared-calendars/${membership.calendar_id}/members` + ); + return data; + }, + }); + const members = membersQuery.data ?? []; + + const { confirming: leaveConfirming, handleClick: handleLeaveClick } = useConfirmAction( + async () => { + await leaveCalendar({ calendarId: membership.calendar_id, memberId: membership.id }); + onClose(); + } + ); + + const handleColorSelect = async (color: string) => { + setLocalColor(color); + await updateColor({ calendarId: membership.calendar_id, localColor: color }); + }; + + const handleInvite = async (conn: Connection) => { + await invite({ + calendarId: membership.calendar_id, + connectionId: conn.id, + permission: 'read_only', + canAddOthers: false, + }); + membersQuery.refetch(); + }; + + return ( + + + + + Shared Calendar Settings + + +
+
+

{membership.calendar_name}

+
+ Your permission: + +
+
+ +
+ +
+ {colorSwatches.map((c) => ( +
+
+ +
+ + +
+ + {membership.can_add_others && ( +
+ + +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 91a1e88..783aca8 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; -import { Check, X, Bell, UserPlus } from 'lucide-react'; +import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; import { useConnections } from '@/hooks/useConnections'; +import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import axios from 'axios'; import { getErrorMessage } from '@/lib/api'; import type { AppNotification } from '@/types'; @@ -11,6 +12,7 @@ import type { AppNotification } from '@/types'; export default function NotificationToaster() { const { notifications, unreadCount, markRead } = useNotifications(); const { respond } = useConnections(); + const { respondInvite } = useSharedCalendars(); const queryClient = useQueryClient(); const maxSeenIdRef = useRef(0); const initializedRef = useRef(false); @@ -18,7 +20,9 @@ export default function NotificationToaster() { // Track in-flight request IDs so repeated clicks are blocked const respondingRef = useRef>(new Set()); // Always call the latest respond — Sonner toasts capture closures at creation time + const respondInviteRef = useRef(respondInvite); const respondRef = useRef(respond); + respondInviteRef.current = respondInvite; respondRef.current = respond; const markReadRef = useRef(markRead); markReadRef.current = markRead; @@ -56,6 +60,34 @@ export default function NotificationToaster() { [], ); + + const handleCalendarInviteRespond = useCallback( + async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => { + if (respondingRef.current.has(inviteId + 100000)) return; + respondingRef.current.add(inviteId + 100000); + + toast.dismiss(toastId); + const loadingId = toast.loading( + action === 'accept' ? 'Accepting calendar invite\u2026' : 'Declining invite\u2026', + ); + + try { + await respondInviteRef.current({ inviteId, action }); + toast.dismiss(loadingId); + markReadRef.current([notificationId]).catch(() => {}); + } catch (err) { + toast.dismiss(loadingId); + if (axios.isAxiosError(err) && err.response?.status === 409) { + markReadRef.current([notificationId]).catch(() => {}); + } else { + toast.error(getErrorMessage(err, 'Failed to respond to invite')); + } + } finally { + respondingRef.current.delete(inviteId + 100000); + } + }, + [], + ); // Track unread count changes to force-refetch the list useEffect(() => { if (unreadCount > prevUnreadRef.current && initializedRef.current) { @@ -91,11 +123,16 @@ export default function NotificationToaster() { if (newNotifications.some((n) => n.type === 'connection_request')) { queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] }); } + if (newNotifications.some((n) => n.type === 'calendar_invite')) { + queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); + } // Show toasts newNotifications.forEach((notification) => { if (notification.type === 'connection_request' && notification.source_id) { showConnectionRequestToast(notification); + } else if (notification.type === 'calendar_invite' && notification.source_id) { + showCalendarInviteToast(notification); } else { toast(notification.title || 'New Notification', { description: notification.message || undefined, @@ -104,7 +141,7 @@ export default function NotificationToaster() { }); } }); - }, [notifications, handleConnectionRespond]); + }, [notifications, handleConnectionRespond, handleCalendarInviteRespond]); const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; @@ -145,5 +182,45 @@ export default function NotificationToaster() { ); }; + + const showCalendarInviteToast = (notification: AppNotification) => { + const inviteId = notification.source_id!; + const calendarName = (notification.data as Record)?.calendar_name || 'a calendar'; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Calendar Invite

+

+ {notification.message || `You've been invited to ${calendarName}`} +

+
+ + +
+
+
+
+ ), + { id: `calendar-invite-${inviteId}`, duration: 30000 }, + ); + }; return null; } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 9abd863..09d726d 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,11 +1,12 @@ import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; import { useNotifications } from '@/hooks/useNotifications'; import { useConnections } from '@/hooks/useConnections'; +import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import axios from 'axios'; @@ -16,6 +17,9 @@ import type { AppNotification } from '@/types'; const typeIcons: Record = { connection_request: { icon: UserPlus, color: 'text-violet-400' }, connection_accepted: { icon: UserPlus, color: 'text-green-400' }, + calendar_invite: { icon: Calendar, color: 'text-purple-400' }, + calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' }, + calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, info: { icon: Info, color: 'text-blue-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' }, }; @@ -33,11 +37,17 @@ export default function NotificationsPage() { } = useNotifications(); const { incomingRequests, respond, isResponding } = useConnections(); + const { incomingInvites, respondInvite, isResponding: isRespondingInvite } = useSharedCalendars(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); // Build a set of pending connection request IDs for quick lookup + const pendingInviteIds = useMemo( + () => new Set(incomingInvites.map((inv) => inv.id)), + [incomingInvites], + ); + const pendingRequestIds = useMemo( () => new Set(incomingRequests.map((r) => r.id)), [incomingRequests], @@ -46,6 +56,10 @@ export default function NotificationsPage() { // Eagerly fetch incoming requests when notifications contain connection_request // entries whose source_id isn't in pendingRequestIds yet (stale connections data) useEffect(() => { + // Also refresh calendar invites + if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) { + queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] }); + } const hasMissing = notifications.some( (n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id), ); @@ -106,6 +120,27 @@ export default function NotificationsPage() { } }; + + const handleCalendarInviteRespond = async ( + notification: AppNotification, + action: 'accept' | 'reject', + ) => { + if (!notification.source_id) return; + try { + await respondInvite({ inviteId: notification.source_id, action }); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } else { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + } + }; const handleNotificationClick = async (notification: AppNotification) => { // Don't navigate for pending connection requests — let user act inline if ( @@ -250,6 +285,32 @@ export default function NotificationsPage() {
)} + + {/* Calendar invite actions (inline) */} + {notification.type === 'calendar_invite' && + notification.source_id && + pendingInviteIds.has(notification.source_id) && ( +
+ + +
+ )} {/* Timestamp + actions */}
diff --git a/frontend/src/hooks/useCalendars.ts b/frontend/src/hooks/useCalendars.ts index 444ec6d..825de5e 100644 --- a/frontend/src/hooks/useCalendars.ts +++ b/frontend/src/hooks/useCalendars.ts @@ -1,13 +1,38 @@ +import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; -import type { Calendar } from '@/types'; +import type { Calendar, SharedCalendarMembership } from '@/types'; export function useCalendars() { - return useQuery({ + const ownedQuery = useQuery({ queryKey: ['calendars'], queryFn: async () => { const { data } = await api.get('/calendars'); return data; }, }); + + const sharedQuery = useQuery({ + queryKey: ['calendars', 'shared'], + queryFn: async () => { + const { data } = await api.get('/shared-calendars'); + return data; + }, + refetchInterval: 5_000, + staleTime: 3_000, + }); + + const allCalendarIds = useMemo(() => { + const owned = (ownedQuery.data ?? []).map((c) => c.id); + const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id); + return new Set([...owned, ...shared]); + }, [ownedQuery.data, sharedQuery.data]); + + return { + data: ownedQuery.data ?? [], + sharedData: sharedQuery.data ?? [], + allCalendarIds, + isLoading: ownedQuery.isLoading, + isLoadingShared: sharedQuery.isLoading, + }; } diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index 7febfb6..a1e00fe 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -88,6 +88,8 @@ export function useConnections() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); }, }); diff --git a/frontend/src/hooks/useSharedCalendars.ts b/frontend/src/hooks/useSharedCalendars.ts new file mode 100644 index 0000000..a60e10b --- /dev/null +++ b/frontend/src/hooks/useSharedCalendars.ts @@ -0,0 +1,196 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import api, { getErrorMessage } from '@/lib/api'; +import axios from 'axios'; +import type { CalendarMemberInfo, CalendarInvite, CalendarPermission } from '@/types'; + +export function useSharedCalendars() { + const queryClient = useQueryClient(); + + const incomingInvitesQuery = useQuery({ + queryKey: ['calendar-invites', 'incoming'], + queryFn: async () => { + const { data } = await api.get<{ invites: CalendarInvite[]; total: number }>( + '/shared-calendars/invites/incoming' + ); + return data.invites; + }, + refetchOnMount: 'always' as const, + }); + + const fetchMembers = async (calendarId: number) => { + const { data } = await api.get( + `/shared-calendars/${calendarId}/members` + ); + return data; + }; + + const useMembersQuery = (calendarId: number | null) => + useQuery({ + queryKey: ['calendar-members', calendarId], + queryFn: () => fetchMembers(calendarId!), + enabled: calendarId != null, + }); + + const inviteMutation = useMutation({ + mutationFn: async ({ + calendarId, + connectionId, + permission, + canAddOthers, + }: { + calendarId: number; + connectionId: number; + permission: CalendarPermission; + canAddOthers: boolean; + }) => { + const { data } = await api.post(`/shared-calendars/${calendarId}/invite`, { + connection_id: connectionId, + permission, + can_add_others: canAddOthers, + }); + return data; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] }); + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + toast.success('Invite sent'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to send invite')); + }, + }); + + const updateMemberMutation = useMutation({ + mutationFn: async ({ + calendarId, + memberId, + permission, + canAddOthers, + }: { + calendarId: number; + memberId: number; + permission?: CalendarPermission; + canAddOthers?: boolean; + }) => { + const body: Record = {}; + if (permission !== undefined) body.permission = permission; + if (canAddOthers !== undefined) body.can_add_others = canAddOthers; + const { data } = await api.put( + `/shared-calendars/${calendarId}/members/${memberId}`, + body + ); + return data; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] }); + toast.success('Member updated'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to update member')); + }, + }); + + const removeMemberMutation = useMutation({ + mutationFn: async ({ + calendarId, + memberId, + }: { + calendarId: number; + memberId: number; + }) => { + await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] }); + queryClient.invalidateQueries({ queryKey: ['calendars'] }); + queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] }); + toast.success('Member removed'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to remove member')); + }, + }); + + const respondInviteMutation = useMutation({ + mutationFn: async ({ + inviteId, + action, + }: { + inviteId: number; + action: 'accept' | 'reject'; + }) => { + const { data } = await api.put(`/shared-calendars/invites/${inviteId}/respond`, { + action, + }); + return data; + }, + onSuccess: (_, variables) => { + toast.dismiss(`calendar-invite-${variables.inviteId}`); + queryClient.invalidateQueries({ queryKey: ['calendar-invites'] }); + queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + toast.success(variables.action === 'accept' ? 'Calendar added' : 'Invite declined'); + }, + onError: (error, variables) => { + if (axios.isAxiosError(error) && error.response?.status === 409) { + toast.dismiss(`calendar-invite-${variables.inviteId}`); + queryClient.invalidateQueries({ queryKey: ['calendar-invites'] }); + queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + return; + } + toast.error(getErrorMessage(error, 'Failed to respond to invite')); + }, + }); + + const updateColorMutation = useMutation({ + mutationFn: async ({ + calendarId, + localColor, + }: { + calendarId: number; + localColor: string | null; + }) => { + await api.put(`/shared-calendars/${calendarId}/members/me/color`, { + local_color: localColor, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to update color')); + }, + }); + + const leaveCalendarMutation = useMutation({ + mutationFn: async ({ calendarId, memberId }: { calendarId: number; memberId: number }) => { + await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success('Left calendar'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to leave calendar')); + }, + }); + + return { + incomingInvites: incomingInvitesQuery.data ?? [], + isLoadingInvites: incomingInvitesQuery.isLoading, + useMembersQuery, + invite: inviteMutation.mutateAsync, + isInviting: inviteMutation.isPending, + updateMember: updateMemberMutation.mutateAsync, + removeMember: removeMemberMutation.mutateAsync, + respondInvite: respondInviteMutation.mutateAsync, + isResponding: respondInviteMutation.isPending, + updateColor: updateColorMutation.mutateAsync, + leaveCalendar: leaveCalendarMutation.mutateAsync, + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4476907..2bcb02a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -81,6 +81,7 @@ export interface Calendar { is_default: boolean; is_system: boolean; is_visible: boolean; + is_shared: boolean; created_at: string; updated_at: string; } @@ -439,3 +440,50 @@ export interface Connection { export interface UmbralSearchResponse { found: boolean; } + +// ── Shared Calendars ────────────────────────────────────────────── + +export type CalendarPermission = 'read_only' | 'create_modify' | 'full_access'; + +export interface SharedCalendarMembership { + id: number; + calendar_id: number; + calendar_name: string; + calendar_color: string; + local_color: string | null; + permission: CalendarPermission; + can_add_others: boolean; + is_owner: false; +} + +export interface CalendarMemberInfo { + id: number; + calendar_id: number; + user_id: number; + umbral_name: string; + preferred_name: string | null; + permission: CalendarPermission; + can_add_others: boolean; + local_color: string | null; + status: 'pending' | 'accepted'; + invited_at: string; + accepted_at: string | null; +} + +export interface CalendarInvite { + id: number; + calendar_id: number; + calendar_name: string; + calendar_color: string; + owner_umbral_name: string; + inviter_umbral_name: string; + permission: CalendarPermission; + invited_at: string; +} + +export interface EventLockInfo { + locked: boolean; + locked_by_name: string | null; + expires_at: string | null; + is_permanent: boolean; +} From eedfaaf8595e11d6e0de7943c9ac8f60d81c77cb Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 05:24:43 +0800 Subject: [PATCH 04/21] Phase 4: Event locking + permission gating for shared calendars - useEventLock hook with auto-release on unmount/event change - EventLockBanner component for locked event display - EventDetailPanel: lock acquire on edit, release on save/cancel, permission-gated edit/delete buttons - CalendarPage: permission map from owned+shared calendars, per-event editable gating Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarPage.tsx | 19 ++- .../components/calendar/EventDetailPanel.tsx | 113 +++++++++++++----- .../components/calendar/EventLockBanner.tsx | 30 +++++ frontend/src/hooks/useEventLock.ts | 69 +++++++++++ 4 files changed, 199 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/calendar/EventLockBanner.tsx create mode 100644 frontend/src/hooks/useEventLock.ts diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index d177b0e..51b15b2 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -9,7 +9,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types'; +import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; @@ -42,7 +42,7 @@ export default function CalendarPage() { const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); - const { data: calendars = [] } = useCalendars(); + const { data: calendars = [], sharedData } = useCalendars(); const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); @@ -62,6 +62,14 @@ export default function CalendarPage() { return map; }, [locations]); + // Build permission map: calendar_id -> permission level + const permissionMap = useMemo(() => { + const map = new Map(); + calendars.forEach((cal) => map.set(cal.id, 'owner')); + sharedData.forEach((m) => map.set(m.calendar_id, m.permission)); + return map; + }, [calendars, sharedData]); + // Handle navigation state from dashboard useEffect(() => { const state = location.state as { date?: string; view?: string; eventId?: number } | null; @@ -139,6 +147,9 @@ export default function CalendarPage() { [selectedEventId, events], ); + const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; + const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false; + // Escape key closes detail panel useEffect(() => { if (!panelOpen) return; @@ -498,6 +509,8 @@ export default function CalendarPage() { onClose={handlePanelClose} onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} + myPermission={selectedEventPermission} + isSharedEvent={selectedEventIsShared} />
@@ -520,6 +533,8 @@ export default function CalendarPage() { onClose={handlePanelClose} onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} + myPermission={selectedEventPermission} + isSharedEvent={selectedEventIsShared} />
diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 882a0bf..8122dab 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -3,14 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, parseISO } from 'date-fns'; import { - X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, + X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types'; +import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarPermission, EventLockInfo } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { useEventLock } from '@/hooks/useEventLock'; import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; +import EventLockBanner from './EventLockBanner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; @@ -126,6 +128,8 @@ interface EventDetailPanelProps { onSaved?: () => void; onDeleted?: () => void; locationName?: string; + myPermission?: CalendarPermission | 'owner' | null; + isSharedEvent?: boolean; } interface EditState { @@ -218,6 +222,8 @@ export default function EventDetailPanel({ onSaved, onDeleted, locationName, + myPermission, + isSharedEvent = false, }: EventDetailPanelProps) { const queryClient = useQueryClient(); const { data: calendars = [] } = useCalendars(); @@ -233,6 +239,11 @@ export default function EventDetailPanel({ staleTime: 5 * 60 * 1000, }); + const { acquire: acquireLock, release: releaseLock, isAcquiring: isAcquiringLock } = useEventLock( + isSharedEvent && event ? (typeof event.id === 'number' ? event.id : null) : null + ); + const [lockInfo, setLockInfo] = useState(null); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating @@ -247,12 +258,17 @@ export default function EventDetailPanel({ const isRecurring = !!(event?.is_recurring || event?.parent_event_id); + // Permission helpers + const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access'; + const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access'; + // Reset state when event changes useEffect(() => { setIsEditing(false); setScopeStep(null); setEditScope(null); setLocationSearch(''); + setLockInfo(null); if (event) setEditState(buildEditStateFromEvent(event)); }, [event?.id]); @@ -307,6 +323,7 @@ export default function EventDetailPanel({ } }, onSuccess: () => { + if (isSharedEvent) releaseLock(); invalidateAll(); toast.success(isCreating ? 'Event created' : 'Event updated'); if (isCreating) { @@ -343,7 +360,30 @@ export default function EventDetailPanel({ // --- Handlers --- - const handleEditStart = () => { + const handleEditStart = async () => { + // For shared events, acquire lock first (owners skip locking) + if (isSharedEvent && myPermission !== 'owner' && event && typeof event.id === 'number') { + try { + await acquireLock(); + setLockInfo(null); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axErr = err as { response?: { status?: number; data?: { detail?: string; locked_by_name?: string; expires_at?: string; is_permanent?: boolean } } }; + if (axErr.response?.status === 423) { + setLockInfo({ + locked: true, + locked_by_name: axErr.response.data?.locked_by_name || 'another user', + expires_at: axErr.response.data?.expires_at || null, + is_permanent: axErr.response.data?.is_permanent || false, + }); + return; + } + } + toast.error('Failed to acquire edit lock'); + return; + } + } + if (isRecurring) { setScopeStep('edit'); } else { @@ -361,8 +401,6 @@ export default function EventDetailPanel({ } else if (scopeStep === 'delete') { // Delete with scope — execute immediately setScopeStep(null); - // The deleteMutation will read editScope, but we need to set it first - // Since setState is async, use the mutation directly with the scope const scopeParam = `?scope=${scope}`; api.delete(`/events/${event!.id}${scopeParam}`).then(() => { invalidateAll(); @@ -376,6 +414,7 @@ export default function EventDetailPanel({ }; const handleEditCancel = () => { + if (isSharedEvent) releaseLock(); setIsEditing(false); setEditScope(null); setLocationSearch(''); @@ -507,37 +546,42 @@ export default function EventDetailPanel({ <> {!event?.is_virtual && ( <> - - {confirmingDelete ? ( - - ) : ( + {canEdit && ( )} + {canDelete && ( + confirmingDelete ? ( + + ) : ( + + ) + )} )}
diff --git a/frontend/src/components/calendar/SharedCalendarSection.tsx b/frontend/src/components/calendar/SharedCalendarSection.tsx index 89997d7..9d52124 100644 --- a/frontend/src/components/calendar/SharedCalendarSection.tsx +++ b/frontend/src/components/calendar/SharedCalendarSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Pencil } from 'lucide-react'; +import { Ghost, Pencil } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; import type { SharedCalendarMembership } from '@/types'; import SharedCalendarSettings from './SharedCalendarSettings'; @@ -36,7 +36,8 @@ export default function SharedCalendarSection({ return ( <>
-
+
+ Shared Calendars diff --git a/frontend/src/hooks/useEventLock.ts b/frontend/src/hooks/useEventLock.ts index d15ce9b..9c8d695 100644 --- a/frontend/src/hooks/useEventLock.ts +++ b/frontend/src/hooks/useEventLock.ts @@ -14,9 +14,9 @@ export function useEventLock(eventId: number | null) { ); return data; }, - onSuccess: () => { + onSuccess: (_data, lockedId) => { lockHeldRef.current = true; - activeEventIdRef.current = eventId; + activeEventIdRef.current = lockedId; }, }); @@ -64,6 +64,5 @@ export function useEventLock(eventId: number | null) { release, isAcquiring: acquireMutation.isPending, acquireError: acquireMutation.error, - lockHeld: lockHeldRef.current, }; } From f45b7a211554db26113e1b2efa8899400a01596e Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 06:23:45 +0800 Subject: [PATCH 06/21] Fix 4 reported bugs from Phase 4 testing 1. Invite auto-sends at read_only: now stages connection with permission selector (Read Only / Create Modify / Full Access) before sending 2. Shared calendars missing from event create dropdown: members with create_modify+ permission now see shared calendars in calendar picker 3. Shared calendar category not showing for owner: owner's shared calendars now appear under SHARED CALENDARS section with "Owner" badge 4. Event creation not updating calendar: handlePanelClose now invalidates calendar-events query to ensure FullCalendar refreshes Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarForm.tsx | 66 +++++++++++++++---- .../components/calendar/CalendarSidebar.tsx | 21 +++--- .../components/calendar/EventDetailPanel.tsx | 9 ++- .../calendar/SharedCalendarSection.tsx | 40 ++++++++++- 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index d718ec8..842b694 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -1,4 +1,4 @@ -import { useState, FormEvent } from 'react'; +import { useState, FormEvent, useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; @@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { Select } from '@/components/ui/select'; import { useConnections } from '@/hooks/useConnections'; import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import CalendarMemberSearch from './CalendarMemberSearch'; @@ -36,6 +37,8 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { const [color, setColor] = useState(calendar?.color || '#3b82f6'); const [isShared, setIsShared] = useState(calendar?.is_shared ?? false); + const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null); + const { connections } = useConnections(); const { invite, isInviting, updateMember, removeMember } = useSharedCalendars(); @@ -93,14 +96,19 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { mutation.mutate(); }; - const handleInvite = async (conn: Connection) => { - if (!calendar) return; + const handleSelectConnection = useCallback((conn: Connection) => { + setPendingInvite({ conn, permission: 'read_only' }); + }, []); + + const handleSendInvite = async () => { + if (!calendar || !pendingInvite) return; await invite({ calendarId: calendar.id, - connectionId: conn.id, - permission: 'read_only', + connectionId: pendingInvite.conn.id, + permission: pendingInvite.permission, canAddOthers: false, }); + setPendingInvite(null); }; const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => { @@ -179,12 +187,48 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
- + {pendingInvite ? ( +
+
+ + {pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name} + + +
+
+ + +
+
+ ) : ( + + )}
- {/* Owned calendars list */} + {/* Owned calendars list (non-shared only) */}
- {calendars.map((cal) => ( + {calendars.filter((c) => !c.is_shared).map((cal) => (
- {/* Shared calendars section */} - + {/* Shared calendars section -- owned + member */} + {(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && ( + c.is_shared)} + memberships={sharedCalendars} + visibleSharedIds={visibleSharedIds} + onVisibilityChange={handleSharedVisibilityChange} + onEditCalendar={handleEdit} + onToggleCalendar={handleToggle} + /> + )} {/* Templates section */}
diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 1aa2449..b4e0e0e 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -227,8 +227,13 @@ export default function EventDetailPanel({ isSharedEvent = false, }: EventDetailPanelProps) { const queryClient = useQueryClient(); - const { data: calendars = [] } = useCalendars(); - const selectableCalendars = calendars.filter((c) => !c.is_system); + const { data: calendars = [], sharedData: sharedMemberships = [] } = useCalendars(); + const selectableCalendars = [ + ...calendars.filter((c) => !c.is_system), + ...sharedMemberships + .filter((m) => m.permission === 'create_modify' || m.permission === 'full_access') + .map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })), + ]; const defaultCalendar = calendars.find((c) => c.is_default); const { data: locations = [] } = useQuery({ diff --git a/frontend/src/components/calendar/SharedCalendarSection.tsx b/frontend/src/components/calendar/SharedCalendarSection.tsx index 9d52124..d42308c 100644 --- a/frontend/src/components/calendar/SharedCalendarSection.tsx +++ b/frontend/src/components/calendar/SharedCalendarSection.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Ghost, Pencil } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; -import type { SharedCalendarMembership } from '@/types'; +import type { Calendar, SharedCalendarMembership } from '@/types'; import SharedCalendarSettings from './SharedCalendarSettings'; const STORAGE_KEY = 'umbra_shared_cal_visibility'; @@ -19,19 +19,25 @@ function saveVisibility(v: Record) { } interface SharedCalendarSectionProps { + ownedSharedCalendars?: Calendar[]; memberships: SharedCalendarMembership[]; visibleSharedIds: Set; onVisibilityChange: (calendarId: number, visible: boolean) => void; + onEditCalendar?: (calendar: Calendar) => void; + onToggleCalendar?: (calendar: Calendar) => void; } export default function SharedCalendarSection({ + ownedSharedCalendars = [], memberships, visibleSharedIds, onVisibilityChange, + onEditCalendar, + onToggleCalendar, }: SharedCalendarSectionProps) { const [settingsFor, setSettingsFor] = useState(null); - if (memberships.length === 0) return null; + if (memberships.length === 0 && ownedSharedCalendars.length === 0) return null; return ( <> @@ -43,6 +49,36 @@ export default function SharedCalendarSection({
+ {ownedSharedCalendars.map((cal) => ( +
+ onToggleCalendar?.(cal)} + className="shrink-0" + style={{ + accentColor: cal.color, + borderColor: cal.is_visible ? cal.color : undefined, + backgroundColor: cal.is_visible ? cal.color : undefined, + }} + /> + + {cal.name} + Owner + +
+ ))} {memberships.map((m) => { const color = m.local_color || m.calendar_color; const isVisible = visibleSharedIds.has(m.calendar_id); From 14fc085009191bdb9e1576c60a0d7109ae2882b5 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 15:38:52 +0800 Subject: [PATCH 07/21] =?UTF-8?q?Phase=205:=20Shared=20calendar=20polish?= =?UTF-8?q?=20=E2=80=94=20scoped=20polling,=20admin=20stats,=20dual=20pane?= =?UTF-8?q?l=20fix,=20edge=20case=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scope shared calendar polling to CalendarPage only (other consumers no longer poll) - Add admin sharing stats card (owned/member/invites sent/received) in UserDetailSection - Fix dual EventDetailPanel mount via JS media query breakpoint (replaces CSS hidden) - Auto-close panel + toast when shared calendar is removed while viewing Co-Authored-By: Claude Opus 4.6 --- .../components/admin/UserDetailSection.tsx | 23 +++++++- .../src/components/calendar/CalendarPage.tsx | 54 ++++++++++++------- frontend/src/hooks/useAdmin.ts | 18 +++++++ frontend/src/hooks/useCalendars.ts | 8 ++- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx index 51261a4..26ab578 100644 --- a/frontend/src/components/admin/UserDetailSection.tsx +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -1,10 +1,10 @@ -import { X, User, ShieldCheck, Loader2 } from 'lucide-react'; +import { X, User, ShieldCheck, Share2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Select } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin'; +import { useAdminUserDetail, useAdminSharingStats, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin'; import { getRelativeTime } from '@/lib/date-utils'; import { cn } from '@/lib/utils'; import type { UserRole } from '@/types'; @@ -57,6 +57,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) { const { data: user, isLoading, error } = useAdminUserDetail(userId); const updateRole = useUpdateRole(); + const { data: sharingStats } = useAdminSharingStats(userId); const handleRoleChange = async (newRole: UserRole) => { if (!user || newRole === user.role) return; @@ -218,6 +219,24 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection /> + + {/* Sharing Stats */} + + +
+
+ +
+ Sharing +
+
+ + + + + + +
); } diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 51b15b2..cf5ad2a 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -42,7 +42,7 @@ export default function CalendarPage() { const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); - const { data: calendars = [], sharedData } = useCalendars(); + const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true }); const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); @@ -99,6 +99,15 @@ export default function CalendarPage() { const panelOpen = panelMode !== 'closed'; + // Track desktop breakpoint to prevent dual EventDetailPanel mount + const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches); + useEffect(() => { + const mql = window.matchMedia('(min-width: 1024px)'); + const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, []); + // Continuously resize calendar during panel open/close CSS transition useEffect(() => { let rafId: number; @@ -150,6 +159,15 @@ export default function CalendarPage() { const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false; + // Close panel if shared calendar was removed while viewing + useEffect(() => { + if (!selectedEvent || allCalendarIds.size === 0) return; + if (!allCalendarIds.has(selectedEvent.calendar_id)) { + handlePanelClose(); + toast.info('This calendar is no longer available'); + } + }, [allCalendarIds, selectedEvent]); + // Escape key closes detail panel useEffect(() => { if (!panelOpen) return; @@ -497,29 +515,27 @@ export default function CalendarPage() {
{/* Detail panel (desktop) */} -
- -
+ {panelOpen && isDesktop && ( +
+ +
+ )}
{/* Mobile detail panel overlay */} - {panelOpen && ( + {panelOpen && !isDesktop && (
({ + queryKey: ['admin', 'users', userId, 'sharing-stats'], + queryFn: async () => { + const { data } = await api.get(`/admin/users/${userId}/sharing-stats`); + return data; + }, + enabled: userId !== null, + }); +} + export function useAdminDashboard() { return useQuery({ queryKey: ['admin', 'dashboard'], diff --git a/frontend/src/hooks/useCalendars.ts b/frontend/src/hooks/useCalendars.ts index 825de5e..7bbfd3f 100644 --- a/frontend/src/hooks/useCalendars.ts +++ b/frontend/src/hooks/useCalendars.ts @@ -3,7 +3,11 @@ import { useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; import type { Calendar, SharedCalendarMembership } from '@/types'; -export function useCalendars() { +interface UseCalendarsOptions { + pollingEnabled?: boolean; +} + +export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {}) { const ownedQuery = useQuery({ queryKey: ['calendars'], queryFn: async () => { @@ -18,7 +22,7 @@ export function useCalendars() { const { data } = await api.get('/shared-calendars'); return data; }, - refetchInterval: 5_000, + refetchInterval: pollingEnabled ? 5_000 : false, staleTime: 3_000, }); From b401fd9392104fbf2afa7c6538e43afbb9a4e08b Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 16:46:15 +0800 Subject: [PATCH 08/21] Phase 6: Real-time sync, drag-drop guards, security fix, invite bug fix, UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Event polling (5s refetchInterval) so collaborators see changes without refresh - Lock status polling in EventDetailPanel view mode — proactive lock banner - Per-event editable flag blocks drag on read-only shared events - Read-only permission guard in handleEventDrop/handleEventResize - M-01 security fix: block non-owners from moving events off shared calendars (403) - Fix invite response type (backend returns list, not wrapper object) - Remove is_shared from CalendarCreate/CalendarUpdate input schemas - New PermissionToggle segmented control (Eye/Pencil/Shield icons) - CalendarMemberRow restructured into spacious two-line card layout - CalendarForm dialog widened (sm:max-w-2xl), polished invite card with accent border - SharedCalendarSettings dialog widened (sm:max-w-lg) - CalendarMemberList max-height increased (max-h-48 → max-h-72) Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/events.py | 12 +++ backend/app/schemas/calendar.py | 4 +- .../src/components/calendar/CalendarForm.tsx | 27 +++-- .../calendar/CalendarMemberList.tsx | 2 +- .../components/calendar/CalendarMemberRow.tsx | 100 +++++++++--------- .../src/components/calendar/CalendarPage.tsx | 13 +++ .../components/calendar/EventDetailPanel.tsx | 25 +++++ .../components/calendar/PermissionToggle.tsx | 46 ++++++++ .../calendar/SharedCalendarSettings.tsx | 2 +- frontend/src/hooks/useSharedCalendars.ts | 4 +- 10 files changed, 163 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/calendar/PermissionToggle.tsx diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index df83bb0..140d046 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -356,6 +356,18 @@ async def update_event( if "calendar_id" in update_data and update_data["calendar_id"] is not None: await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) + # M-01: Block non-owners from moving events off shared calendars + if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: + source_cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id) + ) + source_cal = source_cal_result.scalar_one_or_none() + if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id: + raise HTTPException( + status_code=403, + detail="Only the calendar owner can move events between calendars", + ) + start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) if end_dt is not None and end_dt < start: diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py index 15da21f..0ce2b05 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -8,7 +8,6 @@ class CalendarCreate(BaseModel): name: str = Field(min_length=1, max_length=100) color: str = Field("#3b82f6", max_length=20) - is_shared: bool = False class CalendarUpdate(BaseModel): @@ -17,7 +16,6 @@ class CalendarUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) color: Optional[str] = Field(None, max_length=20) is_visible: Optional[bool] = None - is_shared: Optional[bool] = None class CalendarResponse(BaseModel): @@ -27,9 +25,9 @@ class CalendarResponse(BaseModel): is_default: bool is_system: bool is_visible: bool + is_shared: bool = False created_at: datetime updated_at: datetime - is_shared: bool = False owner_umbral_name: Optional[str] = None my_permission: Optional[str] = None my_can_add_others: bool = False diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 842b694..6ad8466 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -15,7 +15,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; -import { Select } from '@/components/ui/select'; +import PermissionToggle from './PermissionToggle'; import { useConnections } from '@/hooks/useConnections'; import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import CalendarMemberSearch from './CalendarMemberSearch'; @@ -131,7 +131,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -188,29 +188,28 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
{pendingInvite ? ( -
+
- + {pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
-
- + onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)} + /> +
-
- ) : ( - - )} + )} +
+ + {/* Row 2: Permission control or badge */} +
+ {readOnly ? ( + + ) : isOwner ? ( + <> + onUpdatePermission?.(member.id, p)} + /> + {(member.permission === 'create_modify' || member.permission === 'full_access') && ( + + )} + + ) : ( + + )} +
); } diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index cf5ad2a..ba8899a 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -149,6 +149,7 @@ export default function CalendarPage() { const { data } = await api.get('/events'); return data; }, + refetchInterval: 5_000, }); const selectedEvent = useMemo( @@ -279,10 +280,12 @@ export default function CalendarPage() { allDay: event.all_day, backgroundColor: event.calendar_color || 'hsl(var(--accent-color))', borderColor: event.calendar_color || 'hsl(var(--accent-color))', + editable: permissionMap.get(event.calendar_id) !== 'read_only', extendedProps: { is_virtual: event.is_virtual, is_recurring: event.is_recurring, parent_event_id: event.parent_event_id, + calendar_id: event.calendar_id, }, })); @@ -307,6 +310,11 @@ export default function CalendarPage() { toast.info('Click the event to edit recurring events'); return; } + if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') { + info.revert(); + toast.error('You have read-only access to this calendar'); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr @@ -331,6 +339,11 @@ export default function CalendarPage() { toast.info('Click the event to edit recurring events'); return; } + if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') { + info.revert(); + toast.error('You have read-only access to this calendar'); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index b4e0e0e..f7ee3ff 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -250,6 +250,7 @@ export default function EventDetailPanel({ ); const [lockInfo, setLockInfo] = useState(null); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating @@ -262,6 +263,30 @@ export default function EventDetailPanel({ const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); const [locationSearch, setLocationSearch] = useState(''); + // Poll lock status in view mode for shared events (Stream A: real-time lock awareness) + const viewLockQuery = useQuery({ + queryKey: ['event-lock', event?.id], + queryFn: async () => { + const { data } = await api.get( + `/shared-calendars/events/${event!.id}/lock` + ); + return data; + }, + enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating, + refetchInterval: 5_000, + }); + + // Show/hide lock banner proactively in view mode + useEffect(() => { + if (viewLockQuery.data && !isEditing && !isCreating) { + if (viewLockQuery.data.locked) { + setLockInfo(viewLockQuery.data); + } else { + setLockInfo(null); + } + } + }, [viewLockQuery.data, isEditing, isCreating]); + const isRecurring = !!(event?.is_recurring || event?.parent_event_id); // Permission helpers diff --git a/frontend/src/components/calendar/PermissionToggle.tsx b/frontend/src/components/calendar/PermissionToggle.tsx new file mode 100644 index 0000000..3e7cdd1 --- /dev/null +++ b/frontend/src/components/calendar/PermissionToggle.tsx @@ -0,0 +1,46 @@ +import { Eye, Pencil, Shield } from 'lucide-react'; +import type { CalendarPermission } from '@/types'; + +const segments: { value: CalendarPermission; label: string; shortLabel: string; icon: typeof Eye }[] = [ + { value: 'read_only', label: 'Read Only', shortLabel: 'Read', icon: Eye }, + { value: 'create_modify', label: 'Create & Modify', shortLabel: 'Edit', icon: Pencil }, + { value: 'full_access', label: 'Full Access', shortLabel: 'Full', icon: Shield }, +]; + +interface PermissionToggleProps { + value: CalendarPermission; + onChange: (permission: CalendarPermission) => void; + compact?: boolean; + className?: string; +} + +export default function PermissionToggle({ value, onChange, compact = false, className = '' }: PermissionToggleProps) { + return ( +
+ {segments.map((seg) => { + const isActive = value === seg.value; + const Icon = seg.icon; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/calendar/SharedCalendarSettings.tsx b/frontend/src/components/calendar/SharedCalendarSettings.tsx index 95dc1f4..62d0caa 100644 --- a/frontend/src/components/calendar/SharedCalendarSettings.tsx +++ b/frontend/src/components/calendar/SharedCalendarSettings.tsx @@ -69,7 +69,7 @@ export default function SharedCalendarSettings({ membership, onClose }: SharedCa return ( - + Shared Calendar Settings diff --git a/frontend/src/hooks/useSharedCalendars.ts b/frontend/src/hooks/useSharedCalendars.ts index a60e10b..d643c53 100644 --- a/frontend/src/hooks/useSharedCalendars.ts +++ b/frontend/src/hooks/useSharedCalendars.ts @@ -10,10 +10,10 @@ export function useSharedCalendars() { const incomingInvitesQuery = useQuery({ queryKey: ['calendar-invites', 'incoming'], queryFn: async () => { - const { data } = await api.get<{ invites: CalendarInvite[]; total: number }>( + const { data } = await api.get( '/shared-calendars/invites/incoming' ); - return data.invites; + return data; }, refetchOnMount: 'always' as const, }); From 1b36e6b6a7efe1cd0b21e2f7d22f8b627531c9e2 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 16:54:25 +0800 Subject: [PATCH 09/21] Widen shared calendar dialogs + single-line member rows - CalendarForm: max-w-3xl when sharing (was sm:max-w-2xl, overridden by base max-w-xl) - SharedCalendarSettings: max-w-2xl (was sm:max-w-lg) - CalendarMemberRow: back to single-line with PermissionToggle inline (less cramped) - Use unprefixed max-w classes so twMerge properly overrides DialogContent base Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarForm.tsx | 2 +- .../components/calendar/CalendarMemberRow.tsx | 85 +++++++++---------- .../calendar/SharedCalendarSettings.tsx | 2 +- 3 files changed, 40 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 6ad8466..be19997 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -131,7 +131,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} diff --git a/frontend/src/components/calendar/CalendarMemberRow.tsx b/frontend/src/components/calendar/CalendarMemberRow.tsx index 1ee0d2e..6cb7755 100644 --- a/frontend/src/components/calendar/CalendarMemberRow.tsx +++ b/frontend/src/components/calendar/CalendarMemberRow.tsx @@ -30,32 +30,47 @@ export default function CalendarMemberRow({ const initial = displayName.charAt(0).toUpperCase(); return ( -
- {/* Row 1: Avatar + Name + Status + Remove */} -
-
- {initial} -
+
+
+ {initial} +
-
-
- {displayName} - {member.status === 'pending' && ( - - Pending - - )} -
- {member.preferred_name && ( - {member.umbral_name} +
+
+ {displayName} + {member.status === 'pending' && ( + + Pending + )}
+ {member.preferred_name && ( + {member.umbral_name} + )} +
- {isOwner && !readOnly && ( + {readOnly ? ( + + ) : isOwner ? ( +
+ onUpdatePermission?.(member.id, p)} + /> + {(member.permission === 'create_modify' || member.permission === 'full_access') && ( + + )} - )} -
- - {/* Row 2: Permission control or badge */} -
- {readOnly ? ( - - ) : isOwner ? ( - <> - onUpdatePermission?.(member.id, p)} - /> - {(member.permission === 'create_modify' || member.permission === 'full_access') && ( - - )} - - ) : ( - - )} -
+
+ ) : ( + + )}
); } diff --git a/frontend/src/components/calendar/SharedCalendarSettings.tsx b/frontend/src/components/calendar/SharedCalendarSettings.tsx index 62d0caa..c4c85b8 100644 --- a/frontend/src/components/calendar/SharedCalendarSettings.tsx +++ b/frontend/src/components/calendar/SharedCalendarSettings.tsx @@ -69,7 +69,7 @@ export default function SharedCalendarSettings({ membership, onClose }: SharedCa return ( - + Shared Calendar Settings From a2f4d297a36cb59d86f3378be278c83bdd8cf8c7 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 16:58:51 +0800 Subject: [PATCH 10/21] Single-line member rows + purple umbral name - Flatten member row to strict single line: avatar | name | umbral name (violet) | pending badge | permission toggle | controls - Umbral name shown in text-violet-400 for visual differentiation Co-Authored-By: Claude Opus 4.6 --- .../components/calendar/CalendarMemberRow.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/calendar/CalendarMemberRow.tsx b/frontend/src/components/calendar/CalendarMemberRow.tsx index 6cb7755..0ffc290 100644 --- a/frontend/src/components/calendar/CalendarMemberRow.tsx +++ b/frontend/src/components/calendar/CalendarMemberRow.tsx @@ -35,17 +35,15 @@ export default function CalendarMemberRow({ {initial}
-
-
- {displayName} - {member.status === 'pending' && ( - - Pending - - )} -
+
+ {displayName} {member.preferred_name && ( - {member.umbral_name} + {member.umbral_name} + )} + {member.status === 'pending' && ( + + Pending + )}
From 38334b77a336a5d09eb148db7d6f4e0b56f4ff31 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 17:02:29 +0800 Subject: [PATCH 11/21] =?UTF-8?q?Shrink=20color=20picker=20swatches=20(h-8?= =?UTF-8?q?=20=E2=86=92=20h-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/CalendarForm.tsx | 2 +- frontend/src/components/calendar/SharedCalendarSettings.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index be19997..1a17e18 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -157,7 +157,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { key={c} type="button" onClick={() => setColor(c)} - className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110" + className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110" style={{ backgroundColor: c, borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent', diff --git a/frontend/src/components/calendar/SharedCalendarSettings.tsx b/frontend/src/components/calendar/SharedCalendarSettings.tsx index c4c85b8..1d0b049 100644 --- a/frontend/src/components/calendar/SharedCalendarSettings.tsx +++ b/frontend/src/components/calendar/SharedCalendarSettings.tsx @@ -92,7 +92,7 @@ export default function SharedCalendarSettings({ membership, onClose }: SharedCa key={c} type="button" onClick={() => handleColorSelect(c)} - className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110" + className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110" style={{ backgroundColor: c, borderColor: localColor === c ? 'hsl(0 0% 98%)' : 'transparent', From c55af91c6021a895cd1e30b43df57c740d31b0c3 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 17:16:35 +0800 Subject: [PATCH 12/21] Fix two shared calendar bugs: lock banner missing and calendar not found on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (lock banner): Owners bypassed lock acquisition entirely, so no DB lock was created when an owner edited a shared event. Members polling GET /shared-calendars/events/{id}/lock correctly saw `locked: false`. Fix: remove the `myPermission !== 'owner'` guard in handleEditStart so owners also acquire a temporary 5-min edit lock when editing shared events, making the banner visible to all other members. Bug 2 (calendar not found on save): PUT /events/{id} called _verify_calendar_ownership whenever calendar_id appeared in the payload, even when it was unchanged. For shared-calendar members this always 404'd because they don't own the calendar. Fix: add `update_data["calendar_id"] != event.calendar_id` to the guard — ownership is only verified when the calendar is actually being changed (existing M-01 guard handles the move-off-shared case). Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/events.py | 4 +++- frontend/src/components/calendar/EventDetailPanel.tsx | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 140d046..e7bd57f 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -353,7 +353,9 @@ async def update_event( 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 # SEC-04: if calendar_id is being changed, verify the target belongs to the user - if "calendar_id" in update_data and update_data["calendar_id"] is not None: + # Only verify ownership when the calendar is actually changing — members submitting + # an unchanged calendar_id must not be rejected just because they aren't the owner. + if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id: await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) # M-01: Block non-owners from moving events off shared calendars diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index f7ee3ff..0fe4b34 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -392,8 +392,8 @@ export default function EventDetailPanel({ // --- Handlers --- const handleEditStart = async () => { - // For shared events, acquire lock first (owners skip locking) - if (isSharedEvent && myPermission !== 'owner' && event && typeof event.id === 'number') { + // For shared events, acquire lock first + if (isSharedEvent && event && typeof event.id === 'number') { try { await acquireLock(); setLockInfo(null); From e62503424c72075b5d7c88aa928d90e0451f4fad Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 17:26:22 +0800 Subject: [PATCH 13/21] Fix event lock system: banner persistence, stale isEditing guard, and Edit button gating - Remove isEditing guard from viewLockQuery effect so lock banner shows for user B even after user A transitions into edit mode (fixes banner disappearing) - Disable Edit button proactively when lockInfo.locked is already known from polling, preventing the user from even attempting acquireLock when a lock is active - Fix acquire callback dep array in useEventLock (missing acquireMutation) Co-Authored-By: Claude Opus 4.6 --- .../components/calendar/EventDetailPanel.tsx | 19 +++++++++---------- frontend/src/hooks/useEventLock.ts | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 0fe4b34..b656fbe 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -276,16 +276,15 @@ export default function EventDetailPanel({ refetchInterval: 5_000, }); - // Show/hide lock banner proactively in view mode + // Show/hide lock banner proactively in view mode (poll data always authoritative) useEffect(() => { - if (viewLockQuery.data && !isEditing && !isCreating) { - if (viewLockQuery.data.locked) { - setLockInfo(viewLockQuery.data); - } else { - setLockInfo(null); - } + if (!viewLockQuery.data) return; + if (viewLockQuery.data.locked) { + setLockInfo(viewLockQuery.data); + } else { + setLockInfo(null); } - }, [viewLockQuery.data, isEditing, isCreating]); + }, [viewLockQuery.data]); const isRecurring = !!(event?.is_recurring || event?.parent_event_id); @@ -581,8 +580,8 @@ export default function EventDetailPanel({ size="icon" className="h-7 w-7" onClick={handleEditStart} - disabled={isAcquiringLock} - title="Edit event" + disabled={isAcquiringLock || !!(lockInfo && lockInfo.locked)} + title={lockInfo && lockInfo.locked ? `Locked by ${lockInfo.locked_by_name || 'another user'}` : 'Edit event'} > {isAcquiringLock ? : } diff --git a/frontend/src/hooks/useEventLock.ts b/frontend/src/hooks/useEventLock.ts index 9c8d695..3495773 100644 --- a/frontend/src/hooks/useEventLock.ts +++ b/frontend/src/hooks/useEventLock.ts @@ -34,7 +34,7 @@ export function useEventLock(eventId: number | null) { if (!eventId) return null; const data = await acquireMutation.mutateAsync(eventId); return data; - }, [eventId]); + }, [eventId, acquireMutation]); const release = useCallback(async () => { const id = activeEventIdRef.current; From 3dcf9d16710b0b378c1c87eaa8e136e440849713 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 17:41:47 +0800 Subject: [PATCH 14/21] =?UTF-8?q?Fix=20isSharedEvent=20excluding=20calenda?= =?UTF-8?q?r=20owners=20=E2=80=94=20lock=20banner=20never=20appeared=20for?= =?UTF-8?q?=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selectedEventIsShared check used `permissionMap.get(...) !== 'owner'` which excluded calendar owners from all shared-event behavior (lock polling, lock acquisition, lock banner display). Replaced with a sharedCalendarIds set that includes both owned shared calendars (via cal.is_shared) and memberships. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/CalendarPage.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index ba8899a..3f0543a 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -70,6 +70,14 @@ export default function CalendarPage() { return map; }, [calendars, sharedData]); + // Set of calendar IDs that are shared (owned or membership) + const sharedCalendarIds = useMemo(() => { + const ids = new Set(); + calendars.forEach((cal) => { if (cal.is_shared) ids.add(cal.id); }); + sharedData.forEach((m) => ids.add(m.calendar_id)); + return ids; + }, [calendars, sharedData]); + // Handle navigation state from dashboard useEffect(() => { const state = location.state as { date?: string; view?: string; eventId?: number } | null; @@ -158,7 +166,7 @@ export default function CalendarPage() { ); const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; - const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false; + const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false; // Close panel if shared calendar was removed while viewing useEffect(() => { From 8f777dd15ac5ef64ee15d03c5769cac9375a59d9 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 17:47:26 +0800 Subject: [PATCH 15/21] Fix lock banner: use viewLockQuery.data directly instead of syncing through state Root cause: the previous approach synced poll data into lockInfo via a useEffect. When the user selected an event with cached lock data, both the poll-data effect and the event-change reset effect ran in the same render cycle. The event-change effect ran second (effects are ordered by definition) and cleared lockInfo to null. On the next render, viewLockQuery.data hadn't changed (TanStack Query structural sharing returns same reference), so the poll-data effect never re-fired. Result: lockInfo stayed null, banner stayed hidden until the next polling interval returned new data. Fix: derive activeLockInfo directly from viewLockQuery.data (structural sharing means it's always the latest authoritative value from TanStack Query) with lockInfo as a fallback for the 423-error path only. Also add refetchIntervalInBackground:true and refetchOnMount:'always' to ensure polling doesn't pause on tab switch and always fires a fresh fetch when the component mounts. Co-Authored-By: Claude Opus 4.6 --- .../components/calendar/EventDetailPanel.tsx | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index b656fbe..8362a1f 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -264,6 +264,7 @@ export default function EventDetailPanel({ const [locationSearch, setLocationSearch] = useState(''); // Poll lock status in view mode for shared events (Stream A: real-time lock awareness) + // lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly. const viewLockQuery = useQuery({ queryKey: ['event-lock', event?.id], queryFn: async () => { @@ -274,18 +275,22 @@ export default function EventDetailPanel({ }, enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating, refetchInterval: 5_000, + refetchIntervalInBackground: true, + refetchOnMount: 'always', }); - // Show/hide lock banner proactively in view mode (poll data always authoritative) + // Clear 423-error lockInfo when poll confirms lock is gone useEffect(() => { - if (!viewLockQuery.data) return; - if (viewLockQuery.data.locked) { - setLockInfo(viewLockQuery.data); - } else { + if (viewLockQuery.data && !viewLockQuery.data.locked) { setLockInfo(null); } }, [viewLockQuery.data]); + // Derived: authoritative lock state — poll data wins, 423 error lockInfo as fallback + const activeLockInfo: EventLockInfo | null = + (viewLockQuery.data?.locked ? viewLockQuery.data : null) ?? + (lockInfo?.locked ? lockInfo : null); + const isRecurring = !!(event?.is_recurring || event?.parent_event_id); // Permission helpers @@ -580,8 +585,8 @@ export default function EventDetailPanel({ size="icon" className="h-7 w-7" onClick={handleEditStart} - disabled={isAcquiringLock || !!(lockInfo && lockInfo.locked)} - title={lockInfo && lockInfo.locked ? `Locked by ${lockInfo.locked_by_name || 'another user'}` : 'Edit event'} + disabled={isAcquiringLock || !!activeLockInfo} + title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'} > {isAcquiringLock ? : } @@ -629,12 +634,12 @@ export default function EventDetailPanel({ {/* Body */}
- {/* Lock banner */} - {lockInfo && lockInfo.locked && ( + {/* Lock banner — shown when activeLockInfo reports a lock (poll-authoritative) */} + {activeLockInfo && ( )} From 206144d20d9af925123a1bdf66b109427f22d0db Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 23:37:05 +0800 Subject: [PATCH 16/21] Fix 2 pentest findings: unlock permission check + permanent lock preservation SC-01: unlock_event now verifies caller has access to the calendar before revealing lock state. Previously any authenticated user could probe event existence via 404/204/403 response differences. SC-02: acquire_lock no longer overwrites permanent locks. If the owner holds a permanent lock and clicks Edit, the existing lock is returned as-is instead of being downgraded to a 5-minute temporary lock. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/shared_calendars.py | 5 +++++ backend/app/services/calendar_sharing.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py index 047e68e..3ad07ba 100644 --- a/backend/app/routers/shared_calendars.py +++ b/backend/app/routers/shared_calendars.py @@ -716,6 +716,11 @@ async def unlock_event( if not event: raise HTTPException(status_code=404, detail="Event not found") + # SC-01: Verify caller has access to this calendar before revealing lock state + 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") + lock_result = await db.execute( select(EventLock).where(EventLock.event_id == event_id) ) diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index 9be24d0..71bba15 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -66,8 +66,20 @@ async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLo """ Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event. Only succeeds if no unexpired lock exists or the existing lock is held by the same user. + Permanent locks are never overwritten — if the same user holds one, it is returned as-is. Returns the lock or raises 423 Locked. """ + # Check for existing permanent lock first + existing = await db.execute( + select(EventLock).where(EventLock.event_id == event_id) + ) + existing_lock = existing.scalar_one_or_none() + if existing_lock and existing_lock.is_permanent: + if existing_lock.locked_by == user_id: + # Owner holds permanent lock — return it without downgrading + return existing_lock + raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner") + now = datetime.now() expires = now + timedelta(minutes=LOCK_DURATION_MINUTES) From dd862bfa48003158eed96ce24b32e5c75d36b67f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 23:41:08 +0800 Subject: [PATCH 17/21] Fix QA review findings: 3 critical, 5 warnings, 1 suggestion Critical: - C-01: Populate member_count in GET /calendars for shared calendars - C-02: Differentiate 423 lock errors in drag-drop onError (show lock-specific toast) - C-03: Add expired lock purge to APScheduler housekeeping job Warnings: - W-01: Replace setattr loop with explicit field assignment in update_member - W-02: Cap sync `since` param to 7 days to prevent unbounded scans - W-05: Remove cosmetic isShared toggle (is_shared is auto-managed by invite flow) - W-06: Populate preferred_name in _build_member_response from user model - W-07: Add releaseMutation to release callback dependency array Suggestion: - S-06: Remove unused ConvertToSharedRequest schema Co-Authored-By: Claude Opus 4.6 --- backend/app/jobs/notifications.py | 14 ++++++++++ backend/app/routers/calendars.py | 26 +++++++++++++++++-- backend/app/routers/shared_calendars.py | 15 ++++++++--- backend/app/schemas/shared_calendar.py | 3 --- .../src/components/calendar/CalendarForm.tsx | 14 ++-------- .../src/components/calendar/CalendarPage.tsx | 7 ++++- frontend/src/hooks/useEventLock.ts | 2 +- 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index ca15d2f..7798f0c 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -21,6 +21,7 @@ from app.models.notification import Notification as AppNotification from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent from app.models.calendar import Calendar +from app.models.event_lock import EventLock from app.models.todo import Todo from app.models.project import Project from app.models.ntfy_sent import NtfySent @@ -300,6 +301,18 @@ async def _purge_resolved_requests(db: AsyncSession) -> None: await db.commit() + +async def _purge_expired_locks(db: AsyncSession) -> None: + """Remove non-permanent event locks that have expired.""" + await db.execute( + delete(EventLock).where( + EventLock.is_permanent == False, # noqa: E712 + EventLock.expires_at < datetime.now(), + ) + ) + await db.commit() + + # ── Entry point ─────────────────────────────────────────────────────────────── async def run_notification_dispatch() -> None: @@ -343,6 +356,7 @@ async def run_notification_dispatch() -> None: await _purge_expired_sessions(db) await _purge_old_notifications(db) await _purge_resolved_requests(db) + await _purge_expired_locks(db) except Exception: # Broad catch: job failure must never crash the scheduler or the app diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 2e324a7..7dec7fc 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException, Path from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update +from sqlalchemy import func, select, update from typing import List from app.database import get_db from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent +from app.models.calendar_member import CalendarMember from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse from app.routers.auth import get_current_user from app.models.user import User @@ -23,7 +24,28 @@ async def get_calendars( .where(Calendar.user_id == current_user.id) .order_by(Calendar.is_default.desc(), Calendar.name.asc()) ) - return result.scalars().all() + calendars = result.scalars().all() + + # Populate member_count for shared calendars + cal_ids = [c.id for c in calendars if c.is_shared] + count_map: dict[int, int] = {} + if cal_ids: + counts = await db.execute( + select(CalendarMember.calendar_id, func.count()) + .where( + CalendarMember.calendar_id.in_(cal_ids), + CalendarMember.status == "accepted", + ) + .group_by(CalendarMember.calendar_id) + ) + count_map = dict(counts.all()) + + return [ + CalendarResponse.model_validate(c, from_attributes=True).model_copy( + update={"member_count": count_map.get(c.id, 0)} + ) + for c in calendars + ] @router.post("/", response_model=CalendarResponse, status_code=201) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py index 3ad07ba..f84edb7 100644 --- a/backend/app/routers/shared_calendars.py +++ b/backend/app/routers/shared_calendars.py @@ -4,7 +4,7 @@ Shared calendars router — invites, membership, locks, sync. All endpoints live under /api/shared-calendars. """ import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request @@ -60,7 +60,7 @@ def _build_member_response(member: CalendarMember) -> dict: "calendar_id": member.calendar_id, "user_id": member.user_id, "umbral_name": member.user.umbral_name if member.user else "", - "preferred_name": None, + "preferred_name": member.user.preferred_name if member.user else None, "permission": member.permission, "can_add_others": member.can_add_others, "local_color": member.local_color, @@ -462,8 +462,10 @@ async def update_member( if not update_data: raise HTTPException(status_code=400, detail="No fields to update") - for key, value in update_data.items(): - setattr(member, key, value) + if "permission" in update_data: + member.permission = update_data["permission"] + if "can_add_others" in update_data: + member.can_add_others = update_data["can_add_others"] await log_audit_event( db, @@ -581,6 +583,11 @@ async def sync_shared_calendars( """Sync events and member changes since a given timestamp. Cap 500 events.""" MAX_EVENTS = 500 + # Cap since to 7 days ago to prevent unbounded scans + floor = datetime.now() - timedelta(days=7) + if since < floor: + since = floor + cal_id_list: list[int] = [] if calendar_ids: for part in calendar_ids.split(","): diff --git a/backend/app/schemas/shared_calendar.py b/backend/app/schemas/shared_calendar.py index 5b5420f..453aaa2 100644 --- a/backend/app/schemas/shared_calendar.py +++ b/backend/app/schemas/shared_calendar.py @@ -34,9 +34,6 @@ class UpdateLocalColorRequest(BaseModel): return v -class ConvertToSharedRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - class CalendarMemberResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 1a17e18..3040b93 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -14,7 +14,6 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; import PermissionToggle from './PermissionToggle'; import { useConnections } from '@/hooks/useConnections'; import { useSharedCalendars } from '@/hooks/useSharedCalendars'; @@ -35,7 +34,6 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { const queryClient = useQueryClient(); const [name, setName] = useState(calendar?.name || ''); const [color, setColor] = useState(calendar?.color || '#3b82f6'); - const [isShared, setIsShared] = useState(calendar?.is_shared ?? false); const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null); @@ -131,7 +129,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -170,15 +168,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { {showSharing && ( <> -
- - -
- - {isShared && ( + {calendar?.is_shared && (
diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 3f0543a..dab78d2 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -9,6 +9,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; +import axios from 'axios'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; @@ -244,7 +245,11 @@ export default function CalendarPage() { onError: (error, variables) => { variables.revert(); queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); - toast.error(getErrorMessage(error, 'Failed to update event')); + if (axios.isAxiosError(error) && error.response?.status === 423) { + toast.error('Event is locked by another user'); + } else { + toast.error(getErrorMessage(error, 'Failed to update event')); + } }, }); diff --git a/frontend/src/hooks/useEventLock.ts b/frontend/src/hooks/useEventLock.ts index 3495773..8df39f5 100644 --- a/frontend/src/hooks/useEventLock.ts +++ b/frontend/src/hooks/useEventLock.ts @@ -44,7 +44,7 @@ export function useEventLock(eventId: number | null) { } catch { // Fire-and-forget on release errors } - }, []); + }, [releaseMutation]); // Auto-release on unmount or eventId change useEffect(() => { From cdbf3175aa4e51c75f8839b7bc539f3d118772a6 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 23:45:10 +0800 Subject: [PATCH 18/21] Fix remaining QA warnings: lazy=raise on CalendarMember + bidirectional connection check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W-03: invite_member now verifies the target user has a reciprocal UserConnection row before sending the invite. W-04: CalendarMember relationships changed from lazy="selectin" to lazy="raise". All queries that access .user, .calendar, or .inviter already use explicit selectinload() — verified across all routers and services. Co-Authored-By: Claude Opus 4.6 --- backend/app/models/calendar_member.py | 6 +++--- backend/app/routers/shared_calendars.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/app/models/calendar_member.py b/backend/app/models/calendar_member.py index 56f647f..2f7ad26 100644 --- a/backend/app/models/calendar_member.py +++ b/backend/app/models/calendar_member.py @@ -46,8 +46,8 @@ class CalendarMember(Base): ) accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="selectin") - user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="selectin") + calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="raise") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") inviter: Mapped[Optional["User"]] = relationship( - foreign_keys=[invited_by], lazy="selectin" + foreign_keys=[invited_by], lazy="raise" ) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py index f84edb7..b9ed123 100644 --- a/backend/app/routers/shared_calendars.py +++ b/backend/app/routers/shared_calendars.py @@ -163,6 +163,16 @@ async def invite_member( target_user_id = connection.connected_user_id + # W-03: Verify bidirectional connection still active + reverse_conn = await db.execute( + select(UserConnection.id).where( + UserConnection.user_id == target_user_id, + UserConnection.connected_user_id == current_user.id, + ) + ) + if not reverse_conn.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Connection is no longer active") + if target_user_id == calendar.user_id: raise HTTPException(status_code=400, detail="Cannot invite the calendar owner") From 1bc1e37518f9dd7c8d00513c8040af1961d6bd02 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 23:49:49 +0800 Subject: [PATCH 19/21] Fix W-06 regression: preferred_name is on Settings, not User model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _build_member_response helper tried to access member.user.preferred_name but User model has no preferred_name field (it's on Settings). With lazy="raise" this caused a 500 on GET /shared-calendars/{id}/members. Reverted to None — the list_members endpoint already patches preferred_name from Settings. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/shared_calendars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py index b9ed123..1531412 100644 --- a/backend/app/routers/shared_calendars.py +++ b/backend/app/routers/shared_calendars.py @@ -60,7 +60,7 @@ def _build_member_response(member: CalendarMember) -> dict: "calendar_id": member.calendar_id, "user_id": member.user_id, "umbral_name": member.user.umbral_name if member.user else "", - "preferred_name": member.user.preferred_name if member.user else None, + "preferred_name": None, "permission": member.permission, "can_add_others": member.can_add_others, "local_color": member.local_color, From 59c89c904c7ab76770f7c1aff6adfa06af4c6118 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 23:58:50 +0800 Subject: [PATCH 20/21] Resizable calendar sidebar with localStorage persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar width adjustable via click-and-drag (180–400px range, default 224px). Width persists to localStorage across sessions. Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarPage.tsx | 53 ++++++++++++++++++- .../components/calendar/CalendarSidebar.tsx | 5 +- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index dab78d2..27b03c5 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -47,6 +47,51 @@ export default function CalendarPage() { const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); + // Resizable sidebar + const SIDEBAR_STORAGE_KEY = 'umbra-calendar-sidebar-width'; + const SIDEBAR_MIN = 180; + const SIDEBAR_MAX = 400; + const SIDEBAR_DEFAULT = 224; // w-56 + const [sidebarWidth, setSidebarWidth] = useState(() => { + const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY); + if (saved) { + const n = parseInt(saved, 10); + if (!isNaN(n) && n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) return n; + } + return SIDEBAR_DEFAULT; + }); + const isResizingRef = useRef(false); + + const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizingRef.current = true; + const startX = e.clientX; + const startWidth = sidebarWidth; + + const onMouseMove = (ev: MouseEvent) => { + const newWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + (ev.clientX - startX))); + setSidebarWidth(newWidth); + }; + + const onMouseUp = () => { + isResizingRef.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [sidebarWidth]); + + // Persist sidebar width on change + useEffect(() => { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth)); + }, [sidebarWidth]); + // Location data for event panel const { data: locations = [] } = useQuery({ queryKey: ['locations'], @@ -423,7 +468,11 @@ export default function CalendarPage() { return (
- + +
{/* Custom toolbar */} diff --git a/frontend/src/components/calendar/CalendarSidebar.tsx b/frontend/src/components/calendar/CalendarSidebar.tsx index 689dc75..9838b1b 100644 --- a/frontend/src/components/calendar/CalendarSidebar.tsx +++ b/frontend/src/components/calendar/CalendarSidebar.tsx @@ -14,9 +14,10 @@ import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedC interface CalendarSidebarProps { onUseTemplate?: (template: EventTemplate) => void; onSharedVisibilityChange?: (visibleIds: Set) => void; + width: number; } -export default function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange }: CalendarSidebarProps) { +export default function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }: CalendarSidebarProps) { const queryClient = useQueryClient(); const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars(); const [showForm, setShowForm] = useState(false); @@ -93,7 +94,7 @@ export default function CalendarSidebar({ onUseTemplate, onSharedVisibilityChang }; return ( -
+
Calendars