Merge feature/shared-calendars: full shared calendar system

Shared calendars with invite flow, granular permissions (read_only/create_modify/full_access),
event locking (5-min TTL + permanent), real-time sync (5s polling), drag-drop guards, resizable
sidebar, and polished UI components. QA reviewed, pentested, all findings actioned.

20 commits across 6 phases + QA/pentest fixes + UI polish.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-07 00:57:29 +08:00
commit f0e8f450f3
43 changed files with 3185 additions and 100 deletions

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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))

View File

@ -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")

View File

@ -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

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
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("/")

View File

@ -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",
]

View File

@ -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")

View File

@ -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())

View File

@ -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"
)

View File

@ -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")

View File

@ -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",
)

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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)

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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
/>
</CardContent>
</Card>
{/* Sharing Stats */}
<Card className="col-span-1">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Share2 className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">Sharing</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<DetailRow label="Calendars Shared" value={String(sharingStats?.shared_calendars_owned ?? 0)} />
<DetailRow label="Member Of" value={String(sharingStats?.calendars_member_of ?? 0)} />
<DetailRow label="Invites Sent" value={String(sharingStats?.pending_invites_sent ?? 0)} />
<DetailRow label="Invites Received" value={String(sharingStats?.pending_invites_received ?? 0)} />
</CardContent>
</Card>
</div>
);
}

View File

