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/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/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/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..2f7ad26 --- /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="raise") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") + inviter: Mapped[Optional["User"]] = relationship( + foreign_keys=[invited_by], lazy="raise" + ) 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/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/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/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..e7bd57f 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 @@ -327,9 +353,23 @@ 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 + 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: @@ -342,6 +382,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 +412,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 +428,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 +448,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 +471,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 +488,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..1531412 --- /dev/null +++ b/backend/app/routers/shared_calendars.py @@ -0,0 +1,871 @@ +""" +Shared calendars router — invites, membership, locks, sync. + +All endpoints live under /api/shared-calendars. +""" +import logging +from datetime import datetime, timedelta +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 + + # 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") + + 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") + + 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, + 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 + + # 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(","): + 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") + + # 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) + ) + 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/schemas/calendar.py b/backend/app/schemas/calendar.py index e9e2753..0ce2b05 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -25,7 +25,13 @@ class CalendarResponse(BaseModel): is_default: bool is_system: bool is_visible: bool + is_shared: bool = False created_at: datetime updated_at: datetime + 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..453aaa2 --- /dev/null +++ b/backend/app/schemas/shared_calendar.py @@ -0,0 +1,76 @@ +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 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 diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py new file mode 100644 index 0000000..71bba15 --- /dev/null +++ b/backend/app/services/calendar_sharing.py @@ -0,0 +1,217 @@ +""" +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. + 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) + + 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; 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/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 429a94a..3040b93 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 { useState, FormEvent, useCallback } from 'react'; +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 PermissionToggle from './PermissionToggle'; +import { useConnections } from '@/hooks/useConnections'; +import { useSharedCalendars } from '@/hooks/useSharedCalendars'; +import CalendarMemberSearch from './CalendarMemberSearch'; +import CalendarMemberList from './CalendarMemberList'; interface CalendarFormProps { calendar: Calendar | null; @@ -21,14 +26,8 @@ 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) { @@ -36,6 +35,23 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { const [name, setName] = useState(calendar?.name || ''); const [color, setColor] = useState(calendar?.color || '#3b82f6'); + const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null); + + 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 () => { if (calendar) { @@ -78,11 +94,42 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { mutation.mutate(); }; + const handleSelectConnection = useCallback((conn: Connection) => { + setPendingInvite({ conn, permission: 'read_only' }); + }, []); + + const handleSendInvite = async () => { + if (!calendar || !pendingInvite) return; + await invite({ + calendarId: calendar.id, + connectionId: pendingInvite.conn.id, + permission: pendingInvite.permission, + canAddOthers: false, + }); + setPendingInvite(null); + }; + + const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => { + if (!calendar) return; + await updateMember({ calendarId: calendar.id, memberId, permission }); + }; + + const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => { + if (!calendar) return; + await updateMember({ calendarId: calendar.id, memberId, canAddOthers }); + }; + + const handleRemoveMember = async (memberId: number) => { + if (!calendar) return; + await removeMember({ calendarId: calendar.id, memberId }); + }; + const canDelete = calendar && !calendar.is_default && !calendar.is_system; + const showSharing = calendar && !calendar.is_system; return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -108,7 +155,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', @@ -119,6 +166,72 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { + {showSharing && ( + <> + {calendar?.is_shared && ( +
+
+ + + You (Owner) + +
+ + {pendingInvite ? ( +
+
+ + {pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name} + + +
+
+ setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)} + /> +
+ +
+
+ ) : ( + + )} + + +
+ )} + + )} + {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..9e2d975 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'; @@ -9,7 +9,8 @@ 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 axios from 'axios'; +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,9 +43,62 @@ export default function CalendarPage() { const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); - const { data: calendars = [] } = useCalendars(); + const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true }); + 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 sidebarRef = useRef(null); + + const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizingRef.current = true; + const startX = e.clientX; + const startWidth = sidebarWidth; + let latestWidth = startWidth; + + const onMouseMove = (ev: MouseEvent) => { + latestWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + (ev.clientX - startX))); + // Direct DOM mutation — bypasses React entirely during drag, zero re-renders + if (sidebarRef.current) { + sidebarRef.current.style.width = latestWidth + 'px'; + } + }; + + const onMouseUp = () => { + isResizingRef.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + // Single React state commit on release — triggers localStorage persist + final reconciliation + setSidebarWidth(latestWidth); + }; + + 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'], @@ -61,6 +115,22 @@ 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]); + + // 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; @@ -90,6 +160,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; @@ -131,6 +210,7 @@ export default function CalendarPage() { const { data } = await api.get('/events'); return data; }, + refetchInterval: 5_000, }); const selectedEvent = useMemo( @@ -138,6 +218,18 @@ export default function CalendarPage() { [selectedEventId, events], ); + const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; + const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false; + + // Close panel if shared calendar was removed while viewing + 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; @@ -149,8 +241,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 => { @@ -202,7 +297,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')); + } }, }); @@ -246,10 +345,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, }, })); @@ -274,6 +375,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 @@ -298,6 +404,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 @@ -364,7 +475,11 @@ export default function CalendarPage() { return (
- + +
{/* Custom toolbar */} @@ -482,27 +597,27 @@ export default function CalendarPage() {
{/* Detail panel (desktop) */} -
- -
+ {panelOpen && isDesktop && ( +
+ +
+ )}
{/* Mobile detail panel overlay */} - {panelOpen && ( + {panelOpen && !isDesktop && (
diff --git a/frontend/src/components/calendar/CalendarSidebar.tsx b/frontend/src/components/calendar/CalendarSidebar.tsx index 9636064..27cd799 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, forwardRef } 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,42 @@ 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; + width: number; } -export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) { +const CalendarSidebar = forwardRef(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) { 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 () => { @@ -71,7 +94,7 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) }; return ( -
+
Calendars
- {/* Calendars list */} + {/* Owned calendars list (non-shared only) */}
- {calendars.map((cal) => ( + {calendars.filter((c) => !c.is_shared).map((cal) => (
+ {/* 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 */}
@@ -185,4 +220,6 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) )}
); -} +}); + +export default CalendarSidebar; \ No newline at end of file diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 882a0bf..8362a1f 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -3,14 +3,17 @@ 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 axios from 'axios'; 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 +129,8 @@ interface EventDetailPanelProps { onSaved?: () => void; onDeleted?: () => void; locationName?: string; + myPermission?: CalendarPermission | 'owner' | null; + isSharedEvent?: boolean; } interface EditState { @@ -218,10 +223,17 @@ export default function EventDetailPanel({ onSaved, onDeleted, locationName, + myPermission, + 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({ @@ -233,6 +245,12 @@ 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 @@ -245,14 +263,47 @@ 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) + // 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 () => { + 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, + refetchIntervalInBackground: true, + refetchOnMount: 'always', + }); + + // Clear 423-error lockInfo when poll confirms lock is gone + useEffect(() => { + 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 + 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,15 +358,16 @@ export default function EventDetailPanel({ } }, onSuccess: () => { + if (isSharedEvent) releaseLock(); invalidateAll(); toast.success(isCreating ? 'Event created' : 'Event updated'); if (isCreating) { onClose(); + onSaved?.(); } else { setIsEditing(false); setEditScope(null); } - onSaved?.(); }, onError: (error) => { toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event')); @@ -343,7 +395,28 @@ export default function EventDetailPanel({ // --- Handlers --- - const handleEditStart = () => { + const handleEditStart = async () => { + // For shared events, acquire lock first + if (isSharedEvent && event && typeof event.id === 'number') { + try { + await acquireLock(); + setLockInfo(null); + } catch (err: unknown) { + if (axios.isAxiosError(err) && err.response?.status === 423) { + const data = err.response.data as { locked_by_name?: string; expires_at?: string; is_permanent?: boolean } | undefined; + setLockInfo({ + locked: true, + locked_by_name: data?.locked_by_name || 'another user', + expires_at: data?.expires_at || null, + is_permanent: data?.is_permanent || false, + }); + return; + } + toast.error('Failed to acquire edit lock'); + return; + } + } + if (isRecurring) { setScopeStep('edit'); } else { @@ -361,8 +434,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 +447,7 @@ export default function EventDetailPanel({ }; const handleEditCancel = () => { + if (isSharedEvent) releaseLock(); setIsEditing(false); setEditScope(null); setLocationSearch(''); @@ -507,37 +579,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 new file mode 100644 index 0000000..d42308c --- /dev/null +++ b/frontend/src/components/calendar/SharedCalendarSection.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Ghost, Pencil } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import type { Calendar, 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 { + 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 && ownedSharedCalendars.length === 0) return null; + + return ( + <> +
+
+ + + Shared Calendars + +
+
+ {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); + 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..1d0b049 --- /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/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index ac0cb52..8a394fa 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -61,6 +61,24 @@ export function useAdminUserDetail(userId: number | null) { }); } +interface SharingStats { + shared_calendars_owned: number; + calendars_member_of: number; + pending_invites_sent: number; + pending_invites_received: number; +} + +export function useAdminSharingStats(userId: number | null) { + return useQuery({ + 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 444ec6d..7bbfd3f 100644 --- a/frontend/src/hooks/useCalendars.ts +++ b/frontend/src/hooks/useCalendars.ts @@ -1,13 +1,42 @@ +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({ +interface UseCalendarsOptions { + pollingEnabled?: boolean; +} + +export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {}) { + 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: pollingEnabled ? 5_000 : false, + 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/useEventLock.ts b/frontend/src/hooks/useEventLock.ts new file mode 100644 index 0000000..8df39f5 --- /dev/null +++ b/frontend/src/hooks/useEventLock.ts @@ -0,0 +1,68 @@ +import { useRef, useEffect, useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import api from '@/lib/api'; +import type { EventLockInfo } from '@/types'; + +export function useEventLock(eventId: number | null) { + const lockHeldRef = useRef(false); + const activeEventIdRef = useRef(null); + + const acquireMutation = useMutation({ + mutationFn: async (id: number) => { + const { data } = await api.post( + `/shared-calendars/events/${id}/lock` + ); + return data; + }, + onSuccess: (_data, lockedId) => { + lockHeldRef.current = true; + activeEventIdRef.current = lockedId; + }, + }); + + const releaseMutation = useMutation({ + mutationFn: async (id: number) => { + await api.delete(`/shared-calendars/events/${id}/lock`); + }, + onSuccess: () => { + lockHeldRef.current = false; + activeEventIdRef.current = null; + }, + }); + + const acquire = useCallback(async () => { + if (!eventId) return null; + const data = await acquireMutation.mutateAsync(eventId); + return data; + }, [eventId, acquireMutation]); + + const release = useCallback(async () => { + const id = activeEventIdRef.current; + if (!id || !lockHeldRef.current) return; + try { + await releaseMutation.mutateAsync(id); + } catch { + // Fire-and-forget on release errors + } + }, [releaseMutation]); + + // Auto-release on unmount or eventId change + useEffect(() => { + return () => { + const id = activeEventIdRef.current; + if (id && lockHeldRef.current) { + // Fire-and-forget cleanup + api.delete(`/shared-calendars/events/${id}/lock`).catch(() => {}); + lockHeldRef.current = false; + activeEventIdRef.current = null; + } + }; + }, [eventId]); + + return { + acquire, + release, + isAcquiring: acquireMutation.isPending, + acquireError: acquireMutation.error, + }; +} diff --git a/frontend/src/hooks/useSharedCalendars.ts b/frontend/src/hooks/useSharedCalendars.ts new file mode 100644 index 0000000..d643c53 --- /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( + '/shared-calendars/invites/incoming' + ); + return data; + }, + 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; +}