loopable / web /src /alerts /alertsModel.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
16.7 kB
// ---------------------------------------------------------------------------
// 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 };
}