// --------------------------------------------------------------------------- // alerts/alertsModel.ts — WAVE 20 item 25 (contract C-ALERT): the inbox's PURE // half. React-free and fetch-free, so `verify_alerts.py` runs it under node. // // An alert says "tell me when a record ENTERS this view". The client half is // small, and every part of it fails silently when it is wrong: // // · an unread count taken from `items.length` rather than from the server's // own `unread` disagrees with the badge the moment a page is capped or a // read lands in another tab — and a badge that says 3 when the list shows 9 // teaches the reader to ignore the badge; // · a notification whose `viewId` no longer resolves must open NOTHING rather // than a wrong view — alerts outlive the views they were made from; // · `topic` is the surface's scope key ("customer", "product", "ut_…"), and // the ROUTE is a registry key ("customer_data") — mapping one to the other // by guesswork sends every click to a page that does not exist. // --------------------------------------------------------------------------- /** * ⭐⭐ WAVE 32 · T20 · CONTRACT C3 — WHERE AN INBOX ITEM OPENS. * * `module` is the destination surface (`"database"` or `"automation"`) and `id` is what to open * in it. `tab` is the SUB-SELECTION inside that module: the literal `"runs"` for an automation's * run log, or the VIEW ID to select on a database. One optional key, two destinations. * * ⛔ EVERY FIELD IS A PLAIN `string`, NEVER A UNION, and that is alertsModel's wave-9 law rather * than laziness: the vocabulary is the SERVER's, and a client union over it turns "the server grew * a module" into "the client silently drops the row". An unknown module is refused by the * FRAME's dispatcher, out loud, which is a different thing from never arriving. */ export interface NotificationTarget { module: string; id: string; tab?: string; } /** C3's `kind` vocabulary, mirroring `routes_alerts.NOTIF_KIND_*`. Held as constants so the * Inbox's branch and the gate's fixtures cannot disagree about the word. */ export const NOTIF_KIND_ALERT = "alert"; export const NOTIF_KIND_AUTOMATION = "automation"; export const NOTIF_KIND_SHARE = "share"; /** C3's `target.module` vocabulary, and the automation sub-selection. */ export const TARGET_MODULE_DATABASE = "database"; export const TARGET_MODULE_AUTOMATION = "automation"; export const TARGET_TAB_RUNS = "runs"; /** One notification, as `GET /api/v1/notifications` sends it. */ export interface Notification { id: string; alertId: string; viewId: string; topic: string; rowId: string; /** What entered — the record's own label. */ label: string; /** What the alert is called, so a row reads without opening anything. */ alertLabel: string; /** UTC WITH OFFSET (D-18). Kept as the server's STRING: re-formatting it here * would re-introduce the browser-clock drift the offset exists to remove. */ at: string; read: boolean; /** * WAVE 23 (contract C6) — WHAT KIND of notification this is. * * Absent (and every notification written before this wave) means the original one: a record * ENTERED a watched view, routed by `topic` + `viewId`. `"automation_review"` means a card * arrived at a review stage and routes by `autoId` instead — a different destination reached * from the same list. * * ⛔ A STRING, NEVER A UNION, and that is the wave-9 law rather than laziness: the vocabulary * is the SERVER's, and a client union over it turns "the server grew a kind" into "the client * silently drops the row". Unknown kinds fall through to the view route, which is exactly what * they did before this field existed. */ kind?: string; /** `automation_review` only: the automation whose review stage a card reached. Absent on * every other kind — and an `automation_review` row that arrives WITHOUT one opens nothing * rather than guessing, the same posture `viewId` gets. */ autoId?: string; /** Advisory. A surface that does not scroll to a stage simply selects the automation. */ stageId?: string; /** How many cards arrived in the batch. C6 queues ONE notification per run naming the count, * never one per record — so this is the number the row's own text is built from. */ count?: number; /** * ⭐ WAVE 32 · C3 — THE HEADER LINE, so the Inbox can be laid out like mail. * * `subject` is what the item is ABOUT (the alert's name, the automation's name, the database * that was shared); `label` stays what HAPPENED (the record that entered, the run summary). * They were one field, which is why the pane could only ever render a sentence with no sender. * Absent on a server that predates this wave — {@link subjectOf} falls back rather than * rendering a blank header. */ subject?: string; /** ⭐ WAVE 32 · C3 — where clicking it goes. ABSENT when this product cannot resolve a * destination (an alert on a table this account can no longer route to), and that absence is * load-bearing: the Inbox renders such a row as plainly unclickable rather than as a click * that silently does nothing. */ target?: NotificationTarget; } /** One alert, as `GET /api/v1/alerts` sends it. */ export interface Alert { id: string; viewId: string; topic: string; owner: string; label: string; createdAt: string; /** How many records are in its remembered set right now. */ matched: number; seeded: boolean; lastRunAt: string; lastError: string; } export interface Inbox { unread: number; items: Notification[]; } export const EMPTY_INBOX: Inbox = { unread: 0, items: [] }; /** * ⭐⭐ WAVE 31 · T23 (owner item 1) — WHAT THE PANE IS ENTITLED TO SAY, AS A FUNCTION. * * Owner, verbatim: *"Alerts shows notification, but when clicked it says nothing, and it doesn't * remove the notification number."* Both halves are ONE mechanism. `AlertsPane` held its own * `inbox` seeded to {@link EMPTY_INBOX} and had no pending state, so between opening the panel * and `GET /notifications` answering — measured at **3,280 ms live** — and for ever after a * failed fetch: * · `items` was `[]`, so the pane printed **"Nothing new."** — a claim, not a wait; * · `unread` was `0`, so **"Mark all read" was `disabled`**, and the badge the frame had * already loaded could never be cleared. * A confident sentence about somebody's inbox, and the one control that would fix it, both * switched off by the same uninitialised state. * * ⛔ IT IS A FUNCTION BECAUSE THE PANE IS TSX AND TSX IS NOT UNDER TEST HERE. `verify_alerts` * compiles and RUNS this module under node; markup it cannot reach is markup no control can * mutate. Deciding here means the truth table is asserted and each branch is load-bearing. */ export type PaneView = "pending" | "rows" | "empty" | "error"; /** * `pending` while the first read is in flight · `error` when it failed and we have nothing to * show · `rows` when there is something · `empty` ONLY when a successful read returned nothing. * * ⚠ ROWS WIN OVER AN ERROR, and that is deliberate rather than lax: a refresh that fails while * the pane already holds notifications should not blank them — the reader loses real information * to a transient. The error still reaches them as the pane's message line. */ export function paneView( phase: "pending" | "ready" | "error", rowCount: number, seededUnread = 0 ): PaneView { if (rowCount > 0) return "rows"; if (phase === "pending") return "pending"; if (phase === "error") return "error"; // ⚠ A SUCCESSFUL READ WITH NO ROWS AND A NON-ZERO COUNT IS NOT "nothing new". The badge says // there is something; the page we were given does not contain it. Saying "Nothing new" there // is the same false confidence in a different costume. return seededUnread > 0 ? "error" : "empty"; } /** * May "Mark all read" be pressed? * * ⛔ NOT `unread === 0`, WHICH IS THE SHIPPED BUG. That test asked the PANE's own state — zero * until its fetch lands, zero for ever if the fetch fails — so the control was dead in exactly * the situations the owner hit. The question is about the ACCOUNT, so it is asked of the count * the frame already holds, and a failed read does not take the verb away: `POST /notifications/ * read` with `ids: null` clears the account's inbox whether or not we managed to list it. */ export function canMarkAll(unread: number, busy = false): boolean { return !busy && Math.max(0, Math.floor(unread)) > 0; } const str = (v: unknown): string => (typeof v === "string" ? v : ""); const num = (v: unknown): number => (typeof v === "number" && isFinite(v) ? v : 0); /** * ⭐ WAVE 32 · C3 — one wire `target` → a {@link NotificationTarget}, or `null`. * * ⛔ BOTH `module` AND `id` ARE REQUIRED, and dropping either test is the failure this guard is * for: a target with a module and no id dispatches an open request naming NOTHING — a click that * appears to work and silently does not, which is this repo's most-repeated shape. `null` is * rendered as an unclickable row, which a reader can SEE. */ export function parseTarget(raw: unknown): NotificationTarget | null { if (!raw || typeof raw !== "object") return null; const t = raw as Record; const module = str(t.module).trim(); const id = str(t.id).trim(); if (!module || !id) return null; const tab = str(t.tab).trim(); return { module, id, ...(tab ? { tab } : {}) }; } /** * `GET /notifications` → the inbox, fail-closed. * * ⚠ `unread` COMES FROM THE SERVER, and is not recounted from `items`. The two * can legitimately differ — the list is what this page holds, the count is what * the account has — and recomputing it here would make the badge a function of * whatever the last fetch happened to include. */ export function parseInbox(body: unknown): Inbox { const b = (body && typeof body === "object" ? body : {}) as Record; const raw = Array.isArray(b.items) ? b.items : []; const items: Notification[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const n = item as Record; const id = str(n.id); // An id-less notification cannot be marked read, so it would sit unread for // ever and hold the badge up. Dropped, not rendered. if (!id) continue; items.push({ id, alertId: str(n.alertId), viewId: str(n.viewId), topic: str(n.topic), rowId: String(n.rowId ?? ""), label: str(n.label) || String(n.rowId ?? ""), alertLabel: str(n.alertLabel), at: str(n.at), read: n.read === true, // WAVE 23 C6 — ADDITIVE and spread-conditional, exactly like `NavPage`'s flags: a payload // that predates this wave keeps today's shape rather than gaining four `undefined` keys, // and an `automation_review` row missing its `autoId` is left WITHOUT one rather than // with an empty string that would render as a real destination. ...(str(n.kind) ? { kind: str(n.kind) } : {}), ...(str(n.autoId) ? { autoId: str(n.autoId) } : {}), ...(str(n.stageId) ? { stageId: str(n.stageId) } : {}), ...(num(n.count) > 0 ? { count: num(n.count) } : {}), // ⭐ WAVE 32 · C3 — additive and spread-conditional, exactly like the four above. ...(str(n.subject) ? { subject: str(n.subject) } : {}), ...(parseTarget(n.target) ? { target: parseTarget(n.target)! } : {}), }); } return { unread: Math.max(0, num(b.unread)), items }; } /** `GET /alerts` → the alert list, fail-closed. */ export function parseAlerts(body: unknown): Alert[] { const b = (body && typeof body === "object" ? body : {}) as Record; const raw = Array.isArray(b.alerts) ? b.alerts : []; const out: Alert[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const a = item as Record; const id = str(a.id); if (!id) continue; out.push({ id, viewId: str(a.viewId), topic: str(a.topic), owner: str(a.owner), label: str(a.label) || "Untitled alert", createdAt: str(a.createdAt), matched: num(a.matched), seeded: a.seeded === true, lastRunAt: str(a.lastRunAt), lastError: str(a.lastError), }); } return out; } /** * A topic (the grid's scope key) → the hash route that renders it. * * The two built-ins are the only pair that differ, and they differ because the * REGISTRY names the surface while the GRID names the scope; a user table is its * own key in both. `null` for anything else: a notification for a topic this * client cannot route to must do nothing, not navigate somewhere plausible. */ export function routeForTopic(topic: string): string | null { const t = str(topic).trim(); if (t === "customer") return "customer_data"; if (t === "product") return "product_data"; if (/^ut_[A-Za-z0-9_]+$/.test(t)) return t; return null; } /** * The stamp, made readable WITHOUT touching a clock. * * ⛔ NO `new Date()`, NO `toLocaleString()`, and that is the whole design. The * server sends UTC WITH ITS OFFSET (D-18) precisely so every reader sees the * same instant; parsing it into a browser Date and formatting it back would * re-introduce the drift the offset exists to remove — a tenant a day ahead * being told an event happened tomorrow ([[date-window-vocabulary]]). This is * STRING SURGERY: keep the date and the minutes, drop the seconds and the `T`. * Anything that does not look like an ISO stamp passes through untouched, so a * format this function has never seen is shown as sent rather than mangled. */ export function stampText(at: string): string { const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(str(at)); return m ? `${m[1]} ${m[2]}` : str(at); } /** * The badge's text. Never the raw number past 99: a nav row is 236px wide and a * four-digit badge pushes the label out of it. */ export function badgeText(unread: number): string { const n = Math.max(0, Math.floor(unread)); if (n <= 0) return ""; return n > 99 ? "99+" : String(n); } /** * Apply a read/unread change LOCALLY, mirroring what the server just stored, and * return the new inbox with the count corrected. * * `ids === null` is "all of them" (the API's own convention for mark-all). The * count is derived from the ITEMS here — deliberately, and it is the one place * that is right to do so: the server's answer is in flight, and the alternative * is a badge that keeps its old number until the refetch lands. */ export function applyRead(inbox: Inbox, ids: string[] | null, read: boolean): Inbox { const wanted = ids === null ? null : new Set(ids); const items = inbox.items.map((n) => wanted === null || wanted.has(n.id) ? { ...n, read } : n ); const seenUnread = items.filter((n) => !n.read).length; // A page can hold fewer notifications than the account has, so a partial read // must SUBTRACT from the server's count rather than replace it with this // page's tally — except when marking everything, where zero is the answer. if (wanted === null) return { unread: read ? 0 : items.length, items }; const changed = inbox.items.filter( (n) => wanted.has(n.id) && n.read !== read ).length; const delta = read ? -changed : changed; return { unread: Math.max(seenUnread, inbox.unread + delta), items }; } /** * The shell↔rail channel for "make an alert out of this view" — the view rail * raises it, the frame (which knows the current route, and therefore the topic) * answers. Same reason as C-SHARE's event: the rail is host-neutral and cannot * import the shell, and it does not know its own scope key. */ export const ALERT_CREATE_EVENT = "aios:alert-create"; export interface AlertCreateRequest { viewId: string; label: string; } export function parseAlertCreate(detail: unknown): AlertCreateRequest | null { if (!detail || typeof detail !== "object") return null; const d = detail as Record; const viewId = str(d.viewId).trim(); if (!viewId) return null; return { viewId, label: str(d.label).trim() || viewId }; }