import { calendarById, type CalendarSlug } from "@/lib/calendars"; import { API_BASE_URL } from "@/lib/env"; import { parseApiEvents } from "@/lib/events"; import type { AdminUser, ApiEvent, CalendarEvent, EventPayload } from "@/lib/types"; /** * Every call to the API, made from the browser straight to the API's origin. * * `credentials: "include"` is mandatory, not a detail: the API is a different * origin, so without it the browser leaves the session cookie behind and every * call is anonymous. That fails as a plain 401, which reads like an expired * login rather than a missing option. * * Callers must tell 401 from 403 (see `ApiError.status`): 401 means nobody is * signed in and is the only status worth a trip to the login page; redirecting * on 403 loops, because signing in succeeds and lands straight back on the * refusal. */ export class ApiError extends Error { status: number; body: unknown; constructor(status: number, body: { message?: string } & Record, message?: string) { super(message ?? body?.message ?? `API request failed with ${status}`); this.status = status; this.body = body; } } const request = async (path: string, init?: RequestInit): Promise => { let res: Response; try { res = await fetch(`${API_BASE_URL}${path}`, { ...init, credentials: "include", headers: { "Content-Type": "application/json", ...init?.headers }, }); } catch { // Network failure or a CORS refusal - the browser hides which. Status 0 // keeps it apart from anything the API itself answered. throw new ApiError(0, {}, "Keine Verbindung zum Server."); } if (res.status === 204) { return undefined as T; } const data = await res.json().catch(() => ({})); if (!res.ok) { throw new ApiError(res.status, data); } return data as T; }; const eventsPath = "/calendar/events"; export const api = { /** Who is signed in, across all four apps. */ me: (): Promise => request("/admin/me"), /** * One calendar's events, as the admin view: all statuses, deleted ones * already dropped by the parser. A signed-in editor gets drafts; the same * URL without a session returns only published events (or 403 for the * internal calendars). */ getEvents: async (slug: CalendarSlug): Promise => parseApiEvents(await request(`${eventsPath}/${slug}/json`)), createEvent: async (payload: EventPayload): Promise => { const res = await request<{ eventId?: number }>(eventsPath, { method: "POST", body: JSON.stringify(payload) }); if (typeof res?.eventId !== "number") { throw new ApiError(200, res ?? {}, "Der Server hat keine Termin-ID zurückgegeben."); } return res.eventId; }, /** * The API answers 200 even when nothing was written, with * `{message: "0 rows were updated"}` - so that body is a failure here. */ updateEvent: async (eventId: number, payload: EventPayload): Promise => { const res = await request<{ message?: string }>(`${eventsPath}/${eventId}`, { method: "PUT", body: JSON.stringify(payload), }); if (res?.message === "0 rows were updated") { throw new ApiError(200, res, "Der Termin wurde nicht gespeichert."); } }, /** Moves the event to `payload.calendarId`. Only the calendar changes server-side. */ moveEvent: async (eventId: number, payload: EventPayload): Promise => { if (!calendarById(payload.calendarId)) { throw new Error(`Unknown calendar ${payload.calendarId}`); } await request(`${eventsPath}/move/${eventId}`, { method: "PUT", body: JSON.stringify(payload) }); }, /** * Ready-made iCal URLs per calendar, with the shared password in place where * one is needed. Calendars whose password isn't configured are missing. */ getSubscriptions: (): Promise<{ calendar: CalendarSlug; icalUrl: string }[]> => request(`${eventsPath}/subscriptions`), /** A soft delete: the API appends a version with status DELETED. */ deleteEvent: async (eventId: number): Promise => { await request(`${eventsPath}/${eventId}`, { method: "DELETE" }); }, };