With the default redirect:follow, the browser follows the OIDC guard's 302 to Authelia (cross-origin, credentialed) and the fetch HANGS — meQuery stays 'loading' so the SPA spins forever and the isError-driven login redirect never fires. redirect:manual surfaces the 302 as an opaqueredirect (status 0) that we detect as auth-required and throw, letting CalendarShell navigate to /api/login. +4 fetchMe tests.
282 lines
9.6 KiB
TypeScript
282 lines
9.6 KiB
TypeScript
/**
|
|
* 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<MeResponse> {
|
|
// 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<MeResponse>
|
|
}
|
|
|
|
// ── /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<OccurrencesResponse> {
|
|
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<OccurrencesResponse>
|
|
}
|
|
|
|
// 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<CreateEventResponse> {
|
|
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<CreateEventResponse>
|
|
}
|
|
|
|
/**
|
|
* 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<CreateEventResponse> {
|
|
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<CreateEventResponse>
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<SyncStatus> {
|
|
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<SyncStatus>
|
|
}
|
|
|
|
/**
|
|
* 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<WritableCalendar[]> {
|
|
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
|
|
}
|