// --------------------------------------------------------------------------- // alerts/alertsApi.ts — WAVE 20 item 25 (C-ALERT): the seven calls, and nothing // else. Every one carries the session cookie and fails CLOSED: an unreadable // answer is an empty inbox, never a half-parsed one. // // Shaped like `shell/session.ts` on purpose — the same `{ok}` result type, the // same 4xx/5xx message policy — because the failure that matters is identical: // a wrong `credentials` word produces no client-side symptom at all, just a 401 // from a server that never saw a session. // --------------------------------------------------------------------------- import { API_V1, CREDENTIALS } from "../apiContract"; import { EMPTY_INBOX, parseAlerts, parseInbox } from "./alertsModel"; import type { Alert, Inbox } from "./alertsModel"; export type Result = | { ok: true; value: T } | { ok: false; status: number; message: string }; /** 4xx text is POLICY the reader needs ("this view has no filter"); a 5xx's text * is the server's internals and is never shown. Same split as session.ts. */ export function errorMessage(status: number, message?: string): string { if (status >= 500 || !message) { return status >= 500 ? "Something went wrong on our side. Try again in a moment." : `The server answered ${status}.`; } return message; } async function call( path: string, init: RequestInit, read: (body: unknown) => T ): Promise> { let res: Response; try { res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init }); } catch { return { ok: false, status: 0, message: "Cannot reach the server." }; } const body = (await res.json().catch(() => null)) as unknown; if (!res.ok) { const detail = (body as { error?: { message?: string } } | null)?.error?.message; return { ok: false, status: res.status, message: errorMessage(res.status, detail) }; } return { ok: true, value: read(body) }; } const json = (data: unknown): RequestInit => ({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); export function fetchInbox(): Promise> { return call("/notifications", {}, parseInbox); } /** `ids: null` marks EVERYTHING — the API's own convention, not a shortcut. */ export function markRead(ids: string[] | null, read = true): Promise> { return call("/notifications/read", json({ ids, read }), () => true as const); } export function fetchAlerts(): Promise> { return call("/alerts", {}, parseAlerts); } /** * `POST /alerts`. ⚠ 400 `no_filter` is a REAL ANSWER, not a failure to handle: * a view with no active filter matches every row, so an alert on it would seed * with the whole table and could never see an entrant again. The message rides * back to the caller verbatim — that is the difference between "refused" and * "silently incapable". */ export function createAlert( viewId: string, topic: string, label?: string ): Promise> { return call("/alerts", json({ viewId, topic, ...(label ? { label } : {}) }), (b) => b); } export function deleteAlert(id: string): Promise> { return call(`/alerts/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const); } /** Run one alert now. Answers `{new:[rowId], seeded}` or `{skipped:""}`. */ export function runAlert(id: string): Promise> { return call(`/alerts/${encodeURIComponent(id)}/run`, { method: "POST" }, (b) => b); } export { EMPTY_INBOX };