UMBRA/backend/app/services/calendar_sharing.py
Kyle Pope be1fdc4551 Calendar backend optimisations: safety caps, shared calendar fix, query consolidation
Phase 1: Recurrence safety — MAX_OCCURRENCES=730 hard cap, adaptive 90-day
horizon for daily events (interval<7), RecurrenceRule cross-field validation,
ID bounds on location_id/calendar_id schemas.

Phase 2: Dashboard correctness — shared calendar events now included in
/dashboard and /upcoming via get_accessible_calendar_ids helper. Project stats
consolidated into single GROUP BY query (saves 1 DB round-trip).

Phase 3: Write performance — bulk db.add_all() for child events, removed
redundant SELECT in this_and_future delete path.

Phase 4: Frontend query efficiency — staleTime: 30_000 on calendar events
query eliminates redundant refetches on mount/view switch. Backend LIMIT 2000
safety guard on events endpoint.

Phase 5: Rate limiting — nginx limit_req zone on /api/events (30r/m) to
prevent DB flooding via recurrence amplification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:31:48 +08:00

240 lines
8.6 KiB
Python

"""
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, update
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_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[int]:
"""Return all calendar IDs the user can access (owned + accepted shared memberships)."""
owned = await db.execute(select(Calendar.id).where(Calendar.user_id == user_id))
shared = await db.execute(
select(CalendarMember.calendar_id).where(
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
)
)
return [r[0] for r in owned.all()] + [r[0] for r in shared.all()]
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.
AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
"""
result = await db.execute(
select(
Calendar.user_id,
CalendarMember.permission,
)
.outerjoin(
CalendarMember,
(CalendarMember.calendar_id == Calendar.id)
& (CalendarMember.user_id == user_id)
& (CalendarMember.status == "accepted"),
)
.where(Calendar.id == calendar_id)
)
row = result.one_or_none()
if not row:
return None
owner_id, member_permission = row.tuple()
if owner_id == user_id:
return "owner"
return member_permission
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},
)
# AC-5: Single aggregation query instead of N per-calendar checks
all_cal_ids = a_cal_ids + b_cal_ids
if all_cal_ids:
# Find which calendars still have members
has_members_result = await db.execute(
select(CalendarMember.calendar_id)
.where(CalendarMember.calendar_id.in_(all_cal_ids))
.group_by(CalendarMember.calendar_id)
)
cals_with_members = {row[0] for row in has_members_result.all()}
# Reset is_shared on calendars with no remaining members
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
if empty_cal_ids:
await db.execute(
update(Calendar)
.where(Calendar.id.in_(empty_cal_ids))
.values(is_shared=False)
)