Phase 2: Shared calendars backend core + QA fixes
Router: invite/accept/reject flow, membership CRUD, event locking (timed + permanent), sync endpoint, local color override. Services: permission hierarchy, atomic lock acquisition, disconnect cascade. Events: shared calendar scoping, permission/lock enforcement, updated_by tracking. Admin: sharing-stats endpoint. nginx: rate limits for invite + sync. QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future), W-01 (pre-commit response build), W-02 (owned calendar short-circuit), W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e4b45763b4
commit
e6e81c59e7
@ -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("/")
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -18,7 +18,9 @@ from app.schemas.calendar_event import (
|
||||
)
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.services.recurrence import generate_occurrences
|
||||
from app.services.calendar_sharing import check_lock_for_edit, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -142,13 +144,18 @@ async def get_events(
|
||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||
are what get displayed on the calendar.
|
||||
"""
|
||||
# Scope events through calendar ownership
|
||||
# Scope events through calendar ownership + shared memberships
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
shared_calendar_ids = select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
|
||||
|
||||
query = (
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
|
||||
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
|
||||
)
|
||||
|
||||
# Exclude parent template rows — they are not directly rendered
|
||||
@ -219,8 +226,13 @@ async def create_event(
|
||||
if not data.get("calendar_id"):
|
||||
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
|
||||
else:
|
||||
# SEC-04: verify the target calendar belongs to the requesting user
|
||||
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
|
||||
# SEC-04: verify ownership OR shared calendar permission
|
||||
cal_ownership_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_ownership_result.scalar_one_or_none():
|
||||
# Not owned — check shared calendar permission
|
||||
await require_permission(db, data["calendar_id"], current_user.id, "create_modify")
|
||||
|
||||
# Serialize RecurrenceRule object to JSON string for DB storage
|
||||
# Exclude None values so defaults in recurrence service work correctly
|
||||
@ -229,7 +241,7 @@ async def create_event(
|
||||
|
||||
if rule_json:
|
||||
# Parent template: is_recurring=True, no parent_event_id
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id)
|
||||
db.add(parent)
|
||||
await db.flush() # assign parent.id before generating children
|
||||
|
||||
@ -258,7 +270,7 @@ async def create_event(
|
||||
return result.scalar_one()
|
||||
|
||||
else:
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None)
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
|
||||
@ -277,13 +289,18 @@ async def get_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
shared_calendar_ids = select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -302,13 +319,18 @@ async def update_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
shared_calendar_ids = select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -316,6 +338,10 @@ async def update_event(
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Shared calendar: require create_modify+ and check lock
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
update_data = event_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Extract scope before applying fields to the model
|
||||
@ -342,6 +368,7 @@ async def update_event(
|
||||
# Detach from parent so it's an independent event going forward
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = False
|
||||
event.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
elif scope == "this_and_future":
|
||||
@ -371,6 +398,7 @@ async def update_event(
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = True
|
||||
event.original_start = None
|
||||
event.updated_by = current_user.id
|
||||
|
||||
# Inherit parent's recurrence_rule if none was provided in update
|
||||
if not event.recurrence_rule and parent_rule:
|
||||
@ -386,6 +414,7 @@ async def update_event(
|
||||
# This IS a parent — update it and regenerate all children
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.updated_by = current_user.id
|
||||
|
||||
# Delete all existing children and regenerate
|
||||
if event.recurrence_rule:
|
||||
@ -405,6 +434,7 @@ async def update_event(
|
||||
# No scope — plain update (non-recurring events or full-series metadata)
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
@ -427,11 +457,16 @@ async def delete_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
shared_calendar_ids = select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -439,6 +474,10 @@ async def delete_event(
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Shared calendar: require full_access+ and check lock
|
||||
await require_permission(db, event.calendar_id, current_user.id, "full_access")
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
if scope == "this":
|
||||
# Delete just this one occurrence
|
||||
await db.delete(event)
|
||||
|
||||
849
backend/app/routers/shared_calendars.py
Normal file
849
backend/app/routers/shared_calendars.py
Normal file
@ -0,0 +1,849 @@
|
||||
"""
|
||||
Shared calendars router — invites, membership, locks, sync.
|
||||
|
||||
All endpoints live under /api/shared-calendars.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.settings import Settings
|
||||
from app.models.user import User
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.shared_calendar import (
|
||||
CalendarInviteResponse,
|
||||
CalendarMemberResponse,
|
||||
InviteMemberRequest,
|
||||
LockStatusResponse,
|
||||
RespondInviteRequest,
|
||||
SyncResponse,
|
||||
UpdateLocalColorRequest,
|
||||
UpdateMemberRequest,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.calendar_sharing import (
|
||||
PERMISSION_RANK,
|
||||
acquire_lock,
|
||||
get_user_permission,
|
||||
release_lock,
|
||||
require_permission,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PENDING_INVITE_CAP = 10
|
||||
|
||||
|
||||
# -- Helpers ---------------------------------------------------------------
|
||||
|
||||
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
|
||||
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _build_member_response(member: CalendarMember) -> dict:
|
||||
return {
|
||||
"id": member.id,
|
||||
"calendar_id": member.calendar_id,
|
||||
"user_id": member.user_id,
|
||||
"umbral_name": member.user.umbral_name if member.user else "",
|
||||
"preferred_name": None,
|
||||
"permission": member.permission,
|
||||
"can_add_others": member.can_add_others,
|
||||
"local_color": member.local_color,
|
||||
"status": member.status,
|
||||
"invited_at": member.invited_at,
|
||||
"accepted_at": member.accepted_at,
|
||||
}
|
||||
|
||||
|
||||
# -- GET / — List accepted memberships ------------------------------------
|
||||
|
||||
@router.get("/")
|
||||
async def list_shared_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List calendars the current user has accepted membership in."""
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
.options(selectinload(CalendarMember.calendar))
|
||||
.order_by(CalendarMember.accepted_at.desc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"calendar_id": m.calendar_id,
|
||||
"calendar_name": m.calendar.name if m.calendar else "",
|
||||
"calendar_color": m.calendar.color if m.calendar else "",
|
||||
"local_color": m.local_color,
|
||||
"permission": m.permission,
|
||||
"can_add_others": m.can_add_others,
|
||||
"is_owner": False,
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
|
||||
|
||||
# -- POST /{cal_id}/invite — Invite via connection_id ---------------------
|
||||
|
||||
@router.post("/{cal_id}/invite", status_code=201)
|
||||
async def invite_member(
|
||||
body: InviteMemberRequest,
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Invite a connected user to a shared calendar."""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
if not calendar:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
is_owner = calendar.user_id == current_user.id
|
||||
inviter_perm = "owner" if is_owner else None
|
||||
|
||||
if not is_owner:
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
if not member.can_add_others:
|
||||
raise HTTPException(status_code=403, detail="You do not have permission to invite others")
|
||||
if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0):
|
||||
raise HTTPException(status_code=403, detail="Read-only members cannot invite others")
|
||||
inviter_perm = member.permission
|
||||
|
||||
# Permission ceiling
|
||||
if inviter_perm != "owner":
|
||||
if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cannot grant a permission level higher than your own",
|
||||
)
|
||||
|
||||
# Resolve connection_id -> connected user
|
||||
conn_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.id == body.connection_id,
|
||||
UserConnection.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
connection = conn_result.scalar_one_or_none()
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
|
||||
target_user_id = connection.connected_user_id
|
||||
|
||||
if target_user_id == calendar.user_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot invite the calendar owner")
|
||||
|
||||
target_result = await db.execute(
|
||||
select(User).where(User.id == target_user_id)
|
||||
)
|
||||
target = target_result.scalar_one_or_none()
|
||||
if not target or not target.is_active:
|
||||
raise HTTPException(status_code=404, detail="Target user not found or inactive")
|
||||
|
||||
existing = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == target_user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="User already invited or is a member")
|
||||
|
||||
pending_count = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
) or 0
|
||||
if pending_count >= PENDING_INVITE_CAP:
|
||||
raise HTTPException(status_code=429, detail="Too many pending invites for this calendar")
|
||||
|
||||
if not calendar.is_shared:
|
||||
calendar.is_shared = True
|
||||
|
||||
new_member = CalendarMember(
|
||||
calendar_id=cal_id,
|
||||
user_id=target_user_id,
|
||||
invited_by=current_user.id,
|
||||
permission=body.permission,
|
||||
can_add_others=body.can_add_others,
|
||||
status="pending",
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.flush()
|
||||
|
||||
inviter_settings = await _get_settings_for_user(db, current_user.id)
|
||||
inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name
|
||||
|
||||
cal_name = calendar.name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=target_user_id,
|
||||
type="calendar_invite",
|
||||
title="Calendar Invite",
|
||||
message=f"{inviter_display} invited you to '{cal_name}'",
|
||||
data={"calendar_id": cal_id, "calendar_name": cal_name},
|
||||
source_type="calendar_invite",
|
||||
source_id=new_member.id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_sent",
|
||||
actor_id=current_user.id,
|
||||
target_id=target_user_id,
|
||||
detail={
|
||||
"calendar_id": cal_id,
|
||||
"calendar_name": cal_name,
|
||||
"permission": body.permission,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
response = {
|
||||
"message": "Invite sent",
|
||||
"member_id": new_member.id,
|
||||
"calendar_id": cal_id,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
|
||||
# -- PUT /invites/{id}/respond — Accept or reject -------------------------
|
||||
|
||||
@router.put("/invites/{invite_id}/respond")
|
||||
async def respond_to_invite(
|
||||
body: RespondInviteRequest,
|
||||
request: Request,
|
||||
invite_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Accept or reject a calendar invite."""
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.id == invite_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
.options(selectinload(CalendarMember.calendar))
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Invite not found or already resolved")
|
||||
|
||||
calendar_name = invite.calendar.name if invite.calendar else "Unknown"
|
||||
calendar_owner_id = invite.calendar.user_id if invite.calendar else None
|
||||
inviter_id = invite.invited_by
|
||||
|
||||
if body.action == "accept":
|
||||
invite.status = "accepted"
|
||||
invite.accepted_at = datetime.now()
|
||||
|
||||
notify_user_id = inviter_id or calendar_owner_id
|
||||
if notify_user_id:
|
||||
user_settings = await _get_settings_for_user(db, current_user.id)
|
||||
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=notify_user_id,
|
||||
type="calendar_invite_accepted",
|
||||
title="Invite Accepted",
|
||||
message=f"{display} accepted your invite to '{calendar_name}'",
|
||||
data={"calendar_id": invite.calendar_id},
|
||||
source_type="calendar_invite",
|
||||
source_id=invite.id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_accepted",
|
||||
actor_id=current_user.id,
|
||||
detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Invite accepted"}
|
||||
|
||||
else:
|
||||
member_id = invite.id
|
||||
calendar_id = invite.calendar_id
|
||||
|
||||
notify_user_id = inviter_id or calendar_owner_id
|
||||
if notify_user_id:
|
||||
user_settings = await _get_settings_for_user(db, current_user.id)
|
||||
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=notify_user_id,
|
||||
type="calendar_invite_rejected",
|
||||
title="Invite Rejected",
|
||||
message=f"{display} declined your invite to '{calendar_name}'",
|
||||
data={"calendar_id": calendar_id},
|
||||
source_type="calendar_invite",
|
||||
source_id=member_id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_rejected",
|
||||
actor_id=current_user.id,
|
||||
detail={"calendar_id": calendar_id, "calendar_name": calendar_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.delete(invite)
|
||||
await db.commit()
|
||||
return {"message": "Invite rejected"}
|
||||
|
||||
|
||||
# -- GET /invites/incoming — Pending invites -------------------------------
|
||||
|
||||
@router.get("/invites/incoming", response_model=list[CalendarInviteResponse])
|
||||
async def get_incoming_invites(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List pending calendar invites for the current user."""
|
||||
offset = (page - 1) * per_page
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
.options(
|
||||
selectinload(CalendarMember.calendar),
|
||||
selectinload(CalendarMember.inviter),
|
||||
)
|
||||
.order_by(CalendarMember.invited_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
invites = result.scalars().all()
|
||||
|
||||
# Batch-fetch owner names to avoid N+1
|
||||
owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar})
|
||||
if owner_ids:
|
||||
owner_result = await db.execute(
|
||||
select(User.id, User.umbral_name).where(User.id.in_(owner_ids))
|
||||
)
|
||||
owner_names = {row.id: row.umbral_name for row in owner_result.all()}
|
||||
else:
|
||||
owner_names = {}
|
||||
|
||||
responses = []
|
||||
for inv in invites:
|
||||
owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else ""
|
||||
|
||||
responses.append(CalendarInviteResponse(
|
||||
id=inv.id,
|
||||
calendar_id=inv.calendar_id,
|
||||
calendar_name=inv.calendar.name if inv.calendar else "",
|
||||
calendar_color=inv.calendar.color if inv.calendar else "",
|
||||
owner_umbral_name=owner_name,
|
||||
inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "",
|
||||
permission=inv.permission,
|
||||
invited_at=inv.invited_at,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# -- GET /{cal_id}/members — Member list -----------------------------------
|
||||
|
||||
@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse])
|
||||
async def list_members(
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all members of a shared calendar. Requires membership or ownership."""
|
||||
perm = await get_user_permission(db, cal_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(CalendarMember.calendar_id == cal_id)
|
||||
.options(selectinload(CalendarMember.user))
|
||||
.order_by(CalendarMember.invited_at.asc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
user_ids = [m.user_id for m in members]
|
||||
if user_ids:
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name)
|
||||
.where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
pref_names = {row.user_id: row.preferred_name for row in settings_result.all()}
|
||||
else:
|
||||
pref_names = {}
|
||||
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = _build_member_response(m)
|
||||
resp["preferred_name"] = pref_names.get(m.user_id)
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ----------
|
||||
|
||||
@router.put("/{cal_id}/members/{member_id}")
|
||||
async def update_member(
|
||||
body: UpdateMemberRequest,
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
member_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a member permission or can_add_others. Owner only."""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can update members")
|
||||
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.id == member_id,
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(member, key, value)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.member_updated",
|
||||
actor_id=current_user.id,
|
||||
target_id=member.user_id,
|
||||
detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Member updated"}
|
||||
|
||||
|
||||
# -- DELETE /{cal_id}/members/{mid} — Remove member or leave ---------------
|
||||
|
||||
@router.delete("/{cal_id}/members/{member_id}", status_code=204)
|
||||
async def remove_member(
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
member_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a member or leave a shared calendar."""
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.id == member_id,
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
|
||||
is_self = member.user_id == current_user.id
|
||||
is_owner = calendar and calendar.user_id == current_user.id
|
||||
|
||||
if not is_self and not is_owner:
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members")
|
||||
|
||||
target_user_id = member.user_id
|
||||
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.locked_by == target_user_id,
|
||||
EventLock.event_id.in_(
|
||||
select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(member)
|
||||
|
||||
remaining = await db.execute(
|
||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||
)
|
||||
if not remaining.scalar_one_or_none() and calendar:
|
||||
calendar.is_shared = False
|
||||
|
||||
action = "calendar.member_left" if is_self else "calendar.member_removed"
|
||||
await log_audit_event(
|
||||
db,
|
||||
action=action,
|
||||
actor_id=current_user.id,
|
||||
target_id=target_user_id,
|
||||
detail={"calendar_id": cal_id, "member_id": member_id},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# -- PUT /{cal_id}/members/me/color — Update local color -------------------
|
||||
|
||||
@router.put("/{cal_id}/members/me/color")
|
||||
async def update_local_color(
|
||||
body: UpdateLocalColorRequest,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user local color for a shared calendar."""
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Membership not found")
|
||||
|
||||
member.local_color = body.local_color
|
||||
await db.commit()
|
||||
return {"message": "Color updated"}
|
||||
|
||||
|
||||
# -- GET /sync — Sync endpoint ---------------------------------------------
|
||||
|
||||
@router.get("/sync", response_model=SyncResponse)
|
||||
async def sync_shared_calendars(
|
||||
since: datetime = Query(...),
|
||||
calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Sync events and member changes since a given timestamp. Cap 500 events."""
|
||||
MAX_EVENTS = 500
|
||||
|
||||
cal_id_list: list[int] = []
|
||||
if calendar_ids:
|
||||
for part in calendar_ids.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
cal_id_list.append(int(part))
|
||||
cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause
|
||||
|
||||
owned_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
)
|
||||
owned_ids = {row[0] for row in owned_ids_result.all()}
|
||||
|
||||
member_ids_result = await db.execute(
|
||||
select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member_ids = {row[0] for row in member_ids_result.all()}
|
||||
|
||||
accessible = owned_ids | member_ids
|
||||
if cal_id_list:
|
||||
accessible = accessible & set(cal_id_list)
|
||||
|
||||
if not accessible:
|
||||
return SyncResponse(events=[], member_changes=[], server_time=datetime.now())
|
||||
|
||||
accessible_list = list(accessible)
|
||||
|
||||
events_result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.where(
|
||||
CalendarEvent.calendar_id.in_(accessible_list),
|
||||
CalendarEvent.updated_at >= since,
|
||||
)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.order_by(CalendarEvent.updated_at.desc())
|
||||
.limit(MAX_EVENTS + 1)
|
||||
)
|
||||
events = events_result.scalars().all()
|
||||
truncated = len(events) > MAX_EVENTS
|
||||
events = events[:MAX_EVENTS]
|
||||
|
||||
event_dicts = []
|
||||
for e in events:
|
||||
event_dicts.append({
|
||||
"id": e.id,
|
||||
"title": e.title,
|
||||
"start_datetime": e.start_datetime.isoformat() if e.start_datetime else None,
|
||||
"end_datetime": e.end_datetime.isoformat() if e.end_datetime else None,
|
||||
"all_day": e.all_day,
|
||||
"calendar_id": e.calendar_id,
|
||||
"calendar_name": e.calendar.name if e.calendar else "",
|
||||
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||
"updated_by": e.updated_by,
|
||||
})
|
||||
|
||||
members_result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.calendar_id.in_(accessible_list),
|
||||
(CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since),
|
||||
)
|
||||
.options(selectinload(CalendarMember.user))
|
||||
)
|
||||
member_changes = members_result.scalars().all()
|
||||
|
||||
member_dicts = []
|
||||
for m in member_changes:
|
||||
member_dicts.append({
|
||||
"id": m.id,
|
||||
"calendar_id": m.calendar_id,
|
||||
"user_id": m.user_id,
|
||||
"umbral_name": m.user.umbral_name if m.user else "",
|
||||
"permission": m.permission,
|
||||
"status": m.status,
|
||||
"invited_at": m.invited_at.isoformat() if m.invited_at else None,
|
||||
"accepted_at": m.accepted_at.isoformat() if m.accepted_at else None,
|
||||
})
|
||||
|
||||
return SyncResponse(
|
||||
events=event_dicts,
|
||||
member_changes=member_dicts,
|
||||
server_time=datetime.now(),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
# -- Event Lock Endpoints --------------------------------------------------
|
||||
|
||||
@router.post("/events/{event_id}/lock")
|
||||
async def lock_event(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Acquire a 5-minute editing lock on an event."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
|
||||
lock = await acquire_lock(db, event_id, current_user.id)
|
||||
|
||||
# Build response BEFORE commit — ORM objects expire after commit
|
||||
response = {
|
||||
"locked": True,
|
||||
"locked_by_name": current_user.umbral_name,
|
||||
"expires_at": lock.expires_at,
|
||||
"is_permanent": lock.is_permanent,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}/lock", status_code=204)
|
||||
async def unlock_event(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Release a lock. Only the holder or calendar owner can release."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
if not lock:
|
||||
return None
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
is_owner = calendar and calendar.user_id == current_user.id
|
||||
|
||||
if lock.locked_by != current_user.id and not is_owner:
|
||||
raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release")
|
||||
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/lock", response_model=LockStatusResponse)
|
||||
async def get_lock_status(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the lock status of an event."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock)
|
||||
.where(EventLock.event_id == event_id)
|
||||
.options(selectinload(EventLock.holder))
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
|
||||
if not lock:
|
||||
return LockStatusResponse(locked=False)
|
||||
|
||||
now = datetime.now()
|
||||
if not lock.is_permanent and lock.expires_at and lock.expires_at < now:
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return LockStatusResponse(locked=False)
|
||||
|
||||
return LockStatusResponse(
|
||||
locked=True,
|
||||
locked_by_name=lock.holder.umbral_name if lock.holder else None,
|
||||
expires_at=lock.expires_at,
|
||||
is_permanent=lock.is_permanent,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/owner-lock")
|
||||
async def set_permanent_lock(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Set a permanent lock on an event. Calendar owner only."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks")
|
||||
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
|
||||
VALUES (:event_id, :user_id, :now, NULL, true)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
locked_by = :user_id,
|
||||
locked_at = :now,
|
||||
expires_at = NULL,
|
||||
is_permanent = true
|
||||
"""),
|
||||
{"event_id": event_id, "user_id": current_user.id, "now": now},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Permanent lock set", "is_permanent": True}
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}/owner-lock", status_code=204)
|
||||
async def remove_permanent_lock(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a permanent lock. Calendar owner only."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks")
|
||||
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.event_id == event_id,
|
||||
EventLock.is_permanent == True,
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
205
backend/app/services/calendar_sharing.py
Normal file
205
backend/app/services/calendar_sharing.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""
|
||||
Calendar sharing service — permission checks, lock management, disconnect cascade.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3}
|
||||
LOCK_DURATION_MINUTES = 5
|
||||
|
||||
|
||||
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
|
||||
"""
|
||||
Returns "owner" if the user owns the calendar, the permission string
|
||||
if they are an accepted member, or None if they have no access.
|
||||
"""
|
||||
cal = await db.execute(
|
||||
select(Calendar).where(Calendar.id == calendar_id)
|
||||
)
|
||||
calendar = cal.scalar_one_or_none()
|
||||
if not calendar:
|
||||
return None
|
||||
if calendar.user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
member = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == calendar_id,
|
||||
CalendarMember.user_id == user_id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
row = member.scalar_one_or_none()
|
||||
return row.permission if row else None
|
||||
|
||||
|
||||
async def require_permission(
|
||||
db: AsyncSession, calendar_id: int, user_id: int, min_level: str
|
||||
) -> str:
|
||||
"""
|
||||
Raises 403 if the user lacks at least min_level permission.
|
||||
Returns the actual permission string (or "owner").
|
||||
"""
|
||||
perm = await get_user_permission(db, calendar_id, user_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
if perm == "owner":
|
||||
return "owner"
|
||||
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission on this calendar")
|
||||
return perm
|
||||
|
||||
|
||||
async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock:
|
||||
"""
|
||||
Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event.
|
||||
Only succeeds if no unexpired lock exists or the existing lock is held by the same user.
|
||||
Returns the lock or raises 423 Locked.
|
||||
"""
|
||||
now = datetime.now()
|
||||
expires = now + timedelta(minutes=LOCK_DURATION_MINUTES)
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
|
||||
VALUES (:event_id, :user_id, :now, :expires, false)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
locked_by = :user_id,
|
||||
locked_at = :now,
|
||||
expires_at = :expires,
|
||||
is_permanent = false
|
||||
WHERE event_locks.expires_at < :now
|
||||
OR event_locks.locked_by = :user_id
|
||||
RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent
|
||||
"""),
|
||||
{"event_id": event_id, "user_id": user_id, "now": now, "expires": expires},
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=423, detail="Event is locked by another user")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.id == row.id)
|
||||
)
|
||||
return lock_result.scalar_one()
|
||||
|
||||
|
||||
async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None:
|
||||
"""Delete the lock only if held by this user."""
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.event_id == event_id,
|
||||
EventLock.locked_by == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def check_lock_for_edit(
|
||||
db: AsyncSession, event_id: int, user_id: int, calendar_id: int
|
||||
) -> None:
|
||||
"""
|
||||
For shared calendars: verify no active lock by another user blocks this edit.
|
||||
For personal (non-shared) calendars: no-op.
|
||||
"""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.is_shared).where(Calendar.id == calendar_id)
|
||||
)
|
||||
is_shared = cal_result.scalar_one_or_none()
|
||||
if not is_shared:
|
||||
return
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
if not lock:
|
||||
return
|
||||
now = datetime.now()
|
||||
if lock.is_permanent and lock.locked_by != user_id:
|
||||
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
|
||||
if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now):
|
||||
raise HTTPException(status_code=423, detail="Event is locked by another user")
|
||||
|
||||
|
||||
async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None:
|
||||
"""
|
||||
When a connection is severed:
|
||||
1. Delete CalendarMember rows where one user is a member of the other's calendars
|
||||
2. Delete EventLock rows held by the disconnected user on affected calendars
|
||||
3. Reset is_shared=False on calendars with no remaining members
|
||||
"""
|
||||
# Find calendars owned by each user
|
||||
a_cal_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_a_id)
|
||||
)
|
||||
a_cal_ids = [row[0] for row in a_cal_ids_result.all()]
|
||||
|
||||
b_cal_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_b_id)
|
||||
)
|
||||
b_cal_ids = [row[0] for row in b_cal_ids_result.all()]
|
||||
|
||||
# Delete user_b's memberships on user_a's calendars + locks
|
||||
if a_cal_ids:
|
||||
await db.execute(
|
||||
delete(CalendarMember).where(
|
||||
CalendarMember.calendar_id.in_(a_cal_ids),
|
||||
CalendarMember.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM event_locks
|
||||
WHERE locked_by = :user_id
|
||||
AND event_id IN (
|
||||
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_b_id, "cal_ids": a_cal_ids},
|
||||
)
|
||||
|
||||
# Delete user_a's memberships on user_b's calendars + locks
|
||||
if b_cal_ids:
|
||||
await db.execute(
|
||||
delete(CalendarMember).where(
|
||||
CalendarMember.calendar_id.in_(b_cal_ids),
|
||||
CalendarMember.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM event_locks
|
||||
WHERE locked_by = :user_id
|
||||
AND event_id IN (
|
||||
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||
)
|
||||
|
||||
# Reset is_shared on calendars with no remaining members
|
||||
all_cal_ids = a_cal_ids + b_cal_ids
|
||||
for cal_id in all_cal_ids:
|
||||
remaining = await db.execute(
|
||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||
)
|
||||
if not remaining.scalar_one_or_none():
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
cal = cal_result.scalar_one_or_none()
|
||||
if cal:
|
||||
cal.is_shared = False
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user