| // --------------------------------------------------------------------------- | |
| // 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| const viewId = str(d.viewId).trim(); | |
| if (!viewId) return null; | |
| return { viewId, label: str(d.label).trim() || viewId }; | |
| } | |