ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
import { createHash, createHmac, timingSafeEqual } from "crypto";
/* The link a ward manager taps in an email to approve or decline, without signing in.
*
* Three properties matter, and each is bought a specific way.
*
* **It cannot be forged.** HMAC over the payload, with a key derived separately from the session
* and staff-session keys, so a valid approval link is not a valid anything else.
*
* **It cannot be used twice.** There is no table of spent tokens: the link is only honoured while
* the request is still `awaiting`, and approving or declining moves it. Both links in the same
* email therefore die together the moment either is used — which is exactly the behaviour you
* want when a manager taps Approve and then wonders about Decline.
*
* **It cannot be spent by a machine.** The link is a GET that renders a page; the decision is a
* POST from that page. This is not ceremony. Corporate mail scanners and link-preview crawlers
* fetch every URL in every message, and a GET that approved a uniform request would be approved
* by Outlook before the manager saw it.
*/
const TTL_MS = 14 * 24 * 60 * 60 * 1000; // a fortnight: leave covers most of it, and stale is safe here
function key() {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET not set");
return createHash("sha256").update("threadcount:approval:v1:" + s).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export type ApprovalClaim = { rid: string; mid: string };
export function signApprovalToken(requestId: string, managerStaffId: string): string {
const payload = b64url(Buffer.from(JSON.stringify({ rid: requestId, mid: managerStaffId, exp: Date.now() + TTL_MS })));
const sig = b64url(createHmac("sha256", key()).update(payload).digest());
return `${payload}.${sig}`;
}
export function readApprovalToken(raw: string | undefined): ApprovalClaim | null {
if (!raw) return null;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", key()).update(payload).digest());
const a = Buffer.from(sig), b = Buffer.from(expect);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const d = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
if (!d.rid || !d.mid || !d.exp || d.exp < Date.now()) return null;
return { rid: String(d.rid), mid: String(d.mid) };
} catch {
return null;
}
}
export function approvalUrl(token: string) {
const base = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
return `${base}/my/approve?t=${encodeURIComponent(token)}`;
}
/* ---------- the garments, as an email reads them ----------
*
* A request covers as many garments as the person needed, so every email about one has to list
* them rather than name a single item. Both emails below print the same block, and so do the
* "ready to collect" and "on the round" notes in lib/ops.ts, which is why it lives here and not
* inside one of them: a manager approving three garments and the wearer collecting them should be
* reading the same three lines.
*/
export type EmailLine = { qty: number; item: string; size: string; status?: string; declineReason?: string | null };
/** One garment to a line, indented so it sits in a plain-text email as its own block. A refused
* garment says so against itself — a bag that turns up two garments short with no explanation is
* exactly what this flow exists to prevent. */
export function garmentBlock(lines: readonly EmailLine[]): string {
return lines
.map((l) => {
const g = ` ${l.qty} × ${l.item} — size ${l.size}`;
return l.status === "declined" ? `${g} — declined: ${l.declineReason || "not approved"}` : g;
})
.join("\n");
}
/** The email a manager gets when one of their staff asks for uniform. */
export function approvalEmail(opts: {
managerFirst: string; subjectName: string; raisedByName?: string;
lines: readonly EmailLine[]; reason: string; note: string; url: string; facility: string;
}) {
/* Who actually asked. A manager, or the counter, raising on somebody's behalf used to be
* invisible here, so the approver read "Ali has asked for uniform" about a request Ali had never
* seen. The wearer is still named, because it is her allowance being spent; the raiser is named
* as well, because they are the one who can answer a question about it. */
const raisedBy = (opts.raisedByName || "").trim();
const subject = raisedBy ? `Uniform request for ${opts.subjectName}` : `Uniform request from ${opts.subjectName}`;
const asked = raisedBy
? `${raisedBy} has raised a uniform request for ${opts.subjectName}, and it needs your approval before the linen room can act on it.`
: `${opts.subjectName} has asked for uniform and needs your approval before the linen room can act on it.`;
/* Paragraphs, joined by blank lines — not a list of lines with blanks written in among them.
* These are plain-text emails with no HTML alternative, so the blank lines are the only
* formatting there is, and a filter that drops empty strings drops the spacing along with the
* optional lines it was aimed at. Written this way the optional slots are `null`, which cannot
* be confused with a separator. */
const detail = [
garmentBlock(opts.lines),
opts.reason ? ` Reason: ${opts.reason}` : null,
opts.note ? ` Note: ${opts.note}` : null,
].filter((l): l is string => l !== null).join("\n");
const text = [
`Hi ${opts.managerFirst || "there"},`,
asked,
detail,
`Approve or decline here:\n${opts.url}`,
"The link opens a page showing the request — nothing is decided until you choose. It works once.",
`${opts.facility} · ThreadCount`,
].join("\n\n");
return { subject, text };
}
/** Told to the staff member once their manager has decided.
*
* `approved` is the whole request's answer rather than one garment's: true when at least one line
* survived, which is the moment the linen room has a pick to do. `reason` is set only when a
* single reason covers the whole refusal — a partly approved request, or one refused for two
* different reasons, carries the reason against the garment it belongs to instead. */
export function decisionEmail(opts: {
staffFirst: string; managerName: string; approved: boolean; reason?: string;
lines: readonly EmailLine[]; facility: string;
}) {
const partly = opts.approved && opts.lines.some((l) => l.status === "declined");
const subject = partly
? "Part of your uniform request was approved"
: opts.approved ? "Your uniform request was approved" : "Your uniform request was declined";
const opening = partly
? `${opts.managerName} approved part of your request. The approved garments are with the linen room now; the rest are below, with the reason.`
: opts.approved
? `${opts.managerName} approved your request. It's with the linen room now.`
: opts.reason
? `${opts.managerName} declined your request — ${opts.reason.toLowerCase()}.`
: `${opts.managerName} declined your request. The reason against each garment is below.`;
// A single reason is stated once, in the sentence above, and not repeated against every garment:
// that reads like a form letter. Where the reasons differ, the block carries them.
const detail = opts.reason
? garmentBlock(opts.lines.map((l) => ({ qty: l.qty, item: l.item, size: l.size })))
: garmentBlock(opts.lines);
const text = [
`Hi ${opts.staffFirst || "there"},`,
opening,
detail,
opts.approved ? "You'll hear again when it's ready to collect or on its way to your ward." : null,
`${opts.facility} · ThreadCount`,
].filter((l): l is string => l !== null).join("\n\n");
return { subject, text };
}