ThreadCount Community edition
Release / release (push) Has been skipped

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-17 05:49:16 +10:00
commit f72f0626b0
481 changed files with 59411 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
import { createSign } from "node:crypto";
import { readFileSync } from "node:fs";
import { prisma } from "./db";
import { approvedLines } from "./staffreq";
import { fmtDate } from "./compute";
/* Telling somebody's phone, without telling the ward.
*
* Every sender here is fire-and-forget: it takes ids, reads what it needs itself, never throws and
* never blocks the op that called it. An op that succeeded must not fail because a notification
* could not be sent — the request is the record, and this is a courtesy on top of it.
*
* ⛔ Three rules the text obeys, stated once in notifyText() so they cannot drift apart:
* · no counts — the same rule as every staff screen: a ward is told words;
* · no money, ever, anywhere near a wearer;
* · NO COLLECTION CODE. A notification body is drawn on a lock screen, and the code is what a
* bag is handed over against, so printing it there hands the bag to whoever walks past the
* phone on the desk. The code stays behind the sign-in, on the order and on the full-screen
* card. This is a deliberate difference from the approved mockup, documented in the manual.
*
* Preferences are honoured HERE rather than by the callers: a trigger should say what happened,
* not work out who wants to hear about it. Somebody with no preference row wants everything, which
* is what a new account has.
*
* Nothing about the credential is in this repository. The path to a service-account key file comes
* from TC_FCM_KEY_FILE and the project id is read out of that file; with the variable unset every
* send returns at once, nothing is logged beyond a single line, and the app tells the person
* notifications are not set up on this server.
*/
type Key = { client_email: string; private_key: string; project_id: string; token_uri?: string };
let key: Key | null | undefined; // undefined = not looked at yet; null = nothing configured
let said = false;
function saidOnce() {
if (said) return;
said = true;
// One line, once, with no path and no payload in it — the same shape as mail's.
console.info("[push] no notification sender configured — nothing is sent");
}
function loadKey(): Key | null {
if (key !== undefined) return key;
const path = process.env.TC_FCM_KEY_FILE;
if (!path) { key = null; saidOnce(); return key; }
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<Key>;
if (!raw.client_email || !raw.private_key || !raw.project_id) throw new Error("incomplete");
key = { client_email: raw.client_email, private_key: raw.private_key, project_id: raw.project_id, token_uri: raw.token_uri };
} catch {
// Never the path, and never the reason in detail: this line is read in a log somebody else may
// hold, and which file it is is exactly the part that is nobody's business.
console.warn("[push] the notification key could not be read — nothing is sent");
key = null;
}
return key;
}
/** Is a sender configured on this server at all? The Account screen asks through `push.register`,
* so a facility with no key says so plainly rather than offering a switch that does nothing. */
export function pushConfigured(): boolean {
return !!loadKey();
}
/* ---------------------------------------------------------------- the transport ----
*
* FCM HTTP v1, and no new dependency: a service-account JWT signed with node:crypto, exchanged for
* an access token, then one POST per device. The alternative was the Firebase Admin SDK — a large
* dependency in every self-hosted image for two HTTP calls.
*/
const b64url = (b: Buffer | string) =>
(typeof b === "string" ? Buffer.from(b) : b).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
let bearer: { value: string; until: number } | null = null;
async function accessToken(): Promise<string | null> {
const k = loadKey();
if (!k) return null;
if (bearer && bearer.until > Date.now()) return bearer.value;
try {
const now = Math.floor(Date.now() / 1000);
const aud = k.token_uri || "https://oauth2.googleapis.com/token";
const claims = { iss: k.client_email, scope: "https://www.googleapis.com/auth/firebase.messaging", aud, iat: now, exp: now + 3600 };
const unsigned = `${b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }))}.${b64url(JSON.stringify(claims))}`;
const sig = createSign("RSA-SHA256").update(unsigned).sign(k.private_key);
const assertion = `${unsigned}.${b64url(sig)}`;
const r = await fetch(aud, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
});
if (!r.ok) { console.warn("[push] token exchange refused", r.status); return null; }
const j = (await r.json()) as { access_token?: string; expires_in?: number };
if (!j.access_token) return null;
// Five minutes early, so a token never expires between minting it and using it.
bearer = { value: j.access_token, until: Date.now() + Math.max(60, (j.expires_in || 3600) - 300) * 1000 };
return bearer.value;
} catch (e) {
console.warn("[push] could not mint an access token:", (e as Error).message);
return null;
}
}
export type Msg = { title: string; body: string; url: string; tag: string };
/** Write to every device given, and say how many were accepted — which is the number a test can
* assert on. With no devices it does nothing at all, and on a server where nobody has turned
* notifications on that is the expected answer, not a failure. */
async function push(devices: readonly { id: string; token: string }[], msg: Msg): Promise<number> {
if (!devices.length) return 0;
const k = loadKey();
if (!k) return 0;
const auth = await accessToken();
if (!auth) return 0;
let sent = 0;
for (const d of devices) {
try {
const r = await fetch(`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(k.project_id)}/messages:send`, {
method: "POST",
headers: { authorization: `Bearer ${auth}`, "content-type": "application/json" },
body: JSON.stringify({
message: {
token: d.token,
notification: { title: msg.title, body: msg.body },
android: { priority: "HIGH", notification: { channel_id: "threadcount", tag: msg.tag } },
data: { url: msg.url },
},
}),
});
if (r.ok) { sent++; continue; }
const text = await r.text().catch(() => "");
/* A token that is gone is a row that should be gone with it: a phone wiped, an app removed, a
* registration rotated. Left behind, it is a write attempted on every send for ever.
*
* ⛔ But only when the complaint is about the TOKEN. FCM answers 400 INVALID_ARGUMENT for any
* malformed message — a field it no longer accepts, a bad enum, an oversize payload — so the
* bare word made a single mistake in the message shape delete every registered phone it was
* sent to. The kit-check fan-out writes to every holder in the facility at once, which is the
* send that would have done it, silently, to everybody. Anything else is this server's fault
* and is logged, not paid for by somebody's phone. */
const aboutTheToken = /UNREGISTERED/.test(text)
|| (/INVALID_ARGUMENT/.test(text) && /message\.token|registration token/i.test(text));
if (r.status === 404 || r.status === 410 || aboutTheToken) {
await prisma.staffDevice.deleteMany({ where: { id: d.id } }).catch(() => {});
// Never the token and never a name: which phone it was is nobody's business in a log.
console.info("[push] a registration is gone; its device was forgotten", r.status);
continue;
}
console.warn("[push] FCM refused a message", r.status);
} catch (e) {
console.warn("[push] could not send:", (e as Error).message);
}
}
return sent;
}
/* ---------------------------------------------------------------- who gets it ---- */
type Switch = "approved" | "ready" | "round" | "kitcheck" | "waiting";
/** The devices to write to: this person's phones, provided they still have an account, are still
* on the register, and have not turned this particular switch off. A missing preference row is
* every default, so nobody has to be written a row to be told. */
async function devicesFor(staffIds: readonly string[], which: Switch): Promise<{ id: string; token: string }[]> {
const ids = [...new Set(staffIds.filter(Boolean))];
if (!ids.length) return [];
const [prefs, devices] = await Promise.all([
prisma.staffNotifyPref.findMany({ where: { staffId: { in: ids } } }),
prisma.staffDevice.findMany({
// Somebody who cannot act on it is not told: off the register, or with no account to open.
where: { staffId: { in: ids }, staff: { inactive: false, account: { isNot: null } } },
select: { id: true, token: true, staffId: true },
}),
]);
const off = new Set(prefs.filter((p) => p[which] === false).map((p) => p.staffId));
return devices.filter((d) => !off.has(d.staffId)).map((d) => ({ id: d.id, token: d.token }));
}
/* ---------------------------------------------------------------- the words ---- */
/** Garment names, deduplicated, at most three. No sizes, no quantities, no money, no code. */
function names(lines: readonly { item: { item: string } }[]): string {
const seen: string[] = [];
for (const l of lines) if (!seen.includes(l.item.item)) seen.push(l.item.item);
return seen.slice(0, 3).join(", ") + (seen.length > 3 ? " …" : "");
}
export type Kind = "approved" | "partly" | "declined" | "ready" | "round" | "kitcheck" | "waiting";
/** Every notification's text, in one function, so the three rules above are obeyed once rather
* than in seven call sites. */
export function notifyText(
kind: Kind,
f: { id?: string; code?: string; garments?: string; ward?: string; who?: string; dueBy?: string },
): Msg {
const order = `/my/orders/${f.id || ""}`;
const tag = f.id || "";
switch (kind) {
case "approved": return { title: "Approved", body: `${f.code} · ${f.garments}`, url: order, tag };
case "partly": return { title: "Partly approved", body: `${f.code} · ${f.garments}`, url: order, tag };
case "declined": return { title: "Declined", body: `${f.code} · ${f.garments}`, url: order, tag };
case "ready": return { title: "Ready to collect", body: `${f.code} · ${f.garments}`, url: order, tag };
case "round": return { title: "On the ward round", body: `${f.code} · ${f.ward ? `arriving on ${f.ward}` : "arriving on your ward"}`, url: order, tag };
case "kitcheck": return { title: "Kit check is open", body: `Confirm what you still have by ${f.dueBy}`, url: "/my/kitcheck", tag: "kitcheck" };
case "waiting": return { title: "Waiting on you", body: `${f.who} · ${f.garments}`, url: `/my/approvals/${f.id || ""}`, tag };
}
}
/* ---------------------------------------------------------------- the senders ---- */
const REQ = {
lines: { include: { item: { select: { item: true } } }, orderBy: { sort: "asc" } },
subject: { select: { id: true, first: true, last: true, dept: true } },
} as const;
async function decided(requestId: string): Promise<number> {
if (!pushConfigured()) return 0;
const r = await prisma.request.findUnique({ where: { id: requestId }, include: REQ });
if (!r || r.status === "awaiting") return 0;
const yes = approvedLines(r.lines);
const kind: Kind = yes.length === 0 ? "declined" : yes.length === r.lines.length ? "approved" : "partly";
// What survived the decision is the bag; a refusal is described by what was asked for, because
// there is no bag.
const about = yes.length ? yes : r.lines;
return push(await devicesFor([r.subjectId], "approved"), notifyText(kind, { id: r.id, code: r.code, garments: names(about) }));
}
async function waiting(requestId: string): Promise<number> {
if (!pushConfigured()) return 0;
const r = await prisma.request.findUnique({ where: { id: requestId }, include: REQ });
/* Addressed to somebody, and still theirs to answer. Deliberately never gated on "is a manager":
* a request reaches somebody who manages nobody two ordinary ways — the linen room re-addresses
* one that arrived without an approver, or a manager's last report moves away — and that person
* has no team screen they visit out of habit, so this is the only thing that tells them. */
if (!r || !r.managerId || r.status !== "awaiting") return 0;
const who = `${r.subject.first} ${r.subject.last}`.trim();
return push(await devicesFor([r.managerId], "waiting"), notifyText("waiting", { id: r.id, who, garments: names(r.lines) }));
}
async function ready(requestId: string): Promise<number> {
if (!pushConfigured()) return 0;
const r = await prisma.request.findUnique({ where: { id: requestId }, include: REQ });
if (!r) return 0;
// ⛔ The collection code is deliberately not in this body. See the header.
return push(await devicesFor([r.subjectId], "ready"), notifyText("ready", { id: r.id, code: r.code, garments: names(approvedLines(r.lines)) }));
}
async function onRound(requestId: string): Promise<number> {
if (!pushConfigured()) return 0;
const r = await prisma.request.findUnique({ where: { id: requestId }, include: REQ });
if (!r) return 0;
return push(await devicesFor([r.subjectId], "round"), notifyText("round", { id: r.id, code: r.code, ward: r.subject.dept }));
}
async function kitCheck(facilityId: string, dueBy: string): Promise<number> {
if (!pushConfigured()) return 0;
// The one send addressed to a room rather than to a person, so it is the one that has to be
// careful: only people actually holding something, and a count in the log — never a name and
// never a ward.
const holders = await prisma.issue.findMany({
where: { facilityId, returnedDate: null, handedIn: null },
select: { staffId: true },
distinct: ["staffId"],
});
const devices = await devicesFor(holders.map((h) => h.staffId), "kitcheck");
const n = await push(devices, notifyText("kitcheck", { dueBy: fmtDate(dueBy) }));
if (n) console.info(`[push] kit check: ${n} device${n === 1 ? "" : "s"} told`);
return n;
}
/* The fire-and-forget faces the ops call. Nothing waits on one, and nothing fails because of one. */
const fire = (p: Promise<number>) => { void p.catch((e) => console.warn("[push]", (e as Error).message)); };
/** Approved, partly approved or declined → the person it is for. */
export function notifyDecided(requestId: string): void { fire(decided(requestId)); }
/** A request landing on whoever it is addressed to. */
export function notifyWaiting(requestId: string): void { fire(waiting(requestId)); }
/** Held at the counter → the person it is for. No code in the text. */
export function notifyReady(requestId: string): void { fire(ready(requestId)); }
/** Out on the ward round → the person it is for. */
export function notifyOnRound(requestId: string): void { fire(onRound(requestId)); }
/** A kit check opened → everybody in the facility who is holding something. */
export function notifyKitCheck(facilityId: string, dueBy: string): void { fire(kitCheck(facilityId, dueBy)); }
/** The awaited forms, which answer with how many devices were written to. For tests and tooling
* only: an op that waited on a send would be an op that fails when Google is slow. */
export const pushSenders = { decided, waiting, ready, onRound, kitCheck };