@ -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<CalendarMemberInfo[]>(
`/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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogContent className={calendar?.is_shared && showSharing ? 'max-w-3xl' : undefined}>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
@ -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) {
</div>
</div>
{showSharing && (
<>
{calendar?.is_shared && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="mb-0">Members</Label>
<span className="text-[11px] text-muted-foreground">
You (Owner)
</span>
</div>
{pendingInvite ? (
<div
className="rounded-lg border border-border bg-card-elevated p-4 space-y-3 animate-fade-in"
style={{ borderLeftWidth: '3px', borderLeftColor: 'hsl(var(--accent-color) / 0.5)' }}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
</span>
<button
type="button"
onClick={() => setPendingInvite(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
<div className="flex items-center gap-3">
<PermissionToggle
value={pendingInvite.permission}
onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)}
/>
<div className="flex-1" />
<Button
type="button"
size="sm"
onClick={handleSendInvite}
disabled={isInviting}
>
{isInviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</div>
) : (
<CalendarMemberSearch
connections={connections}
existingMembers={members}
onSelect={handleSelectConnection}
isLoading={isInviting}
/>
)}
<CalendarMemberList
members={members}
isLoading={membersQuery.isLoading}
isOwner={true}
onUpdatePermission={handleUpdatePermission}
onUpdateCanAddOthers={handleUpdateCanAddOthers}
onRemove={handleRemoveMember}
/>
</div>
)}
</>
)}
<DialogFooter>
{canDelete && (
<Button

View File

@ -0,0 +1,55 @@
import { Loader2 } from 'lucide-react';
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
import CalendarMemberRow from './CalendarMemberRow';
interface CalendarMemberListProps {
members: CalendarMemberInfo[];
isLoading?: boolean;
isOwner: boolean;
readOnly?: boolean;
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
onRemove?: (memberId: number) => void;
}
export default function CalendarMemberList({
members,
isLoading = false,
isOwner,
readOnly = false,
onUpdatePermission,
onUpdateCanAddOthers,
onRemove,
}: CalendarMemberListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (members.length === 0) {
return (
<p className="text-xs text-muted-foreground py-2">
Search your connections to add members
</p>
);
}
return (
<div className="space-y-2 max-h-72 overflow-y-auto">
{members.map((member) => (
<CalendarMemberRow
key={member.id}
member={member}
isOwner={isOwner}
readOnly={readOnly}
onUpdatePermission={onUpdatePermission}
onUpdateCanAddOthers={onUpdateCanAddOthers}
onRemove={onRemove}
/>
))}
</div>
);
}

View File

@ -0,0 +1,86 @@
import { X, UserPlus } from 'lucide-react';
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
import { Checkbox } from '@/components/ui/checkbox';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import PermissionBadge from './PermissionBadge';
import PermissionToggle from './PermissionToggle';
interface CalendarMemberRowProps {
member: CalendarMemberInfo;
isOwner: boolean;
readOnly?: boolean;
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
onRemove?: (memberId: number) => void;
}
export default function CalendarMemberRow({
member,
isOwner,
readOnly = false,
onUpdatePermission,
onUpdateCanAddOthers,
onRemove,
}: CalendarMemberRowProps) {
const { confirming, handleClick: handleRemoveClick } = useConfirmAction(
() => onRemove?.(member.id)
);
const displayName = member.preferred_name || member.umbral_name;
const initial = displayName.charAt(0).toUpperCase();
return (
<div className="flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-200 hover:border-border/80">
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-sm text-violet-400 font-medium">{initial}</span>
</div>
<div className="flex items-center gap-2 min-w-0 flex-1 truncate">
<span className="text-sm font-medium truncate">{displayName}</span>
{member.preferred_name && (
<span className="text-xs text-violet-400 truncate shrink-0">{member.umbral_name}</span>
)}
{member.status === 'pending' && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-400 font-medium shrink-0">
Pending
</span>
)}
</div>
{readOnly ? (
<PermissionBadge permission={member.permission} />
) : isOwner ? (
<div className="flex items-center gap-2.5 shrink-0">
<PermissionToggle
value={member.permission}
onChange={(p) => onUpdatePermission?.(member.id, p)}
/>
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
<label className="flex items-center gap-1.5 cursor-pointer shrink-0" title="Can add others">
<Checkbox
checked={member.can_add_others}
onChange={() => onUpdateCanAddOthers?.(member.id, !member.can_add_others)}
className="h-3.5 w-3.5"
/>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</label>
)}
<button
type="button"
onClick={handleRemoveClick}
className="text-muted-foreground hover:text-destructive transition-colors"
title={confirming ? 'Click again to confirm' : 'Remove member'}
>
{confirming ? (
<span className="text-[10px] text-destructive font-medium px-1">Sure?</span>
) : (
<X className="h-4 w-4" />
)}
</button>
</div>
) : (
<PermissionBadge permission={member.permission} />
)}
</div>
);
}

View File

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="relative">
<div className="relative">
{isLoading ? (
<Loader2 className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground animate-spin" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder="Search connections to invite..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
className="pl-8 h-9 text-sm"
/>
</div>
{focused && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden max-h-40 overflow-y-auto">
{filtered.map((conn) => {
const displayName = conn.connected_preferred_name || conn.connected_umbral_name;
const initial = displayName.charAt(0).toUpperCase();
return (
<button
key={conn.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(conn)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div className="h-6 w-6 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs text-violet-400 font-medium">{initial}</span>
</div>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{displayName}</span>
{conn.connected_preferred_name && (
<span className="text-xs text-muted-foreground">{conn.connected_umbral_name}</span>
)}
</div>
</button>
);
})}
</div>
)}
{focused && query.trim() && filtered.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No matching connections</p>
</div>
)}
</div>
);
}

View File

@ -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<CreateDefaults | null>(null);
const { settings } = useSettings();
const { data: calendars = [] } = useCalendars();
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(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<HTMLDivElement>(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<number, CalendarPermission | 'owner'>();
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<number>();
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<CalendarEvent[]>('/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'] });
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 (
<div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar onUseTemplate={handleUseTemplate} />
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
<div
onMouseDown={handleSidebarMouseDown}
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
/>
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */}
@ -482,11 +597,8 @@ export default function CalendarPage() {
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
{panelOpen && isDesktop && (
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
@ -494,15 +606,18 @@ export default function CalendarPage() {
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/>
</div>
)}
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
{panelOpen && !isDesktop && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
@ -516,6 +631,8 @@ export default function CalendarPage() {
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/>
</div>
</div>

View File

@ -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<number>) => void;
width: number;
}
export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) {
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(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<Calendar | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null);
const [sharedVisibility, setSharedVisibility] = useState<Record<number, boolean>>(() => 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 (
<div className="w-56 shrink-0 border-r bg-card flex flex-col">
<div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}>
<div className="h-16 px-4 border-b flex items-center justify-between shrink-0">
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
<Button
@ -84,9 +107,9 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Calendars list */}
{/* Owned calendars list (non-shared only) */}
<div className="space-y-0.5">
{calendars.map((cal) => (
{calendars.filter((c) => !c.is_shared).map((cal) => (
<div
key={cal.id}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
@ -116,6 +139,18 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
))}
</div>
{/* Shared calendars section -- owned + member */}
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (
<SharedCalendarSection
ownedSharedCalendars={calendars.filter((c) => c.is_shared)}
memberships={sharedCalendars}
visibleSharedIds={visibleSharedIds}
onVisibilityChange={handleSharedVisibilityChange}
onEditCalendar={handleEdit}
onToggleCalendar={handleToggle}
/>
)}
{/* Templates section */}
<div className="space-y-1.5">
<div className="flex items-center justify-between px-2">
@ -185,4 +220,6 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
)}
</div>
);
}
});
export default CalendarSidebar;

View File

@ -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<EventLockInfo | null>(null);
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
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<EventLockInfo>(
`/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,16 +579,20 @@ export default function EventDetailPanel({
<>
{!event?.is_virtual && (
<>
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit event"
disabled={isAcquiringLock || !!activeLockInfo}
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
>
<Pencil className="h-3.5 w-3.5" />
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</Button>
{confirmingDelete ? (
)}
{canDelete && (
confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteStart}
@ -537,6 +613,7 @@ export default function EventDetailPanel({
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)
)}
</>
)}
@ -557,6 +634,15 @@ export default function EventDetailPanel({
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{/* Lock banner — shown when activeLockInfo reports a lock (poll-authoritative) */}
{activeLockInfo && (
<EventLockBanner
lockedByName={activeLockInfo.locked_by_name || 'another user'}
expiresAt={activeLockInfo.expires_at}
isPermanent={activeLockInfo.is_permanent}
/>
)}
{scopeStep ? (
/* Scope selection step */
<div className="space-y-3">

View File

@ -0,0 +1,30 @@
import { Lock } from 'lucide-react';
import { format, parseISO } from 'date-fns';
interface EventLockBannerProps {
lockedByName: string;
expiresAt: string | null;
isPermanent?: boolean;
}
export default function EventLockBanner({ lockedByName, expiresAt, isPermanent = false }: EventLockBannerProps) {
return (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 mb-3 animate-fade-in">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-amber-400 shrink-0" />
<div>
<p className="text-sm text-amber-200">
{isPermanent
? `Locked by owner (${lockedByName})`
: `Locked for editing by ${lockedByName}`}
</p>
{!isPermanent && expiresAt && (
<p className="text-xs text-muted-foreground">
Lock expires at {(() => { try { return format(parseISO(expiresAt), 'h:mm a'); } catch { return 'unknown'; } })()}
</p>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { Eye, Pencil, Shield } from 'lucide-react';
import type { CalendarPermission } from '@/types';
const config: Record<CalendarPermission, { label: string; icon: typeof Eye; bg: string; text: string }> = {
read_only: { label: 'Read Only', icon: Eye, bg: 'bg-blue-500/10', text: 'text-blue-400' },
create_modify: { label: 'Create/Modify', icon: Pencil, bg: 'bg-amber-500/10', text: 'text-amber-400' },
full_access: { label: 'Full Access', icon: Shield, bg: 'bg-green-500/10', text: 'text-green-400' },
};
interface PermissionBadgeProps {
permission: CalendarPermission;
showIcon?: boolean;
}
export default function PermissionBadge({ permission, showIcon = true }: PermissionBadgeProps) {
const c = config[permission];
const Icon = c.icon;
return (
<span className={`inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded-full font-medium ${c.bg} ${c.text}`}>
{showIcon && <Icon className="h-3 w-3" />}
{c.label}
</span>
);
}

View File

@ -0,0 +1,46 @@
import { Eye, Pencil, Shield } from 'lucide-react';
import type { CalendarPermission } from '@/types';
const segments: { value: CalendarPermission; label: string; shortLabel: string; icon: typeof Eye }[] = [
{ value: 'read_only', label: 'Read Only', shortLabel: 'Read', icon: Eye },
{ value: 'create_modify', label: 'Create & Modify', shortLabel: 'Edit', icon: Pencil },
{ value: 'full_access', label: 'Full Access', shortLabel: 'Full', icon: Shield },
];
interface PermissionToggleProps {
value: CalendarPermission;
onChange: (permission: CalendarPermission) => void;
compact?: boolean;
className?: string;
}
export default function PermissionToggle({ value, onChange, compact = false, className = '' }: PermissionToggleProps) {
return (
<div className={`inline-flex items-center rounded-md border border-border overflow-hidden ${className}`}>
{segments.map((seg) => {
const isActive = value === seg.value;
const Icon = seg.icon;
return (
<button
key={seg.value}
type="button"
onClick={() => onChange(seg.value)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium transition-colors duration-150 ${
isActive
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: isActive ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: isActive ? 'hsl(var(--accent-color))' : undefined,
}}
title={seg.label}
>
<Icon className="h-3 w-3 shrink-0" />
{!compact && <span>{seg.shortLabel}</span>}
</button>
);
})}
</div>
);
}

View File

@ -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<number, boolean> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
} catch {
return {};
}
}
function saveVisibility(v: Record<number, boolean>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(v));
}
interface SharedCalendarSectionProps {
ownedSharedCalendars?: Calendar[];
memberships: SharedCalendarMembership[];
visibleSharedIds: Set<number>;
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<SharedCalendarMembership | null>(null);
if (memberships.length === 0 && ownedSharedCalendars.length === 0) return null;
return (
<>
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 px-2">
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Shared Calendars
</span>
</div>
<div className="space-y-0.5">
{ownedSharedCalendars.map((cal) => (
<div
key={`owned-${cal.id}`}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={cal.is_visible}
onChange={() => onToggleCalendar?.(cal)}
className="shrink-0"
style={{
accentColor: cal.color,
borderColor: cal.is_visible ? cal.color : undefined,
backgroundColor: cal.is_visible ? cal.color : undefined,
}}
/>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: cal.color }}
/>
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">Owner</span>
<button
type="button"
onClick={() => onEditCalendar?.(cal)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
))}
{memberships.map((m) => {
const color = m.local_color || m.calendar_color;
const isVisible = visibleSharedIds.has(m.calendar_id);
return (
<div
key={m.id}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={isVisible}
onChange={() => onVisibilityChange(m.calendar_id, !isVisible)}
className="shrink-0"
style={{
accentColor: color,
borderColor: isVisible ? color : undefined,
backgroundColor: isVisible ? color : undefined,
}}
/>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-sm text-foreground truncate flex-1">{m.calendar_name}</span>
<button
onClick={() => setSettingsFor(m)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</div>
{settingsFor && (
<SharedCalendarSettings
membership={settingsFor}
onClose={() => setSettingsFor(null)}
/>
)}
</>
);
}
export { STORAGE_KEY, loadVisibility, saveVisibility };

View File

@ -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<CalendarMemberInfo[]>(
`/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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>Shared Calendar Settings</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">{membership.calendar_name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">Your permission:</span>
<PermissionBadge permission={membership.permission} />
</div>
</div>
<div className="space-y-2">
<Label>Your Color</Label>
<div className="flex gap-2">
{colorSwatches.map((c) => (
<button
key={c}
type="button"
onClick={() => handleColorSelect(c)}
className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110"
style={{
backgroundColor: c,
borderColor: localColor === c ? 'hsl(0 0% 98%)' : 'transparent',
boxShadow: localColor === c ? `0 0 0 2px ${c}40` : 'none',
}}
/>
))}
</div>
</div>
<div className="space-y-2">
<Label>Members ({members.length})</Label>
<CalendarMemberList
members={members}
isLoading={membersQuery.isLoading}
isOwner={false}
readOnly={true}
/>
</div>
{membership.can_add_others && (
<div className="space-y-2">
<Label>Add Members</Label>
<CalendarMemberSearch
connections={connections}
existingMembers={members}
onSelect={handleInvite}
isLoading={isInviting}
/>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-border">
<Button
variant="destructive"
size="sm"
onClick={handleLeaveClick}
>
<LogOut className="mr-2 h-4 w-4" />
{leaveConfirming ? 'Sure?' : 'Leave Calendar'}
</Button>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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<Set<number>>(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<string, string>)?.calendar_name || 'a calendar';
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<Calendar className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Calendar Invite</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || `You've been invited to ${calendarName}`}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleCalendarInviteRespond(inviteId, 'accept', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleCalendarInviteRespond(inviteId, 'reject', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
);
};
return null;
}

View File

@ -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<string, { icon: typeof Bell; color: string }> = {
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<Filter>('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() {
</div>
)}
{/* Calendar invite actions (inline) */}
{notification.type === 'calendar_invite' &&
notification.source_id &&
pendingInviteIds.has(notification.source_id) && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleCalendarInviteRespond(notification, 'accept'); }}
disabled={isRespondingInvite}
className="gap-1 h-7 text-xs"
>
{isRespondingInvite ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleCalendarInviteRespond(notification, 'reject'); }}
disabled={isRespondingInvite}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums">

View File

@ -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<SharingStats>({
queryKey: ['admin', 'users', userId, 'sharing-stats'],
queryFn: async () => {
const { data } = await api.get<SharingStats>(`/admin/users/${userId}/sharing-stats`);
return data;
},
enabled: userId !== null,
});
}
export function useAdminDashboard() {
return useQuery<AdminDashboardData>({
queryKey: ['admin', 'dashboard'],

View File

@ -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<Calendar[]>('/calendars');
return data;
},
});
const sharedQuery = useQuery({
queryKey: ['calendars', 'shared'],
queryFn: async () => {
const { data } = await api.get<SharedCalendarMembership[]>('/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,
};
}

View File

@ -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'] });
},
});

View File

@ -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<number | null>(null);
const acquireMutation = useMutation({
mutationFn: async (id: number) => {
const { data } = await api.post<EventLockInfo>(
`/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,
};
}

View File

@ -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<CalendarInvite[]>(
'/shared-calendars/invites/incoming'
);
return data;
},
refetchOnMount: 'always' as const,
});
const fetchMembers = async (calendarId: number) => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/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<string, unknown> = {};
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,
};
}

View File

@ -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;
}