notify: codes replace the type enum, and android goes data-only

the sidecar now sends what the design describes.

producer contract: { code, userId, n?, id? }. validated on the SHAPE of the
code, never against the catalogue — a producer firing a code the mapping has
not caught up with is sending a real event, and dropping it here would trade a
degraded notification for none when the device already has a defined fallback.
the shape check still catches a typo at the producer instead of on a phone.

`ok` is gone. a failed job is its own code, officer/core:job-failed, which is
the thing the five-value enum could not express without a boolean bolted on.

apns: mutable-content: 1, alert.title fixed to the generic, and officer:{c,n,id,mv}
beside it. deliberately NOT the catalogue's title — sending the real text would
hand apple the category vocabulary in plaintext, which is what the code exists
to withhold.

fcm: data-only, no notification block. a notification block is rendered by the
system, which would put the code on the lock screen and give the app no chance
to resolve it. priority high is load-bearing — a normal-priority data message
can sit in doze indefinitely.

discord renders server-side from the catalogue, because it has no app to
resolve anything. safe: catalogue titles are templates and carry no user
content.

the catalogue is loaded ONCE per dispatch, not per channel or per device, so
every copy of one notification carries the same mv. two channels reading disk
either side of a plugin install would otherwise tell two devices different
things about the same event.

40 tests. verified live: a malformed code is refused, officer/core:test is
accepted and dispatches to zero configured channels.
This commit is contained in:
2026-08-19 19:37:05 +00:00
parent 55d6eba511
commit e598a5dcba
12 changed files with 268 additions and 168 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { Catalogue } from '../../notifications/catalogue';
import type { Notification } from './types';
// Rendering a code into words, SERVER-SIDE.
//
// This is not what iOS and Android see. They get the code and render it themselves from their synced copy
// of the catalogue — that is the entire point of the design. This exists for the two cases where the
// server is the last stop:
//
// - Discord, which has no app to render anything.
// - The APNs/FCM fallback string, shown only when the device's own rendering does not run.
//
// Replaced text.ts, which held a hardcoded switch over a five-value enum. That enum was the rigidity the
// catalogue exists to remove: adding a notification meant editing a server file AND shipping an app.
/** The generic shown when a device cannot render for itself. Never a decoy sentence — see the doc. */
export const FALLBACK_TITLE = 'Officer';
const interpolate = (template: string, n: Notification): string =>
template.replace(/\{(n|id)\}/g, (_, key: 'n' | 'id') => {
const value = key === 'n' ? n.n : n.id;
return value === undefined ? '' : String(value);
});
export type Rendered = { title: string; body?: string; thread: string };
/**
* Render one notification against the catalogue.
*
* An unknown code renders as the generic rather than throwing or being dropped: the event is real even
* when the mapping has not caught up, and the device has its own defined fallback for the same case.
*/
export function renderNotification(n: Notification, catalogue: Catalogue): Rendered {
const entry = catalogue.entries[n.code];
if (!entry) return { title: FALLBACK_TITLE, thread: n.code };
const body = entry.body ? interpolate(entry.body, n) : undefined;
return {
title: interpolate(entry.title, n),
...(body ? { body } : {}),
thread: entry.thread ?? n.code,
};
}
/** The grouping key for a code, whether or not the catalogue knows it. */
export function threadFor(n: Notification, catalogue: Catalogue): string {
return catalogue.entries[n.code]?.thread ?? n.code;
}