diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index ca15d2f..7798f0c 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -21,6 +21,7 @@ from app.models.notification import Notification as AppNotification from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent from app.models.calendar import Calendar +from app.models.event_lock import EventLock from app.models.todo import Todo from app.models.project import Project from app.models.ntfy_sent import NtfySent @@ -300,6 +301,18 @@ async def _purge_resolved_requests(db: AsyncSession) -> None: await db.commit() + +async def _purge_expired_locks(db: AsyncSession) -> None: + """Remove non-permanent event locks that have expired.""" + await db.execute( + delete(EventLock).where( + EventLock.is_permanent == False, # noqa: E712 + EventLock.expires_at < datetime.now(), + ) + ) + await db.commit() + + # ── Entry point ─────────────────────────────────────────────────────────────── async def run_notification_dispatch() -> None: @@ -343,6 +356,7 @@ async def run_notification_dispatch() -> None: await _purge_expired_sessions(db) await _purge_old_notifications(db) await _purge_resolved_requests(db) + await _purge_expired_locks(db) except Exception: # Broad catch: job failure must never crash the scheduler or the app diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 2e324a7..7dec7fc 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException, Path from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update +from sqlalchemy import func, select, update from typing import List from app.database import get_db from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent +from app.models.calendar_member import CalendarMember from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse from app.routers.auth import get_current_user from app.models.user import User @@ -23,7 +24,28 @@ async def get_calendars( .where(Calendar.user_id == current_user.id) .order_by(Calendar.is_default.desc(), Calendar.name.asc()) ) - return result.scalars().all() + calendars = result.scalars().all() + + # Populate member_count for shared calendars + cal_ids = [c.id for c in calendars if c.is_shared] + count_map: dict[int, int] = {} + if cal_ids: + counts = await db.execute( + select(CalendarMember.calendar_id, func.count()) + .where( + CalendarMember.calendar_id.in_(cal_ids), + CalendarMember.status == "accepted", + ) + .group_by(CalendarMember.calendar_id) + ) + count_map = dict(counts.all()) + + return [ + CalendarResponse.model_validate(c, from_attributes=True).model_copy( + update={"member_count": count_map.get(c.id, 0)} + ) + for c in calendars + ] @router.post("/", response_model=CalendarResponse, status_code=201) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py index 3ad07ba..f84edb7 100644 --- a/backend/app/routers/shared_calendars.py +++ b/backend/app/routers/shared_calendars.py @@ -4,7 +4,7 @@ Shared calendars router — invites, membership, locks, sync. All endpoints live under /api/shared-calendars. """ import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request @@ -60,7 +60,7 @@ def _build_member_response(member: CalendarMember) -> dict: "calendar_id": member.calendar_id, "user_id": member.user_id, "umbral_name": member.user.umbral_name if member.user else "", - "preferred_name": None, + "preferred_name": member.user.preferred_name if member.user else None, "permission": member.permission, "can_add_others": member.can_add_others, "local_color": member.local_color, @@ -462,8 +462,10 @@ async def update_member( 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) + if "permission" in update_data: + member.permission = update_data["permission"] + if "can_add_others" in update_data: + member.can_add_others = update_data["can_add_others"] await log_audit_event( db, @@ -581,6 +583,11 @@ async def sync_shared_calendars( """Sync events and member changes since a given timestamp. Cap 500 events.""" MAX_EVENTS = 500 + # Cap since to 7 days ago to prevent unbounded scans + floor = datetime.now() - timedelta(days=7) + if since < floor: + since = floor + cal_id_list: list[int] = [] if calendar_ids: for part in calendar_ids.split(","): diff --git a/backend/app/schemas/shared_calendar.py b/backend/app/schemas/shared_calendar.py index 5b5420f..453aaa2 100644 --- a/backend/app/schemas/shared_calendar.py +++ b/backend/app/schemas/shared_calendar.py @@ -34,9 +34,6 @@ class UpdateLocalColorRequest(BaseModel): return v -class ConvertToSharedRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - class CalendarMemberResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 1a17e18..3040b93 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -14,7 +14,6 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; import PermissionToggle from './PermissionToggle'; import { useConnections } from '@/hooks/useConnections'; import { useSharedCalendars } from '@/hooks/useSharedCalendars'; @@ -35,7 +34,6 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { const queryClient = useQueryClient(); const [name, setName] = useState(calendar?.name || ''); const [color, setColor] = useState(calendar?.color || '#3b82f6'); - const [isShared, setIsShared] = useState(calendar?.is_shared ?? false); const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null); @@ -131,7 +129,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -170,15 +168,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { {showSharing && ( <> -
- - -
- - {isShared && ( + {calendar?.is_shared && (
diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 3f0543a..dab78d2 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -9,6 +9,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; +import axios from 'axios'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; @@ -244,7 +245,11 @@ export default function CalendarPage() { onError: (error, variables) => { variables.revert(); queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); - toast.error(getErrorMessage(error, 'Failed to update event')); + if (axios.isAxiosError(error) && error.response?.status === 423) { + toast.error('Event is locked by another user'); + } else { + toast.error(getErrorMessage(error, 'Failed to update event')); + } }, }); diff --git a/frontend/src/hooks/useEventLock.ts b/frontend/src/hooks/useEventLock.ts index 3495773..8df39f5 100644 --- a/frontend/src/hooks/useEventLock.ts +++ b/frontend/src/hooks/useEventLock.ts @@ -44,7 +44,7 @@ export function useEventLock(eventId: number | null) { } catch { // Fire-and-forget on release errors } - }, []); + }, [releaseMutation]); // Auto-release on unmount or eventId change useEffect(() => {