/** * 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). */ // ── /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', }) if (res.type === 'opaqueredirect' || res.status === 401) { // Session missing/expired → the guard wants us at Authelia. Signal the caller // (CalendarShell) to perform a TOP-LEVEL navigation to /api/login via // maybeRedirectToLogin() — a document navigation is not CORS-restricted. throw new Error('GET /api/me: authentication required') } if (!res.ok) { throw new Error(`GET /api/me failed: ${res.status}`) } 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 // `${uid}::${dtstart_iso}` — stable identity 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 } 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', }) if (!res.ok) { throw new Error(`GET /api/events failed: ${res.status}`) } 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 recurrence: RecurrencePreset 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', body: JSON.stringify(payload), }) if (!res.ok) { throw new Error(`POST /api/events/create failed: ${res.status}`) } 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', body: JSON.stringify(payload), }) if (!res.ok) { throw new Error(`PATCH /api/events/${uid}/edit failed: ${res.status}`) } 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', }) if (!res.ok) { throw new Error(`DELETE /api/events/${uid} failed: ${res.status}`) } } // ── /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', }) if (!res.ok) { throw new Error(`GET /api/events/sync-status failed: ${res.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', }) if (!res.ok) { throw new Error(`GET /api/events/writable-calendars failed: ${res.status}`) } const body = (await res.json()) as { calendars: WritableCalendar[] } return body.calendars }