/** * Typed API client for the FamilySync backend. * * credentials: 'include' is required so the OIDC session cookie is sent with * every cross-origin request (Vite dev proxy routes to :3000; production is * same-origin via Pangolin). * * Auth note: the OIDC guard's 302 to Authelia is CORS-blocked for fetch/XHR — * browsers do not follow cross-origin redirects from XHR to an external IdP. * Re-authentication therefore requires a TOP-LEVEL navigation to /api/login * (see apps/pwa/src/lib/loginRedirect.ts). fetchMe and other fetch calls here * are pure data fetches; they throw on non-ok responses and leave the redirect * decision to the caller (CalendarShell via maybeRedirectToLogin). * * D-11 (Plan 06-05): All fetch wrappers now use redirect:'manual' and throw a * typed SessionExpiredError on 401 / opaqueredirect. The global QueryCache / * MutationCache error handler in main.tsx catches this class and arms the * session-expiry interstitial. A generic Error is still thrown for other non-ok * statuses so error UI can distinguish auth failures from transient errors. */ // ── Auth error ──────────────────────────────────────────────────────────── /** * Typed error thrown by all fetch wrappers when the server returns a 401 or * an opaque redirect (the OIDC guard's 302 to Authelia surfaced as * res.type==='opaqueredirect' via redirect:'manual'). * * Object.setPrototypeOf is required so instanceof checks work correctly after * TypeScript compilation to ES5 / CommonJS, where extending built-in Error * breaks the prototype chain. */ export class SessionExpiredError extends Error { readonly name = 'SessionExpiredError'; constructor() { super('Session expired — re-authentication required'); Object.setPrototypeOf(this, SessionExpiredError.prototype); } } /** * Unified auth-response checker applied to every fetch in this module. * * - 401 or opaqueredirect → throws SessionExpiredError (caught by global QueryCache handler) * - other non-ok → throws a generic Error (distinguishable from auth failures) * - ok → no-op (caller proceeds to parse body) * * @param res The fetch Response object * @param label A short human-readable description for the generic error message */ function handleAuthResponse(res: Response, label: string): void { if (res.type === 'opaqueredirect' || res.status === 401) { throw new SessionExpiredError(); } if (!res.ok) { throw new Error(`${label} failed: ${res.status}`); } } // ── /api/me ──────────────────────────────────────────────────────────────── export interface MeUser { id: number; displayName: string | null; color: string; } export interface MeResponse { user: MeUser; } export async function fetchMe(): Promise { // redirect: 'manual' is critical. The OIDC guard answers an unauthenticated // request with a 302 to Authelia (cross-origin). With the default // redirect: 'follow', the browser follows that credentialed cross-origin // redirect and the fetch HANGS (never resolves, never rejects) — leaving the // query stuck "loading" so the SPA spins forever and the auth-redirect below // never fires. With 'manual', the 302 comes back as an opaqueredirect // (res.type === 'opaqueredirect', res.status === 0) that we detect immediately. const res = await fetch('/api/me', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/me'); return res.json() as Promise; } // ── /api/events (windowed — Phase 2) ───────────────────────────────────── /** * A single concrete occurrence of a calendar event. * * Mirrors the CalendarOccurrence shape produced by the backend's * expandOccurrences() helper (apps/api/src/broker/expand.ts). * * calendarId and ownerUserId are both present: * calendarId — DB calendar-row id (do NOT use for Schedule-X routing) * ownerUserId — DB user id (use for Schedule-X calendarId routing) * isShared — true when this event belongs to the shared-family calendar * * hydrateEvents() uses isShared/ownerUserId — never String(calendarId) — * to build the Schedule-X calendarId that keys into buildCalendarConfig(). */ export interface CalendarOccurrence { id: string; // `ev--` — stable identity (server: expand.ts makeOccurrenceId) uid: string; calendarId: number; // DB calendar-row id — do NOT use for SX calendarId routing calendarName: string; ownerUserId: number; // DB user id — the correct Schedule-X routing key /** * Display name of the calendar owner (users.displayName from the API). * Null when the user has not configured a display name. * Popover renders: isShared ? 'Family' : (ownerName ?? calendarName) */ ownerName: string | null; color: string; // hex from users.color or shared-family constant isShared: boolean; // true → 'shared' slot; false → String(ownerUserId) slot title: string; start: string; // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed end: string; allDay: boolean; location: string | null; description: string | null; /** * True when this occurrence belongs to a recurring series (has an RRULE). * Mirrors CalendarOccurrence.hasRrule in apps/api/src/broker/expand.ts — must * stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05). */ hasRrule: boolean; } export interface OccurrencesResponse { occurrences: CalendarOccurrence[]; } /** * Fetch windowed calendar occurrences. * * The ?start=&end= window is mandatory — an unwindowed call would expand 500+ * cached events with all their recurring occurrences (RESEARCH.md Pitfall 5). * * @param start ISO date string 'YYYY-MM-DD' — window start (inclusive) * @param end ISO date string 'YYYY-MM-DD' — window end (exclusive) */ export async function fetchEvents(start: string, end: string): Promise { const res = await fetch(`/api/events?start=${start}&end=${end}`, { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/events'); return res.json() as Promise; } // Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05 // when the Phase 1 broker-proof component was retired. // ── /api/events (write — Plan 03-05) ───────────────────────────────────────── /** * Recurrence presets supported by the EventForm. * Maps 1:1 to the RRULE frequency values the API accepts. */ export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; /** * Payload for creating or updating a calendar event. * Mirrors the Zod schema on POST /api/events/create and PATCH /api/events/:uid/edit. */ export interface CreateEventPayload { title: string; allDay: boolean; start: string; // 'YYYY-MM-DD' for allDay; ISO 8601 for timed end: string; // same format as start // WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve // the event's existing RRULE (the occurrence contract does not expose recurrence, so // the form cannot echo it back without silently resetting it to 'none'). recurrence?: RecurrencePreset; /** * RRULE UNTIL date (D-06). ISO 'YYYY-MM-DD' string. Only sent when recurrence !== 'none' * and the user selects the "On date" bound. Mutually exclusive with recurrenceCount. * The outbox worker converts this to RRULE UNTIL format (DATE for all-day, DATETIME UTC for timed). */ recurrenceUntil?: string; /** * RRULE COUNT (D-06). Integer >= 1. Only sent when recurrence !== 'none' and the user * selects the "After N times" bound. Mutually exclusive with recurrenceUntil. */ recurrenceCount?: number; location?: string; description?: string; calendarUrl?: string; // omit to use the member's default writable calendar (D-01) } /** Response from POST /api/events/create and PATCH /api/events/:uid/edit */ export interface CreateEventResponse { uid: string; } /** * A writable calendar returned by GET /api/events/writable-calendars. * The server is the authoritative source of the writable set (D-03). * The client never derives writability — it reads this endpoint verbatim. */ export interface WritableCalendar { url: string; displayName: string; color: string; isShared: boolean; } /** * Create a new calendar event. * * POSTs to /api/events/create and returns immediately with 202 + uid. * The server enqueues the write to CalDAV asynchronously (D-05/D-12). */ export async function createEvent(payload: CreateEventPayload): Promise { const res = await fetch('/api/events/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(payload), }); handleAuthResponse(res, 'POST /api/events/create'); return res.json() as Promise; } /** * Update an existing calendar event. * * PATCHes /api/events/:uid/edit with the updated payload. * Returns 202 + uid; the write is enqueued asynchronously (D-05). */ export async function updateEvent( uid: string, payload: CreateEventPayload, ): Promise { const res = await fetch(`/api/events/${uid}/edit`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', redirect: 'manual', body: JSON.stringify(payload), }); handleAuthResponse(res, `PATCH /api/events/${uid}/edit`); return res.json() as Promise; } /** * Delete a calendar event. * * DELETEs /api/events/:uid; the API enqueues the delete to the outbox (D-05). * Returns 202 Accepted (async). Throws on any non-ok response. */ export async function deleteEvent(uid: string): Promise { const res = await fetch(`/api/events/${uid}`, { method: 'DELETE', credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, `DELETE /api/events/${uid}`); } // ── /api/events/sync-status (Plan 03-06) ───────────────────────────────────── /** * Status values for an outbox write operation. * Mirrors the calendarOutbox.status enum on the server. */ export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead'; /** * Response from GET /api/events/sync-status?uid= * The server returns the current outbox status for the given UID + member. */ export interface SyncStatus { uid: string; status: SyncStatusValue; /** Present on failed status — may contain '412' prefix for conflict detection. */ error?: string; } /** * Poll the sync-status for a specific event UID. * * Used by SyncStateToast to track pending → done | failed | dead transitions. * The server filters by the current member so no cross-member leakage (T-03-19). */ export async function fetchSyncStatus(uid: string): Promise { const res = await fetch(`/api/events/sync-status?uid=${uid}`, { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/events/sync-status'); return res.json() as Promise; } /** * Fetch the authoritative list of writable calendars for the current member. * * The server owns the D-03 writable set (WHERE userId=currentUser.id OR isShared=1). * The client NEVER derives writability — it reads this endpoint verbatim. * Drives the calendar picker visibility (D-02: hidden when only 1 writable calendar). */ export async function fetchWritableCalendars(): Promise { const res = await fetch('/api/events/writable-calendars', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/events/writable-calendars'); const body = (await res.json()) as { calendars: WritableCalendar[] }; return body.calendars; }