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.config import settings
|
||||||
from app.database import engine
|
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 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
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# 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 notification as _notification_model # noqa: F401
|
||||||
from app.models import connection_request as _connection_request_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 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(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
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(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||||
|
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.audit_log import AuditLog
|
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.backup_code import BackupCode
|
||||||
from app.models.session import UserSession
|
from app.models.session import UserSession
|
||||||
from app.models.settings import Settings
|
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
|
# GET /config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -49,6 +49,7 @@ from app.services.connection import (
|
|||||||
resolve_shared_profile,
|
resolve_shared_profile,
|
||||||
send_connection_ntfy,
|
send_connection_ntfy,
|
||||||
)
|
)
|
||||||
|
from app.services.calendar_sharing import cascade_on_disconnect
|
||||||
from app.services.notification import create_notification
|
from app.services.notification import create_notification
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -823,6 +824,9 @@ async def remove_connection(
|
|||||||
if reverse_conn:
|
if reverse_conn:
|
||||||
await db.delete(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(
|
await log_audit_event(
|
||||||
db,
|
db,
|
||||||
action="connection.removed",
|
action="connection.removed",
|
||||||
|
|||||||
@ -18,7 +18,9 @@ from app.schemas.calendar_event import (
|
|||||||
)
|
)
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.calendar_member import CalendarMember
|
||||||
from app.services.recurrence import generate_occurrences
|
from app.services.recurrence import generate_occurrences
|
||||||
|
from app.services.calendar_sharing import check_lock_for_edit, require_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -142,13 +144,18 @@ async def get_events(
|
|||||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||||
are what get displayed on the calendar.
|
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)
|
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 = (
|
query = (
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.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
|
# Exclude parent template rows — they are not directly rendered
|
||||||
@ -219,8 +226,13 @@ async def create_event(
|
|||||||
if not data.get("calendar_id"):
|
if not data.get("calendar_id"):
|
||||||
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
|
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
|
||||||
else:
|
else:
|
||||||
# SEC-04: verify the target calendar belongs to the requesting user
|
# SEC-04: verify ownership OR shared calendar permission
|
||||||
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
|
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
|
# Serialize RecurrenceRule object to JSON string for DB storage
|
||||||
# Exclude None values so defaults in recurrence service work correctly
|
# Exclude None values so defaults in recurrence service work correctly
|
||||||
@ -229,7 +241,7 @@ async def create_event(
|
|||||||
|
|
||||||
if rule_json:
|
if rule_json:
|
||||||
# Parent template: is_recurring=True, no parent_event_id
|
# 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)
|
db.add(parent)
|
||||||
await db.flush() # assign parent.id before generating children
|
await db.flush() # assign parent.id before generating children
|
||||||
|
|
||||||
@ -258,7 +270,7 @@ async def create_event(
|
|||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
new_event = CalendarEvent(**data, recurrence_rule=None)
|
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
|
||||||
db.add(new_event)
|
db.add(new_event)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@ -277,13 +289,18 @@ async def get_event(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
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(
|
result = await db.execute(
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
.where(
|
.where(
|
||||||
CalendarEvent.id == event_id,
|
CalendarEvent.id == event_id,
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
@ -302,13 +319,18 @@ async def update_event(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
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(
|
result = await db.execute(
|
||||||
select(CalendarEvent)
|
select(CalendarEvent)
|
||||||
.options(selectinload(CalendarEvent.calendar))
|
.options(selectinload(CalendarEvent.calendar))
|
||||||
.where(
|
.where(
|
||||||
CalendarEvent.id == event_id,
|
CalendarEvent.id == event_id,
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
@ -316,6 +338,10 @@ async def update_event(
|
|||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
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)
|
update_data = event_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Extract scope before applying fields to the model
|
# 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
|
# Detach from parent so it's an independent event going forward
|
||||||
event.parent_event_id = None
|
event.parent_event_id = None
|
||||||
event.is_recurring = False
|
event.is_recurring = False
|
||||||
|
event.updated_by = current_user.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
elif scope == "this_and_future":
|
elif scope == "this_and_future":
|
||||||
@ -371,6 +398,7 @@ async def update_event(
|
|||||||
event.parent_event_id = None
|
event.parent_event_id = None
|
||||||
event.is_recurring = True
|
event.is_recurring = True
|
||||||
event.original_start = None
|
event.original_start = None
|
||||||
|
event.updated_by = current_user.id
|
||||||
|
|
||||||
# Inherit parent's recurrence_rule if none was provided in update
|
# Inherit parent's recurrence_rule if none was provided in update
|
||||||
if not event.recurrence_rule and parent_rule:
|
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
|
# This IS a parent — update it and regenerate all children
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(event, key, value)
|
setattr(event, key, value)
|
||||||
|
event.updated_by = current_user.id
|
||||||
|
|
||||||
# Delete all existing children and regenerate
|
# Delete all existing children and regenerate
|
||||||
if event.recurrence_rule:
|
if event.recurrence_rule:
|
||||||
@ -405,6 +434,7 @@ async def update_event(
|
|||||||
# No scope — plain update (non-recurring events or full-series metadata)
|
# No scope — plain update (non-recurring events or full-series metadata)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(event, key, value)
|
setattr(event, key, value)
|
||||||
|
event.updated_by = current_user.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -427,11 +457,16 @@ async def delete_event(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
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(
|
result = await db.execute(
|
||||||
select(CalendarEvent).where(
|
select(CalendarEvent).where(
|
||||||
CalendarEvent.id == event_id,
|
CalendarEvent.id == event_id,
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
@ -439,6 +474,10 @@ async def delete_event(
|
|||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
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":
|
if scope == "this":
|
||||||
# Delete just this one occurrence
|
# Delete just this one occurrence
|
||||||
await db.delete(event)
|
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;
|
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
|
# 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;
|
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
|
# 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_search_limit:10m rate=10r/m;
|
||||||
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/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;
|
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
|
# Admin API — rate-limited separately from general /api traffic
|
||||||
location /api/admin/ {
|
location /api/admin/ {
|
||||||
limit_req zone=admin_limit burst=10 nodelay;
|
limit_req zone=admin_limit burst=10 nodelay;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user