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
+172
View File
@@ -0,0 +1,172 @@
"use client";
/* Analytics.
*
* Self-hosted Umami on ThreadCount's own infrastructure, served from analytics.threadcount.tech.
* Cookieless: it derives a rotating daily visitor hash server-side and stores nothing on the
* device, which is why there is no consent banner. Nothing leaves for a third party, no advertising
* network is involved, and the browser's Do Not Track setting is honoured.
*
* Two separate Umami sites, on purpose:
* marketing — the public pages anyone can read.
* app — the signed-in linen room, plus the Android shell (which loads /m from the site).
* They are different populations carrying very different privacy weight, and keeping them apart
* means the app's numbers can be reset or deleted without losing the marketing history.
*
* The hard rule here is that no record identifier ever reaches the analytics database. Paths carry
* staff, location and order ids — /m/person/<cuid>, /app/staff/<cuid> — so every path is scrubbed
* before it is sent, automatic page tracking is turned OFF so nothing is reported that hasn't been
* through `scrubPath`, and query strings are dropped whole rather than filtered. Event payloads
* are counts and fixed words only; never a name, a barcode, a facility or a free-text error. */
import { isNative } from "@/lib/nativescan";
/* `pulse.js`, not `script.js`: the shared Umami instance serves the tracker under both names
* (TRACKER_SCRIPT_NAME=script,pulse; posts go to /api/pulse) because the filter lists most
* ad-blockers ship match the default `script.js` + `/api/send` pair and silently drop those
* visitors. Same origin either way, so the CSP is unchanged. (2026-09-13) */
export const UMAMI_SRC = "https://analytics.threadcount.tech/pulse.js";
/** Umami website ids. Overridable, but committed so a fresh clone reports to the right place. */
export const MARKETING_ID = process.env.NEXT_PUBLIC_UMAMI_SITE_ID || "6dfdcd93-6931-4eda-bb0d-0a6278c27613";
export const APP_ID = process.env.NEXT_PUBLIC_UMAMI_APP_ID || "31c1f581-2bcc-48bb-a914-70da40c0359a";
type Payload = Record<string, unknown>;
type Umami = { track: (fn: (p: Payload) => Payload) => void };
declare global { interface Window { umami?: Umami } }
/* Which of the two Umami sites this page belongs to. Set by <Analytics>, read here so that the
app's payloads can be scrubbed harder than the public site's. */
let currentSite: "marketing" | "app" = "marketing";
export function setSite(site: "marketing" | "app") { currentSite = site; }
/** A cuid or a uuid — anything long enough to be a record id rather than a route name. */
const ID_LIKE = /^(?:[a-z0-9]{20,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
/** `/m/person/cmtpldt9f00iab` -> `/m/person/:id`. Query strings are dropped entirely. */
export function scrubPath(path: string): string {
const clean = (path || "/").split("?")[0].split("#")[0];
return clean
.split("/")
.map((seg) => (ID_LIKE.test(seg) ? ":id" : seg))
.join("/") || "/";
}
/** Referrers from our own site can carry ids too; outside referrers are the useful ones, kept whole. */
export function scrubReferrer(ref: string): string {
if (!ref) return "";
try {
const u = new URL(ref);
if (typeof location !== "undefined" && u.origin === location.origin) return scrubPath(u.pathname);
return u.origin + scrubPath(u.pathname);
} catch {
return "";
}
}
/** Android shell or a browser. Without this, Play installs and real usage can never be reconciled. */
export function surface(): "android" | "web" {
try { return isNative() ? "android" : "web"; } catch { return "web"; }
}
/* Umami's own `track(name, data)` helper composes `{...defaultPayload, name, data}`, and that
default payload carries the live `location.pathname + search`. Sending an event from
/m/person/<id> that way posts the staff id regardless of how carefully page views are scrubbed.
The function form hands us the whole payload to rewrite, so everything goes through this. */
function send(extra: Payload) {
const u = typeof window !== "undefined" ? window.umami : undefined;
if (!u) return;
try {
u.track((p: Payload) => {
const url = scrubPath(String(extra.url ?? (typeof location !== "undefined" ? location.pathname : "/")));
const out: Payload = {
...p,
...extra,
url,
referrer: scrubReferrer(String(p.referrer || (typeof document !== "undefined" ? document.referrer : "") || "")),
// The website id, every time. Umami's script initialises once per document and keeps the
// id of the FIRST tag that loaded it, so after a client-side hop from a public page into
// /app (the demo, the auth page's own navigation) every page view under /app went to the
// marketing website — 51 of them by 2026-09-13. Naming the site on the payload makes the
// destination follow the layout, not the load order.
website: currentSite === "app" ? APP_ID : MARKETING_ID,
};
// Page titles are free text. None of the app's carry a name today, but one added later
// would leak silently, so the app reports its path instead. The public site's titles are
// fixed marketing copy and are worth keeping.
if (currentSite === "app") out.title = url;
return out;
});
} catch { /* analytics must never break a page */ }
}
/** A scrubbed page view. Called on every route change; automatic tracking is off. */
export function pageview(path: string) {
send({ url: path });
}
/** A named event. `data` may hold counts and fixed words — never anything identifying. */
export function track(event: string, data?: Record<string, string | number | boolean>) {
send({ name: event, data: { ...data, surface: surface() } });
}
/* Which mutations are worth recording, and what they are called in the dashboard.
*
* A deliberate whitelist rather than every op: there are 57, and most are routine edits whose
* volume would say more about a facility's day than about whether ThreadCount works. Anything not
* named here sends nothing at all. */
export const TRACKED_OPS: Record<string, string> = {
"issue.create": "issue_created",
"issue.exchange": "size_exchanged",
"issue.return": "garment_returned",
"stocktake.apply": "count_committed",
"order.create": "reorder_created",
"order.receive": "delivery_received",
"pickup.pickedUp": "pickup_completed",
"barcode.bind": "barcode_bound",
"barcode.generate": "barcode_generated",
"catalog.removeSize": "size_removed",
"import.rows": "data_imported",
"location.save": "location_saved",
"users.add": "user_invited",
"settings.update": "settings_changed",
"me.deleteAccount": "account_deleted",
// The linen room's half of the staff-app flow (the staff half is below). Without these the
// dashboard could see a request raised and never see it fulfilled.
"request.pick": "request_picked",
"request.round": "request_sent_on_round",
"request.collected": "request_collected",
"request.raise": "request_raised_at_counter",
"approval.add": "approval_recorded",
// Adoption of the staff app starts here: a code handed to a wearer. Compare with staff_activated.
"staff.selfCode": "staff_code_issued",
"order.status": "order_status_changed",
"backup.restore": "backup_restored",
};
/* The staff app's own whitelist, kept separate from the coordinator one because the two surfaces
* answer different questions. This one is here to tell us whether the app is being used at all,
* or whether everything still goes through the counter. Same rule as above: a fixed list, and
* anything not named here sends nothing. */
export const TRACKED_STAFF_OPS: Record<string, string> = {
"request.create": "staff_request_raised",
"request.approve": "staff_request_approved",
"request.decline": "staff_request_declined",
"request.message": "staff_message_sent",
"damage.report": "staff_damage_reported",
"dispute.raise": "staff_record_queried",
"waitlist.join": "staff_waitlist_joined",
"waitlist.accept": "staff_waitlist_accepted",
"kit.answer": "staff_kit_answered",
"round.sign": "staff_round_signed",
};
/** Coarse buckets for a refusal. The server's message is never sent — only which kind it was. */
export function failureKind(error: string): string {
const e = (error || "").toLowerCase();
if (e.includes("needs a reason")) return "variance_reason_required";
if (e.includes("admin only")) return "not_permitted";
if (e.includes("not enough")) return "insufficient_stock";
if (e.includes("already")) return "already_done";
if (e.includes("unknown")) return "unknown_record";
if (e.includes("network")) return "network";
return "other";
}
+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 };
}
+117
View File
@@ -0,0 +1,117 @@
import { prisma } from "@/lib/db";
import type { SessionUser } from "@/lib/session";
import type { StaffSession } from "@/lib/staffsession";
/* The audit trail.
*
* One rule shapes this file: **record what was touched, never what it said.** A trail that quoted
* payloads would become a second copy of the staff register — names, phone numbers, payroll
* numbers — growing forever, outside every retention rule that governs the first copy, and dumped
* into every backup. So only record identifiers get through, and only ones from a fixed list.
*
* That still answers the question people actually ask, which is "who changed this record, and
* when", because the id points at the record whose current state you can go and look at.
*
* Three kinds of thing are recorded, and the op name says which:
* `issue.create` a coordinator's change, through /api/mutate
* `staff:damage.report` something a wearer, ward manager or ward desk did, through the staff app
* `auth:signin` getting in or out, and changes to a second factor
* The prefixes matter because the ids in the two columns come from different tables: `userId` on a
* `staff:` row is a Staff id, not a User id, and reading one as the other would name the wrong
* person in the one place that exists to name the right one.
*/
/** Payload keys allowed into `target`. Everything else — names, notes, reasons, barcodes, emails,
* quantities that could reconstruct a person's holdings — is dropped. */
const ID_KEYS = new Set([
"id", "itemId", "staffId", "orderId", "lineId", "locationId", "supplierId",
"deptId", "userId", "issueId", "pickupId", "stocktakeId", "si",
// Staff-app payloads name their own records too.
"subjectId", "photoId",
]);
/** Ops not worth a row. Everything else is recorded, because an audit trail with a curated view of
* what counts as important is one that has already lost the argument. */
const SKIP = new Set(["photo.put"]);
function safeTarget(payload: unknown): string {
if (!payload || typeof payload !== "object") return "";
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
if (!ID_KEYS.has(k)) continue;
if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
// Ids are cuids; anything longer is not an id and has no business here.
else if (typeof v === "string" && v.length <= 40) out[k] = v;
}
const s = JSON.stringify(out);
return s === "{}" ? "" : s.slice(0, 500);
}
export type Actor = { facilityId: string; userId: string; userName: string };
/**
* The one writer.
*
* Never throws and never blocks the caller's response: a failure to write history must not undo
* work that already succeeded, and a person shouldn't see an error because the log was busy.
*/
function write(actor: Actor, op: string, target: string, ip: string): void {
void prisma.auditEvent
.create({
data: {
facilityId: actor.facilityId,
userId: actor.userId,
// Denormalised on purpose: the trail has to still name the person after their account is
// deleted, and deleting an account is precisely the kind of event you look back at.
userName: actor.userName.slice(0, 120),
op: op.slice(0, 60),
target,
ip: ip.slice(0, 60),
},
})
.catch((e) => console.error("[audit] could not record", op, (e as Error).message));
}
/** Record one successful coordinator change. */
export function recordAudit(user: SessionUser, op: string, payload: unknown, ip: string): void {
if (SKIP.has(op)) return;
const name = [user.first, user.last].filter(Boolean).join(" ").trim() || user.email;
write({ facilityId: user.facilityId, userId: user.id, userName: name }, op, safeTarget(payload), ip);
}
/**
* Record one successful change made from the staff app — a wearer, a ward manager or the desk.
*
* `result` is folded in because most of these ops create something: the request, the damage report
* or the dispute exists only once the op has run, so its id is in the answer rather than in what
* was sent, and an id is the whole reason to have the row. Anything the payload already named wins,
* and only an id is taken from the result.
*/
export function recordStaffAudit(sess: StaffSession, op: string, payload: unknown, ip: string, result?: unknown): void {
const name = [sess.first, sess.last].filter(Boolean).join(" ").trim() || sess.email;
const made = result && typeof result === "object" ? (result as { id?: unknown }).id : undefined;
const sent = (payload && typeof payload === "object" ? payload : {}) as Record<string, unknown>;
const target = typeof sent.id === "string" || typeof made !== "string" ? sent : { ...sent, id: made };
write({ facilityId: sess.facilityId, userId: sess.staffId, userName: name }, `staff:${op}`, safeTarget(target), ip);
}
/**
* Record a change by someone the session machinery cannot name — the ward manager deciding from an
* emailed link has no session at all, only a signed token, and the person is looked up by hand.
*/
export function recordFor(actor: Actor, op: string, payload: unknown, ip: string): void {
write(actor, op, safeTarget(payload), ip);
}
/**
* Record something that happened to an account rather than to a record: a sign-in, a sign-out, a
* second factor turned on or off, a password set from a reset link.
*
* `detail` is a fixed word written in our own source — "recovery", "email-link" — never anything a
* caller typed, and it is filtered to letters, digits and dashes so that stays true even if someone
* later wires it to something they shouldn't.
*/
export function recordAuthEvent(actor: Actor, op: string, ip: string, detail = ""): void {
const d = detail.replace(/[^a-z0-9.-]/gi, "").slice(0, 24);
write(actor, op, d ? JSON.stringify({ how: d }) : "", ip);
}
+114
View File
@@ -0,0 +1,114 @@
// Barcode symbologies, rendered as SVG so a label prints crisply at any size and needs no font.
//
// A garment nobody can scan silently vanishes from every count, so a reprint has to reproduce the
// SAME symbol the supplier printed: a valid 13-digit code goes out as EAN-13 (what the supplier's
// own label was), anything else as Code 128 set B.
const EAN_A = ["0001101", "0011001", "0010011", "0111101", "0100011", "0110001", "0101111", "0111011", "0110111", "0001011"];
const EAN_B = ["0100111", "0110011", "0011011", "0100001", "0011101", "0111001", "0000101", "0010001", "0001001", "0010111"];
const EAN_C = ["1110010", "1100110", "1101100", "1000010", "1011100", "1001110", "1010000", "1000100", "1001000", "1110100"];
// Which of the first six digits use the B table, chosen by the leading digit.
const EAN_PARITY = ["AAAAAA", "AABABB", "AABBAB", "AABBBA", "ABAABB", "ABBAAB", "ABBBAA", "ABABAB", "ABABBA", "ABBABA"];
export function ean13CheckDigit(first12: string): number {
let sum = 0;
for (let i = 0; i < 12; i++) sum += +first12[i] * (i % 2 === 0 ? 1 : 3);
return (10 - (sum % 10)) % 10;
}
export function isEan13(code: string): boolean {
return /^\d{13}$/.test(code) && ean13CheckDigit(code.slice(0, 12)) === +code[12];
}
/* A barcode for a garment that arrived without one.
*
* Plenty of stock reaches a linen room unlabelled — the cafe shirts came with nothing at all — and
* a garment nobody can scan drops out of every count. So the room prints its own, and the number it
* prints has to be one that can never turn out to belong to somebody else's product.
*
* GS1 reserves the prefixes 20-29 for restricted circulation: codes used inside one business, on
* its own shelves, which GS1 undertakes never to issue to a manufacturer. That is exactly this
* case, so a generated code starts 29 and carries a real check digit. Two things follow from it
* being a genuine EAN-13 rather than an invented string: any scanner in the building reads it
* without being taught anything, and it is the width the label sheet was laid out around.
*
* The number says nothing about the garment, on purpose. Encoding the item and size would make the
* printed label wrong the moment a size is removed or a product renamed, and the binding in the
* database already knows what it points at. */
export function inHouseEan13(seq: number): string {
const body = "29" + String(Math.max(1, Math.floor(seq))).padStart(10, "0");
return body + ean13CheckDigit(body);
}
/** True for a code this facility printed itself, rather than one that came in on a garment. */
export const isInHouse = (code: string) => /^29\d{11}$/.test(code) && isEan13(code);
/** EAN-13 as a run of 1/0 modules, 95 wide. */
function ean13Bits(code: string): string {
const parity = EAN_PARITY[+code[0]];
let out = "101";
for (let i = 1; i <= 6; i++) out += (parity[i - 1] === "A" ? EAN_A : EAN_B)[+code[i]];
out += "01010";
for (let i = 7; i <= 12; i++) out += EAN_C[+code[i]];
return out + "101";
}
// Code 128: 107 symbols of 11 modules each, given as bar/space run lengths.
const C128 = ["212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312", "132212", "221213", "221312", "231212", "112232", "122132", "122231", "113222", "123122", "123221", "223211", "221132", "221231", "213212", "223112", "312131", "311222", "321122", "321221", "312212", "322112", "322211", "212123", "212321", "232121", "111323", "131123", "131321", "112313", "132113", "132311", "211313", "231113", "231311", "112133", "112331", "132131", "113123", "113321", "133121", "313121", "211331", "231131", "213113", "213311", "213131", "311123", "311321", "331121", "312113", "312311", "332111", "314111", "221411", "431111", "111224", "111422", "121124", "121421", "141122", "141221", "112214", "112412", "122114", "122411", "142112", "142211", "241211", "221114", "413111", "241112", "134111", "111242", "121142", "121241", "114212", "124112", "124211", "411212", "421112", "421211", "212141", "214121", "412121", "111143", "111341", "131141", "114113", "114311", "411113", "411311", "113141", "114131", "311141", "411131", "211412", "211214", "211232", "233111"];
const C128_STOP = "2331112";
/** Symbol values for a Code 128 set B string, START B first, check symbol last. */
export function code128Values(text: string): number[] {
const vals = [104]; // START B
for (const ch of text) {
const c = ch.charCodeAt(0);
vals.push(c >= 32 && c <= 127 ? c - 32 : 0);
}
let sum = vals[0];
for (let i = 1; i < vals.length; i++) sum += vals[i] * i;
vals.push(sum % 103); // check symbol
return vals;
}
/** Code 128 set B as a run of 1/0 modules. Set B covers ASCII 32127, which is every code we bind. */
function code128Bits(text: string): string {
const vals = code128Values(text);
let bits = "";
for (const v of vals) {
let bar = true;
for (const run of C128[v]) { bits += (bar ? "1" : "0").repeat(+run); bar = !bar; }
}
let bar = true;
for (const run of C128_STOP) { bits += (bar ? "1" : "0").repeat(+run); bar = !bar; }
return bits;
}
const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] as string));
/** Bars plus the human-readable code beneath, as a self-contained SVG string. */
export function barcodeSvg(code: string, opts: { height?: number; module?: number; quiet?: number; text?: boolean } = {}): string {
const clean = String(code || "").trim();
if (!clean) return "";
const ean = isEan13(clean);
const bits = ean ? ean13Bits(clean) : code128Bits(clean);
const m = opts.module ?? 2;
const h = opts.height ?? 54;
const quiet = opts.quiet ?? 10;
const showText = opts.text !== false;
const w = bits.length * m + quiet * 2 * m;
const textH = showText ? 16 : 0;
const rects: string[] = [];
let i = 0;
while (i < bits.length) {
if (bits[i] === "0") { i++; continue; }
let run = 0;
while (i + run < bits.length && bits[i + run] === "1") run++;
rects.push(`<rect x="${(quiet + i) * m}" y="0" width="${run * m}" height="${h}" fill="#201e1d"/>`);
i += run;
}
const label = showText
? `<text x="${w / 2}" y="${h + 13}" text-anchor="middle" font-family="Archivo, system-ui, sans-serif" font-size="12" font-weight="600" letter-spacing="1.5" fill="#201e1d">${esc(clean)}</text>`
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + textH}" viewBox="0 0 ${w} ${h + textH}" role="img" aria-label="Barcode ${esc(clean)}"><rect width="${w}" height="${h + textH}" fill="#ffffff"/>${rects.join("")}${label}</svg>`;
}
export const barcodeKind = (code: string) => (isEan13(code) ? "EAN-13" : "Code 128");
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { itemMap, ledger, staffMap, variantList, type Snapshot } from "./compute";
import { TRACKED_OPS, failureKind, track } from "@/lib/analytics";
import { noteRev, useLiveRefresh } from "@/lib/live";
type Ctx = {
s: Snapshot;
isAdmin: boolean;
busy: boolean;
refresh: () => void;
mutate: <T = unknown>(op: string, payload?: unknown) => Promise<{ ok: true; result: T } | { ok: false; error: string }>;
};
const SnapshotContext = createContext<Ctx | null>(null);
export function SnapshotProvider({ snap, children }: { snap: Snapshot; children: React.ReactNode }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [inflight, setInflight] = useState(0);
const refresh = useCallback(() => startTransition(() => router.refresh()), [router]);
useLiveRefresh(refresh);
const mutate = useCallback(async <T,>(op: string, payload?: unknown) => {
setInflight((n) => n + 1);
try {
const r = await fetch("/api/mutate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op, payload }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
/* A dead session, sent back to the door this person actually came in by.
*
* This provider is shared by the desktop app and the counter app, and pushing everyone at
* /auth landed a nurse holding a phone on the website's sign-in pane — which also offers to
* create a facility — and then, after signing in, on /app rather than back where they were.
* The server guard and the /m layout both already send /m to /m/login; this was the one path
* that disagreed. A full navigation rather than a router push: the cookie is gone and every
* page behind it is server-rendered, so there is nothing left in the client tree worth
* keeping. */
if (r.status === 401) {
const p = typeof location === "undefined" ? "" : location.pathname + location.search;
const back = encodeURIComponent(p || "/app");
window.location.assign(p.startsWith("/m") ? `/m/login?next=${back}` : `/auth?next=${back}`);
}
const err = (j && j.error) || "Request failed";
// Refusals are worth counting — a facility repeatedly blocked by the variance gate is
// telling us something. Only the op name and a coarse category go out, never the message.
if (TRACKED_OPS[op]) track("action_refused", { action: TRACKED_OPS[op], reason: failureKind(String(err)) });
return { ok: false as const, error: err };
}
// The revision this write produced, so the live poll recognises it as ours and does not
// refresh the screen a second time a few seconds from now.
noteRev(j.rev);
startTransition(() => router.refresh());
// One event per whitelisted action. Everything else in ops.ts sends nothing at all.
if (TRACKED_OPS[op]) track(TRACKED_OPS[op]);
return { ok: true as const, result: j.result as T };
} catch {
if (TRACKED_OPS[op]) track("action_refused", { action: TRACKED_OPS[op], reason: "network" });
/* Not "nothing was saved", which we cannot know.
*
* fetch rejects when the answer never arrives, and the request may well have reached the
* server and committed before the wifi dropped — issuing deducts stock, a request emails a
* manager. None of the ops carry an idempotency key, so a retry invited by a flat "nothing was
* saved" writes the whole thing a second time. Telling the truth costs one extra glance at the
* record and is the only advice that cannot make it worse. */
return { ok: false as const, error: "The connection dropped before we heard back, so we cant say whether that saved. Check the record before trying again." };
} finally {
setInflight((n) => n - 1);
}
}, [router]);
const value = useMemo<Ctx>(() => ({ s: snap, isAdmin: snap.session.role === "Admin", busy: pending || inflight > 0, refresh, mutate }), [snap, pending, inflight, refresh, mutate]);
return <SnapshotContext.Provider value={value}>{children}</SnapshotContext.Provider>;
}
export function useSnap() {
const c = useContext(SnapshotContext);
if (!c) throw new Error("useSnap outside provider");
return c;
}
export function useDerived() {
const { s } = useSnap();
return useMemo(() => ({ L: ledger(s), byId: itemMap(s), staffById: staffMap(s), variants: variantList(s) }), [s]);
}
+1236
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
import type { NextRequest } from "next/server";
// State-changing routes only accept same-origin requests carrying JSON. Browsers send Sec-Fetch-Site on every
// request and Origin on cross-origin POSTs, so a cross-site <form> (even text/plain) can't reach them.
export function sameOriginJson(req: NextRequest, json = true): string | null {
const sfs = req.headers.get("sec-fetch-site");
if (sfs && sfs !== "same-origin" && sfs !== "none") return "Cross-site request refused";
const origin = req.headers.get("origin");
if (origin) {
const host = req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
try { if (new URL(origin).host !== host) return "Cross-site request refused"; } catch { return "Bad origin"; }
}
if (json && !(req.headers.get("content-type") || "").toLowerCase().includes("application/json")) return "Expected JSON";
return null;
}
+79
View File
@@ -0,0 +1,79 @@
// Small RFC-4180-ish CSV parser (handles quotes, CRLF, BOM). Returns rows as objects keyed by header.
// Throws rather than hand back a file it only half-understood. A register that half-imports is worse
// than one that is refused: rows after a bad quote fold into their neighbour and vanish, and the
// admin is told "379 created" with no errors, so nobody notices the twenty nurses who can no longer
// be issued a uniform at the counter or raise a request in the staff app.
/** Undo the apostrophe csvEsc adds when a cell would otherwise read as a spreadsheet formula.
*
* Exports prefix a cell starting `= + - @` or a space with `'` so that opening the file does not
* execute it. That apostrophe is the spreadsheet's own convention for "treat this as text" and is
* not part of the value, so a file exported from here and imported straight back used to gain one:
* a phone number written `+61 7 ...` came home as `'+61 7 ...`. Only the exact shape the guard
* produces is removed, so a value that genuinely begins with an apostrophe is left alone. */
export function unguard(cell: string): string {
return /^'[\s=+\-@]/.test(cell) ? cell.slice(1) : cell;
}
export function parseCsv(text: string): Record<string, string>[] {
const src = text.replace(/^/, "");
const rows: string[][] = [];
// The line each row started on, so a refusal points at the row in the admin's spreadsheet rather
// than at a number we invented after the blank lines were dropped.
const rowLine: number[] = [];
let row: string[] = [], cell = "", inQ = false;
// A double-quote only opens a quoted field at the very start of a cell. Anywhere else it is just a
// character — an inch mark in a size or notes cell, a nickname in quotes — and treating it as an
// opening quote is what used to eat every comma and line break for the rest of the file.
let fresh = true;
// Line of the quote we are currently inside, so a refusal can point the admin at the bad row.
let line = 1, quoteLine = 0, start = 1;
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (inQ) {
if (c === '"') { if (src[i + 1] === '"') { cell += '"'; i++; } else inQ = false; }
else { if (c === "\n") line++; cell += c; }
} else if (c === '"' && fresh) { inQ = true; fresh = false; quoteLine = line; }
else if (c === ",") { row.push(cell); cell = ""; fresh = true; }
else if (c === "\n" || c === "\r") { if (c === "\r" && src[i + 1] === "\n") i++; row.push(cell); rows.push(row); rowLine.push(start); row = []; cell = ""; fresh = true; line++; start = line; }
else { cell += c; fresh = false; }
}
if (inQ) throw new Error(`Line ${quoteLine}: a quote is opened and never closed, so every row after it would be read as part of this one. Nothing was imported — fix the quoting in the file and import again.`);
if (cell !== "" || row.length) { row.push(cell); rows.push(row); rowLine.push(start); }
const kept = rows.map((r, i) => ({ cells: r, line: rowLine[i] })).filter((r) => r.cells.some((x) => x.trim() !== ""));
if (!kept.length) return [];
const headers = kept[0].cells.map((h) => h.trim());
/* The unclosed-quote refusal above only fires when the quotes never rebalance before the end of
the file. A stray quote at the start of a cell, in a file that has other properly quoted fields,
rebalances at the next one and does its damage quietly: everything between the two — commas,
line breaks, whole rows of staff — is swallowed into a single cell, and those people are simply
not on the register afterwards. The row shapes are what give it away: a swallowed row leaves its
neighbour the wrong width, so the whole file is turned away by line number rather than
part-loaded. Not a proof of correctness — a stray quote that reopens in the same column it
closed in lands on a row of the right width — but it catches the shapes a real file arrives in. */
const ragged = kept.slice(1).filter((r) => r.cells.length !== headers.length);
if (ragged.length) {
const lines = ragged.map((r) => r.line);
const shown = lines.slice(0, 5).join(", ") + (lines.length > 5 ? ` and ${lines.length - 5} more` : "");
throw new Error(`Line${lines.length === 1 ? "" : "s"} ${shown}: the wrong number of columns — line ${ragged[0].line} has ${ragged[0].cells.length} where the header row has ${headers.length}. This is usually a stray " at the start of a cell, which swallows the commas and line breaks after it until the next quote, so the rows in between disappear into it. Nothing was imported — fix the file and import again.`);
}
// unguard() here rather than in each importer: the apostrophe is an artefact of how the file was
// written, so it should be gone before anybody reads a value out of it.
return kept.slice(1).map((r) => { const o: Record<string, string> = {}; headers.forEach((h, i) => { o[h] = unguard((r.cells[i] ?? "").trim()); }); return o; });
}
export const CSV_TEMPLATES: Record<string, { name: string; headers: string; example: string; note: string }> = {
catalog: { name: "Catalogue", headers: "item,gender,sku,supplier,cost,group,sizes,notes", example: `"RN Active Scrub Top",Unisex,NL-RN-TOP-01,"Northline Workwear",30.75,"Registered Nurse|Enrolled Nurse","2XS|XS|S|M|L|XL|2XL|3XL|4XL",`, note: "sizes separated by | (or , inside quotes). group may list several staff groups separated by |; All means every group. Gender: Men's / Women's / Unisex. Re-importing the same item+gender+SKU updates cost and adds new sizes." },
// fte is listed because the importer has always read it and the template never offered it: a
// coordinator loading a roster of a couple of hundred nurses filled in the columns the template
// named, imported a register with no FTE on a single row, and then had to set every one of them
// by hand on the profile before anybody could be issued their first kit.
//
// The note is read on the same Settings screen that sets the ceiling, and it used to tell a
// coordinator nursing had no limit at all. So it says what ent still is — a yearly report figure —
// and that it turns nobody away.
staff: { name: "Staff register", headers: "num,first,last,phone,group,dept,cc,manager,fte,style,top,pants,ccoverride,ent,start,notes", example: `00100234,Mara,Whitfield,"0400 111 222","Registered Nurse","Willow Ward",RGH-3010,00100199,0.8,Women's,M,12,,,2024-03-11,`, note: "num is the staff/payroll number and must be unique. style is the cut of uniform this person is offered — Men's, Women's, or Either for both — and blank means every style, as every record reads today until somebody sets it. manager is the staff number (not the name) of the person who approves their requests — nobody can raise a request in the staff app until it is set, and managers can appear anywhere in the file. fte is the combined FTE — 1.0 down to 0.1, or Casual — and is what the starting kit is worked out from for groups on the FTE table: such a row without one proposes no kit. dept + cc creates the department if it doesn't exist. start is a date, written as YYYY-MM-DD. ent is this person's own figure for the yearly report — what their drawing since 1 July is measured against — and blank uses the facility default; groups on the FTE table aren't measured against one. It limits nothing at the counter: the ceiling on what anyone holds is set under Settings → General, the same for every group." },
depts: { name: "Departments & cost centres", headers: "dept,cc", example: `"Emergency Department",RGH-4040`, note: "One row per department/ward with its cost centre code." },
barcodes: { name: "Supplier barcodes", headers: "sku,gender,item,size,barcode", example: `NL-RN-TOP-01,Unisex,"RN Active Scrub Top",M,9357732548036`, note: "Match the catalogue row by sku (+ gender/item if a SKU is shared), then size. EAN-13 or any scannable code." },
reorder: { name: "Reorder levels", headers: "sku,gender,item,size,reorder", example: `NL-RN-TOP-01,Unisex,"RN Active Scrub Top",M,3`, note: "Sets the per-size reorder level (overwrites). Sizes without one use the facility default." },
opening: { name: "Opening balances", headers: "sku,gender,item,size,opening,reorder", example: `NL-RN-TOP-01,Unisex,"RN Active Scrub Top",M,12,3`, note: "Sets the opening count per size (overwrites). reorder is optional and sets the per-line reorder level." },
};
+107
View File
@@ -0,0 +1,107 @@
import { prisma } from "./db";
import { availability } from "./staffdata";
import { facilityDate } from "./compute";
import { holdEndsAt, holdExpired } from "./staffreq";
import type { StaffSession } from "./staffsession";
/* The two periodic surfaces: the kit check cycle and the waitlist. */
/** 2A. Null when there is no cycle open — the screen shouldn't exist between rounds. */
export async function kitCheckData(sess: StaffSession) {
const cycle = await prisma.kitCheck.findFirst({
where: { facilityId: sess.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true, facility: { select: { timezone: true } } },
});
if (!cycle) return null;
const [issues, answers, previous] = await Promise.all([
// handedIn, not just returnedDate: a garment handed back at the counter joins the pre-loved
// pool without ever being marked returned, and asking someone to confirm they still hold it
// is how a kit check teaches people the record is wrong.
prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
select: { qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
}),
prisma.kitCheckAnswer.findMany({
where: { kitCheckId: cycle.id, staffId: sess.staffId },
select: { itemId: true, sizeIndex: true, confirmed: true },
}),
// "Last confirmed" comes from the previous cycle they actually answered, not from the cycle
// before this one — somebody who missed the last round should be told the truth.
prisma.kitCheckAnswer.findFirst({
where: { staffId: sess.staffId, kitCheckId: { not: cycle.id } },
orderBy: { answeredAt: "desc" },
select: { answeredAt: true },
}),
]);
const byKey = new Map<string, { itemId: string; item: string; size: string; si: number; onRecord: number }>();
for (const i of issues) {
const k = `${i.item.id}:${i.sizeIndex}`;
const cur = byKey.get(k) || { itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, onRecord: 0 };
cur.onRecord += i.qty;
byKey.set(k, cur);
}
const answered = new Map(answers.map((a) => [`${a.itemId}:${a.sizeIndex}`, a.confirmed]));
return {
dueBy: cycle.dueBy,
lastConfirmed: previous ? facilityDate(previous.answeredAt, cycle.facility.timezone) : "",
rows: [...byKey.values()]
.sort((a, b) => a.item.localeCompare(b.item) || a.size.localeCompare(b.size))
.map((r) => ({ ...r, answered: answered.has(`${r.itemId}:${r.si}`) ? answered.get(`${r.itemId}:${r.si}`)! : null })),
};
}
/** 2B. Position is FIFO on join time, counting only people still waiting. */
export async function waitlistData(sess: StaffSession, itemId: string, si: number) {
const item = await prisma.catalogItem.findFirst({
where: { id: itemId, facilityId: sess.facilityId },
select: { id: true, item: true, sizes: true },
});
if (!item || si < 0 || si >= item.sizes.length) return null;
const [queue, mine, avail] = await Promise.all([
prisma.waitlistEntry.findMany({
where: { facilityId: sess.facilityId, itemId, sizeIndex: si, leftAt: null, acceptedAt: null },
orderBy: { createdAt: "asc" },
select: { id: true, staffId: true, createdAt: true, offeredAt: true },
}),
prisma.waitlistEntry.findFirst({ where: { staffId: sess.staffId, itemId, sizeIndex: si, leftAt: null }, select: { id: true, offeredAt: true, acceptedAt: true } }),
availability(sess.facilityId, [itemId]),
]);
const sizes = avail[itemId] || [];
const mineIndex = queue.findIndex((q) => q.staffId === sess.staffId);
const joined = mineIndex >= 0;
return {
itemId: item.id,
item: item.item,
size: String(item.sizes[si]),
si,
lastRestocked: sizes.find((s) => s.si === si)?.countedOn || "",
// Already on the list: their real place. Not on it: the place they would take.
position: joined ? mineIndex + 1 : queue.length + 1,
ahead: joined ? mineIndex : queue.length,
joined,
entryId: mine?.id ?? null,
offeredAt: mine?.offeredAt?.toISOString() ?? null,
// The screen and the offer email both promise the garment is held for 48 hours, so the
// deadline is computed rather than implied, and the screen is told when it has passed —
// an offer bar that can no longer be accepted is worse than none.
holdUntil: holdEndsAt(mine?.offeredAt ?? null)?.toISOString() ?? null,
offerExpired: holdExpired(mine?.offeredAt ?? null),
// Accepting raises a request and the entry stays put, so without this the screen keeps
// offering "Accept it" for something already accepted and every tap is refused.
acceptedAt: mine?.acceptedAt?.toISOString() ?? null,
// Nearest sizes either way that are actually on the shelf — most people would rather have
// something that fits approximately today.
alternatives: sizes
.filter((s) => s.si !== si && s.word !== "none")
.sort((a, b) => Math.abs(a.si - si) - Math.abs(b.si - si))
.slice(0, 3)
.map((s) => ({ si: s.si, size: s.size, word: s.word })),
};
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
function makeClient() {
const max = parseInt(process.env.DB_POOL_MAX || "", 10);
// DB_POOL_MAX=1 is only for the local `prisma dev` embedded server, which can't handle concurrent queries. Never set it in production.
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, ...(Number.isFinite(max) && max > 0 ? { max } : {}) });
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
}
export const prisma = globalForPrisma.prisma ?? makeClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+267
View File
@@ -0,0 +1,267 @@
// Public working demo: one fictional facility ("Riverside General Hospital") that anyone can enter
// as Admin or Issuer without signing up. All visitors share it; a timer resets it every 20 minutes.
// Everything is seeded through the real ops layer so the ledger, drafts, pickups and reports are
// exactly what the app would have produced. Nothing here is real facility data.
import { randomBytes } from "crypto";
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import { runOp } from "./ops";
import type { SessionUser } from "./session";
import { addDays, facilityToday } from "./compute";
import { deletePhotoDir } from "./photostore";
export const DEMO_FACILITY = "Riverside General Hospital";
export const DEMO_RESET_MINUTES = 20;
/** The demo room stands somewhere: the seeded dates are the ones this zone calls today. */
const DEMO_TZ = "Australia/Brisbane";
const ADMIN_EMAIL = "demo-admin@threadcount.tech";
const ISSUER_EMAIL = "demo-issuer@threadcount.tech";
const TOPS = "XS|S|M|L|XL|2XL|3XL";
const PANTS = "XS|S|M|L|XL|2XL|3XL";
const TROUSERS = "77R|82R|87R|92R|97R|102R|107R";
const NL = "Northline Workwear", HE = "Harbour Embroidery";
// The demo's staff groups, spread over the three routes a facility chooses between so that a
// visitor meets all three — on the register, at the counter and on the printed order form: the
// nurses on the FTE table, Support Services on the starting kit, Kitchen and Security on manager
// approval. Plain names any Australian employer would recognise, because this is the facility every
// prospect walks into, and it must not read as any one hospital's organisation chart.
const FTE_GROUPS = ["Registered Nurse", "Enrolled Nurse"];
const KIT_GROUPS = ["Support Services"];
const APPROVAL_GROUPS = ["Kitchen", "Security"];
// sku, item, gender, supplier, cost, groups, sizes. Groups go in through the CSV importer, so several
// are separated by | exactly as a coordinator's file writes them: the scrub pant is worn by both
// nursing groups, the vest by Support Services and Security, and "All" is every group.
//
// All three cuts are stocked, because the uniform-style rule is invisible in a catalogue that is
// entirely unisex: the scrub top and the softshell come in a women's cut as well, the security
// shirt and trouser are the men's, and everything else is worn by anybody. So a visitor who opens
// the counter for a nurse set to Men's sees the men's and unisex garments and not the women's cut,
// and a visitor who blanks that nurse's style sees the whole catalogue again.
const CATALOG: [string, string, string, string, number, string, string][] = [
["RN-TOP", "RN Active Scrub Top", "Unisex", NL, 31.5, "Registered Nurse", TOPS],
["RN-PANT", "Elastic Waist Scrub Pant", "Unisex", NL, 28.0, "Registered Nurse|Enrolled Nurse", PANTS],
["EN-TOP", "EN Scrub Top", "Unisex", NL, 29.5, "Enrolled Nurse", TOPS],
["EN-PANT", "EN Scrub Pant", "Unisex", NL, 27.0, "Enrolled Nurse", PANTS],
["SS-POLO", "Support Services Polo", "Unisex", HE, 22.5, "Support Services", TOPS],
["SS-CARGO", "Support Services Cargo Pant", "Unisex", NL, 34.0, "Support Services", TROUSERS],
["SS-VEST", "Hi-Vis Safety Vest", "Unisex", HE, 16.5, "Support Services|Security", TOPS],
["SEC-SHIRT", "Security Shirt", "Male", HE, 39.0, "Security", TOPS],
["SEC-PANT", "Security Trouser", "Male", NL, 42.0, "Security", TROUSERS],
["KIT-JKT", "Chef Jacket", "Unisex", NL, 36.0, "Kitchen", TOPS],
["KIT-PANT", "Kitchen Check Pant", "Unisex", NL, 29.0, "Kitchen", PANTS],
["ALL-JKT", "Softshell Jacket", "Unisex", HE, 58.0, "All", TOPS],
["ALL-CARD", "Knit Cardigan", "Unisex", HE, 44.0, "All", TOPS],
["RN-TOP-W", "RN Active Scrub Top", "Female", NL, 31.5, "Registered Nurse", TOPS],
["ALL-JKT-W", "Softshell Jacket", "Female", HE, 58.0, "All", TOPS],
];
const DEPTS: [string, string][] = [
["Willow Ward", "RGH-3010"], ["Alder Ward", "RGH-3020"], ["ICU", "RGH-4010"], ["Emergency", "RGH-4020"],
["Theatres", "RGH-4030"], ["Support Services", "RGH-5010"], ["Security", "RGH-5020"], ["Kitchen", "RGH-5030"],
];
// num, first, last, group, dept, top, pants, phone, fte, uniform style. The nurses carry an FTE
// because on the FTE table it is what proposes their starting kit — without one, every nurse in the
// demo would read "no FTE recorded" and the route would have nothing to show. The other two routes
// never read it.
//
// The last column is the cut of uniform each one is offered, and the demo carries all four states a
// register holds: most people set to Men's or Women's, Thanh and Sam on Either because they wear
// whichever fits, and Jordan and Zoe left blank — nobody has said, which offers them everything and
// is what every row on a real register reads until a coordinator goes through it. The three
// security officers are on the men's cut because the shirt and trouser their group wears are only
// made in it; a women's-cut security shirt is the sort of gap this column is meant to expose.
const STAFF: [string, string, string, string, string, string, string, string, string, string][] = [
["100231", "Maya", "Whitfield", "Registered Nurse", "Willow Ward", "M", "M", "0400 111 201", "1.0", "Women's"],
["100244", "Kofi", "Osei", "Registered Nurse", "ICU", "L", "L", "0400 111 202", "0.8", "Men's"],
["100251", "Thanh", "Nguyen", "Enrolled Nurse", "Alder Ward", "L", "L", "0400 111 203", "1.0", "Either"],
["100263", "Riya", "Patel", "Support Services", "Support Services", "S", "87R", "0400 111 204", "", "Women's"],
["100270", "Liam", "Cartwright", "Security", "Security", "XL", "97R", "0400 111 205", "", "Men's"],
["100288", "Sofia", "Marino", "Registered Nurse", "Emergency", "S", "S", "0400 111 206", "1.0", "Women's"],
["100293", "Jordan", "Blake", "Support Services", "Willow Ward", "M", "87R", "0400 111 207", "", ""],
["100301", "Aisha", "Rahman", "Registered Nurse", "Theatres", "M", "M", "0400 111 208", "0.6", "Women's"],
["100315", "Ethan", "Kowalski", "Kitchen", "Kitchen", "L", "L", "0400 111 209", "", "Men's"],
["100322", "Grace", "O'Neill", "Enrolled Nurse", "ICU", "M", "M", "0400 111 210", "0.8", "Women's"],
["100337", "Daniel", "Park", "Support Services", "Support Services", "L", "92R", "0400 111 211", "", "Men's"],
["100349", "Priya", "Sharma", "Registered Nurse", "Alder Ward", "XS", "XS", "0400 111 212", "0.6", "Women's"],
["100356", "Marcus", "Reid", "Security", "Security", "2XL", "102R", "0400 111 213", "", "Men's"],
["100361", "Hannah", "Lindqvist", "Support Services", "Emergency", "S", "82R", "0400 111 214", "", "Women's"],
["100378", "Yusuf", "Demir", "Kitchen", "Kitchen", "M", "M", "0400 111 215", "", "Men's"],
["100384", "Chloe", "Bennett", "Registered Nurse", "ICU", "M", "M", "0400 111 216", "1.0", "Women's"],
["100392", "Noah", "Fitzgerald", "Enrolled Nurse", "Theatres", "L", "L", "0400 111 217", "0.5", "Men's"],
["100405", "Isabella", "Russo", "Registered Nurse", "Willow Ward", "S", "S", "0400 111 218", "0.9", "Women's"],
["100417", "Sam", "Taylor", "Support Services", "Support Services", "M", "87R", "0400 111 219", "", "Either"],
["100423", "Amara", "Okafor", "Support Services", "Alder Ward", "L", "92R", "0400 111 220", "", "Women's"],
["100438", "Lucas", "Moreau", "Security", "Security", "L", "92R", "0400 111 221", "", "Men's"],
["100446", "Zoe", "Campbell", "Registered Nurse", "Emergency", "M", "M", "0400 111 222", "0.5", ""],
["100459", "Oliver", "Hughes", "Kitchen", "Kitchen", "XL", "XL", "0400 111 223", "", "Men's"],
["100467", "Mei", "Tanaka", "Enrolled Nurse", "Willow Ward", "S", "S", "0400 111 224", "0.4", "Women's"],
];
function sizesOf(sku: string) { return CATALOG.find((c) => c[0] === sku)![6].split("|"); }
function si(sku: string, size: string) { return sizesOf(sku).indexOf(size); }
function topSku(group: string) { return ({ "Registered Nurse": "RN-TOP", "Enrolled Nurse": "EN-TOP", "Support Services": "SS-POLO", Security: "SEC-SHIRT", Kitchen: "KIT-JKT" } as Record<string, string>)[group]; }
function pantSku(group: string) { return ({ "Registered Nurse": "RN-PANT", "Enrolled Nurse": "EN-PANT", "Support Services": "SS-CARGO", Security: "SEC-PANT", Kitchen: "KIT-PANT" } as Record<string, string>)[group]; }
export async function findDemoFacility() {
return prisma.facility.findFirst({ where: { isDemo: true }, include: { users: true } });
}
/** Drop and rebuild the demo facility. Safe to call at any time; visitors mid-session simply see fresh data. */
export async function resetDemo() {
// The images go with the rows. Visitors sign for deliveries in the demo like anyone else, and a
// facility that is rebuilt three times an hour would otherwise leave a new orphaned directory of
// anonymous uploads on disk every twenty minutes, with nothing left in the database to name them.
const old = await prisma.facility.findMany({ where: { isDemo: true }, select: { id: true } });
await prisma.facility.deleteMany({ where: { isDemo: true } }); // every relation cascades from Facility
for (const o of old) await deletePhotoDir(o.id);
const today = facilityToday(DEMO_TZ);
// The groups and both route lists are written here, on a row built fresh every reset, rather than
// patched onto the old one: a visitor who moved a group between routes in the last twenty minutes
// leaves nothing behind, and a demo still carrying groups from before these were changed takes
// the new ones at its next reset.
const fac = await prisma.facility.create({ data: {
name: DEMO_FACILITY, location: "Linen Room, Level B1", coordinator: "Alex Demo", timezone: DEMO_TZ,
staffGroups: [...FTE_GROUPS, ...KIT_GROUPS, ...APPROVAL_GROUPS], nursingGroups: FTE_GROUPS, kitGroups: KIT_GROUPS,
glAccount: "631020", journalDesc: "Uniform issues", exceptionHigh: 8, slipOrg: "Riverside Health", isDemo: true, demoResetAt: null, // stamped when seeding completes, so a half-built facility is always rebuilt
lastBackup: addDays(today, -9),
} });
const pw = async () => bcrypt.hash(randomBytes(24).toString("hex"), 8); // nobody logs in with these — entry is via /api/auth/demo
const adminU = await prisma.user.create({ data: { facilityId: fac.id, email: ADMIN_EMAIL, passwordHash: await pw(), first: "Alex", last: "Demo", title: "Uniform Coordinator", role: "ADMIN" } });
await prisma.user.create({ data: { facilityId: fac.id, email: ISSUER_EMAIL, passwordHash: await pw(), first: "Sam", last: "Demo", title: "Linen Room Assistant", role: "ISSUER" } });
const me: SessionUser = { id: adminU.id, facilityId: fac.id, email: adminU.email, first: adminU.first, last: adminU.last, title: adminU.title, role: "ADMIN", isDemo: true, viaSso: false };
const op = (name: string, payload: unknown) => runOp(me, name, payload);
for (const [name, cc] of DEPTS) await op("dept.save", { name, cc });
for (const [name, contact, phone, account, lead] of [[NL, "Dana Whitlock", "1300 555 010", "RGH-4471", 10], [HE, "Priya Nair", "07 3555 0190", "RGH-EMB-22", 21]] as const) {
const r = (await op("supplier.add", { name })) as { id: string };
await op("supplier.update", { id: r.id, contact, phone, account, lead: String(lead) });
}
await op("import.rows", { kind: "catalog", rows: CATALOG.map(([sku, item, gender, supplier, cost, group, sizes]) => ({ sku, item, gender, supplier, cost: String(cost), group, sizes })) });
// The style column goes in through the importer like every other one — a register arrives as a
// file, and the demo is seeded the way a real facility is loaded.
await op("import.rows", { kind: "staff", rows: STAFF.map(([num, first, last, group, dept, top, pants, phone, fte, style]) => ({ num, first, last, group, dept, top, pants, phone, fte, style, start: addDays(today, -(200 + (parseInt(num, 10) % 900))) })) });
// Opening stock: a healthy shelf, with a few sizes deliberately thin so reorder flags and "sizes out" have something to show.
const opening: { sku: string; size: string; opening: string; reorder: string }[] = [];
const thin = new Set(["RN-TOP:S", "KIT-JKT:L", "SEC-SHIRT:XL", "SEC-PANT:97R", "SS-CARGO:107R", "ALL-JKT:M"]);
for (const [sku, , , , , , sizes] of CATALOG) for (const size of sizes.split("|")) {
const k = `${sku}:${size}`; const core = ["S", "M", "L", "XL", "82R", "87R", "92R"].includes(size);
opening.push({ sku, size, opening: thin.has(k) ? (k === "ALL-JKT:M" ? "0" : "2") : String(core ? 14 + ((sku.length + size.length) % 7) : 5), reorder: core ? "4" : "2" });
}
await op("import.rows", { kind: "opening", rows: opening });
await op("import.rows", { kind: "barcodes", rows: [
{ sku: "RN-TOP", size: "M", barcode: "9312345000011" }, { sku: "RN-TOP", size: "L", barcode: "9312345000028" }, { sku: "RN-PANT", size: "M", barcode: "9312345000035" },
{ sku: "EN-TOP", size: "L", barcode: "9312345000042" }, { sku: "SS-POLO", size: "M", barcode: "9312345000059" }, { sku: "SEC-SHIRT", size: "XL", barcode: "9312345000066" },
] });
const items = Object.fromEntries((await prisma.catalogItem.findMany({ where: { facilityId: fac.id } })).map((i) => [i.sku, i.id]));
const staff = Object.fromEntries((await prisma.staff.findMany({ where: { facilityId: fac.id } })).map((s) => [s.num, s.id]));
const byNum = Object.fromEntries(STAFF.map((s) => [s[0], s]));
const line = (sku: string, size: string, qty: number, src: "stock" | "order" | "preloved" = "stock") => ({ itemId: items[sku], si: si(sku, size), qty, src });
const set = (num: string, qty = 1, src: "stock" | "order" = "stock") => { const s = byNum[num]; return [line(topSku(s[3]), s[5], qty, src), line(pantSku(s[3]), s[6], qty, src)]; };
// Signed order forms for nurses on the FTE table, each against the hours on the form. One is
// signed above what the table proposes, so the note a manager's discretion leaves on the record
// has an example to show.
const approvals: [string, string, number, string][] = [["100231", "J. Barnes, Manager 3A", 5, "1.0"], ["100244", "R. Achebe, Manager ICU", 4, "0.8"], ["100288", "L. Torres, Manager ED", 3, "1.0"], ["100349", "M. Quinn, Manager 3B", 4, "0.6"], ["100384", "R. Achebe, Manager ICU", 5, "1.0"], ["100446", "L. Torres, Manager ED", 3, "0.5"]];
for (const [num, by, sets, fte] of approvals) await op("approval.add", { staffId: staff[num], by, sets: String(sets), fte, date: addDays(today, -20 - (parseInt(num, 10) % 15)) });
// Kitchen and Security are on manager approval: no starting kit, and every set they hold was signed
// for. Without these their records would tell a visitor each set needs a manager's signature and
// then show sets issued with none. One form is for two sets with one still to collect, so a
// balance from this route shows beside the FTE table's.
const approvalForms: [string, string, number][] = [["100270", "D. Walsh, Security Manager", 1], ["100356", "D. Walsh, Security Manager", 1], ["100438", "D. Walsh, Security Manager", 1], ["100315", "K. Byrne, Catering Manager", 1], ["100378", "K. Byrne, Catering Manager", 1], ["100459", "K. Byrne, Catering Manager", 2]];
for (const [num, by, sets] of approvalForms) await op("approval.add", { staffId: staff[num], by, sets: String(sets), date: addDays(today, -20 - (parseInt(num, 10) % 15)) });
// Issues. Nurses with a signed form draw it down; the other nurses take sets from the kit their
// hours propose. Support Services take a set from their starting kit, and Riya comes back for two
// more tops — more as needed, nothing handed back first. Kitchen and Security draw their signed sets.
const issued: [string, number][] = [["100231", 2], ["100244", 1], ["100288", 2], ["100349", 1], ["100384", 3], ["100446", 1]];
for (const [num, sets] of issued) await op("issue.create", { staffId: staff[num], apDeduct: sets, lines: set(num, sets) });
for (const num of ["100251", "100322", "100392", "100467"]) await op("issue.create", { staffId: staff[num], apDeduct: 0, lines: set(num, 1) });
for (const num of ["100263", "100293", "100337", "100361", "100417", "100423"]) await op("issue.create", { staffId: staff[num], lines: set(num, 1) });
for (const [num] of approvalForms) await op("issue.create", { staffId: staff[num], apDeduct: 1, lines: set(num, 1) });
await op("issue.create", { staffId: staff["100263"], lines: [line("SS-POLO", "S", 2)] });
await op("issue.create", { staffId: staff["100301"], apDeduct: 0, lines: [line("ALL-CARD", "M", 1)] });
// Order-in lines create staff orders; two have arrived and sit on the pickup call list, one is still with the supplier.
await op("issue.create", { staffId: staff["100244"], apDeduct: 0, lines: [line("ALL-JKT", "L", 1, "order")] });
await op("issue.create", { staffId: staff["100251"], apDeduct: 0, lines: [line("EN-TOP", "L", 2, "order")] });
await op("issue.create", { staffId: staff["100263"], lines: [line("SS-CARGO", "87R", 1, "order")] });
await op("issue.create", { staffId: staff["100405"], apDeduct: 0, lines: [line("ALL-JKT", "S", 1, "order")] });
const staffOrders = await prisma.order.findMany({ where: { facilityId: fac.id, orderFor: "Staff Member" }, include: { lines: true }, orderBy: { createdAt: "asc" } });
const so = (num: string) => staffOrders.find((o) => o.staffId === staff[num])!;
for (const [num, days, contacted] of [["100244", 16, true], ["100251", 4, false], ["100263", 1, false]] as const) {
const o = so(num);
await prisma.order.update({ where: { id: o.id }, data: { ref: `NL-${48100 + parseInt(num.slice(-3), 10)}`, date: addDays(today, -days - 12) } });
await op("order.receive", { id: o.id, date: addDays(today, -days), invoice: `INV-${77120 + parseInt(num.slice(-3), 10)}`, lines: o.lines.map((l) => ({ lineId: l.id, arrived: l.qty, dest: "pickup" })) });
const pk = await prisma.pickup.findFirst({ where: { orderId: o.id } });
if (pk) await prisma.pickup.update({ where: { id: pk.id }, data: { received: addDays(today, -days), contacted } });
}
await prisma.order.update({ where: { id: so("100405").id }, data: { ref: "HE-20931", expected: addDays(today, 9) } });
// Replenishment drafts were built automatically from the shelf issues. Send one, receive one short (→ back order), leave one overdue.
const drafts = await prisma.order.findMany({ where: { facilityId: fac.id, replenish: true, status: "Draft" }, include: { lines: true }, orderBy: { supplier: "asc" } });
const nl = drafts.find((d) => d.supplier === NL), he = drafts.find((d) => d.supplier === HE);
if (nl) {
await op("order.status", { id: nl.id, status: "Ordered" });
await prisma.order.update({ where: { id: nl.id }, data: { ref: "NL-48211", tracking: "AP7731920021", date: addDays(today, -18), expected: addDays(today, -6) } });
}
if (he) {
await op("order.status", { id: he.id, status: "Ordered" });
await prisma.order.update({ where: { id: he.id }, data: { ref: "HE-20877", date: addDays(today, -25), expected: addDays(today, -4) } });
await op("order.receive", { id: he.id, date: addDays(today, -3), invoice: "INV-77044", lines: he.lines.map((l, i) => ({ lineId: l.id, arrived: i === 0 ? Math.max(0, l.qty - 1) : l.qty, dest: "shelf" })) });
}
// A stock order placed by hand that has fully landed.
const landed = (await op("order.create", { orderFor: "Stock", supplier: NL, lines: [{ itemId: items["RN-TOP"], size: "M", qty: 12 }, { itemId: items["RN-PANT"], size: "M", qty: 12 }, { itemId: items["RN-TOP"], size: "L", qty: 8 }], notes: "Winter top-up" })) as { id: string };
await op("order.status", { id: landed.id, status: "Ordered" });
const lo = await prisma.order.findUniqueOrThrow({ where: { id: landed.id }, include: { lines: true } });
await prisma.order.update({ where: { id: lo.id }, data: { ref: "NL-47960", date: addDays(today, -40) } });
await op("order.receive", { id: lo.id, date: addDays(today, -28), invoice: "INV-76902", lines: lo.lines.map((l) => ({ lineId: l.id, arrived: l.qty, dest: "shelf" })) });
// A filed stocktake with a couple of variances, and one return.
await op("stocktake.apply", { lines: [
{ itemId: items["RN-TOP"], si: si("RN-TOP", "M"), counted: 21 }, { itemId: items["RN-PANT"], si: si("RN-PANT", "M"), counted: 20 },
{ itemId: items["SS-POLO"], si: si("SS-POLO", "M"), counted: 15 }, { itemId: items["SEC-SHIRT"], si: si("SEC-SHIRT", "L"), counted: 17 },
] });
const ret = await prisma.issue.findFirst({ where: { facilityId: fac.id, staffId: staff["100337"] } });
if (ret) await op("issue.return", { id: ret.id, cond: "Returned - Good" });
await op("alteration.add", { staffId: staff["100301"], garment: "Scrub pant (M)", desc: "Hem 4 cm" });
// Pre-loved pool: old try-on range added directly, a hand-in from a leaver, and one free reissue from the pool.
await op("stock.moves", { mode: "Pre-loved", lines: [line("RN-TOP", "M", 3), line("RN-PANT", "M", 2), line("SS-POLO", "L", 2), line("ALL-CARD", "S", 1)] });
await op("handin.add", { staffId: staff["100392"], credit: true, lines: [{ ...line("EN-TOP", "L", 2), cond: "Good", laundered: true }, { ...line("EN-PANT", "L", 1), cond: "Rag", laundered: false }] });
// Mei is an Enrolled Nurse, so her free reissue is the scrub pant both nursing groups wear — the
// RN-only top would need the coordinator's off-group override at the counter.
await op("issue.create", { staffId: staff["100467"], apDeduct: 0, lines: [line("RN-PANT", "M", 1, "preloved")] });
// Spread issue dates back across the last three months so the reports and trend bars have shape.
const iss = await prisma.issue.findMany({ where: { facilityId: fac.id, direct: false }, orderBy: { createdAt: "asc" } });
for (let i = 0; i < iss.length; i++) await prisma.issue.update({ where: { id: iss[i].id }, data: { date: addDays(today, -((i * 37) % 80)) } });
const st = await prisma.stocktake.findFirst({ where: { facilityId: fac.id } });
if (st) await prisma.stocktake.update({ where: { id: st.id }, data: { date: addDays(today, -12) } });
return prisma.facility.update({ where: { id: fac.id }, data: { demoResetAt: new Date() } });
}
/** The demo facility, rebuilt if it is missing or if the reset timer has slipped.
*
* The window is half again the reset interval rather than an hour: everyone shares this facility,
* so whatever one visitor types is on the screen of every visitor behind them, and the promise on
* /demo is that it resets every twenty minutes. A stalled systemd timer should cost a few extra
* minutes, not the rest of the hour. */
const DEMO_STALE_MS = DEMO_RESET_MINUTES * 90 * 1000;
let resetting: Promise<unknown> | null = null;
export async function ensureDemo() {
const f = await findDemoFacility();
if (f && f.demoResetAt && Date.now() - f.demoResetAt.getTime() < DEMO_STALE_MS) return f;
// Concurrent visitors share one rebuild instead of racing several.
if (!resetting) resetting = resetDemo().finally(() => { resetting = null; });
await resetting;
return (await findDemoFacility())!;
}
export function demoUserFor(f: NonNullable<Awaited<ReturnType<typeof findDemoFacility>>>, as: string) {
return f.users.find((u) => u.role === (as === "issuer" ? "ISSUER" : "ADMIN")) || f.users[0];
}
+188
View File
@@ -0,0 +1,188 @@
import { prisma } from "./db";
import { availability, bagLines, linesSummary, reqLines } from "./staffdata";
import { facilityDate, facilityToday, garmentForGroup, garmentForStyle, isPantItem, isTopItem } from "./compute";
import { garmentCount } from "./staffreq";
import type { StaffSession } from "./staffsession";
/* What a manager raising for one of their own reports sees, and what the ward desk sees when the
* trolley arrives.
*
* Neither list is the facility. The raise list is the manager's reporting line, and the round is
* one ward's bags. Both queries start from that scope rather than filtering down to it, which is
* the difference that matters the day somebody adds a search box.
*/
/** Who this manager raises for: their active reports, never themselves. */
const reportsOf = (sess: StaffSession) => ({ facilityId: sess.facilityId, managerId: sess.staffId, inactive: false, id: { not: sess.staffId } });
/** Everyone the raise screen may pick from: the people who name this manager as theirs, wherever
* those people happen to sit. That is the same relationship `request.create` enforces on the
* server and the same one that makes this person the approver — which is why the request they
* raise here goes up a level rather than back to them.
*
* Each row carries the two things whoever is typing is least likely to know: what that person is
* holding right now, and the size the record last saw them in.
*
* The awkward part is the hand-in: a garment handed back is gone from the person even though the
* issue row never says returned, and a copy of this that forgot it would tell a manager a nurse
* still holds three tunics she gave back a fortnight ago, and the request she needs would be
* declined as over allowance.
*
* Every issue is read, not just the live ones: a returned garment is spent, but it is still the
* best evidence of what fits. Newest first, so the first row wins per garment. */
export async function teamPeople(sess: StaffSession) {
const staff = await prisma.staff.findMany({
where: reportsOf(sess),
orderBy: [{ last: "asc" }, { first: "asc" }],
select: {
id: true, first: true, last: true, num: true, group: true, top: true, pants: true,
account: { select: { id: true } },
},
});
const ids = staff.map((s) => s.id);
const issues = ids.length
? await prisma.issue.findMany({
where: { staffId: { in: ids } },
orderBy: { date: "desc" },
select: { staffId: true, itemId: true, sizeIndex: true, qty: true, returnedDate: true, handedIn: true, item: { select: { sizes: true } } },
})
: [];
const held = new Map<string, Record<string, number>>();
const lastSizes = new Map<string, Record<string, string>>();
for (const i of issues) {
const sizes = lastSizes.get(i.staffId) || {};
if (!(i.itemId in sizes)) sizes[i.itemId] = String(i.item.sizes[i.sizeIndex] ?? "");
lastSizes.set(i.staffId, sizes);
// Nobody has a locker — the uniform lives at their house — so "still holding it" is the only
// thing this can mean, and a hand-in ends it just as surely as a return does.
if (i.returnedDate || i.handedIn) continue;
const mine = held.get(i.staffId) || {};
mine[i.itemId] = (mine[i.itemId] || 0) + i.qty;
held.set(i.staffId, mine);
}
return staff.map((s) => ({
id: s.id,
name: `${s.first} ${s.last}`.trim(),
num: s.num,
group: s.group,
// The design surfaces this on the selected person: whoever is raising needs to know whether the
// outcome will reach them directly or has to be passed on by hand.
hasApp: !!s.account,
recordedTop: s.top,
recordedPants: s.pants,
/** Garment id → how many they are holding right now. */
held: held.get(s.id) || {},
/** Garment id → the size of the last one they were issued, for everything the register has
* no recorded size for. */
lastSizes: lastSizes.get(s.id) || {},
}));
}
/** The garments the raise screen offers. The person is picked on the phone, after this list is
* drawn, so it cannot be one person's group: it is every garment that is for at least one of the
* people in teamPeople() — the same reporting line, so the two lists agree — plus the garments
* for every group. The same is asked of the cut: a garment is on the list if one of those people
* is offered it — which, for anybody blank or set to Either, is every cut. request.create still
* asks both questions of the one person picked, and refuses anything outside their group or their
* style. */
export async function deskCatalogue(sess: StaffSession) {
const [all, reports] = await Promise.all([
prisma.catalogItem.findMany({
where: { facilityId: sess.facilityId, archived: false },
orderBy: { sort: "asc" },
select: { id: true, item: true, type: true, gender: true, sizes: true, groups: true },
}),
prisma.staff.findMany({ where: reportsOf(sess), select: { group: true, uniformStyle: true }, distinct: ["group", "uniformStyle"] }),
]);
// A blank group is on nobody's list, so garmentForGroup(it, "") is true of an every-group garment
// alone — which keeps those on the screen when this manager has no reports. The blank style beside
// it is the one nobody has set, which is offered every cut, so that fallback hides nothing.
//
// Group and cut are asked of the SAME person, one pair at a time, rather than of two lists: a
// manager with a man in Security and a woman in Nursing must not be offered the women's Nursing
// tunic for him because somebody on their list is in Nursing and somebody is set to Women's.
const people = [{ group: "", uniformStyle: "" }, ...reports];
const items = all.filter((i) => people.some((r) => garmentForGroup(i, r.group) && garmentForStyle(i, r.uniformStyle)));
const avail = await availability(sess.facilityId, items.map((i) => i.id));
return items.map((i) => ({
id: i.id, item: i.item, type: i.type, gender: i.gender,
sizes: avail[i.id] || i.sizes.map((s, si) => ({ size: String(s), si, word: "none" as const, countedOn: "" })),
recorded: "",
isTop: isTopItem(i),
isPant: isPantItem(i),
}));
}
/** The label and meta `request.round` stamps on a bag when it goes out (lib/ops.ts). Request has
* no column saying which ward the trolley was sent to, so that event is the only durable record
* of it, and this screen is fenced on it rather than on where the wearer sits today. */
const ROUTED_TO_ROUND = "Out on the ward round";
const dueOn = (ward: string) => `Due on ${ward}`;
/** 2D. Three lists: what to sign, what nobody collected, and what has been signed today. */
export async function roundData(sess: StaffSession) {
const me = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { dept: true, wardDesk: true, facility: { select: { timezone: true } } },
});
// A blank ward is not a ward. `dept` defaults to an empty string, so querying on it as-is hands
// a clerk whose ward was never filled in the bags of every other ward-less person in the
// facility — and the round they could sign for is fenced the same way in lib/staffops.ts, so the
// screen would only be listing work it then refuses. There is no round without a ward.
if (!me.wardDesk || !me.dept) return null;
const rows = await prisma.request.findMany({
where: {
facilityId: sess.facilityId,
// The ward the bag was left on, not the ward the wearer is on now. Selecting on
// `subject.dept` made the bag follow the person: a nurse who transfers between the trolley
// leaving and the desk signing took her bag with her on screen — off the round of the ward
// it is physically sitting on, and onto a ward it never reached, where signing would stamp a
// delivery, name a real signer and issue the garments against her for a handover that never
// happened. Nothing edits a staff member's ward through this path, so it changes silently.
// round.sign fences the same bag in lib/staffops.ts and the two have to agree on the ward,
// or a bag is either signable by the wrong desk or signable by nobody.
events: { some: { label: ROUTED_TO_ROUND, meta: dueOn(me.dept) } },
OR: [{ status: "round" }, { status: "delivered", claimedAt: null }],
},
orderBy: { createdAt: "asc" },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true } },
},
});
// A bag on the round holds the approved lines and nothing else, which is exactly what bagLines()
// hands back — a declined garment must never appear on a sheet somebody is about to sign for.
const shape = (r: (typeof rows)[number]) => {
const lines = bagLines(reqLines(r.lines));
return {
id: r.id, code: r.code,
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
lines, summary: linesSummary(lines), garments: garmentCount(lines), lineCount: lines.length,
status: r.status, signerName: r.signerName, signedAt: r.signedAt?.toISOString() ?? null,
claimedAt: r.claimedAt?.toISOString() ?? null,
since: r.createdAt.toISOString(),
};
};
// Which day a bag was signed for is a question about the ward's day, not the server's. Comparing
// against the process's local midnight put every bag signed before the server's rollover into
// "unclaimed from earlier rounds" — on a UTC host that is an entire Australian morning round,
// filed as abandoned on the one screen whose job is telling this morning's work from stale bags.
const tz = me.facility.timezone;
const today = facilityToday(tz);
const delivered = rows.filter((r) => r.status === "delivered");
const signedOn = (r: (typeof rows)[number]) => (r.signedAt ? facilityDate(r.signedAt, tz) : "");
return {
ward: me.dept,
toSign: rows.filter((r) => r.status === "round").map(shape),
// Signed for on the ward on an earlier day and still nobody has taken it away.
unclaimed: delivered.filter((r) => signedOn(r) !== "" && signedOn(r) < today).map(shape),
signedToday: delivered.filter((r) => signedOn(r) >= today).map(shape),
};
}
+11
View File
@@ -0,0 +1,11 @@
/* Which edition this process is.
*
* Hosted is threadcount.tech: plans, the operations console, the demo, the public site, and error
* reports and usage statistics that go to ThreadCount's own infrastructure. Community is the same
* code run by somebody else, from the Dockerfile, with EDITION=community in the environment: every
* feature, no plans, no ceiling, nothing reported anywhere, and none of the hosted-only doors open.
*
* Server-side only — the browser bundle never sees EDITION. Client code that must behave
* differently off threadcount.tech decides by hostname instead (components/Analytics.tsx,
* lib/glitchtip.ts), which has the same effect and needs no build-time flag. */
export const COMMUNITY = process.env.EDITION === "community";
+47
View File
@@ -0,0 +1,47 @@
"use client";
/* One place the app reports a crash from.
*
* The error boundaries call this rather than talking to a reporting SDK directly, so that what
* gets sent — and what gets stripped first — is decided in a single file rather than at each
* boundary. ThreadCount's URLs carry staff, location and order ids, exactly as they do for
* analytics, and a crash report is if anything more likely to drag one along: the page URL, the
* referrer and the breadcrumb trail all contain them.
*
* Until the reporter is wired this is a no-op in production and a console line in development,
* which is deliberately better than the previous behaviour of losing the error entirely. */
import { scrubPath } from "@/lib/analytics";
type Reporter = {
captureException: (e: unknown, ctx?: Record<string, unknown>) => void;
};
/** Set by the reporting bootstrap once it has initialised. Absent = reporting is off. */
function reporter(): Reporter | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { __tcReporter?: Reporter }).__tcReporter;
}
/** The current location with record ids removed, safe to attach to a report. */
export function safeLocation(): string {
if (typeof location === "undefined") return "";
return scrubPath(location.pathname);
}
/**
* Report a crash.
* `where` distinguishes a route boundary from the root one — a global error means the layout
* itself failed, which is a different and more serious shape of problem.
*/
export function reportError(error: unknown, where: "route" | "global" | "client") {
const r = reporter();
if (r) {
try {
r.captureException(error, { tags: { boundary: where }, extra: { path: safeLocation() } });
return;
} catch { /* a reporter that throws must not take the page with it */ }
}
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.error(`[threadcount:${where}]`, error);
}
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
/* What a scan feels like.
*
* Counting a shelf is done looking at garments, not at the phone, so every scan has to confirm
* itself physically. There are two things worth saying, and they have to feel different:
*
* tick — read one, keep going.
* reject — that code isn't on this shelf; look up.
*
* On the web that's `navigator.vibrate`. Inside the Android shell it isn't: the WebView only
* honours navigator.vibrate when the app declares android.permission.VIBRATE, which arrives with
* @capacitor/haptics — until this plugin was added the tick was silent on a real phone and only
* the beep survived. Native also gets the platform's own haptic engine, which is a cleaner tap
* than a raw motor pulse.
*
* Settings can turn the lot off; the beep and the buzz share one switch because they are one
* signal wearing two coats. */
import { isNative } from "@/lib/nativescan";
type HapticsModule = {
Haptics: {
impact: (o: { style: string }) => Promise<void>;
notification: (o: { type: string }) => Promise<void>;
};
ImpactStyle: { Light: string; Medium: string; Heavy: string };
NotificationType: { Success: string; Warning: string; Error: string };
};
let mod: HapticsModule | null = null;
let loading: Promise<HapticsModule | null> | null = null;
/** Dynamically imported: most people meet /m in a browser, where none of this ships. */
function load(): Promise<HapticsModule | null> {
if (!isNative()) return Promise.resolve(null);
if (mod) return Promise.resolve(mod);
if (!loading) {
loading = import("@capacitor/haptics")
.then((m) => (mod = m as unknown as HapticsModule))
.catch(() => null); // shell built without the plugin — the web path still works
}
return loading;
}
/** Honours the Settings switch, and survives a browser that blocks storage entirely. */
function wanted(): boolean {
try { return localStorage.getItem("tc.beep") !== "0"; } catch { return true; }
}
function buzz(pattern: number | number[]) {
try { navigator.vibrate?.(pattern); } catch { /* not every device has a motor */ }
}
function beep(hz: number, seconds: number, gain = 0.05) {
try {
const AC = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
const Ctor = AC.AudioContext || AC.webkitAudioContext;
if (!Ctor) return;
const ctx = new Ctor();
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = hz; g.gain.value = gain;
o.connect(g); g.connect(ctx.destination);
o.start(); o.stop(ctx.currentTime + seconds);
setTimeout(() => ctx.close().catch(() => {}), seconds * 1000 + 200);
} catch { /* audio is blocked until a gesture — the haptic still fires */ }
}
/** One garment read. Short and high, so a shelf of them doesn't become a drone. */
export function scanTick() {
if (!wanted()) return;
void load().then((m) => {
if (m) m.Haptics.impact({ style: m.ImpactStyle.Light }).catch(() => {});
else buzz(35);
});
beep(1180, 0.06);
}
/** That code doesn't belong here. Two longer pulses and a lower note — unmistakably not a tick. */
export function scanReject() {
if (!wanted()) return;
void load().then((m) => {
if (m) m.Haptics.notification({ type: m.NotificationType.Warning }).catch(() => {});
else buzz([50, 60, 50]);
});
beep(320, 0.16, 0.06);
}
+143
View File
@@ -0,0 +1,143 @@
/* Error reporting to ThreadCount's own GlitchTip.
*
* GlitchTip speaks Sentry's protocol, so this posts a Sentry "store" event by hand rather than
* pulling in @sentry/nextjs. Three reasons that's the right trade here:
* - the SDK is tens of kilobytes on a phone that a nurse uses in a linen room;
* - it hooks the build (withSentryConfig) on a very new Next, which is a compatibility risk the
* project doesn't need;
* - and everything that leaves this file has to be scrubbed first, which is far easier to
* guarantee when there is exactly one function doing the sending.
*
* What it costs: no source maps, so client stack frames are minified, and no automatic
* breadcrumbs. The message, the scrubbed path and the stack are still vastly better than the
* nothing that was here before.
*
* The DSN's public key is not a secret — Sentry-protocol keys are designed to sit in client
* bundles — so it is safe in NEXT_PUBLIC_.
*/
/* Committed rather than env-only, exactly as the Umami site ids are. NEXT_PUBLIC_ values are baked
at build time, and prod builds on the server — so leaving this to an env var means one forgotten
line in secrets.env silently ships a release that reports nothing, which is the failure mode
this whole file exists to prevent. The key is public by protocol design. */
const DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN
|| "https://e50e9944-0aae-41ed-8691-5bbec1878f65@errors.threadcount.tech/1";
type Parsed = { url: string; key: string };
/** `https://<key>@host/<projectId>` -> the store endpoint and the auth key. */
function parseDsn(dsn: string): Parsed | null {
try {
const u = new URL(dsn);
const projectId = u.pathname.replace(/^\//, "");
if (!u.username || !projectId) return null;
return { url: `${u.protocol}//${u.host}/api/${projectId}/store/`, key: u.username };
} catch {
return null;
}
}
const PARSED = DSN ? parseDsn(DSN) : null;
/* The committed DSN belongs to threadcount.tech. A Community instance built from this same code
must not report its errors — scrubbed or not — to somebody else's GlitchTip unless its operator
chose to by setting NEXT_PUBLIC_GLITCHTIP_DSN. So the default is used only where it belongs: on
the server, when this is not the community edition; in the browser, when the page is served
from threadcount.tech. An explicit DSN is honoured anywhere. */
const EXPLICIT = !!process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
function allowed(): boolean {
if (!PARSED) return false;
if (EXPLICIT) return true;
if (typeof window !== "undefined") return /(^|\.)threadcount\.tech$/.test(window.location.hostname);
return process.env.EDITION !== "community";
}
export const errorReportingOn = () => allowed();
/* Redaction.
*
* An error message is not written by us. A Prisma failure quotes the row it choked on, a
* constraint violation quotes the value, and a validation error quotes what someone typed. Any of
* those can carry a staff name, a work email, a payroll number or a record id straight into the
* error tracker, which would undo the care taken everywhere else. */
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
const ID_RE = /\b(?:[a-z0-9]{20,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
const LONG_NUM_RE = /\b\d{6,}\b/g;
export function scrubText(s: string, max = 1000): string {
return String(s || "")
.replace(EMAIL_RE, "[email]")
.replace(ID_RE, "[id]")
.replace(LONG_NUM_RE, "[number]")
.slice(0, max);
}
/** Paths carry record ids; query strings carry more. Keep the shape, drop the specifics. */
export function scrubUrl(raw: string): string {
try {
const u = new URL(raw, "https://threadcount.tech");
const path = u.pathname
.split("/")
.map((seg) => (ID_RE.test(seg) ? ":id" : seg))
.join("/");
ID_RE.lastIndex = 0;
return u.origin + path;
} catch {
return scrubText(raw, 200);
}
}
function uuid(): string {
try {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, "");
} catch { /* fall through */ }
let s = "";
for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
return s;
}
export type ReportInput = {
error: unknown;
where: string;
url?: string;
tags?: Record<string, string>;
extra?: Record<string, unknown>;
};
/** Fire-and-forget. Reporting must never delay, block or break the thing that failed. */
export function report({ error, where, url, tags, extra }: ReportInput): void {
if (!PARSED || !allowed()) return;
try {
const err = error instanceof Error ? error : new Error(String(error));
const digest = (error as { digest?: string })?.digest;
const event = {
event_id: uuid(),
timestamp: new Date().toISOString(),
platform: "javascript",
level: "error",
logger: where,
release: process.env.NEXT_PUBLIC_RELEASE || undefined,
environment: process.env.NODE_ENV === "production" ? "production" : "development",
exception: {
values: [{
type: scrubText(err.name || "Error", 120),
value: scrubText(err.message || String(error), 600),
// Sent as a single scrubbed string: without source maps a structured frame list adds
// nothing a reader can use, and each frame is another place a path could leak.
stacktrace: undefined,
}],
},
request: url ? { url: scrubUrl(url) } : undefined,
tags: { boundary: where, ...(digest ? { digest } : {}), ...(tags || {}) },
extra: { ...extra, stack: scrubText(err.stack || "", 4000) },
};
const body = JSON.stringify(event);
const headers: Record<string, string> = {
"content-type": "application/json",
"x-sentry-auth": `Sentry sentry_version=7, sentry_client=threadcount/1.0, sentry_key=${PARSED.key}`,
};
void fetch(PARSED.url, { method: "POST", headers, body, keepalive: true }).catch(() => {});
} catch { /* a reporter that throws is worse than one that stays quiet */ }
}
+72
View File
@@ -0,0 +1,72 @@
// Legal wording, lifted unchanged from the previous single tabbed page so the documents keep
// saying exactly what they said. Each key is now its own route under (site).
export type Section = { h: string; ps: string[]; list?: string[] };
export const DOCS: Record<string, Section[]> = {
"Privacy Policy": [
{ h: "1. What this covers", ps: ["This policy explains what personal information ThreadCount handles, why, and who can see it. ThreadCount is a uniform stock management application used by a facilitys linen room or uniform service. The facility that deploys ThreadCount is the owner and controller of all data recorded in it; ThreadCount is a tool the facility operates."] },
{ h: "2. The two Android apps", ps: ["There are two, for two different audiences, and they ask for different things.", "“ThreadCount” is the linen rooms counter app. It signs in to the same coordinator account and shows the same records as the website, and it keeps nothing on the phone beyond your session and a stocktake you have started but not yet filed. It asks for one permission — the camera — and only to read the barcode on a garment label.", "“ThreadCount Staff” is for the people who wear the uniform. It shows one person their own record and nothing else. It asks for no permissions at all beyond internet access: no camera, no location, no contacts, no files, no photos, no microphone. It carries no barcode scanner, because nothing a wearer does involves scanning.", "Neither app works without a connection, neither stores your records on the phone, and neither has access to contacts, location, files, messages or call history."] },
{ h: "3. Information we hold", ps: ["ThreadCount stores only what a uniform service needs to issue and account for garments:"], list: ["Staff register: name, payroll/staff number, work phone number, staff group, department or ward, garment sizes, uniform entitlement, start date, and the one named person who approves that staff members requests.", "Activity records: uniform issues, returns, orders placed on a staff members behalf, pickup contact notes, and manager approval records (approver name, sets approved, FTE).", "Coordinator accounts: name, role (Admin or Issuer) and sign-in credentials.", "Staff self-service accounts, where a staff member has claimed one: the email address they chose as their sign-in and a hashed password. Nothing else — the account attaches to the register entry the linen room already holds.", "Requests raised in the staff app: the garment, size, quantity, the reason chosen from a fixed list, an optional note, who approved or declined it and — if declined — which of three reasons was given.", "Messages about a request, between a staff member and their linen room. Each message belongs to one order and is visible only to that person, their manager, whoever raised it on their behalf, and the facilitys coordinators.", "Kit check answers (how many of each garment a person confirms they still hold), waitlist entries, and queries raised through “This isnt right”.", "Security records: the network address an action came from. It is kept with the audit trail of coordinator actions, with a password reset request, and with a message sent through the contact form, so a change can be traced back to where it was made.", "Messages sent through the contact form on this website: the name, work email address, role and facility you give, the topic you pick, what you write, and the network address it came from. This is not a mailing list — it is the enquiry itself, kept only so it can be answered and followed up, and deleted on the schedule in section 12.", "Operational data that is not personal information: catalogue, stock levels, supplier orders, stocktakes and cost centre codes.", "A billing contact, where a facility is on a paid plan: the email address an administrator chose for invoices to go to. Invoices are paid by bank transfer against a purchase order; ThreadCount holds no card or bank details."] },
{ h: "4. What we never collect", ps: ["ThreadCount holds no clinical or patient information of any kind, no home addresses, no payment or banking details, and no biometric data. There is no money anywhere in the product: no prices shown to staff, no basket, no payment. There is no advertising anywhere in ThreadCount, no advertising or marketing network receives anything, and no third party tracks you through it.", "One thing to be plain about: a staff member who sets up self-service chooses their own sign-in address, and that may be a personal one. ThreadCount uses it to sign them in and to send them the notices in section 9 — nothing else. It is never added to a mailing list, never shared, and never used to contact them about anything but their own uniform.", "ThreadCount does keep its own usage statistics and its own error reports, on its own infrastructure and without cookies — sections 5 and 6 set out exactly what those record."] },
{ h: "5. Usage statistics", ps: ["ThreadCount counts how its own pages are used, so that it can be improved. This is run by ThreadCount on ThreadCount\u2019s own Australian-hosted infrastructure at analytics.threadcount.tech. No analytics company, advertising network or other third party is involved, and nothing is sold, shared or used to build a profile of anyone.", "It sets no cookies and stores nothing on your device. Visitors are counted using a value derived fresh each day and never stored, so the same person cannot be recognised from one day to the next, and nobody can be identified from it \u2014 which is also why ThreadCount shows no cookie banner. If your browser or phone sends a Do Not Track signal, the statistics stop entirely for you.", "What is recorded:"], list: ["The page visited, with record identifiers removed before anything is sent \u2014 a visit to one person\u2019s record is recorded as \u201c/m/person/:id\u201d, never with the staff member\u2019s identifier, and the part of a web address after the \u201c?\u201d is discarded entirely.", "Whether the visit came from the Android app or a browser, the browser and operating system, the screen size, the language, and the country \u2014 worked out from the network address, which the statistics themselves do not keep. The places the network address is kept are in section 3: the audit trail, a password reset request, and a message sent through the contact form.", "Counts of a fixed set of actions in the linen rooms own screens: a stocktake filed, a garment issued, returned or exchanged, a delivery received, a pickup collected, an order raised, a barcode bound, a location saved, a spreadsheet imported, someone invited, settings changed, an account deleted, the scanner opened, and a scan of a barcode ThreadCount did not recognise.", "Counts of the same kind from the staff app: a request raised, approved, declined or messaged about, damage reported, a query raised against a record, a waitlist joined or taken up, a kit check answered, and a ward round signed for.", "Counts of the ways in and out: the app or a page being opened, a sign-in or sign-up succeeding or failing, a security check being shown, a second factor being asked for, and a subscription to the product update list. Where ThreadCount refuses an action, the count says which broad kind of refusal it was — never the message it showed.", "For the website, the site that linked you here.", "All of these are counts. Not one of them carries a name, a barcode, a facility, a record identifier, or anything typed into the product."], },
{ h: "6. Error reports", ps: ["When something goes wrong, ThreadCount sends itself a report so the fault can be found and fixed. This runs on ThreadCount\u2019s own Australian-hosted infrastructure at errors.threadcount.tech. No error-reporting company or other third party is involved.", "A report carries the error message, where in the code it happened, the page it happened on and the browser or app version. Before anything is sent it is scrubbed: email addresses, record identifiers and long runs of digits are removed from the message. No record, no staff name and no message content is included."] },
{ h: "7. Camera, signatures and photographs", ps: ["This section is about the linen room\u2019s counter app and the website. The staff app has no camera permission at all and captures no images of any kind.", "Barcode scanning in the counter app uses the device camera. Frames are read on the device to find a barcode and discarded immediately — no video or still image from scanning is stored or sent anywhere.", "Separately, a coordinator can deliberately capture and save a few kinds of image: a signature drawn on screen when a ward receives a delivery, a handover photograph attached to that delivery, a photograph of a damaged garment attached to a return, and a photographed manager approval form or supplier receipt. These are saved to your facilitys record because they are the evidence the record exists for. They are only ever shown to that facilitys own accounts, and they are deleted with the record they belong to."] },
{ h: "8. Why we hold it", ps: ["Data is used solely to run the uniform service: issuing stock to the right person, tracking entitlements and manager approvals, replenishing stock, contacting staff when an ordered garment arrives, and attributing uniform spend to the correct cost centre for the facilitys internal financial reporting."] },
{ h: "9. Communications", ps: ["ThreadCount emails a staff member only about their own uniform, and only once they have set up self-service with an address they chose. There are four such messages: a request needing your approval, if people report to you; the decision your manager made on your request, including the reason if it was declined; a notice that your garments are ready to collect or are out on the ward round; and a notice that a size you were waiting for has arrived. Nothing else is ever sent.", "Nobody is added to any mailing list because their details are in ThreadCount. A staff member who has not set up self-service is never emailed at all, and pickup contact for them still happens in person or by phone, by a coordinator, from the call list.", "Two things do send email, and only ever to someone who asked for it. If you write to us through the contact form we reply to you \u2014 that is all; the form says so, and an address given there is never added to any list. Separately, you can subscribe to occasional product updates from the sign-up on this website. That list is double opt-in, so you are only on it after clicking a confirmation link, every message carries a one-click unsubscribe, and it is used for release notes rather than advertising. It is run on ThreadCount\u2019s own mail infrastructure at lists.threadcount.tech; the addresses are not shared with anyone."] },
{ h: "10. Storage and security", ps: ["Records are stored on ThreadCounts Australian-hosted infrastructure, isolated per facility, and are only ever shown to that facilitys named accounts. Access requires a named account; the Issuer role cannot alter settings, staff records, pricing or history. Backups are exported and retained by the facility. ThreadCount does not transmit records to third parties, to outside analytics companies or to advertising networks; the usage statistics in section 5 are ThreadCounts own, run on its own infrastructure, and contain no records."] },
{ h: "11. Who can see your record", ps: ["You can, if your linen room has given you self-service — that is what the staff app is for, and it shows you your own record and nobody else\u2019s.", "The one person recorded as approving your requests sees the requests you raise and, on their ward view, what the people reporting to them are holding. That same person can raise a request on your behalf and is named on it alongside you; whoever signs for a ward-round delivery is named on the requester\u2019s order.", "Your facilitys uniform coordinators (Admin and Issuer accounts) can see the staff register and issue history in order to run the service. Cost centre reporting shared with finance is aggregated by ward and cost centre; where individual lines appear (for example the staff spend table), they show name, items and value only."] },
{ h: "12. Retention", ps: ["Issue and order history is retained for the periods the facilitys financial record-keeping requires. When a staff member leaves, their register entry is deactivated — which also stops their self-service sign-in working immediately — and facilities may delete records entirely where no linked history must be preserved for audit.", "Requests, their message threads, kit check answers and waitlist entries are part of the facilitys record and are kept and deleted with it.", "Messages sent through the contact form on this website are kept for 12 months from the day they are sent, and then deleted. That is long enough to answer an enquiry and to pick up the conversation it turns into; nothing is kept beyond it, and the address is never added to any list."] },
{ h: "13. Deleting an account", ps: ["Anyone with a ThreadCount sign-in can delete their own account from Settings, without asking anyone. What that removes depends on whether colleagues remain: if other people can still sign in to the facility, only the login goes and the facilitys records stay, because they belong to the linen room rather than to one person. If nobody else can sign in, deleting the account deletes the whole facility — every user, the catalogue, the staff register, and every issue, return, order, delivery, stocktake and photograph. That happens immediately, cannot be undone, and cannot be recovered by us.", "The full explanation, including how to do it and what taking a backup first gets you, is at threadcount.tech/delete-account.",
"Staff self-service accounts work differently, because the record they attach to belongs to the linen room rather than to the account. Ask your uniform coordinator to remove your access: there is a button on your staff record that does it, it takes effect immediately, and it ends every session you have open. Your register entry and issue history stay, because the facility needs them for its own stock and financial records — deleting those is the facility\u2019s decision under section 12, not something an individual sign-in controls. If you would rather not ask your coordinator, or you want a copy of what is held about you first, write to privacy@threadcount.tech and we will route it through your facility\u2019s privacy process."] },
{ h: "14. Access and correction", ps: ["You may ask your uniform coordinator to show you your record, correct your details or sizes, or update your department. Requests the coordinator cannot resolve follow the facilitys standard privacy process, under the Privacy Act 1988 (Cth) and whichever privacy legislation applies in your state or territory — in Queensland, for instance, the Information Privacy Act 2009 (Qld)."] },
],
"Terms of Service": [
{ h: "1. The agreement", ps: ["These terms govern use of the ThreadCount application by a facility and its authorised users. By signing in you agree to use ThreadCount only for managing your facilitys uniform service and in line with these terms and your facilitys policies."] },
{ h: "2. Accounts and roles", ps: ["Accounts are personal and must not be shared. Admin accounts manage settings, the staff register, catalogue, pricing, suppliers and departments; Issuer accounts issue stock, run stocktakes and receive deliveries. You are responsible for activity recorded under your sign-in, which is why each issue, stocktake and override is stamped with the account that made it."] },
{ h: "3. Acceptable use", ps: ["You must not:"], list: ["Access or alter records except as your role requires for the uniform service.", "Record issues, approvals or adjustments you know to be false.", "Disclose staff register information outside the facilitys legitimate processes.", "Attempt to circumvent role restrictions, or share credentials."] },
{ h: "4. The facilitys data", ps: ["All records entered into ThreadCount belong to the facility. The facility may export its full data at any time from Settings and may delete it entirely. ThreadCount claims no rights over facility data."] },
{ h: "5. Accuracy and responsibility", ps: ["ThreadCount records what its users enter. Stock figures, entitlement balances and cost centre attribution are only as accurate as the issues, receipts and stocktakes recorded. Financial journals produced by ThreadCount are drafts for the facilitys finance team to review before posting; ThreadCount is not an accounting system of record."] },
{ h: "6. Availability and changes", ps: ["ThreadCount is provided as-is for the facilitys internal use. Features may change with notice to facility administrators. Exporting and keeping regular backups is the facilitys own responsibility; ThreadCount shows the date of the last export in Settings."] },
{ h: "7. Termination", ps: ["A facility may stop using ThreadCount at any time by exporting its data and deactivating accounts. ThreadCount may suspend accounts used in breach of these terms, at the request of the facilitys administrators. A facility whose paid period has lapsed is made read-only rather than closed — section 9 says how."] },
{ h: "8. Liability", ps: ["To the extent permitted by law, ThreadCounts liability is limited to re-supplying the software. Nothing in these terms excludes rights under the Australian Consumer Law that cannot be excluded."] },
{ h: "9. Fees", ps: [
"The software is free. Where a plan applies, it pays for hosting on threadcount.tech, for the backups kept there, and for support with a response time. The plans and their prices are on the pricing page. Prices are in Australian dollars and exclude GST, which is added where it applies.",
"A facility created before plans were introduced is grandfathered: hosted free, with every feature, for as long as it exists. A change to these terms does not change that.",
"A hosted plan is invoiced annually in advance against the facilitys purchase order, or monthly where card payment is offered, and renews for the same period unless the facility asks to stop. Sixty days notice of any price change is given by email to the facilitys administrators, and no change applies to a period already paid for.",
"If a trial or a paid period ends without payment, the facility has fourteen days grace and then becomes read-only: every report, export, printed document and the full backup keep working, and nothing is deleted. Writing resumes when a payment is recorded. A facility that chooses to leave instead may export everything and go, as section 7 says.",
"Fees are not refunded for a period already begun, except where the Australian Consumer Law requires it or ThreadCount was unavailable for a substantial part of that period.",
] },
],
"Data Security": [
{ h: "Where data lives", ps: ["All records are held on ThreadCounts Australian-hosted infrastructure, kept separate per facility and never shown across facilities. Nothing is sent to external analytics, advertising or telemetry services.", "ThreadCount keeps cookieless usage statistics on its own Australian infrastructure, covering which pages are opened and counts of a fixed set of actions, with record identifiers stripped before anything is sent; no facility record ever forms part of them. Error reports run on the same footing at errors.threadcount.tech, and are scrubbed of email addresses, identifiers and long digit runs before they are sent."] },
{ h: "Access control", ps: ["Two coordinator roles with least-privilege boundaries: Issuers cannot change settings, staff records, prices, suppliers or history; Admins can. Every material action — issues, overrides, price changes, stocktake applications, order receipts — is recorded against the signed-in account with a date.", "A staff self-service account is a different kind of account, not a lesser coordinator one. It lives in its own table with its own sign-in cookie, signed with a separate key, and it carries a different kind of identifier internally. A staff sign-in therefore cannot become a coordinator sign-in — not because a permission check says no, but because the two are not the same kind of thing. A staff member reaches their own record and nothing else; a ward manager additionally sees requests addressed to them and what their own team holds.", "Approval belongs to the ward and fulfilment to the linen room, and neither side can do the others job. The linen room can re-address a waiting request to the right manager, but it cannot decide one, and it cannot approve on a managers behalf.", "An administrator may record anyone as their own manager. That person then approves their own requests, and a signed order form for their own kit may name them as the approver. No self-approval is silent: a request they decide for themselves is written into its timeline as a self-approval, naming them as both the approver and the one it is for, and a signed form they approved for themselves is marked as self-approved on their staff record. The approvals queue sets a managers own requests apart from the ones they are deciding for other people.", "Nobody approves a request they raised for somebody else. When a manager raises a request for one of their own reports, it goes to the raisers own manager instead; if there is nobody above them, or the raiser is their own manager, it waits for the linen room to address it to someone else."] },
{ h: "Device camera and stored images", ps: ["The ThreadCount Staff app requests no permissions beyond internet access — no camera, no location, no files, no photos — and captures nothing. Everything in this section is about the linen rooms counter app and the website.", "In the counter app, barcode scanning uses the device camera locally. Frames are processed on-device to detect a barcode and immediately discarded; nothing from scanning is stored or leaves the device.", "Images a coordinator deliberately captures are different, and are stored: delivery signatures, handover photographs, damage photographs on a return, and photographed manager approvals or supplier receipts. They are held with the facilitys own records, served only to that facilitys signed-in accounts, and deleted with the record they belong to."] },
{ h: "Backups", ps: ["Admins export a complete JSON backup from Settings. Backups contain the staff register and history and must be stored according to the facilitys records policy. Taking them regularly is the facilitys responsibility; Settings shows the date of the last export."] },
{ h: "Incident process", ps: ["Suspected unauthorised access should be reported to the facilitys privacy officer and IT security team under the facilitys incident procedure, and to security@threadcount.tech. ThreadCounts design limits blast radius: no credentials for external systems, no patient data, no financial account numbers."] },
],
"Acceptable Use": [
{ h: "Purpose", ps: ["ThreadCount exists to run a uniform service: know what is on the shelf, prove where it went, charge the right ward. Use it for that."] },
{ h: "Do", ps: [], list: ["Record every issue at the time it happens, against the right staff member.", "Record manager approvals from the signed order form before issuing against them.", "Use overrides sparingly and only with the coordinators authority — they are permanently recorded.", "Keep the register current: sizes, wards and cost centres drive reporting.", "Export a backup at least weekly."] },
{ h: "Dont", ps: [], list: ["Issue stock without recording it, or record it against the wrong person “to fix later”.", "Share your sign-in or leave a signed-in terminal unattended at the counter.", "Look up staff records for any reason other than running the uniform service.", "Adjust stock outside a stocktake or documented correction."] },
],
"About & Contact": [
{ h: "About ThreadCount", ps: ["ThreadCount was built in a hospital linen room by a uniform coordinator who needed it — not adapted from retail inventory software. It manages stock on hand, issuing, supplier ordering, stocktakes, manager approvals and cost centre reporting for facility uniform services."] },
{ h: "Contact", ps: ["Product and account questions: your facilitys uniform coordinator.", "Privacy requests: privacy@threadcount.tech or your facilitys privacy officer.", "Security reports: security@threadcount.tech — include steps to reproduce; do not include staff personal information in the report."] },
{ h: "Document history", ps: ["Privacy Policy, Terms of Service, Data Security and Acceptable Use — first published 28 August 2026. Material changes are notified to facility administrators in the application.",
"13 September 2026: the Terms of Service gained section 9, Fees, ahead of plans being introduced: what a plan pays for, that every facility created before plans existed stays free with everything, how a hosted plan is invoiced and renewed, sixty days notice of price changes, and that a lapsed plan becomes read-only rather than closed. Section 7 refers to it. The Privacy Policys section 3 now names the one new thing a paid plan adds to what is held — a billing contact address — and section 4 still holds: no card or bank details, ever.", "8 September 2026: the Privacy Policy and Data Security pages were revised for the ThreadCount Staff app — what a staff self-service account holds, the four emails a staff member can receive, error reports, who can see a record, and how a staff member has their access removed. Two earlier statements were corrected rather than extended: ThreadCount does now hold a personal email address where a staff member chose one as their sign-in, and it does now email staff about their own requests.", "Later on 8 September 2026, three further statements in the Privacy Policy were corrected. Section 3 now records the network address kept with the audit trail, with a password reset request and with a contact form message, which the policy had not mentioned and section 5 had implied was never kept at all. Section 5 now lists the counted actions in full, including every one the staff app sends. And section 14 no longer names one states privacy legislation as though it governed every facility.",
"11 September 2026: the Data Security page no longer says nobody can approve their own request. A manager with at least one person reporting to them may now approve a request raised for themselves, so the page sets out the control that replaced the old refusal: the test is direct reports on the register rather than the title, and every such decision is recorded in the requests timeline as a self-approval. The rest of the claim is unchanged — the linen room still cannot approve on a managers behalf, and approval and fulfilment are still separate jobs.",
"12 September 2026: the Data Security page no longer limits self-approval to a manager with someone reporting to them. An administrator may now record anyone as their own manager, and every request or signed order form a person approves for themselves is recorded as a self-approval. The page also now states the rule that did not change: nobody approves a request they raised for somebody else."] },
],
};
/* Each document carries its own date. One shared date made an untouched document announce a
revision it had not had, which at a customer is not a cosmetic problem: a changed date on the
Terms sends the whole pack back through legal review for nothing. */
export const DOC_META: Record<string, { path: string; title: string; desc: string; updated: string }> = {
"Privacy Policy": { path: "/privacy", title: "Privacy Policy", desc: "What personal information ThreadCount holds for a facilitys uniform service, why, and who can see it.", updated: "13 September 2026" },
"Terms of Service": { path: "/terms", title: "Terms of Service", desc: "The terms a facility and its authorised users agree to when using ThreadCount.", updated: "13 September 2026" },
"Data Security": { path: "/data-security", title: "Data Security", desc: "Where ThreadCount records live, how access is controlled, and what the device camera does.", updated: "12 September 2026" },
"Acceptable Use": { path: "/acceptable-use", title: "Acceptable Use", desc: "What coordinators should and shouldnt do with the staff register and the issue record.", updated: "28 August 2026" },
// The document history lives in here. It was written but had no route, so the one reader who
// ever wants it — a privacy officer asking what changed and when — could not reach it.
"About & Contact": { path: "/legal-about", title: "About & Contact", desc: "Who wrote ThreadCount, where to send a privacy or security question, and what changed in these documents.", updated: "13 September 2026" },
};
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useEffect, useRef } from "react";
/* Keeping a screen honest about changes it did not make.
*
* Every mutation in this product already ends in router.refresh(), so a screen is never stale about
* its OWN work. What it had no way to learn was that somebody else had changed something: a phone
* left open on a ward went on showing the catalogue as it stood when it was opened, and a
* coordinator adding a garment at the desk had to go and tell the counter to reload.
*
* Polling the snapshot itself to find out would mean every open device re-reading the facility's
* catalogue, register, stock and history every few seconds to discover, nearly always, that nothing
* had happened. So the server keeps a counter and bumps it once per mutation; this asks for that
* one number, and only pays for the real reload when it has moved — with one exception, the first
* answer this tab has no baseline for, for the reason set out in tick().
*
* Two things keep it quiet. It stops entirely while the tab is hidden — a phone in a pocket costs
* nothing, and the first thing it does on becoming visible again is ask, so coming back to the app
* is immediate rather than up to a poll late. And a mutation made HERE records the revision it
* produced, so your own save never bounces the screen a second time a few seconds later.
*/
const POLL_MS = 5000;
/** The last revision this tab knows about, from a poll or from its own mutation. */
let lastRev: number | null = null;
/* Bumped every time lastRev moves, so a poll can tell whether its answer was already out of date
* by the moment it arrived. A question asked before lastRev changed may have been read on the
* server before that change landed; one asked after it cannot have been. That is the whole of how
* we tell "an answer from before my own save" from "the world really is at a smaller number" —
* see the lower-revision branch in tick(). */
let revGen = 0;
function setRev(rev: number) {
lastRev = rev;
revGen++;
}
/** Called by mutate() with the revision its own write produced, so the poll does not re-fire it. */
export function noteRev(rev: unknown) {
if (typeof rev === "number") setRev(rev);
}
export function useLiveRefresh(refresh: () => void) {
const busy = useRef(false);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = () => {
if (timer) clearTimeout(timer);
if (!stopped) timer = setTimeout(tick, POLL_MS);
};
const tick = async () => {
if (stopped) return schedule();
// Hidden tabs ask nothing at all; visibilitychange below wakes them.
if (document.visibilityState !== "visible" || busy.current) return schedule();
busy.current = true;
// Taken before the question goes out, so the answer can be judged against what we knew when
// we asked rather than against what we have learned while waiting.
const genAsked = revGen;
try {
const r = await fetch("/api/rev", { cache: "no-store" });
if (r.ok) {
const { rev } = (await r.json()) as { rev?: unknown };
if (typeof rev === "number") {
if (lastRev === null) {
/* Nothing to compare against, so we cannot prove this screen is current.
*
* The server rendered the page at some revision nobody told the browser, and the
* linen room can mark a bag ready in the gap between that render and this first
* question. Quietly adopting the answer as a baseline loses that change for good:
* the ward phone goes on saying "Being picked" until somebody else in the facility
* happens to move the number again, and the nurse never walks down for the bag. So
* the first answer always reloads. It costs one extra render per page load, and the
* loop below is started at mount rather than a poll later so that render lands at
* launch, before anyone has begun counting into the screen.
*
* Once per fresh load of the app, then — not once per screen. lastRev belongs to the
* tab, so moving between screens inside the app still has a baseline to compare
* with, and anything that happened around that later render leaves the counter above
* the baseline, which the ordinary branch below picks up on its own.
*/
setRev(rev);
refresh();
} else if (rev > lastRev) {
// The ordinary case: somebody else moved the counter on.
setRev(rev);
refresh();
} else if (rev < lastRev && revGen === genAsked) {
/* The counter has genuinely gone backwards, and the screen has to follow it down.
*
* Restoring a backup rebuilds the facility row, and the revision it comes back with
* can be lower than a number this tab has already seen. A tab that only ever accepted
* higher numbers would then refuse every answer for as long as it stayed open — going
* on showing a catalogue and a stock position that no longer exist, with nothing on
* screen to say so. That is worse than the double refresh guarded against below,
* because nothing ever ends it.
*
* What separates the two is whether anything moved lastRev while this question was in
* the air. Nothing did, so this read cannot be an echo of the world before our own
* save: it was issued after we already held the newer number, and the server still
* answered with a smaller one. That is news, not a straggler, so we take it.
*/
setRev(rev);
refresh();
}
/* Anything left is our own save coming back to haunt us: this poll's read ran before
* mutate() bumped the facility, so it answers with the old number while noteRev has
* already recorded the new one. Taking it would walk lastRev backwards and reload the
* screen for a change it had already applied — then again five seconds later when the
* real number reappeared. And the same number twice was never news to begin with. */
}
}
} catch {
// Offline, asleep, or the server restarting mid-deploy. The next tick asks again; a missed
// poll is a few seconds of staleness, not an error worth putting in front of anybody.
} finally {
busy.current = false;
}
schedule();
};
const onVisible = () => { if (document.visibilityState === "visible") void tick(); };
document.addEventListener("visibilitychange", onVisible);
void tick();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, [refresh]);
}
+61
View File
@@ -0,0 +1,61 @@
import nodemailer from "nodemailer";
/** SMTP is optional. With nothing configured the app still records what it needs to — it just
* doesn't post a notification, and says so in the logs rather than failing the caller's request. */
export function mailConfigured() {
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS && process.env.CONTACT_TO);
}
/** Is transactional mail (password resets, staff activation) possible?
* Deliberately separate from mailConfigured(): the contact form additionally needs CONTACT_TO,
* and a missing CONTACT_TO must not silently disable password resets. */
export function transactionalConfigured() {
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS);
}
/** Send to a specific person. Returns false rather than throwing: a caller deciding what to tell
* the user should never be handed an SMTP stack trace. */
export async function sendTo(to: string, subject: string, text: string): Promise<boolean> {
if (!transactionalConfigured()) {
console.warn("[mail] no SMTP configured — not sending:", subject);
return false;
}
try {
const port = parseInt(process.env.SMTP_PORT || "587", 10);
const t = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: port === 465,
auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
});
await t.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER!, to, subject, text });
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
export async function sendMail(subject: string, text: string, replyTo?: string): Promise<boolean> {
if (!mailConfigured()) return false;
try {
const port = parseInt(process.env.SMTP_PORT || "587", 10);
const t = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: port === 465,
auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
});
await t.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER!,
to: process.env.CONTACT_TO!,
replyTo: replyTo || undefined,
subject,
text,
});
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
+197
View File
@@ -0,0 +1,197 @@
import { prisma } from "./db";
import { availability, bagLines, linesSummary, reqLines, type ReqLine } from "./staffdata";
import { addDays, facilityToday, isKitGroup, isNursingGroup } from "./compute";
import { allowance, capState, garmentCounts, setsHeld } from "./sets";
import { decisionSummary, garmentCount, stockLabel } from "./staffreq";
import type { StaffSession } from "./staffsession";
/* What a ward manager sees.
*
* Scoped to the people who name them as their manager, never to a ward or a facility. A manager
* with nobody reporting to them sees nothing here, and a manager cannot reach a request addressed
* to somebody else — the queries below take `managerId: sess.staffId` as their starting point
* rather than filtering for it afterwards.
*/
export type QueueRow = {
id: string; code: string; subjectName: string; subjectGroup: string; subjectNum: string;
/** The whole ask, in the order it was entered. Nothing is decided yet on this screen, so every
* line is still `awaiting` and the summary describes all of them. */
lines: ReqLine[]; summary: string; garments: number; lineCount: number;
reason: string; note: string;
raisedByName: string; createdAt: string;
};
export async function approvalQueue(sess: StaffSession): Promise<QueueRow[]> {
const rows = await prisma.request.findMany({
where: { managerId: sess.staffId, status: "awaiting" },
orderBy: { createdAt: "asc" }, // oldest first: the person waiting longest is the point
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, group: true, num: true } },
},
});
return rows.map((r) => {
const lines = reqLines(r.lines);
return {
id: r.id, code: r.code,
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
subjectGroup: r.subject.group, subjectNum: r.subject.num,
lines, summary: linesSummary(lines), garments: garmentCount(bagLines(lines)), lineCount: lines.length,
reason: r.reason, note: r.note, raisedByName: r.raisedByName,
createdAt: r.createdAt.toISOString(),
};
});
}
/** One line of a request, with the two facts a manager needs to judge that garment on its own:
* whether the shelf has it, and how many the person is already holding. */
export type ReviewLine = ReqLine & { stock: string; held: number; heldThisSize: number };
/** One request, with enough about the person to decide without leaving the screen.
*
* The decision is one action over the whole ask, but a manager may knock back individual garments
* — the tunic and the trousers yes, the fleece no — so everything they would weigh up is returned
* per line as well as per request. */
export async function reviewData(sess: StaffSession, id: string) {
const r = await prisma.request.findFirst({
where: { id, managerId: sess.staffId },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { id: true, first: true, last: true, group: true, num: true, dept: true } },
// What the allowance sum needs from the register: which groups this site puts on the FTE table
// and which on the starting kit, its ceiling, and how many sets that kit is. They ride along
// with the request because they belong to the request's own facility, and because a review
// screen that went and fetched them separately would be quoting settings a second query later.
facility: { select: { nursingGroups: true, kitGroups: true, capSets: true, initialSets: true } },
},
});
if (!r) return null;
const itemIds = [...new Set(r.lines.map((l) => l.itemId))];
const [holdings, approvedThisYear, avail] = await Promise.all([
// A handed-in garment has left the person even though nothing marked it returned, and
// counting them here reads as held *and* handed back at once — which pushes a nurse who did
// exactly what she was asked to over her cap on the very screen that decides her request.
prisma.issue.findMany({
where: { staffId: r.subject.id, returnedDate: null, handedIn: null },
select: { itemId: true, sizeIndex: true, qty: true, item: { select: { type: true, item: true } } },
}),
prisma.request.count({ where: { subjectId: r.subject.id, status: { not: "declined" }, decidedAt: { not: null } } }),
availability(sess.facilityId, itemIds),
]);
const held = holdings.reduce((n, h) => n + h.qty, 0);
const sets = setsHeld(holdings);
// Everything here is about the subject of the request, never about the manager reading it. A
// manager's own group is often on a different route from her staff's, and testing hers instead
// would describe somebody else's allowance to her. Both route answers go in: left without the
// starting-kit one, a person whose group starts on a kit is read as starting on nothing.
const allow = allowance({
group: r.subject.group, held: sets,
nursing: isNursingGroup(r.facility.nursingGroups, r.subject.group),
kit: isKitGroup(r.facility.kitGroups, r.subject.group),
capSets: r.facility.capSets, startingSets: r.facility.initialSets,
});
const lines = reqLines(r.lines);
const reviewLines: ReviewLine[] = lines.map((l) => ({
...l,
stock: stockLabel((avail[l.itemId] || []).find((a) => a.si === l.si)?.word ?? "none").toLowerCase(),
// "Over allowance" is the commonest decline, and the manager should be able to see the reason
// for it against the garment rather than work it out from the totals at the top of the screen.
held: holdings.filter((h) => h.itemId === l.itemId).reduce((n, h) => n + h.qty, 0),
heldThisSize: holdings.filter((h) => h.itemId === l.itemId && h.sizeIndex === l.si).reduce((n, h) => n + h.qty, 0),
}));
return {
id: r.id, code: r.code,
// The screen is reachable from a link in an e-mail as well as from the queue, so it can open
// on a request somebody has already settled. It has to be able to tell.
status: r.status,
subject: {
// The id, so the screen can tell "this is my own request" by identity rather than by staff
// number — numbers are allowed blank on import, and two blanks are equal.
id: r.subject.id,
name: `${r.subject.first} ${r.subject.last}`.trim(),
num: r.subject.num, group: r.subject.group, ward: r.subject.dept,
held, sets, approvedThisYear,
},
lines: reviewLines,
summary: linesSummary(lines), garments: garmentCount(bagLines(lines)), lineCount: lines.length,
// Not null only when the manager has come back to a request they have already settled — the
// screen is reachable after the decision, and it should say what the decision was.
decision: decisionSummary(lines),
reason: r.reason, note: r.note, raisedByName: r.raisedByName,
allowance: {
capped: allow.capped,
// The manager is told the ceiling this person is measured against, and nothing is released by
// anybody on the way up to it.
label: allow.capped ? `${allow.used} of ${allow.cap} sets` : `${r.subject.group || "This role"} — no fixed cap. Your approval is the control.`,
note: allow.note,
// Per half as well as in sets — the ceiling the hand-over applies (lib/sets.ts capState).
// Six tops and two pairs is "2 of 6 sets" and still one top away from an override stamp,
// and the manager deciding a seventh top should see that here, not on the exceptions report.
over: capState({ held: garmentCounts(holdings), capSets: r.facility.capSets }).over
|| (allow.capped && allow.cap !== null && allow.used >= allow.cap),
},
};
}
/** The ward view: who holds what, so a manager can see the shape of it. */
export async function wardData(sess: StaffSession) {
const [me, team] = await Promise.all([
prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { dept: true, facility: { select: { timezone: true, nursingGroups: true, kitGroups: true, capSets: true, initialSets: true } } } }),
prisma.staff.findMany({
where: { managerId: sess.staffId, inactive: false },
orderBy: [{ last: "asc" }, { first: "asc" }],
select: { id: true, first: true, last: true, group: true, start: true },
}),
]);
if (!team.length) return { ward: me.dept, rows: [], anyCapped: false };
const ids = team.map((t) => t.id);
const issues = await prisma.issue.findMany({
where: { staffId: { in: ids }, returnedDate: null, handedIn: null },
select: { staffId: true, qty: true, date: true, item: { select: { type: true, item: true } } },
});
const byStaff = new Map<string, { qty: number; last: string; lines: { item: { type: string; item: string }; qty: number }[] }>();
for (const i of issues) {
const cur = byStaff.get(i.staffId) || { qty: 0, last: "", lines: [] };
cur.qty += i.qty;
if (i.date > cur.last) cur.last = i.date;
cur.lines.push({ item: i.item, qty: i.qty });
byStaff.set(i.staffId, cur);
}
// `start` is a date somebody typed in the facility's own terms, so the 90-day boundary has to be
// a date in those terms too. Derived from UTC it slides a day for however many hours the
// facility's morning runs ahead of it, and the flag flickers on and off across a shift.
const newStarterFrom = addDays(facilityToday(me.facility.timezone), -90);
const rows = team.map((t) => {
const mine = byStaff.get(t.id) || { qty: 0, last: "", lines: [] };
const sets = setsHeld(mine.lines);
// The row is the team member's, so the route is theirs — the manager's own group decides nothing
// about what the people reporting to her may hold. The facility's lists and figures come off the
// row already loaded above, rather than a query per person in a ward-sized loop.
const allow = allowance({
group: t.group, held: sets,
nursing: isNursingGroup(me.facility.nursingGroups, t.group),
kit: isKitGroup(me.facility.kitGroups, t.group),
capSets: me.facility.capSets, startingSets: me.facility.initialSets,
});
return {
id: t.id,
name: `${t.first} ${t.last}`.trim(),
group: t.group,
held: mine.qty,
lastIssued: mine.last,
capped: allow.capped,
setsLabel: allow.capped ? `${allow.used} of ${allow.cap} sets` : "",
isNewStarter: !!t.start && mine.last !== "" && t.start >= newStarterFrom,
};
});
return { ward: me.dept, rows, anyCapped: rows.some((r) => r.capped) };
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
/* The native barcode scanner, used only when the page is running inside the Android shell.
*
* In a browser the app reads barcodes with BarcodeDetector, which is fine on a desk and patchy on
* a ward: it misses crumpled labels and dim light, and it isn't in every Android WebView. Inside
* Capacitor we hand the job to MLKit instead, which is the main reason the app exists at all.
*
* Everything here is dynamically imported. The plugin must never be pulled into the browser
* bundle's critical path — most viewers of /m are on the web, where none of this runs. */
export type NativeBarcode = { rawValue: string };
type ScannerModule = {
BarcodeScanner: {
isSupported: () => Promise<{ supported: boolean }>;
checkPermissions: () => Promise<{ camera: string }>;
requestPermissions: () => Promise<{ camera: string }>;
scan: (opts?: { formats?: unknown[] }) => Promise<{ barcodes: NativeBarcode[] }>;
startScan: (opts?: { formats?: unknown[] }) => Promise<void>;
stopScan: () => Promise<void>;
addListener: (
event: "barcodeScanned",
cb: (r: { barcode: NativeBarcode }) => void,
) => Promise<{ remove: () => Promise<void> }>;
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available: boolean }>;
installGoogleBarcodeScannerModule?: () => Promise<void>;
};
};
/** True only inside the Capacitor shell. In any browser this is false and the web path is used. */
export function isNative(): boolean {
if (typeof window === "undefined") return false;
const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
return !!cap?.isNativePlatform?.();
}
let mod: ScannerModule | null = null;
async function load(): Promise<ScannerModule | null> {
if (!isNative()) return null;
if (mod) return mod;
try {
mod = (await import("@capacitor-mlkit/barcode-scanning")) as unknown as ScannerModule;
return mod;
} catch {
return null; // shell without the plugin — fall back to the web scanner
}
}
/** Ask once, and say plainly what a refusal means rather than failing silently. */
async function ensureReady(m: ScannerModule): Promise<string | null> {
const { BarcodeScanner: S } = m;
const supported = await S.isSupported().catch(() => ({ supported: false }));
if (!supported.supported) return "This device cant scan barcodes.";
let perm = await S.checkPermissions().catch(() => ({ camera: "denied" }));
if (perm.camera !== "granted") perm = await S.requestPermissions().catch(() => ({ camera: "denied" }));
if (perm.camera !== "granted") return "ThreadCount needs the camera to scan. Allow it in Android settings, then try again.";
// On some devices MLKit ships as a downloadable module rather than in the app.
if (S.isGoogleBarcodeScannerModuleAvailable && S.installGoogleBarcodeScannerModule) {
const has = await S.isGoogleBarcodeScannerModuleAvailable().catch(() => ({ available: true }));
if (!has.available) {
try { await S.installGoogleBarcodeScannerModule(); } catch { return "The barcode module is still downloading — try again in a moment."; }
}
}
return null;
}
/* What the scanner is allowed to read.
*
* Left unrestricted, MLKit reads every symbology it knows — and a garment tag is rarely alone in
* the frame. A care label, a carton behind it on the shelf and a poster on the wall all get read as
* eagerly as the swing tag, and whichever the camera locks onto first is what comes back.
*
* Two of those symbologies are worse than noise. ITF and Codabar carry no check digit, so a partial
* read is indistinguishable from a real code: half a carton's ITF-14 decodes cleanly as a shorter,
* entirely valid ITF number. That is a wrong code that looks right, which is the one failure a
* barcode is supposed to make impossible.
*
* So: the retail codes a garment is actually labelled with (EAN and UPC), plus the Code 39/93/128
* family that suppliers and internal label printers use, all of which are either check-digited or
* self-checking. No 2D — nothing in this product is identified by a QR or Data Matrix, and the only
* QR ThreadCount has anything to do with is the one it draws for two-factor setup. */
const SCAN_FORMATS = ["EAN_13", "EAN_8", "UPC_A", "UPC_E", "CODE_128", "CODE_39", "CODE_93"];
/** One read, using MLKit's own full-screen scanner. Resolves null when the person backs out. */
export async function scanOnce(): Promise<{ code: string | null; error?: string }> {
const m = await load();
if (!m) return { code: null, error: "native-unavailable" };
const err = await ensureReady(m);
if (err) return { code: null, error: err };
try {
const res = await m.BarcodeScanner.scan({ formats: SCAN_FORMATS });
const raw = res.barcodes?.[0]?.rawValue;
return { code: raw ? String(raw).trim() : null };
} catch {
return { code: null }; // cancelled
}
}
/** Continuous scanning for a stocktake. The camera preview renders behind the WebView, so the
* page has to go transparent while it runs — `stop()` puts it back. */
export async function startLive(onCode: (code: string) => void): Promise<{ stop: () => Promise<void>; error?: string }> {
const noop = { stop: async () => {} };
const m = await load();
if (!m) return { ...noop, error: "native-unavailable" };
const err = await ensureReady(m);
if (err) return { ...noop, error: err };
const S = m.BarcodeScanner;
document.documentElement.classList.add("tcx-native-scan");
const handle = await S.addListener("barcodeScanned", (r) => {
const raw = r?.barcode?.rawValue;
if (raw) onCode(String(raw).trim());
});
try {
await S.startScan({ formats: SCAN_FORMATS });
} catch {
document.documentElement.classList.remove("tcx-native-scan");
await handle.remove().catch(() => {});
return { ...noop, error: "The camera wouldnt start." };
}
let stopped = false;
return {
stop: async () => {
if (stopped) return;
stopped = true;
document.documentElement.classList.remove("tcx-native-scan");
await S.stopScan().catch(() => {});
await handle.remove().catch(() => {});
},
};
}
+76
View File
@@ -0,0 +1,76 @@
/* A part-finished shelf count, parked in the browser while it is being taken.
*
* The counting screen writes on every tap and the variance screen reads it back, so the two have to
* agree on the key and the shape. They used to agree by importing a helper out of the page module,
* which worked but put the contract in the wrong place; it lives here now so sign-out can reach it
* too.
*
* Scoped to the person as well as the shelf. A counter phone sits on a bench and is shared: keyed
* on the location alone, an abandoned half-count was pre-filled straight into the next person's
* screen, and they would commit somebody else's tally under their own name without ever being told
* a count was already open. The user id keeps them apart, and gives sign-out something it can
* clear.
*
* This is a scratchpad, not a cache. ThreadCount is online-only; nothing here is ever the record,
* and it is deleted the moment the count commits.
*/
const PREFIX = "tc.count.";
/** Where one person's open count of one shelf lives. */
export const countKey = (userId: string, locationId: string) => `${PREFIX}${userId}.${locationId}`;
export type OpenCount = {
/** Counted quantity by variant key. */
n: Record<string, number>;
/** When the tally was last touched, so variance can say how old it is — a count resumed the next
* morning is a different thing from one still in your hand, and the screen should say which. */
savedAt: string;
};
export function readCount(userId: string, locationId: string): OpenCount | null {
try {
const raw = localStorage.getItem(countKey(userId, locationId));
if (!raw) return null;
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object") return null;
const o = v as Partial<OpenCount>;
// Anything that isn't the current shape is treated as no count at all rather than half-read:
// a tally is only worth restoring if it is whole, and starting from zero is honest.
if (!o.n || typeof o.n !== "object") return null;
const n: Record<string, number> = {};
for (const k of Object.keys(o.n)) {
const q = Math.floor(Number((o.n as Record<string, unknown>)[k]));
if (Number.isFinite(q) && q > 0) n[k] = q;
}
return { n, savedAt: typeof o.savedAt === "string" ? o.savedAt : "" };
} catch {
// A cleared, blocked or full store just means there is no count to resume.
return null;
}
}
export function writeCount(userId: string, locationId: string, n: Record<string, number>) {
try {
localStorage.setItem(countKey(userId, locationId), JSON.stringify({ n, savedAt: new Date().toISOString() } satisfies OpenCount));
} catch { /* nothing to do if the store is full or blocked — the count carries on in memory */ }
}
export function clearCount(userId: string, locationId: string) {
try { localStorage.removeItem(countKey(userId, locationId)); } catch { /* already gone */ }
}
/** Everything this person has part-counted, on this device. Sign-out calls it: their tallies are
* theirs, and leaving them behind on a shared phone is the leak the per-person key exists to stop
* — the keys would otherwise sit there until the browser storage was cleared by hand. */
export function clearAllCounts(userId: string) {
try {
const mine = `${PREFIX}${userId}.`;
const doomed: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(mine)) doomed.push(k);
}
for (const k of doomed) localStorage.removeItem(k);
} catch { /* blocked store — there was nothing written to clear either */ }
}
+3425
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import { prisma } from "../db";
import { sendTo } from "../mail";
/* Notices to the owner, by email, over the SMTP the product already has.
*
* Who gets them: OPS_ALERT_TO when set, otherwise every active OWNER operator. What they carry:
* the name of a facility and what happened to it — never a coordinator's name or email, never a
* wearer, never a record. A notice must be safe to sit in a mailbox. Each caller names its own
* condition; nothing here fires on a timer, so there is nothing to dedupe — one event, one mail.
*
* Never throws and never awaited for its own sake: a notice that could fail the action it
* describes would be a reason to skip sending it. */
export async function ownerAddresses(): Promise<string[]> {
const fixed = (process.env.OPS_ALERT_TO || "").split(",").map((s) => s.trim()).filter(Boolean);
if (fixed.length) return fixed;
try {
const owners = await prisma.operator.findMany({ where: { role: "OWNER", inactive: false }, select: { email: true } });
return owners.map((o) => o.email);
} catch {
return [];
}
}
export function notifyOwners(subject: string, text: string): void {
ownerAddresses()
.then((to) => Promise.all(to.map((a) => sendTo(a, subject, text))))
.catch(() => { /* a notice must not fail the action */ });
}
const when = () => new Date().toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
/** A facility signed up. Name only — the person who did it is a contact, and contacts are revealed, not mailed. */
export function alertNewSignup(facility: { id: string; name: string }): void {
notifyOwners(`[ops] New facility — ${facility.name}`, [
`A facility signed up for ThreadCount.`,
"",
`Facility: ${facility.name}`,
`When: ${when()} (Brisbane)`,
`Console: https://ops.threadcount.tech/ops/facilities/${facility.id}`,
"",
"It appears on the console as \"never set up\" until its staff groups are named.",
].join("\n"));
}
/** A facility was deleted from the console. Sent to every owner so a second operator's deletion reaches the first. */
export function alertFacilityDeleted(args: { name: string; by: string; ip: string; counts: string }): void {
notifyOwners(`[ops] Facility deleted — ${args.name}`, [
`${args.by} deleted a facility from the ThreadCount operations console.`,
"",
`Facility: ${args.name}`,
`Had: ${args.counts}`,
`When: ${when()} (Brisbane)`,
`From: ${args.ip || "unknown address"}`,
"",
"Every record, image and account belonging to it is gone. The pre-deploy database dumps on the box are the only copies left.",
].join("\n"));
}
+111
View File
@@ -0,0 +1,111 @@
import crypto from "node:crypto";
/* Cloudflare Access application-JWT verification for the operations console.
*
* ops.threadcount.tech sits behind a Cloudflare Access application whose login methods are the
* ThreadCount brand of Authentik (OIDC) and a one-time-PIN break-glass. Access puts a signed
* assertion on every request it lets through (`Cf-Access-Jwt-Assertion`). Trusting that assertion
* is what makes the console single-sign-on: the app mints an operator session from the verified
* email instead of asking for a second password.
*
* Hand-rolled on node:crypto, the way lib/session.ts and lib/totp.ts are, and RS256-ONLY: the
* only operation ever performed is an RSA verify, so the "alg: none" / HS256-with-a-public-key
* confusion cannot apply. Every check fails CLOSED — a malformed token, wrong issuer or audience,
* expired claim or bad signature returns null and the caller falls back to the password door. It
* never throws. Active only when both variables are set; without them the console simply has no
* SSO, and the fire escape is the whole door. */
const TEAM = process.env.CF_ACCESS_TEAM_DOMAIN; // e.g. example.cloudflareaccess.com
const AUD = process.env.CF_ACCESS_AUD; // the Access application's AUD tag
const ISSUER = TEAM ? `https://${TEAM}` : null;
const CERTS_URL = TEAM ? `https://${TEAM}/cdn-cgi/access/certs` : null;
export function accessSsoConfigured(): boolean {
return Boolean(TEAM && AUD);
}
/** Where to land after single sign-on: only a path under /ops, never the sign-in page (a loop),
* never a scheme or a protocol-relative host (an open redirect). Anything else → /ops. */
export function safeOpsNext(raw: string | null | undefined): string {
if (!raw || (raw !== "/ops" && !raw.startsWith("/ops/")) || raw.startsWith("/ops/login")) return "/ops";
if (/[\\\s]/.test(raw) || raw.includes("://")) return "/ops";
return raw;
}
type Jwk = { kid?: string; kty?: string; n?: string; e?: string };
let jwksCache: { keys: Jwk[]; fetchedAt: number } | null = null;
const JWKS_TTL_MS = 10 * 60 * 1000;
async function getKeys(force = false): Promise<Jwk[]> {
if (!force && jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) return jwksCache.keys;
const r = await fetch(CERTS_URL!, { cache: "no-store" });
if (!r.ok) throw new Error("access certs fetch failed");
const j = (await r.json()) as { keys?: Jwk[] };
const keys = Array.isArray(j.keys) ? j.keys : [];
jwksCache = { keys, fetchedAt: Date.now() };
return keys;
}
function decodeSeg(seg: string): Record<string, unknown> | null {
try {
return JSON.parse(Buffer.from(seg, "base64url").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
/** The verified, lower-cased email, or null on any failure whatsoever. */
export async function verifyAccessJwt(token: string | null | undefined): Promise<string | null> {
if (!token || !TEAM || !AUD || !ISSUER || !CERTS_URL) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [h, p, s] = parts;
const header = decodeSeg(h);
const payload = decodeSeg(p);
if (!header || !payload) return null;
if (header.alg !== "RS256" || typeof header.kid !== "string") return null;
const now = Math.floor(Date.now() / 1000);
const exp = payload.exp;
const iat = payload.iat;
if (typeof exp !== "number" || exp < now - 5) return null; // expired (small skew)
if (typeof iat === "number" && iat > now + 60) return null; // issued in the future
if (payload.iss !== ISSUER) return null;
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!auds.includes(AUD)) return null;
const rawEmail = payload.email;
const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : null;
if (!email) return null;
let keys: Jwk[];
try {
keys = await getKeys();
} catch {
return null;
}
let jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) {
// Unknown kid — Access rotates keys; refresh once before giving up.
try {
keys = await getKeys(true);
} catch {
return null;
}
jwk = keys.find((k) => k.kid === header.kid);
}
if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e) return null;
let pub: crypto.KeyObject;
try {
pub = crypto.createPublicKey({ key: jwk as crypto.JsonWebKey, format: "jwk" });
} catch {
return null;
}
let ok = false;
try {
ok = crypto.verify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, Buffer.from(s, "base64url"));
} catch {
return null;
}
return ok ? email : null;
}
+144
View File
@@ -0,0 +1,144 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { resetDemo } from "../demo";
import { PLANS, isPlanCode } from "../plan";
import { deletePhotoDir } from "../photostore";
import { SWITCH_ROW } from "../switches";
import { alertFacilityDeleted } from "./alerts";
import { logOperatorEvent, type OperatorSession } from "./session";
import { verifyOperatorCode } from "./totp";
/* The console's writes. Four of them, and every one is a control-plane act on the console's own
* records or on a facility's configuration — never on a facility's content.
*
* switches — sign-ups open/closed, demo in/out of service. One row; the environment overrides.
* demo reset — the product's own resetDemo(), on demand instead of on the twenty-minute clock.
* plan — a label and a note on a facility: the billing scaffold. Display only, elsewhere.
* delete — a whole facility, the way the product's own "delete my account" does it: the
* cascade from Facility, then the images. Owner, typed name, live code, and a
* notice to every owner. The one act here that cannot be undone.
*
* All go through the main client on purpose: ops_ro cannot write, and these are the writes the
* console is for. Everything is written to the trail. */
export class ControlError extends Error {
constructor(message: string, public status = 400) { super(message); }
}
export type SwitchKey = "signupsDisabled" | "demoDisabled" | "plansLive";
export async function setSwitch(op: OperatorSession, key: SwitchKey, value: boolean, ip: string) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can change a platform switch.", 403);
await prisma.platformSwitch.upsert({
where: { id: SWITCH_ROW },
create: { id: SWITCH_ROW, [key]: value },
update: { [key]: value },
});
const detail = key === "plansLive" ? (value ? "live" : "off") : value ? "closed" : "open";
logOperatorEvent({ operatorId: op.id, action: "ops:switch", subject: key, detail, ip });
}
export async function resetDemoNow(op: OperatorSession, ip: string) {
await resetDemo();
logOperatorEvent({ operatorId: op.id, action: "ops:demo.reset", ip });
}
export const PLAN_NOTE_MAX = 400;
/* The billing desk. Five acts, each one a change to the six plan columns lib/plan.ts reads, and
* each one written to the trail with what it did. No money moves here: an invoice is raised in the
* accounting tool and its payment recorded with `paid`. Dates are set relative to today so the
* operator never types one. */
export type PlanAct =
| { act: "set"; plan: string; planNote: string; grandfathered?: boolean }
| { act: "trial"; days: number } // start, or extend by, this many days
| { act: "paid"; months: number } // a payment covering this many months from today or from paidUntil
| { act: "readonly"; on: boolean }
| { act: "free" }; // back to free on the current plan (a pilot ended, a refund)
const DAY = 86_400_000;
async function planFacility(facilityId: string) {
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true } });
if (!f) throw new ControlError("No such facility", 404);
if (f.isDemo) throw new ControlError("The demo facility has no plan.");
return f;
}
export async function planControl(op: OperatorSession, facilityId: string, a: PlanAct, ip: string) {
const f = await planFacility(facilityId);
let data: Prisma.FacilityUpdateInput = {};
let detail = "";
switch (a.act) {
case "set": {
if (a.plan !== "" && !isPlanCode(a.plan)) throw new ControlError("Not a plan.");
const n = a.planNote.trim().slice(0, PLAN_NOTE_MAX);
data = { plan: a.plan, planNote: n };
// The promise is an owner's to give or take; a support operator may change the plan and the note.
if (a.grandfathered !== undefined && a.grandfathered !== f.grandfathered) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can change whether a facility is grandfathered.", 403);
data.grandfathered = a.grandfathered;
}
detail = `${a.plan ? PLANS[a.plan as keyof typeof PLANS].label : "no plan"}${data.grandfathered !== undefined ? (data.grandfathered ? " · grandfathered" : " · grandfathering removed") : ""}${n ? " — " + n : ""}`;
break;
}
case "trial": {
const days = Math.min(365, Math.max(1, Math.floor(a.days || 0)));
// Extends a trial still running; starts one from today otherwise. A facility with no paid
// plan code yet gets Hosted Facility — a trial of Hosted Small would be a trial of free.
const from = f.planStatus === "trial" && f.trialEndsAt && f.trialEndsAt > new Date() ? f.trialEndsAt : new Date();
const ends = new Date(from.getTime() + days * DAY);
data = { planStatus: "trial", trialEndsAt: ends, plan: f.plan && f.plan !== "hosted_small" ? f.plan : "hosted_facility" };
detail = `trial to ${ends.toISOString().slice(0, 10)}`;
break;
}
case "paid": {
const months = Math.min(36, Math.max(1, Math.floor(a.months || 0)));
const from = f.planStatus === "active" && f.paidUntil && f.paidUntil > new Date() ? f.paidUntil : new Date();
const until = new Date(from); until.setUTCMonth(until.getUTCMonth() + months);
data = { planStatus: "active", paidUntil: until, plan: f.plan && f.plan !== "hosted_small" ? f.plan : "hosted_facility" };
detail = `paid to ${until.toISOString().slice(0, 10)}`;
break;
}
case "readonly": {
if (a.on) { data = { planStatus: "read_only" }; detail = "read-only"; }
else {
// Back to whatever the dates say: a paid year still running is active, a trial still
// running is a trial, anything else is free.
const now = new Date();
const status = f.paidUntil && f.paidUntil > now ? "active" : f.trialEndsAt && f.trialEndsAt > now ? "trial" : "free";
data = { planStatus: status }; detail = `writable again · ${status}`;
}
break;
}
case "free":
data = { planStatus: "free", trialEndsAt: null, paidUntil: null };
detail = "set free";
break;
}
await prisma.facility.update({ where: { id: f.id }, data });
logOperatorEvent({ operatorId: op.id, action: "ops:plan", facilityId: f.id, subject: f.name, detail, ip });
}
/** Owner + the facility's exact name + a live code from the operator's own second factor. */
export async function deleteFacility(op: OperatorSession, facilityId: string, confirm: string, code: string, ip: string) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can delete a facility.", 403);
const o = await prisma.operator.findUnique({ where: { id: op.id }, select: { totpSecret: true, totpEnabledAt: true } });
if (!o?.totpEnabledAt) throw new ControlError("Enrol a second factor before deleting anything.", 403);
if (!verifyOperatorCode(o, code.replace(/\s+/g, ""))) throw new ControlError("That code isn't right. Use the current one from your app.", 401);
const f = await prisma.facility.findUnique({
where: { id: facilityId },
select: { id: true, name: true, isDemo: true, _count: { select: { users: true, staff: true, issues: true } } },
});
if (!f) throw new ControlError("No such facility", 404);
if (f.isDemo) throw new ControlError("The demo facility is rebuilt, not deleted — use Reset demo.");
if (confirm.trim() !== f.name) throw new ControlError(`Type the facility name exactly — ${f.name} — to confirm.`);
const counts = `${f._count.users} coordinators, ${f._count.staff} staff, ${f._count.issues} issues`;
// Trail first: if the delete fails half-way the record says it was attempted; if it succeeds the
// record outlives the row, because facilityId here is a plain string and not a relation.
logOperatorEvent({ operatorId: op.id, action: "ops:facility.delete", facilityId: f.id, subject: f.name, detail: counts, ip });
await prisma.facility.delete({ where: { id: f.id } }); // every relation cascades from Facility
await deletePhotoDir(f.id);
alertFacilityDeleted({ name: f.name, by: `${op.name} (${op.email})`, ip, counts });
return { name: f.name };
}
+41
View File
@@ -0,0 +1,41 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
/* The console's database clients. Two, and the difference between them is the whole design.
*
* opsDb() connects as `ops_ro`, a Postgres role granted SELECT on control-plane columns only —
* counts, dates, configuration — and nothing on customer content: no wearer's name, no photo, no
* request text, no coordinator's email. The grants are in the ops_ro_grants migration and are
* proven on production by scripts/ops-ro-probe.cjs. The point is that the guard is the database,
* not code review: there is no row-level security in this product and no linter, so a query
* written against the wrong table in some future screen must fail with a permission error rather
* than return data.
*
* revealDb() connects as `ops_reveal`, which may read exactly four columns of one table: a
* facility's id and its three coordinator contacts. It exists so that revealing a contact is not an
* exception carved into code but a second, narrower door with its own key — widening the reveal's
* SELECT fails at the database too. Only lib/ops/reveal.ts may use it.
*
* Both are read-only by construction. The console's own tables — Operator, OperatorEvent,
* RevealGrant — are written through lib/db.ts, because they are the console's records.
*
* Both are lazy: nothing runs at import time, so a box without the variables still starts the
* product; only the console fails, and it fails closed. Small pools — this is a small box, and
* lib/db.ts already holds the main one. */
const g = globalThis as unknown as { __opsDb?: PrismaClient; __revealDb?: PrismaClient };
function make(envVar: "OPS_DATABASE_URL" | "OPS_REVEAL_DATABASE_URL", max: number): PrismaClient {
const url = process.env[envVar];
if (!url) throw new Error(`${envVar} not set`);
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url, max }), log: ["error"] });
}
export function opsDb(): PrismaClient {
if (!g.__opsDb) g.__opsDb = make("OPS_DATABASE_URL", 2);
return g.__opsDb;
}
export function revealDb(): PrismaClient {
if (!g.__revealDb) g.__revealDb = make("OPS_REVEAL_DATABASE_URL", 1);
return g.__revealDb;
}
+181
View File
@@ -0,0 +1,181 @@
import { readdirSync } from "fs";
import path from "path";
import { opsDb } from "./db";
import { entitlements, type Entitlements, type PlanRow } from "../plan";
/* Everything the operations console knows about the facilities, in one place.
*
* This is the only module in the product that reads across facilities, and it reads through
* opsDb() — the ops_ro role, which the database itself restricts to control-plane columns. So the
* shape of what leaves here is decided twice: by the SELECTs below, and by grants that would
* refuse a wider one. Nothing in a projection is a person: no name, no email, no photo, no note.
* Group lists come out as lengths, the GL account as set-or-not, and contacts do not come out at
* all — a reveal is a separate, narrow, trailed path.
*
* The demo facility is rebuilt every twenty minutes and would otherwise dominate every count, so
* every projection splits it out rather than mixing it in.
*
* scripts/check-ops.sh asserts this file never imports the main client. */
const DAY = 24 * 60 * 60 * 1000;
/** The facility columns the console may read — the same allow-list as the ops_ro grant. */
const FACILITY_COLS = {
id: true, name: true, timezone: true, defaultEntitlement: true, initialSets: true, capSets: true,
defaultReorder: true, exceptionHigh: true, varianceReason: true, glAccount: true, journalDesc: true,
lastBackup: true, barcodeLookup: true, staffGroups: true, nursingGroups: true, kitGroups: true,
orderSeq: true, catalogSeq: true, requestSeq: true, rev: true, barcodeSeq: true, slipOrg: true,
isDemo: true, demoResetAt: true, createdAt: true, plan: true, planNote: true, ssoEnabled: true, ssoRequired: true, ssoStaff: true,
planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true,
} as const;
/** The plan as the console shows it — the raw columns for the form, and what they mean today. */
export type PlanView = {
plan: string; planNote: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean;
label: string; state: Entitlements["state"]; readOnly: boolean; maxStaff: number | null;
endsAt: Date | null; graceEndsAt: Date | null;
};
function planView(f: PlanRow & { plan: string; planNote: string }): PlanView {
const e = entitlements(f);
return {
plan: f.plan, planNote: f.planNote, planStatus: f.planStatus, trialEndsAt: f.trialEndsAt, paidUntil: f.paidUntil, grandfathered: f.grandfathered,
label: e.label, state: e.state, readOnly: e.readOnly, maxStaff: e.maxStaff, endsAt: e.endsAt, graceEndsAt: e.graceEndsAt,
};
}
export type FacilityRow = {
id: string; name: string; createdAt: Date; isDemo: boolean; timezone: string;
rev: number; lastBackup: string; backupAgeDays: number | null;
plan: string; planNote: string;
planView: PlanView;
users: number; staff: number; items: number; issues: number; requests: number;
groups: number; setUp: boolean;
state: "live" | "dormant" | "unconfigured" | "demo";
};
/** "YYYY-MM-DD" in the facility's zone → whole days ago, or null when never. */
function ageDays(lastBackup: string): number | null {
if (!lastBackup) return null;
const t = Date.parse(lastBackup + "T00:00:00Z");
return Number.isFinite(t) ? Math.max(0, Math.floor((Date.now() - t) / DAY)) : null;
}
function stateOf(f: { isDemo: boolean; rev: number; staffGroups: string[] }): FacilityRow["state"] {
if (f.isDemo) return "demo";
if (f.staffGroups.length === 0) return "unconfigured";
return f.rev > 0 ? "live" : "dormant";
}
export async function facilities(): Promise<FacilityRow[]> {
const rows = await opsDb().facility.findMany({
select: { ...FACILITY_COLS, _count: { select: { users: true, staff: true, items: true, issues: true, requests: true } } },
orderBy: { createdAt: "desc" },
});
return rows.map((f) => ({
id: f.id, name: f.name, createdAt: f.createdAt, isDemo: f.isDemo, timezone: f.timezone,
rev: f.rev, lastBackup: f.lastBackup, backupAgeDays: ageDays(f.lastBackup), plan: f.plan, planNote: f.planNote, planView: planView(f),
users: f._count.users, staff: f._count.staff, items: f._count.items, issues: f._count.issues, requests: f._count.requests,
groups: f.staffGroups.length, setUp: f.staffGroups.length > 0,
state: stateOf(f),
}));
}
export type FacilityDetail = FacilityRow & {
config: {
capSets: number; initialSets: number; defaultEntitlement: number; defaultReorder: number;
exceptionHigh: number; varianceReason: number;
fteGroups: number; kitGroups: number; groups: number;
glAccountSet: boolean; journalDesc: string; barcodeLookup: boolean; slipOrgSet: boolean;
sso: "off" | "optional" | "required"; ssoStaff: boolean;
};
counts: {
users: number; admins: number; usersWith2fa: number; inactiveUsers: number;
staff: number; activeStaff: number; staffAccounts: number; accountsSeen7d: number;
items: number; issues: number; orders: number; requests: number; events24h: number;
};
seq: { orders: number; catalogue: number; requests: number; barcodes: number };
};
export async function facility(id: string): Promise<FacilityDetail | null> {
const db = opsDb();
const f = await db.facility.findUnique({ where: { id }, select: FACILITY_COLS });
if (!f) return null;
const since7d = new Date(Date.now() - 7 * DAY);
const since24h = new Date(Date.now() - DAY);
const [users, admins, usersWith2fa, inactiveUsers, staff, activeStaff, staffAccounts, accountsSeen7d, items, issues, orders, requests, events24h] = await Promise.all([
db.user.count({ where: { facilityId: id } }),
db.user.count({ where: { facilityId: id, role: "ADMIN", inactive: false } }),
db.user.count({ where: { facilityId: id, totpEnabledAt: { not: null } } }),
db.user.count({ where: { facilityId: id, inactive: true } }),
db.staff.count({ where: { facilityId: id } }),
db.staff.count({ where: { facilityId: id, inactive: false } }),
db.staffAccount.count({ where: { facilityId: id } }),
db.staffAccount.count({ where: { facilityId: id, lastSeenAt: { gte: since7d } } }),
db.catalogItem.count({ where: { facilityId: id } }),
db.issue.count({ where: { facilityId: id } }),
db.order.count({ where: { facilityId: id } }),
db.request.count({ where: { facilityId: id } }),
db.auditEvent.count({ where: { facilityId: id, at: { gte: since24h } } }),
]);
return {
id: f.id, name: f.name, createdAt: f.createdAt, isDemo: f.isDemo, timezone: f.timezone,
rev: f.rev, lastBackup: f.lastBackup, backupAgeDays: ageDays(f.lastBackup), plan: f.plan, planNote: f.planNote, planView: planView(f),
users, staff, items, issues, requests,
groups: f.staffGroups.length, setUp: f.staffGroups.length > 0, state: stateOf(f),
config: {
capSets: f.capSets, initialSets: f.initialSets, defaultEntitlement: f.defaultEntitlement,
defaultReorder: f.defaultReorder, exceptionHigh: f.exceptionHigh, varianceReason: f.varianceReason,
fteGroups: f.nursingGroups.length, kitGroups: f.kitGroups.length, groups: f.staffGroups.length,
glAccountSet: !!f.glAccount, journalDesc: f.journalDesc, barcodeLookup: f.barcodeLookup, slipOrgSet: !!f.slipOrg,
sso: f.ssoEnabled ? (f.ssoRequired ? "required" : "optional") : "off", ssoStaff: f.ssoStaff,
},
counts: { users, admins, usersWith2fa, inactiveUsers, staff, activeStaff, staffAccounts, accountsSeen7d, items, issues, orders, requests, events24h },
seq: { orders: f.orderSeq, catalogue: f.catalogSeq, requests: f.requestSeq, barcodes: f.barcodeSeq },
};
}
export type Overview = {
facilities: number; live: number; dormant: number; unconfigured: number; demo: number;
signupsThisMonth: number; reachedFirstIssue: number;
overdueBackups: FacilityRow[]; neverIssued: FacilityRow[]; unconfiguredList: FacilityRow[];
enquiriesUnhandled: number;
migrations: { applied: number; inRepo: number };
demoRebuiltAgoMin: number | null;
};
/** How many migration folders the repo ships, read from disk on the box that is running. */
function migrationsInRepo(): number {
try {
return readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true }).filter((d) => d.isDirectory()).length;
} catch { return 0; }
}
export async function overview(): Promise<Overview> {
const db = opsDb();
const all = await facilities();
const real = all.filter((f) => !f.isDemo);
const monthStart = new Date(); monthStart.setUTCDate(1); monthStart.setUTCHours(0, 0, 0, 0);
const [enquiriesUnhandled, appliedRows, demo] = await Promise.all([
db.contactMessage.count({ where: { handled: false } }),
db.$queryRaw<{ n: bigint }[]>`SELECT count(*)::bigint AS n FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL`,
db.facility.findFirst({ where: { isDemo: true }, select: { demoResetAt: true } }),
]);
const signups = real.filter((f) => f.createdAt >= monthStart);
return {
facilities: real.length,
live: real.filter((f) => f.state === "live").length,
dormant: real.filter((f) => f.state === "dormant").length,
unconfigured: real.filter((f) => f.state === "unconfigured").length,
demo: all.length - real.length,
signupsThisMonth: signups.length,
reachedFirstIssue: signups.filter((f) => f.issues > 0).length,
// Overdue: live facilities with no export in seven days, or never — the figure the Terms used to promise a reminder on.
overdueBackups: real.filter((f) => f.state === "live" && (f.backupAgeDays === null || f.backupAgeDays > 7)),
neverIssued: real.filter((f) => f.state === "dormant"),
unconfiguredList: real.filter((f) => f.state === "unconfigured"),
enquiriesUnhandled,
migrations: { applied: Number(appliedRows[0]?.n ?? 0), inRepo: migrationsInRepo() },
demoRebuiltAgoMin: demo?.demoResetAt ? Math.floor((Date.now() - demo.demoResetAt.getTime()) / 60000) : null,
};
}
+103
View File
@@ -0,0 +1,103 @@
import { prisma } from "../db";
import { revealDb } from "./db";
import { logOperatorEvent, type OperatorSession } from "./session";
import { sendTo } from "../mail";
/* Revealing a facility's coordinator contacts.
*
* The console's ordinary role cannot read a coordinator's name, email or phone — the production
* probe proves it. Reading one is therefore not a display option but a separate act with four
* parts, all of them here and nowhere else:
*
* 1. a typed reason, kept in full;
* 2. a RevealGrant row on the server — the window is thirty minutes and it is the row that says
* so, not a claim in a cookie, so it can be shortened or revoked by deleting it;
* 3. an OperatorEvent, in the trail that outlives the facility;
* 4. an email to the owner, every time. A trail nobody reads is a record, not a control.
*
* The read itself goes through revealDb(), the ops_reveal role: four columns of one table. A
* future edit that widens the select fails at the database, exactly as it would in a projection.
*
* Scope is the three contact columns of a Facility. Nothing about a wearer is revealable at any
* level, and nothing in this module knows a wearer's table exists. */
export const REVEAL_MINUTES = 30;
/** One grant covers the three contact columns together; they are one decision and one email. */
export const REVEAL_FIELD = "contacts";
export const REASON_MIN = 8;
export const REASON_MAX = 400;
export type Revealed = {
coordinator: string;
coordinatorEmail: string;
coordinatorPhone: string;
reason: string;
at: Date;
expiresAt: Date;
};
/** The operator's own unexpired grant for this facility, if any. Grants are per operator: one
* person's reason does not open the contacts for another. */
export async function activeReveal(operatorId: string, facilityId: string) {
return prisma.revealGrant.findFirst({
where: { operatorId, facilityId, field: REVEAL_FIELD, expiresAt: { gt: new Date() } },
orderBy: { expiresAt: "desc" },
select: { id: true, reason: true, at: true, expiresAt: true },
});
}
/** The contacts, only while a grant is active. Without one this returns null without touching
* the reveal role at all — the grant check comes first, and the read is conditional on it. */
export async function revealedContacts(operatorId: string, facilityId: string): Promise<Revealed | null> {
const g = await activeReveal(operatorId, facilityId);
if (!g) return null;
const f = await revealDb().facility.findUnique({
where: { id: facilityId },
select: { coordinator: true, coordinatorEmail: true, coordinatorPhone: true },
});
if (!f) return null;
return { ...f, reason: g.reason, at: g.at, expiresAt: g.expiresAt };
}
/** Where the copy of every reveal goes. The owner's address, settable apart from the operator
* who did the revealing so that a second operator's reveals still reach the owner. */
export function alertAddress(fallback: string) {
return (process.env.OPS_ALERT_TO || fallback).trim();
}
export async function grantReveal(args: {
operator: OperatorSession;
facilityId: string;
facilityName: string;
reason: string;
ip: string;
}): Promise<{ expiresAt: Date; mailed: boolean }> {
const { operator, facilityId, facilityName, ip } = args;
const reason = args.reason.trim().slice(0, REASON_MAX);
const now = new Date();
const expiresAt = new Date(now.getTime() + REVEAL_MINUTES * 60 * 1000);
await prisma.revealGrant.create({
data: { operatorId: operator.id, facilityId, field: REVEAL_FIELD, reason, at: now, expiresAt },
});
logOperatorEvent({ operatorId: operator.id, action: "ops:reveal", facilityId, subject: facilityName, detail: reason, ip });
// The email says who, which facility, why and until when — never the contacts themselves. It
// is a notice that a reveal happened, and it must be safe to sit in a mailbox.
const when = now.toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
const until = expiresAt.toLocaleTimeString("en-AU", { timeZone: "Australia/Brisbane", hour: "2-digit", minute: "2-digit" });
const text = [
`${operator.name} (${operator.email}) revealed the coordinator contacts of a facility on the ThreadCount operations console.`,
"",
`Facility: ${facilityName}`,
`When: ${when} (Brisbane)`,
`Until: ${until}`,
`From: ${ip || "unknown address"}`,
`Reason: ${reason}`,
"",
"This is written to the operator trail. If it wasn't you, sign the operator out and change the password.",
].join("\n");
const mailed = await sendTo(alertAddress(operator.email), `[ops] Contacts revealed — ${facilityName}`, text);
if (!mailed) logOperatorEvent({ operatorId: operator.id, action: "ops:reveal.unmailed", facilityId, subject: facilityName, ip });
return { expiresAt, mailed };
}
+139
View File
@@ -0,0 +1,139 @@
import { cookies } from "next/headers";
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { prisma } from "../db";
/* Sessions for the operations console.
*
* A third kind of session, kept apart from the other two the same way lib/staffsession.ts keeps
* the wearer's apart from the coordinator's: its own cookie, a signing key derived from a
* separate secret with a separate domain string, and a payload claim named `oid` where the others
* carry `uid` and `sid`. A coordinator or staff cookie pasted into tc_ops fails signature
* verification, and even if it somehow didn't, readOpsToken() rejects a payload with no `oid`.
* There is no arrangement of the other two tokens that becomes this one — not because a condition
* says no, but because the three are not the same shape.
*
* ⛔ The secret is OPS_SESSION_SECRET, never SESSION_SECRET. SESSION_SECRET also derives the key
* that encrypts every customer's TOTP secret, so if this console shared it, a compromised operator
* credential would force a rotation that destroys every facility's second factor. The console
* must be revocable on its own.
*
* ⛔ The cookie is host-only — no Domain attribute — so it is sent to ops.threadcount.tech and
* nowhere else. Domain=.threadcount.tech would hand it to analytics. and errors.threadcount.tech,
* both real applications this product's CSP already trusts.
*
* ⛔ Nothing in here, and nothing in the console, may mint a coordinator or staff session. The
* demo route shows how few lines that takes; an operator signing in as a customer is the data
* plane by another door, and the product's privacy page says it never happens.
*/
export const OPS_COOKIE = "tc_ops";
// Eight hours: an owner console is tuned the opposite way from the two convenience sessions.
const MAX_AGE = 60 * 60 * 8;
function secret() {
const s = process.env.OPS_SESSION_SECRET;
if (!s) throw new Error("OPS_SESSION_SECRET not set");
// Domain separation, and a different master secret underneath it.
return createHash("sha256").update("threadcount:ops:v1:" + s).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** Short fingerprint of the password hash: a password change invalidates every older token. */
export function opsPwVersion(passwordHash: string) {
return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12);
}
/** `sso` marks a session that Cloudflare Access already put a second factor behind. */
export function signOpsSession(oid: string, passwordHash: string, sso = false, maxAge = MAX_AGE) {
const payload = b64url(Buffer.from(JSON.stringify({ oid, pv: opsPwVersion(passwordHash), sso, exp: Date.now() + maxAge * 1000 })));
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
return `${payload}.${sig}`;
}
export function readOpsToken(raw: string | undefined): { oid: string; pv: string; sso: boolean } | null {
if (!raw) return null;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", secret()).update(payload).digest());
const a = Buffer.from(sig), b = Buffer.from(expect);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
if (!data.oid || !data.exp || data.exp < Date.now()) return null;
return { oid: data.oid, pv: String(data.pv || ""), sso: data.sso === true };
} catch {
return null;
}
}
export async function setOpsCookie(oid: string, passwordHash: string, sso = false) {
const jar = await cookies();
jar.set(OPS_COOKIE, signOpsSession(oid, passwordHash, sso), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: MAX_AGE,
});
}
export async function clearOpsCookie() {
const jar = await cookies();
jar.set(OPS_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
}
export type OperatorRole = "OWNER" | "SUPPORT";
export type OperatorSession = {
id: string;
email: string;
name: string;
role: OperatorRole;
/** The second factor was proven at the identity provider, not here. */
viaSso: boolean;
totpEnabled: boolean;
};
/** Re-read per request rather than trusting the token beyond an id, like currentUser(). */
export async function currentOperator(): Promise<OperatorSession | null> {
const jar = await cookies();
const tok = readOpsToken(jar.get(OPS_COOKIE)?.value);
if (!tok) return null;
const o = await prisma.operator.findUnique({
where: { id: tok.oid },
select: { id: true, email: true, name: true, role: true, inactive: true, passwordHash: true, totpEnabledAt: true },
});
if (!o || o.inactive) return null;
if (tok.pv !== opsPwVersion(o.passwordHash)) return null; // password changed since this token was minted
return { id: o.id, email: o.email, name: o.name, role: o.role, viaSso: tok.sso, totpEnabled: !!o.totpEnabledAt };
}
const RANK: Record<OperatorRole, number> = { SUPPORT: 1, OWNER: 2 };
/** Throws rather than returns null so a route cannot forget to check. */
export async function requireOperator(minRole: OperatorRole = "SUPPORT"): Promise<OperatorSession> {
const o = await currentOperator();
if (!o) throw new Error("UNAUTHENTICATED");
if (RANK[o.role] < RANK[minRole]) throw new Error("FORBIDDEN");
return o;
}
/* The trail. Every write the console makes goes through here, and so does every reveal and every
* sign-in. Never awaited by callers and never throws — a trail that could fail the action it
* records would be a reason to skip recording it. `facilityId` is a plain string on purpose (see
* the schema): a facility that leaves cannot take the record of what was looked at with it. */
export function logOperatorEvent(e: { operatorId: string; action: string; facilityId?: string; subject?: string; detail?: string; ip?: string }): void {
prisma.operatorEvent.create({
data: {
operatorId: e.operatorId,
action: e.action.slice(0, 60),
facilityId: (e.facilityId || "").slice(0, 40),
subject: (e.subject || "").slice(0, 200),
detail: (e.detail || "").slice(0, 400),
ip: (e.ip || "").slice(0, 60),
},
}).catch(() => { /* the trail must not fail the action */ });
}
+44
View File
@@ -0,0 +1,44 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { totpVerify } from "../totp";
/* An operator's second factor, stored under the console's own key.
*
* lib/totp.ts encrypts a coordinator's TOTP secret with a key derived from SESSION_SECRET, and
* that is the right blast radius there: rotating the product's secret invalidates customer
* sessions and customer second factors together. It is the wrong blast radius here. An
* operator's secret is kept under a key derived from OPS_SESSION_SECRET, so rotating either
* secret touches only its own side. The cipher, the format and the verification arithmetic are
* the product's own — nothing new is invented, only the key underneath it. */
function key(): Buffer {
const s = process.env.OPS_SESSION_SECRET;
if (!s) throw new Error("OPS_SESSION_SECRET is required to store an operator's TOTP secret");
return createHash("sha256").update(`ops-totp:${s}`).digest();
}
export function encryptOpsSecret(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${enc.toString("base64url")}`;
}
export function decryptOpsSecret(stored: string): string | null {
try {
const [v, iv, tag, enc] = stored.split(".");
if (v !== "v1") return null;
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([d.update(Buffer.from(enc, "base64url")), d.final()]).toString("utf8");
} catch {
return null;
}
}
/** True only when the operator has a second factor enrolled AND the code matches it. */
export function verifyOperatorCode(op: { totpSecret: string; totpEnabledAt: Date | null }, code: string): boolean {
if (!op.totpEnabledAt) return false;
const secret = decryptOpsSecret(op.totpSecret);
if (!secret) return false;
return totpVerify(secret, String(code || "").replace(/\s+/g, ""));
}
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync } from "fs";
import path from "path";
/* What is deployed, and what is running. Two different questions.
*
* `.release.json` is written by scripts/deploy.sh just before the build: the sha it pulled, the
* sha it replaced, and when. `NEXT_PUBLIC_RELEASE` is compiled into the bundle by that same build.
* Normally the two agree. When they don't — the file names a newer sha than the running bundle —
* a deploy pulled and built but the process was never restarted, which is the failure the deploy
* script's own health check exists to catch and the console should show anyway. */
export type Version = {
deployed: { sha: string; from: string; at: Date } | null;
running: string | null;
node: string;
uptimeMin: number;
};
export function version(): Version {
let deployed: Version["deployed"] = null;
try {
const j = JSON.parse(readFileSync(path.join(process.cwd(), ".release.json"), "utf8")) as { sha?: string; from?: string; at?: string };
if (j.sha && j.at) deployed = { sha: String(j.sha), from: String(j.from || ""), at: new Date(j.at) };
} catch { /* no file: a box deployed before the script wrote one, or local dev */ }
return {
deployed,
running: process.env.NEXT_PUBLIC_RELEASE || null,
node: process.version,
uptimeMin: Math.floor(process.uptime() / 60),
};
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
// Device camera capture for attachments: opens the camera (file input with capture), downscales on a canvas
// to <= 900px and returns a JPEG data URL. Resolves null if the user cancels.
export function takePhoto(): Promise<string | null> {
return new Promise((resolve) => {
const inp = document.createElement("input");
inp.type = "file"; inp.accept = "image/*"; inp.setAttribute("capture", "environment");
let done = false;
const finish = (v: string | null) => { if (!done) { done = true; resolve(v); } };
inp.onchange = () => {
const f = inp.files && inp.files[0]; if (!f) { finish(null); return; }
const img = new Image();
img.onload = () => {
const max = 900; const sc = Math.min(1, max / Math.max(img.width, img.height));
const cv = document.createElement("canvas"); cv.width = Math.round(img.width * sc); cv.height = Math.round(img.height * sc);
cv.getContext("2d")!.drawImage(img, 0, 0, cv.width, cv.height);
URL.revokeObjectURL(img.src);
finish(cv.toDataURL("image/jpeg", 0.72));
};
img.onerror = () => finish(null);
img.src = URL.createObjectURL(f);
};
// Cancelling the picker fires no change event; treat focus returning without a file as a cancel.
window.addEventListener("focus", () => setTimeout(() => { if (!inp.files || !inp.files.length) finish(null); }, 800), { once: true });
inp.click();
});
}
/** Upload a data URL to the facility's photo store; returns the photo id. */
export async function uploadPhoto(mutate: (op: string, payload?: unknown) => Promise<{ ok: true; result: unknown } | { ok: false; error: string }>, kind: string, data: string): Promise<{ id: string } | { error: string }> {
const r = await mutate("photo.put", { kind, data });
if (!r.ok) return { error: r.error };
return { id: (r.result as { id: string }).id };
}
export const photoUrl = (id: string) => `/api/photo/${id}`;
export function viewPhoto(id: string) { window.open(photoUrl(id), "_blank", "noopener"); }
+91
View File
@@ -0,0 +1,91 @@
import { createHash } from "crypto";
import { mkdir, readFile, rm, unlink, writeFile } from "fs/promises";
import path from "path";
/* Where signatures and damage photographs actually live.
*
* They used to be base64 inside Photo.data, which meant every image sat in the database, in every
* pg_dump, and in the working set of every query that happened to touch the table. A few hundred
* signatures is fine; a few years of them is a database that is mostly JPEG.
*
* On disk instead, addressed by a path derived from ids we generated — never from anything a
* request supplies. The facility id is part of the path so one room's images are one directory,
* which makes "delete this facility" and "what is this facility using" both trivial.
*/
/** Overridable so dev writes into the working tree and prod writes to a real data directory. */
export function photoRoot(): string {
return process.env.PHOTO_DIR || path.join(process.cwd(), ".photos");
}
const EXT: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png" };
/** `<facilityId>/<photoId>.<ext>` — stored relative, so the root can move without a data migration. */
export function relPath(facilityId: string, photoId: string, mime: string): string {
return `${facilityId}/${photoId}.${EXT[mime] || "bin"}`;
}
/** Refuses anything that isn't the shape we write. Belt and braces: these values come from our own
* ids, but a path read out of a database is still input, and one `..` would be enough. */
function resolveSafe(rel: string): string | null {
if (!/^[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.(jpg|png|bin)$/.test(rel)) return null;
const root = photoRoot();
const full = path.resolve(root, rel);
if (!full.startsWith(path.resolve(root) + path.sep)) return null;
return full;
}
export type ParsedPhoto = { mime: string; bytes: Buffer };
/** Split a `data:image/jpeg;base64,…` URL into its parts, or null if it isn't one. */
export function parseDataUrl(data: string): ParsedPhoto | null {
const m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(data);
if (!m) return null;
return { mime: m[1], bytes: Buffer.from(m[2], "base64") };
}
export async function writePhoto(facilityId: string, photoId: string, p: ParsedPhoto): Promise<string> {
const rel = relPath(facilityId, photoId, p.mime);
const full = resolveSafe(rel);
if (!full) throw new Error("refusing to write an unexpected photo path");
await mkdir(path.dirname(full), { recursive: true });
await writeFile(full, p.bytes);
return rel;
}
export async function readPhoto(rel: string): Promise<Buffer | null> {
const full = resolveSafe(rel);
if (!full) return null;
try { return await readFile(full); } catch { return null; }
}
export async function deletePhoto(rel: string): Promise<void> {
const full = resolveSafe(rel);
if (!full) return;
try { await unlink(full); } catch { /* already gone is the desired state */ }
}
/** Every image a facility owns, directory and all.
*
* Deleting a facility cascades its Photo rows away, and once they are gone nothing is left that
* could ever name the files again — so the files have to go in the same breath, or a signature
* and a photograph of somebody's damaged uniform outlive the record they belonged to. The
* facility id being the first path segment is what makes that one call. */
export async function deletePhotoDir(facilityId: string): Promise<void> {
if (!/^[A-Za-z0-9_-]+$/.test(facilityId)) return;
const root = path.resolve(photoRoot());
const full = path.resolve(root, facilityId);
if (!full.startsWith(root + path.sep)) return;
try { await rm(full, { recursive: true, force: true }); } catch { /* nothing there is the desired state */ }
}
/** For the JSON backup, which stays base64 so the format — and the promise that a backup is
* everything — doesn't change just because storage moved. */
export async function photoAsDataUrl(rel: string, mime: string): Promise<string | null> {
const bytes = await readPhoto(rel);
if (!bytes) return null;
return `data:${mime};base64,${bytes.toString("base64")}`;
}
/** Only used to spot a file written twice; not security-critical. */
export const shortHash = (b: Buffer) => createHash("sha256").update(b).digest("hex").slice(0, 16);
+127
View File
@@ -0,0 +1,127 @@
/* What a facility's plan lets it do today.
*
* Six columns on Facility go in (plan, planStatus, trialEndsAt, paidUntil, grandfathered, and
* isDemo for the one facility that has no plan at all) and one plain object comes out. Every
* write door asks the object — never the plan's name — so a new plan is a row in PLANS here and
* nothing anywhere else. There is no nightly job: the state is worked out from the dates each
* time it is read, so a lapsed trial is read-only the moment the grace runs out and paid-again is
* writable the moment the console records the payment.
*
* The rules, which are also the pricing page's promises:
* - Nothing that exists today is fenced. Plans differ in the staff-record ceiling on the free
* hosted tier, how long backups are kept, and whether the room is read-only for non-payment.
* - Read-only never deletes and never hides. Reports, exports, printing, sign-in and the whole
* backup keep working; only writes are refused, with the reason and where to go.
* - Grandfathered means free with everything, for good — a change of plan cannot take it away.
* The one thing above it is an explicit read_only from the console, kept for abuse.
*/
import { COMMUNITY } from "./edition";
export type PlanCode = "hosted_small" | "hosted_facility" | "health_service" | "private";
/** What the console last recorded. What it means today is `Entitlements.state`. */
export type PlanStatus = "free" | "trial" | "active" | "read_only";
export type PlanState =
| "grandfathered" // free with everything; the promise
| "free" // Hosted Small: free, capped
| "trial" // inside the trial
| "active" // paid, inside the year
| "grace" // trial or year has ended; a fortnight to sort the invoice, still writable
| "read_only"; // grace ran out, or the console said so
export type Entitlements = {
code: PlanCode;
label: string;
state: PlanState;
readOnly: boolean;
/** Staff records the register may hold, or null for no ceiling. */
maxStaff: number | null;
/** How long the hosted backups are kept, for the Plan screen. */
backupDays: number;
/** When the current period ends — the trial, or the paid year. */
endsAt: Date | null;
/** When grace ends and writes stop, when the facility is in grace. */
graceEndsAt: Date | null;
grandfathered: boolean;
};
export const GRACE_DAYS = 14;
export const TRIAL_DAYS = 60;
/** The prices, in Australian dollars before GST — one place, read by the pricing page, the
* structured data, the sign-up choice and the notice to existing rooms. */
export const PRICES = {
hostedMonthly: 129,
hostedAnnual: 1290,
healthServiceAnnual: 4990,
healthServiceFacilities: 5,
healthServiceExtra: 890,
/** Staff records a room may hold and stay on the free hosted plan. */
freeStaff: 60,
} as const;
const DAY = 86_400_000;
export const PLANS: Record<PlanCode, { label: string; maxStaff: number | null; backupDays: number }> = {
hosted_small: { label: "Hosted Small", maxStaff: 60, backupDays: 14 },
hosted_facility: { label: "Hosted Facility", maxStaff: null, backupDays: 35 },
health_service: { label: "Health Service", maxStaff: null, backupDays: 35 },
private: { label: "Private", maxStaff: null, backupDays: 35 },
};
export const PLAN_CODES = Object.keys(PLANS) as PlanCode[];
export const PLAN_STATUSES: PlanStatus[] = ["free", "trial", "active", "read_only"];
export function isPlanCode(x: unknown): x is PlanCode { return typeof x === "string" && x in PLANS; }
export function isPlanStatus(x: unknown): x is PlanStatus { return PLAN_STATUSES.includes(x as PlanStatus); }
/** The columns this reads — one select to share between every caller. */
export const PLAN_COLS = { plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true, isDemo: true } as const;
export type PlanRow = { plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean; isDemo?: boolean };
export function entitlements(f: PlanRow, now: Date = new Date()): Entitlements {
const code: PlanCode = isPlanCode(f.plan) ? f.plan : "hosted_small";
const def = PLANS[code];
const base = { code, label: def.label, backupDays: def.backupDays, grandfathered: f.grandfathered, endsAt: null as Date | null, graceEndsAt: null as Date | null };
// A Community instance has no plans at all: everything, no ceiling, never read-only, whatever
// its columns say. Then the console's read-only beats everything, the demo has no plan, and the
// promise beats the rest.
if (COMMUNITY) return { ...base, label: "Community", state: "grandfathered", readOnly: false, maxStaff: null, grandfathered: true };
if (f.planStatus === "read_only") return { ...base, state: "read_only", readOnly: true, maxStaff: null };
if (f.isDemo) return { ...base, label: "Demo", state: "grandfathered", readOnly: false, maxStaff: null };
// A grandfathered room with no plan recorded is simply "Free"; one the console has put on a
// named plan (a pilot, a health service that later bought) keeps that plan's name.
if (f.grandfathered) return { ...base, label: isPlanCode(f.plan) ? def.label : "Free", state: "grandfathered", readOnly: false, maxStaff: null };
if (f.planStatus === "trial" || f.planStatus === "active") {
const endsAt = f.planStatus === "trial" ? f.trialEndsAt : f.paidUntil;
// No date recorded means the console started a period without an end: writable, and shown as
// such, rather than read-only because somebody forgot a field.
if (!endsAt) return { ...base, state: f.planStatus, readOnly: false, maxStaff: null };
const graceEndsAt = new Date(endsAt.getTime() + GRACE_DAYS * DAY);
if (now < endsAt) return { ...base, state: f.planStatus, readOnly: false, maxStaff: null, endsAt };
if (now < graceEndsAt) return { ...base, state: "grace", readOnly: false, maxStaff: null, endsAt, graceEndsAt };
return { ...base, state: "read_only", readOnly: true, maxStaff: null, endsAt, graceEndsAt };
}
// "free": Hosted Small, capped — or a bigger plan the console set free on purpose (a pilot, an
// internal room), which keeps that plan's ceiling.
return { ...base, state: "free", readOnly: false, maxStaff: def.maxStaff };
}
/** The whole days until `d`, never below zero. */
export function daysUntil(d: Date | null, now: Date = new Date()): number | null {
return d ? Math.max(0, Math.ceil((d.getTime() - now.getTime()) / DAY)) : null;
}
/** What a refused write says. Where to go, never a price. */
export const READ_ONLY_REFUSAL = "Read-only: this facility's plan has lapsed. Reports, exports and the backup still work — see Settings Plan.";
/** Ops that stay open in a read-only room: sorting the plan out, and leaving. */
export const READ_ONLY_ALLOWED = new Set(["plan.billing", "plan.invoice", "me.password", "me.profile", "me.deleteAccount"]);
export function staffRefusal(max: number): string {
return `The register is full for this plan — ${max} staff records. Settings Plan has the next step.`;
}
+12
View File
@@ -0,0 +1,12 @@
import { cache } from "react";
import { switches } from "./switches";
/* Whether the public site tells the plans story yet.
*
* The pricing page promised that "the rooms using it will hear before the website does", so the
* site cannot change on a deploy: it changes when the console's plans switch is turned on, after
* the notice to existing rooms has run — the same switch that starts capping new sign-ups, so the
* words and the product flip together. Cached per request: the footer, the CTA band and the page
* all ask, and one read is enough. The (site) layout revalidates every minute, so a flip reaches
* the prerendered pages within that. */
export const plansLive = cache(async (): Promise<boolean> => (await switches()).plansLive);
+57
View File
@@ -0,0 +1,57 @@
"use client";
// Client-side print helpers: open a window with a self-contained A4/A5 document and print it.
// Matches the prototype's document.write approach; escapes all data so nothing user-entered becomes markup.
export const esc = (v: unknown) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] as string));
const BASE_CSS = "body{font-family:Archivo,system-ui,sans-serif;color:#201e1d;margin:0;font-size:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact}h1{font-size:19px;border-bottom:2px solid #201e1d;padding-bottom:6px;margin:0}.sq{width:11px;height:11px;background:#ec3013;display:inline-block;margin-right:6px}.meta{font-size:11px;color:#555;margin:4px 0 8px}h2{font-size:13px;letter-spacing:.06em;text-transform:uppercase;margin:16px 0 4px}table{width:100%;border-collapse:collapse}th{border-bottom:2px solid #201e1d;text-align:left;font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:4px 6px}td{border-bottom:1px solid #999;padding:4px 6px}.r{text-align:right}.ih td{background:#eee;font-weight:700;border-bottom:2px solid #201e1d}.box{width:70px;border:1.5px solid #201e1d}";
/* The print window's typeface, taken from this page rather than from Google.
*
* The popup is about:blank, so it inherits the app's CSP — style-src 'self' 'unsafe-inline' and
* font-src 'self' data:. The Google Fonts stylesheet this used to inject was therefore refused on
* every single print: the slip came out in system-ui, the console filled with violations, and each
* print made a pointless outbound request to Google from a hospital network. Archivo is already
* self-hosted through next/font, and its @font-face rules are sitting in this document's own
* stylesheets, so they are copied across instead. A sheet we can't read (there shouldn't be one)
* is skipped and the document falls back to the system stack BASE_CSS already names. */
function selfHostedFonts(): string {
let css = "";
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRule[] = [];
try { rules = Array.from(sheet.cssRules); } catch { continue; }
for (const rule of rules) if (rule instanceof CSSFontFaceRule) css += rule.cssText;
}
if (!css) return "";
// next/font hashes the family name (__Archivo_1a2b3c), so the literal "Archivo" in BASE_CSS
// would never match the faces just copied across. The page's own computed stack is the name.
let stack = "";
try { stack = getComputedStyle(document.body).fontFamily; } catch { /* no body to read yet */ }
return css + (stack ? `body{font-family:${stack}}` : "");
}
export function openPrintWindow(title: string, bodyHtml: string, opts: { page?: string; css?: string; width?: number; height?: number } = {}) {
const w = window.open("", "_blank", `width=${opts.width || 820},height=${opts.height || 980}`);
if (!w) { alert("Pop-up blocked — allow pop-ups for ThreadCount to print."); return; }
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>@page{${opts.page || "size:A4;margin:14mm"}}${BASE_CSS}${selfHostedFonts()}${opts.css || ""}</style></head><body>${bodyHtml}</body></html>`);
w.document.close(); w.focus();
setTimeout(() => { try { w.print(); } catch { /* user can print manually */ } }, 450);
}
export type Col = { t: string; r?: boolean };
/** HTML table from columns + rows (cells are escaped). */
export function tbl(cols: Col[], rows: (string | number)[][]): string {
return "<table><tr>" + cols.map((c) => `<th${c.r ? ' class="r"' : ""}>${esc(c.t)}</th>`).join("") + "</tr>" +
rows.map((r) => "<tr>" + r.map((c, i) => `<td${cols[i]?.r ? ' class="r"' : ""}>${esc(c)}</td>`).join("") + "</tr>").join("") + "</table>";
}
/** Ruled A4 document with facility header and "prepared by" line, then titled sections. */
export function printDoc(title: string, meta: string, sections: { h: string; html: string }[]) {
openPrintWindow(title, `<h1><span class="sq"></span>${esc(title)}</h1><div class="meta">${esc(meta)}</div>` + sections.map((s) => `<h2>${esc(s.h)}</h2>${s.html}`).join(""));
}
export function downloadCsv(name: string, csv: string) {
const a = document.createElement("a");
a.href = "data:text/csv;charset=utf-8," + encodeURIComponent(csv);
a.download = name; a.click();
}
+64
View File
@@ -0,0 +1,64 @@
// Small in-memory sliding-window limiter. Single-process deployment, so this is sufficient to blunt
// abuse (signup spam, photo floods, brute force) without external state. Keys are scoped by caller.
//
// Each bucket remembers the window it was created with, and the sweep uses that rather than the
// window of whoever happened to trigger it. The windows in use here run from 60 seconds to 24
// hours, and the 60-second one (/api/mutate) is by far the most frequent caller — so a sweep that
// used the caller's window would continuously evict the 15-minute sign-in lockouts and the 24-hour
// spam ceilings, which is the same as not having them.
type Bucket = { windowMs: number; hits: number[] };
const buckets = new Map<string, Bucket>();
let lastSweep = Date.now();
/** Returns true when the call is allowed; false once `max` calls have happened inside `windowMs`. */
export function allow(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
if (now - lastSweep > 60_000) {
lastSweep = now;
for (const [k, b] of buckets) if (!b.hits.length || now - b.hits[b.hits.length - 1] > b.windowMs) buckets.delete(k);
}
// The window travels with the bucket: a key is always asked about with the same window by the
// same caller, and taking the current one keeps a changed limit from being ignored until the
// bucket empties.
const prev = buckets.get(key);
const hits = (prev?.hits || []).filter((t) => now - t < windowMs);
if (hits.length >= max) { buckets.set(key, { windowMs, hits }); return false; }
hits.push(now); buckets.set(key, { windowMs, hits });
return true;
}
/** True when the bucket is already at its limit, WITHOUT recording an attempt against it. */
export function over(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
const hits = (buckets.get(key)?.hits || []).filter((t) => now - t < windowMs);
return hits.length >= max;
}
/** Record one against the bucket. Pairs with `over` for limits that only count failures. */
export function fail(key: string, windowMs: number): void {
const now = Date.now();
const hits = (buckets.get(key)?.hits || []).filter((t) => now - t < windowMs);
hits.push(now);
buckets.set(key, { windowMs, hits });
}
/* The auth routes count FAILURES, not attempts, and that distinction is what makes the numbers
* defensible.
*
* Every wearer of a uniform in a hospital reaches this product from behind one NAT address, and
* they all arrive at once at shift change. A ceiling on *attempts* per address therefore has to
* choose between being a real brute-force defence and not locking out a ward on a Monday morning —
* there is no number that does both. Counting only the attempts that failed removes the conflict:
* six hundred nurses signing in successfully never touch the bucket, while an address producing
* forty failures against forty different accounts is credential-stuffing and is stopped.
*
* Until the sweep bug above was fixed none of these ceilings was ever actually reached — every
* bucket was forgotten within a minute — so their behaviour under a real deployment had never
* been observed.
*/
/** Client IP as nginx reports it (last X-Forwarded-For entry is the one nginx appended). */
export function clientIp(headers: Headers): string {
const xff = headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
return xff[xff.length - 1] || headers.get("x-real-ip") || "local";
}
+55
View File
@@ -0,0 +1,55 @@
import { createHash, randomBytes, timingSafeEqual } from "crypto";
/* Password reset tokens.
*
* The raw token is shown to exactly one person, once, in one email, and is never stored: the
* database keeps only its SHA-256. That matters because this table lands in every pg_dump, and a
* plaintext token in a leaked backup is a working key to an account until it expires.
*
* SHA-256 rather than bcrypt is the right call here, unusually: the token is 32 bytes of CSPRNG
* output, so there is no dictionary to attack and no need to be slow — and a reset lookup happens
* before the user is authenticated, where a deliberately slow hash is a denial-of-service lever. */
/** One hour. Long enough to walk back to a desk, short enough that a forwarded email goes stale. */
export const RESET_TTL_MS = 60 * 60 * 1000;
export function newResetToken() {
const token = randomBytes(32).toString("base64url");
return { token, tokenHash: hashResetToken(token) };
}
export function hashResetToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
/** Constant-time compare, so a mismatched token can't be found a character at a time. */
export function tokenMatches(a: string, b: string) {
const ab = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
/** The link a person clicks. Absolute, because it is going into an email client. */
export function resetUrl(token: string) {
const base = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
return `${base}/reset?token=${encodeURIComponent(token)}`;
}
export function resetEmail(firstName: string, url: string) {
const subject = "Reset your ThreadCount password";
const text = [
`Hi ${firstName || "there"},`,
"",
"Someone asked to reset the password on your ThreadCount account. If that was you, open this link:",
"",
url,
"",
"The link works once and expires in an hour.",
"",
"If it wasn't you, you can ignore this — your password hasn't changed, and nobody can get in without this link.",
"",
"— ThreadCount",
].join("\n");
return { subject, text };
}
+93
View File
@@ -0,0 +1,93 @@
/* Structured data.
*
* Search engines can read the pages perfectly well; what they can't do is work out that
* ThreadCount is one free product, made by one identifiable outfit, for one narrow job. That is
* what this states — and it is stated only where it is true, because marking up claims a page
* doesn't make is the fastest way to lose the rich result entirely.
*
* Notably absent: aggregateRating and review. There are no ratings, and inventing them is both a
* policy violation and a lie. */
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
import { PRICES } from "./plan";
export const organization = {
"@type": "Organization",
"@id": `${SITE}/#organization`,
name: "ThreadCount",
url: SITE,
logo: `${SITE}/og.png`,
email: "hello@threadcount.tech",
description: "Uniform stock management for healthcare: hospitals, aged care, clinics and community care.",
areaServed: { "@type": "Country", name: "Australia" },
contactPoint: [{
"@type": "ContactPoint",
contactType: "customer support",
email: "hello@threadcount.tech",
availableLanguage: ["English"],
}],
};
export const website = {
"@type": "WebSite",
"@id": `${SITE}/#website`,
url: SITE,
name: "ThreadCount",
publisher: { "@id": `${SITE}/#organization` },
inLanguage: "en-AU",
};
/* The product itself, with its price as it stands. While plans are off the one offer is `price:
"0"` — the honest and useful part, since a free-tier signal is exactly what someone comparing
options is filtering on. Once plans are live the offers are the three editions from the pricing
page, the free one first, read from lib/plan.ts so the markup cannot drift from the page. */
export const softwareApplication = (live: boolean) => ({
"@type": "SoftwareApplication",
"@id": `${SITE}/#software`,
name: "ThreadCount",
applicationCategory: "BusinessApplication",
applicationSubCategory: "Inventory management",
operatingSystem: "Web, Android",
url: SITE,
publisher: { "@id": `${SITE}/#organization` },
description:
"Uniform stock management for hospitals, aged-care homes, clinics and community care: what is on the shelf, who took it, what it cost the ward or clinic, and the supplier orders and stocktakes in between.",
offers: live
? [
{ "@type": "Offer", name: "Community — self-hosted", price: "0", priceCurrency: "AUD", availability: "https://schema.org/InStock" },
{ "@type": "Offer", name: `Hosted Small — up to ${PRICES.freeStaff} staff records`, price: "0", priceCurrency: "AUD", availability: "https://schema.org/InStock" },
{ "@type": "Offer", name: "Hosted Facility — a year", price: String(PRICES.hostedAnnual), priceCurrency: "AUD", availability: "https://schema.org/InStock", url: `${SITE}/pricing` },
{ "@type": "Offer", name: `Health Service — up to ${PRICES.healthServiceFacilities} facilities, a year`, price: String(PRICES.healthServiceAnnual), priceCurrency: "AUD", availability: "https://schema.org/InStock", url: `${SITE}/pricing` },
]
: {
"@type": "Offer",
price: "0",
priceCurrency: "AUD",
availability: "https://schema.org/InStock",
},
featureList: [
"Stock on hand by size",
"Issue to a staff member against their entitlement",
"Barcode scanning with a USB scanner or a phone camera",
"Stocktakes by shelf with variance reasons",
"Supplier orders, deliveries and back orders",
"Cost centre reporting for finance",
],
});
export function faqPage(entries: { q: string; a: string }[]) {
return {
"@type": "FAQPage",
"@id": `${SITE}/faq#faq`,
mainEntity: entries.map(({ q, a }) => ({
"@type": "Question",
name: q,
acceptedAnswer: { "@type": "Answer", text: a },
})),
};
}
/** Wraps whatever is passed in the single @graph envelope crawlers prefer. */
export function graph(...nodes: object[]) {
return { "@context": "https://schema.org", "@graph": nodes };
}
+91
View File
@@ -0,0 +1,91 @@
import { cookies } from "next/headers";
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { prisma } from "./db";
export const COOKIE_NAME = "tc_session";
const MAX_AGE = 60 * 60 * 24 * 14; // 14 days
function secret() {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET not set");
return s;
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** Short fingerprint of the password hash: a password change (or admin reset) invalidates every older token. */
export function pwVersion(passwordHash: string) { return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12); }
/** `sso` marks a session the facility's own identity provider signed in — no password crossed here. */
export function signSession(uid: string, passwordHash: string, maxAge = MAX_AGE, sso = false) {
const payload = b64url(Buffer.from(JSON.stringify({ uid, pv: pwVersion(passwordHash), exp: Date.now() + maxAge * 1000, ...(sso ? { sso: true } : {}) })));
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
return `${payload}.${sig}`;
}
export function readSessionToken(raw: string | undefined): { uid: string; pv: string; sso: boolean } | null {
if (!raw) return null;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", secret()).update(payload).digest());
const a = Buffer.from(sig), b = Buffer.from(expect);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
if (!data.uid || !data.exp || data.exp < Date.now()) return null;
return { uid: data.uid, pv: String(data.pv || ""), sso: data.sso === true };
} catch {
return null;
}
}
export async function setSessionCookie(uid: string, passwordHash: string, sso = false) {
const jar = await cookies();
jar.set(COOKIE_NAME, signSession(uid, passwordHash, MAX_AGE, sso), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: MAX_AGE,
});
}
export async function clearSessionCookie() {
const jar = await cookies();
jar.set(COOKIE_NAME, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
}
export type SessionUser = {
id: string;
facilityId: string;
email: string;
first: string;
last: string;
title: string;
role: "ADMIN" | "ISSUER";
isDemo: boolean;
/** Signed in through the facility's identity provider rather than a password. */
viaSso: boolean;
};
export async function currentUser(): Promise<SessionUser | null> {
const jar = await cookies();
const tok = readSessionToken(jar.get(COOKIE_NAME)?.value);
if (!tok) return null;
const u = await prisma.user.findUnique({
where: { id: tok.uid },
select: { id: true, facilityId: true, email: true, first: true, last: true, title: true, role: true, inactive: true, passwordHash: true, facility: { select: { isDemo: true } } },
});
if (!u || u.inactive) return null;
if (tok.pv !== pwVersion(u.passwordHash)) return null; // password changed since this token was minted
const { inactive: _i, passwordHash: _p, facility, ...rest } = u; void _i; void _p;
return { ...rest, isDemo: facility.isDemo, viaSso: tok.sso };
}
export async function requireUser(): Promise<SessionUser> {
const u = await currentUser();
if (!u) throw new Error("UNAUTHENTICATED");
return u;
}
+293
View File
@@ -0,0 +1,293 @@
/* Set allowances, and what a set is.
*
* One ceiling, and it is a ceiling on what somebody is HOLDING: six sets, at any time, for every
* group in the building. Not six a year — nothing here resets in July, and there is no date in this
* file at all. Six sets is what a person has on their back and in their locker, so the only way past
* a full six is to hand something in — a swap, not an addition — or a coordinator's override,
* recorded as the exception it is.
*
* Each facility names its own staff groups and puts each one on one of three routes. The routes
* differ in how somebody gets up to that ceiling, never in whether they have one:
*
* - The FTE table. The hours worked propose the number — a full-timer five sets, a half-timer
* three, a casual whatever the manager thinks right — and a manager may sign above the proposal,
* up to the ceiling. The proposal lives in lib/compute with the rest of the FTE table; what lives
* here is the ceiling it is signed up to.
* - The starting kit. A fixed number of sets on the first day, then more as they need them, up to
* the same ceiling. Nothing has to be handed back first: asked whether the first set had to come
* back before the next was issued, the owner said no.
* - Manager approval. No starting kit; their manager approves a set at a time.
*
* Which group is on which route is the facility's own answer, two lists of names on its settings
* (Facility.nursingGroups for the FTE table, Facility.kitGroups for the starting kit). Nothing here
* guesses it from the letters in a group's name: the same job is "Housekeeping" in one building and
* "Support Services" in the next, and a guess that misses one takes a whole team's first kit away.
* lib/compute reads the lists; this file is told the answer.
*
* A **set is one top and one pair of trousers**. That matters because ThreadCount's entitlement used
* to count individual garments, and counting garments would let someone take three tops and no
* trousers and be "fully issued". Sets held is therefore the smaller of the two counts, which is
* also how anybody would describe it out loud.
*
* The smaller of the two counts is not a ceiling on its own, though: twenty tops and one pair of
* trousers is "one set" by that measure. So the ceiling bites on each half — at most six tops AND at
* most six pairs — which is what capState() below works out, and why it answers in halves rather
* than with a single number.
*
* This file is the one place that answers "how many sets may this person hold". The starting figure
* and the ceiling are facility settings (Facility.initialSets, Facility.capSets) and are passed in —
* but the sums they go into, and the figures a facility that has never been asked falls back to,
* live here and nowhere else. The counter screens ask through lib/compute, which defers to these;
* that is why nothing here may import compute, or the two would be a cycle.
*/
/** The two fields any of these predicates read of a garment. Callers holding a narrow select (the
* staff app reads the catalogue without costs) don't have to fake a whole Item to ask. */
export type Garment = { type?: string; item?: string };
/** The garment types that make up the two halves of a set. lib/compute re-exports them and builds
* the catalogue's full type vocabulary around them. */
export const TOP_TYPES = ["Shirt", "Polo", "Tunic", "Scrub top", "Blouse"];
export const PANT_TYPES = ["Pants", "Trousers", "Cargo pants", "Shorts", "Skort", "Skirt"];
/** Case-insensitive match against one of those vocabularies. Exported only so lib/compute's own
* blocks — maternity, outerwear — can ask the same question the same way. */
export const isTypeIn = (types: readonly string[], type: string) =>
types.some((t) => t.toLowerCase() === type.toLowerCase());
// An explicit type wins; items saved before the field existed keep the old name-based guess.
//
// The trap worth knowing: type is a free-text field with a datalist behind it, not a closed list,
// and both of these read it as an exact (case-insensitive) match against the vocabulary above. A
// hand-typed "scrubs" or "Scrub Tops" therefore answers false everywhere — worse than leaving type
// blank, which at least falls back to the garment name. Ask through these helpers rather than
// comparing `it.type` yourself, or one typo in the catalogue quietly stops a garment counting as a
// top, and a wearer's holdings stop pairing into sets.
export const isTopItem = (it: Garment | undefined) =>
it?.type ? isTypeIn(TOP_TYPES, it.type) : /top|shirt|polo|tunic|blouse/i.test(it?.item || "");
export const isPantItem = (it: Garment | undefined) =>
it?.type ? isTypeIn(PANT_TYPES, it.type) : /pant|bottom|trouser|skort|short|cargo/i.test(it?.item || "");
/** A set is a top and a bottom. Two garments, for every stream, everywhere the word "set" is used —
* named rather than written as a bare 2, because a bare 2 in a sum is indistinguishable from a
* rounding fudge six months later. */
export const SET_GARMENTS = 2;
/** The starting allocation for a facility that has never been asked. A facility sets its own figure
* (Settings.initialSets, seeded with this one); this is the fallback, not a second rule. */
export const SETS_ON_START = 3;
/** The ceiling, for a facility that has never been asked (Facility.capSets). Six sets is twelve
* garments, and it is everybody's: every group ends at the same place, whichever of the three
* routes it took to get there. */
export const SETS_CAP = 6;
/** The starting sets this facility issues *to the groups on the starting-kit route*. Takes the
* configured figure and stands in for it when there isn't one: a blank or a nonsense number must not
* become an offer of zero sets to somebody starting on Monday, so the standing figure holds until a
* coordinator says otherwise.
*
* Whose number it is matters as much as what it is. Nobody on the other two routes starts on it —
* the FTE table proposes its own number, and manager approval starts on nothing at all — so asking
* this about somebody on manager approval answers a question that was never put, and quoting the
* answer to them promises a kit the counter would turn them away for.
*
* Fractions are floored — the counter can only hand over whole tops and whole trousers, so half a
* set is a loose garment, not an entitlement. */
export function setsOnStart(configured?: number | null): number {
const n = Number(configured);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : SETS_ON_START;
}
/** This facility's ceiling, in sets held at any one time, for anybody at all. Same treatment as the
* starting figure: a blank or a nonsense number must not turn into a ceiling of zero that declines
* the whole hospital, so the standing figure holds until a coordinator says otherwise, and
* fractions are floored because half a set is a loose garment. */
export function setsCap(configured?: number | null): number {
const n = Number(configured);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : SETS_CAP;
}
/** The three routes to the ceiling: the FTE table, the starting kit, and manager approval. */
export type AllowanceRoute = "fte" | "kit" | "approval";
/** Which route somebody is on, given the facility's two answers about their group — is it on the FTE
* table list, is it on the starting-kit list. On neither is manager approval.
*
* The server refuses a group on both lists, but a backup restored from a file somebody edited could
* still carry one, and every screen has to give the same answer when it does. The FTE table wins.
* The order form always asked it first, so a group caught on both goes on getting what it got
* before; and it is the route where a manager's signature stands behind anything past the table's
* proposal, which is the safer of the two to land on than a fixed kit handed over the counter with
* nobody signing. Decided here, once, so the order form, the counter, the wearer's app and the
* manager's review cannot each settle it differently. */
export function allowanceRoute(on: { nursing?: boolean; kit?: boolean }): AllowanceRoute {
if (on.nursing) return "fte";
if (on.kit) return "kit";
return "approval";
}
/** Which half of a set a garment is, or null when it is no part of one — outerwear, maternity wear,
* a hat. One question asked in one place, so that the counter, the wearer's app and the manager's
* review screen never disagree about whether a fleece paired with a pair of trousers.
*
* A maternity garment entered with its proper type answers null here, because a maternity tunic is
* never swapped for a standard one and the two of them are not the two-piece uniform. One saved
* with no type at all is read by its name, exactly as every other untyped garment is. */
export function setHalf(it: Garment | undefined): "top" | "pants" | null {
if (isTopItem(it)) return "top";
if (isPantItem(it)) return "pants";
return null;
}
/** A pile of garments counted the way the ceiling reads it: tops, trousers, whatever is in no set at
* all, and the complete sets the first two make between them. */
export type GarmentCounts = { tops: number; pants: number; other: number; sets: number };
export function garmentCounts(holdings: { item: Garment; qty: number }[]): GarmentCounts {
let tops = 0, pants = 0, other = 0;
for (const h of holdings) {
const half = setHalf(h.item);
if (half === "top") tops += h.qty;
else if (half === "pants") pants += h.qty;
else other += h.qty;
}
return { tops, pants, other, sets: Math.min(tops, pants) };
}
/** How many complete sets a person is holding, from their current holdings. */
export function setsHeld(holdings: { item: Garment; qty: number }[]): number {
return garmentCounts(holdings).sets;
}
/** Loose garments that don't yet pair into a set — useful for saying "3 sets and a spare top". */
export function looseGarments(holdings: { item: Garment; qty: number }[]): { tops: number; pants: number } {
const c = garmentCounts(holdings);
return { tops: c.tops - c.sets, pants: c.pants - c.sets };
}
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/** The ceiling, worked out for one person and whatever is about to be handed over, in the parts a
* screen or a coordinator needs to see the reason.
*
* `cap` is sets. The two halves are counted separately against it — at most six tops and at most
* six pairs — because the smaller of the two counts is not a ceiling: twenty tops and one pair is
* "one set" by that measure, and a locker with twenty tops in it is exactly what the ceiling is
* there to prevent.
*
* `other` is everything that is no part of a set — a fleece, a jacket, maternity wear. The six-set
* ceiling says nothing about those, so they carry the same number as a ceiling of their own,
* counted in garments: nobody needs seven jackets at once either, and with no ceiling at all they
* would be the one thing in the building nothing ever asked about. The same figure as the sets
* ceiling deliberately, so a coordinator has one number to remember and nobody has to keep two
* settings in step.
*
* `over` is the counter's question — after this hand-over, is this person still inside what one
* person holds. `breach` says which of the three ceilings it is and `overBy` how many garments past
* it, because a coordinator asked to tick an override is owed something they can check against the
* person in front of them: "holds six tops and six pairs already" is checkable, a bare refusal is
* not. Past it, the way on is a hand-in — a swap rather than an addition — or that override. */
export type CapState = {
cap: number;
otherCap: number;
tops: number; pants: number; other: number; sets: number;
addTops: number; addPants: number; addOther: number;
afterTops: number; afterPants: number; afterOther: number; afterSets: number;
overTops: number; overPants: number; overOther: number;
over: boolean;
overBy: number;
breach: "tops" | "pants" | "both" | "other" | null;
note: string;
};
export function capState(opts: {
held: { tops: number; pants: number; other?: number };
adding?: { tops?: number; pants?: number; other?: number };
capSets?: number | null;
}): CapState {
const cap = setsCap(opts.capSets);
const tops = Math.max(0, opts.held.tops), pants = Math.max(0, opts.held.pants), other = Math.max(0, opts.held.other || 0);
const addTops = Math.max(0, opts.adding?.tops || 0), addPants = Math.max(0, opts.adding?.pants || 0), addOther = Math.max(0, opts.adding?.other || 0);
const afterTops = tops + addTops, afterPants = pants + addPants, afterOther = other + addOther;
const overTops = Math.max(0, afterTops - cap), overPants = Math.max(0, afterPants - cap), overOther = Math.max(0, afterOther - cap);
const breach = overTops && overPants ? "both" : overTops ? "tops" : overPants ? "pants" : overOther ? "other" : null;
const over = breach !== null;
const overBy = Math.max(overTops, overPants, overOther);
const sets = Math.min(tops, pants), afterSets = Math.min(afterTops, afterPants);
// Said in garments as well as in sets, because a hand-over is garments: "6 sets" on its own tells
// somebody holding six tops and two pairs nothing about why they are being turned away.
const holds = `${plural(afterTops, "top", "tops")} and ${plural(afterPants, "pair", "pairs")}`;
const note = !over
? `${afterSets} of ${cap} sets — ${holds}.${afterOther ? ` Plus ${plural(afterOther, "garment", "garments")} outside a set.` : ""}`
: breach === "other"
? `That would be ${plural(afterOther, "garment", "garments")} outside a set, and ${cap} outside a set is the most anyone holds. Hand one in to make room, or a coordinator can record an override.`
: `That would be ${holds}, and the most anyone holds is ${cap} sets — ${cap} tops and ${cap} pairs. Hand ${breach === "both" ? "a top and a pair" : breach === "tops" ? "a top" : "a pair"} in to make room, or a coordinator can record an override.`;
return {
cap, otherCap: cap,
tops, pants, other, sets,
addTops, addPants, addOther,
afterTops, afterPants, afterOther, afterSets,
overTops, overPants, overOther,
over, overBy, breach, note,
};
}
/** What this person may hold, and how they get there.
*
* `cap` and `max` are both the facility's ceiling, for every route. The starting-kit route used to be
* the exception — its second allocation waited on a hand-in — until the owner said nothing has to
* come back before the next set is issued. So there is one figure, and it is the one the counter
* refuses on; `cap` is kept beside `max` only so nothing calling this had to change the same day.
*
* `start` is the starting-kit route's figure — what they are handed on the first day — and null on
* the other two routes, whose starting number comes from the FTE table or a signature.
*
* The note is written about nobody in particular, because it lands in three places — the wearer's own
* app, the manager's review, and the counter — and "your manager" means a different person in each.
*
* `nursing` and `kit` are the facility's own answers about this person's group — is it on the FTE
* table list, is it on the starting-kit list — from lib/compute's isNursingGroup() and isKitGroup(),
* which read the facility's lists of names. They have to be passed in because this file has no
* facility to ask, and allowanceRoute() above turns the pair into one route. Neither decides
* whether there is a ceiling, only which sentence describes the way to it and whether there is a
* starting figure. Deciding either here from the group name would mean a second test for who is on
* which route, and two tests drift: the last one cost a whole group their allowance when somebody
* renamed a label. Leave `kit` off and the person is read as being on manager approval — told they
* start on nothing — so every caller that can reach the facility's lists has to pass both.
*
* `group` is no longer read: the route comes from the two answers above. It stays in the signature
* so that nothing calling this had to change the day the letters in a name stopped deciding it.
*
* `capped` is always true now and `cap` is never null. Both are kept so that nothing calling this
* had to change on the same day the rule did, and both can go once the screens have.
*
* `startingSets` and `capSets` are the facility's configured figures. Both are optional because the
* staff app holds no facility register, and a wearer's screen quoting the standing figure beats it
* quoting nothing — but any caller that can reach settings should pass them, or a site that issues
* four will go on telling its wearers three. */
export function allowance(opts: {
group?: string | null;
held: number;
startingSets?: number | null;
nursing?: boolean;
kit?: boolean;
capSets?: number | null;
}): { capped: boolean; cap: number | null; max: number; start: number | null; used: number; note: string } {
const max = setsCap(opts.capSets);
const garments = max * SET_GARMENTS;
const route = allowanceRoute(opts);
if (route !== "kit") {
// The FTE table and manager approval both end at the ceiling; what differs is what proposes the
// number on the way up. On the table somebody's hours propose theirs and a manager may sign
// above it; on approval nothing is proposed at all and each set comes with a signature.
const note = route === "fte"
? `Up to ${max} sets — ${garments} garments — at any time. The hours worked propose the starting number, and a manager can sign for more, up to that.`
: `Up to ${max} sets — ${garments} garments — at any time, each one approved by a manager.`;
return { capped: true, cap: max, max, start: null, used: opts.held, note };
}
// Never above the ceiling: a site that set its starting figure to seven would otherwise print an
// offer the counter then declines.
const start = Math.min(max, setsOnStart(opts.startingSets));
return {
capped: true, cap: max, max, start, used: opts.held,
note: `${start} sets on starting, then more as needed, up to ${max} sets — ${garments} garments — at any time. Nothing has to be handed back first.`,
};
}
+169
View File
@@ -0,0 +1,169 @@
import { prisma } from "./db";
import type { Prisma } from "@prisma/client";
import { facilityToday, garmentGroups, groupsLabel, key, type Snapshot } from "./compute";
import { AWAITING_HANDOVER } from "./staffreq";
import type { SessionUser } from "./session";
import { daysUntil, entitlements } from "./plan";
import { SWITCH_ROW } from "./switches";
/* How much history the snapshot carries.
*
* Both /app and /m rebuild this on every navigation and hand the whole thing to a client
* component, so anything unbounded here is a payload that grows for the life of the facility and
* is downloaded again on a ward phone at every tap. The collections below are read newest-first
* and capped: each is shown as a list and nothing derives a balance from it, so an older row
* falling off the end costs a line on a history screen, never a wrong number.
*
* Issues, stock movements and orders are deliberately NOT capped. On-hand is derived by replaying
* every one of them (ledger() in lib/compute.ts), and a garment issued three years ago and never
* handed back is still on that person's record — so a window over any of the three would quietly
* misstate the one figure this product exists to keep.
*
* The numbers clear several years of a busy linen room, and hand-ins in particular are generous:
* entUsed() credits a hand-in back against the allowance for the whole financial year, so that
* window has to comfortably outlast one. */
const HISTORY_TAKE = { stocktakes: 200, handins: 5000, alterations: 500 };
/* The plan as the screens see it: the entitlements plus the dates, days and the billing contact —
* the last for admins only, since it is a contact. `live` is the platform switch: until plans are
* live the Plan tab stays out of Settings and the banner stays quiet, whatever the columns say. */
function planBlock(fac: Parameters<typeof entitlements>[0] & { billingEmail: string; lastBackup: string }, staffCount: number, live: boolean, admin: boolean): Snapshot["plan"] {
const e = entitlements(fac);
return {
code: e.code, label: e.label, state: e.state, readOnly: e.readOnly, maxStaff: e.maxStaff, staff: staffCount, backupDays: e.backupDays,
endsAt: e.endsAt ? e.endsAt.toISOString() : null, daysLeft: daysUntil(e.endsAt),
graceEndsAt: e.graceEndsAt ? e.graceEndsAt.toISOString() : null, graceDaysLeft: daysUntil(e.graceEndsAt),
grandfathered: e.grandfathered, billingEmail: admin ? fac.billingEmail : "", live,
};
}
/** `db` may be a transaction client so callers holding a lock read through the same connection. */
export async function buildSnapshot(user: SessionUser, db: Prisma.TransactionClient | typeof prisma = prisma): Promise<Snapshot> {
const fid = user.facilityId;
const [fac, catalog, barcodes, stock, moves, depts, suppliers, staff, approvals, alterations, issues, orders, pickups, stocktakes, users, handins, locations, costs, owedLines, switchRow] = await Promise.all([
db.facility.findUniqueOrThrow({ where: { id: fid } }),
db.catalogItem.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
db.barcode.findMany({ where: { facilityId: fid } }),
db.stockLevel.findMany({ where: { facilityId: fid } }),
db.stockMove.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
db.department.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
db.supplier.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
db.staff.findMany({ where: { facilityId: fid }, orderBy: [{ last: "asc" }, { first: "asc" }], include: { account: { select: { email: true } } } }),
db.approval.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
db.alteration.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, take: HISTORY_TAKE.alterations }),
db.issue.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
db.order.findMany({
where: { facilityId: fid },
orderBy: { createdAt: "desc" },
include: { lines: { orderBy: { sort: "asc" } }, receipts: { orderBy: { createdAt: "asc" }, include: { lines: true } } },
}),
// Only the pickups still waiting. Every screen that reads them — the call list, the rounds
// screen, a staff record, both home pages — filters on `!pickedUp`, and one that has been
// collected is already on the record as the Issue it created.
db.pickup.findMany({ where: { facilityId: fid, pickedUp: null }, orderBy: { createdAt: "asc" }, include: { lines: true, order: { select: { code: true } } } }),
db.stocktake.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, include: { lines: true }, take: HISTORY_TAKE.stocktakes }),
user.role === "ADMIN"
? db.user.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" }, select: { id: true, email: true, first: true, last: true, title: true, role: true, inactive: true, ssoBreakGlass: true } })
: Promise.resolve([]),
db.handIn.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, include: { lines: true }, take: HISTORY_TAKE.handins }),
db.location.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
db.costChange.findMany({
where: { facilityId: fid },
orderBy: { at: "desc" },
take: 400,
}),
// Garments a manager has approved that nobody has handed over yet. They are owed to that person,
// so they count toward the six sets held on every screen exactly as the counter counts them.
// Without them a staff record shows room the counter then refuses.
db.requestLine.findMany({
where: { status: "approved", request: { facilityId: fid, status: { in: AWAITING_HANDOVER } } },
select: { itemId: true, qty: true, request: { select: { subjectId: true } } },
}),
// Read through `db`, not lib/switches.ts: callers inside lockedTx hold the one connection a
// single-connection pool has, and a query on the global client would wait for it until the
// transaction timed out. The environment's override is applied below, as switches() applies it.
db.platformSwitch.findUnique({ where: { id: SWITCH_ROW }, select: { plansLive: true } }),
]);
const plansLive = process.env.PLANS_LIVE === "1" || !!switchRow?.plansLive;
const bc: Record<string, string> = {};
for (const b of barcodes) bc[b.code] = key(b.itemId, b.sizeIndex);
const st: Record<string, { opening: number; adj: number; reorder: number | null; preloved: number }> = {};
for (const s of stock) st[key(s.itemId, s.sizeIndex)] = { opening: s.opening, adj: s.adj, reorder: s.reorder, preloved: s.preloved };
const placed: Record<string, string> = {};
for (const s of stock) if (s.locationId) placed[key(s.itemId, s.sizeIndex)] = s.locationId;
return {
session: { userId: user.id, name: `${user.first} ${user.last}`, first: user.first, last: user.last, title: user.title, role: user.role === "ADMIN" ? "Admin" : "Issuer", email: user.email },
settings: {
facility: fac.name, location: fac.location, coordinator: fac.coordinator,
// The order form prints the linen room's own contacts in its footer, the way the collection
// slip prints the facility's own name. Nothing about a customer's identity is written down in
// the product, so if these are blank the form's footer is blank.
coordinatorEmail: fac.coordinatorEmail, coordinatorPhone: fac.coordinatorPhone,
defaultEntitlement: fac.defaultEntitlement, defaultReorder: fac.defaultReorder,
// capSets travels with initialSets because they are the two halves of the same question — what
// a facility hands out to start with, and the most anybody may hold. Leave it out and the
// coordinator's ceiling is saved but never reaches the screens that have to honour it. The two
// route lists travel with them: which groups the FTE table proposes for, and which start on the
// kit. Drop kitGroups and every group on it reads as manager approval, told it starts on
// nothing while the counter still hands it the kit.
initialSets: fac.initialSets, capSets: fac.capSets, nursingGroups: fac.nursingGroups, kitGroups: fac.kitGroups,
exceptionHigh: fac.exceptionHigh, varianceReason: fac.varianceReason, glAccount: fac.glAccount, journalDesc: fac.journalDesc, lastBackup: fac.lastBackup, hasLogo: !!fac.logoData,
suppliers: suppliers.map((x) => x.name), staffGroups: fac.staffGroups,
slipCollectionFooter: fac.slipCollectionFooter, slipDeliveryFooter: fac.slipDeliveryFooter, slipOrg: fac.slipOrg,
barcodeLookup: fac.barcodeLookup,
timezone: fac.timezone,
// The switches only; the IdP metadata lives in the SSO service and is read by its own route.
sso: { enabled: fac.ssoEnabled, required: fac.ssoRequired, staff: fac.ssoStaff, domains: fac.ssoDomains },
},
// groups decides; group is only its label, kept because the phone counter's catalogue screens read it.
catalog: catalog.map((c) => ({ id: c.id, sort: c.sort, item: c.item, gender: c.gender, type: c.type, sku: c.sku, supplier: c.supplier, cost: c.cost, groups: garmentGroups(c.groups), group: groupsLabel(c.groups), notes: c.notes, sizes: c.sizes, archived: c.archived })),
barcodes: bc,
stock: st,
locations: locations.map((l) => ({ id: l.id, name: l.name, kind: l.kind, parentId: l.parentId, sort: l.sort, archived: l.archived })),
placed,
moves: moves.map((m) => ({ id: m.id, date: m.date, type: m.type, itemId: m.itemId, si: m.sizeIndex, qty: m.qty, reason: m.reason, byName: m.byName })),
costs: costs.map((c) => ({ id: c.id, itemId: c.itemId, cost: c.cost, previous: c.previous, at: c.at.toISOString(), byName: c.byName })),
depts: depts.map((d) => ({ id: d.id, name: d.name, cc: d.cc })),
supplierDir: suppliers.map((x) => ({ id: x.id, name: x.name, contact: x.contact, phone: x.phone, account: x.account, lead: x.lead })),
// selfCode is a boolean, never the code itself: an outstanding activation code is a credential,
// and the snapshot is downloaded whole into every coordinator's browser. The code is shown once,
// in the response to the op that made it, and then only exists on the printed slip.
//
// When it was printed is not a secret, and a screen needs it: slips expire (see the staleness
// check in app/api/staff/activate/route.ts), so without the date a coordinator looking at an
// outstanding code cannot tell a slip somebody will use tomorrow from one that died weeks ago
// and is only ever going to send that person back to the counter.
staff: staff.map((s) => ({ id: s.id, num: s.num, first: s.first, last: s.last, phone: s.phone, group: s.group, dept: s.dept, top: s.top, pants: s.pants, ccOverride: s.ccOverride, inactive: s.inactive, ent: s.ent, fte: s.fte, uniformStyle: s.uniformStyle, start: s.start, notes: s.notes, selfCode: !!s.activateCode, selfCodeAt: s.activateCodeAt ? s.activateCodeAt.toISOString() : null, selfEmail: s.account?.email ?? "", managerId: s.managerId, wardDesk: s.wardDesk })),
// Both halves of "approved by" travel: the name as signed, and the link to the register row
// it was picked from. The link is null on every approval recorded before the search existed and
// on any approver who was never on the register, so nothing may assume it is there.
approvals: approvals.map((a) => ({ id: a.id, staffId: a.staffId, date: a.date, by: a.byName, byStaffId: a.byStaffId, sets: a.sets, fte: a.fte, notes: a.notes, used: a.used, photoId: a.photoId })),
alterations: alterations.map((a) => ({ id: a.id, staffId: a.staffId, date: a.date, garment: a.garment, desc: a.desc, status: a.status })),
issues: issues.map((i) => ({
id: i.id, date: i.date, staffId: i.staffId, itemId: i.itemId, si: i.sizeIndex, qty: i.qty, cond: i.cond, cost: i.cost, orderCode: i.orderCode,
receipt: i.receipt, returned: i.returnedDate ? { date: i.returnedDate, cond: i.returnedCond || "", photoId: i.returnPhotoId } : null, override: i.override, direct: i.direct,
preloved: i.preloved, handedIn: i.handedIn, createdAt: i.createdAt.toISOString(), offGroup: i.offGroup, offStyle: i.offStyle,
})),
orders: orders.map((o) => ({
id: o.id, code: o.code, date: o.date, source: o.source, orderFor: o.orderFor, staffId: o.staffId, supplier: o.supplier, status: o.status,
ref: o.ref, invoice: o.invoice, tracking: o.tracking, expected: o.expected, received: o.received, cc: o.cc, notes: o.notes, replenish: o.replenish, parentId: o.parentId,
createdAt: o.createdAt.toISOString(),
lines: o.lines.map((l) => ({ id: l.id, itemId: l.itemId, size: l.size, qty: l.qty })),
receipts: o.receipts.map((r) => ({ id: r.id, date: r.date, invoice: r.invoice, note: r.note, photoId: r.photoId, lines: r.lines.map((x) => ({ itemId: x.itemId, size: x.size, qty: x.qty, dest: x.dest, cost: x.cost })) })),
})),
pickups: pickups.map((p) => ({ id: p.id, orderId: p.orderId, orderCode: p.order.code, staffId: p.staffId, received: p.received, contacted: p.contacted, pickedUp: p.pickedUp, deliveredTo: p.deliveredTo, sigId: p.sigId, proofId: p.proofId, deliveredRound: p.deliveredRound, lines: p.lines.map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty })) })),
owedRequestLines: owedLines.map((l) => ({ staffId: l.request.subjectId, itemId: l.itemId, qty: l.qty })),
handins: handins.map((h) => ({ id: h.id, date: h.date, staffId: h.staffId, by: h.byName, credit: h.credit, lines: h.lines.map((l) => ({ itemId: l.itemId, si: l.sizeIndex, qty: l.qty, cond: l.cond, laundered: l.laundered, credited: l.credited })) })),
stocktakes: stocktakes.map((t) => ({ id: t.id, date: t.date, by: t.byName, counted: t.counted, variances: t.variances, mode: t.mode, locationId: t.locationId, lines: t.lines.map((l) => ({ itemId: l.itemId, si: l.sizeIndex, sys: l.sys, counted: l.counted, reason: l.reason })) })),
users: users.map((u) => ({ id: u.id, email: u.email, first: u.first, last: u.last, title: u.title, role: u.role, inactive: u.inactive, ssoBreakGlass: u.ssoBreakGlass })),
demo: fac.isDemo ? { resetAt: fac.demoResetAt ? fac.demoResetAt.toISOString() : null } : null,
plan: planBlock(fac, staff.length, plansLive, user.role === "ADMIN"),
// Everything downstream measures "today" against the facility's own zone, not the server's, so
// the snapshot settles it once here and hands the zone out alongside it. `tz` is a copy of
// settings.timezone, hoisted so a client component formatting a date need not thread settings in.
today: facilityToday(fac.timezone),
tz: fac.timezone,
};
}
+202
View File
@@ -0,0 +1,202 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
import { prisma } from "./db";
/* Single sign-on for a facility's own people, through their own identity provider.
*
* The heavy lifting — SAML or OIDC against a hospital's Entra, Okta or Google — is done by a
* self-hosted BoxyHQ Jackson broker at JACKSON_URL (sso.threadcount.tech). Jackson holds each
* facility's IdP metadata, keyed (tenant = facility id, product = "threadcount"), and exposes the
* finished login as a plain OAuth 2.0 code flow. This module is the whole conversation with it:
*
* management POST/GET/DELETE {JACKSON_URL}/api/v1/sso with `Authorization: Api-Key …`
* (admin settings only; the key never leaves the server);
* front door /api/oauth/authorize → /api/oauth/token → /api/oauth/userinfo (the login routes).
*
* OPTIONAL: with JACKSON_URL or JACKSON_API_KEY unset, ssoConfigured() is false, no button renders
* and every route answers 404 — the product without SSO is exactly the product as it was.
*
* ThreadCount has no per-facility hostname, so a person is routed to their facility's IdP by the
* domain of the email they type: a facility registers its domains, and a domain belongs to one
* facility. The callback URL is one fixed address on the product host, never derived from a
* request header, and Jackson only redirects to what was registered. */
const PRODUCT = "threadcount";
// BoxyHQ's documented convention for a single per-tenant connection: fixed client credentials,
// the real routing in `tenant` and `product`.
const OAUTH_CLIENT_ID = "dummy";
const OAUTH_CLIENT_SECRET = "dummy";
export function ssoConfigured(): boolean {
return !!process.env.JACKSON_URL && !!process.env.JACKSON_API_KEY;
}
function jacksonUrl(): string {
const base = process.env.JACKSON_URL;
if (!base) throw new Error("JACKSON_URL is not set");
return base.replace(/\/+$/, "");
}
function apiKeyHeader(): string {
const key = process.env.JACKSON_API_KEY;
if (!key) throw new Error("JACKSON_API_KEY is not set");
return `Api-Key ${key}`;
}
/** The one redirect target Jackson may use: the product's own host, https, fixed path. */
export function ssoCallbackUrl(): string {
const base = (process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech").replace(/\/+$/, "");
return `${base}/api/auth/sso/callback`;
}
export class SsoError extends Error {}
/* ---------- domains ---------- */
/** The registrable part of an address, lower-cased; null when it is not an address. */
export function domainOf(email: string): string | null {
const m = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/.exec(email.trim().toLowerCase());
return m ? m[1] : null;
}
export function normaliseDomain(raw: string): string | null {
const d = raw.trim().toLowerCase().replace(/^@/, "");
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d) && d.length <= 253 ? d : null;
}
export type SsoFacility = { id: string; name: string; ssoEnabled: boolean; ssoRequired: boolean; ssoStaff: boolean; isDemo: boolean };
/** The facility whose registered domain matches this address and has SSO switched on, if any. */
export async function facilityForEmail(email: string): Promise<SsoFacility | null> {
const d = domainOf(email);
if (!d) return null;
const f = await prisma.facility.findFirst({
where: { ssoEnabled: true, ssoDomains: { has: d } },
select: { id: true, name: true, ssoEnabled: true, ssoRequired: true, ssoStaff: true, isDemo: true },
});
return f && !f.isDemo ? f : null;
}
/** Is this domain already claimed by another facility? Domains route sign-ins, so one owner each. */
export async function domainTakenBy(domain: string, exceptFacilityId: string): Promise<string | null> {
const f = await prisma.facility.findFirst({ where: { ssoDomains: { has: domain }, id: { not: exceptFacilityId } }, select: { name: true } });
return f?.name ?? null;
}
/* ---------- the state cookie: CSRF for the redirect dance ---------- */
export const STATE_COOKIE = "tc_sso";
const STATE_TTL_MS = 10 * 60 * 1000;
export type SsoAudience = "user" | "staff";
function stateKey() {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET not set");
return createHash("sha256").update("threadcount:sso:v1:" + s).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** A fresh nonce, and the signed cookie value that binds it to one facility and one audience. */
export function mintState(facilityId: string, aud: SsoAudience): { nonce: string; cookie: string } {
const nonce = randomBytes(32).toString("base64url");
const payload = b64url(Buffer.from(JSON.stringify({ n: nonce, f: facilityId, a: aud, exp: Date.now() + STATE_TTL_MS })));
const sig = b64url(createHmac("sha256", stateKey()).update(payload).digest());
return { nonce, cookie: `${payload}.${sig}` };
}
/** The facility and audience the cookie was minted for, only if `state` is its nonce. */
export function readState(cookie: string | undefined, state: string): { facilityId: string; aud: SsoAudience } | null {
if (!cookie || !state) return null;
const [payload, sig] = cookie.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", stateKey()).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.n || !d.f || !d.exp || d.exp < Date.now()) return null;
const n = Buffer.from(String(d.n)), s = Buffer.from(state);
if (n.length !== s.length || !timingSafeEqual(n, s)) return null;
return { facilityId: String(d.f), aud: d.a === "staff" ? "staff" : "user" };
} catch {
return null;
}
}
/* ---------- management API ---------- */
export type SsoConnection = { tenant: string; product: string; name?: string; idpMetadata?: { entityID?: string; provider?: string }; clientID?: string };
export async function createOrUpdateConnection(args: { facilityId: string; facilityName: string; metadataUrl?: string; metadataXml?: string }): Promise<SsoConnection> {
const callback = ssoCallbackUrl();
const form = new URLSearchParams();
form.set("tenant", args.facilityId);
form.set("product", PRODUCT);
form.set("name", `ThreadCount — ${args.facilityName}`.slice(0, 120));
form.set("redirectUrl", JSON.stringify([callback]));
form.set("defaultRedirectUrl", callback);
if (args.metadataUrl) form.set("metadataUrl", args.metadataUrl);
else if (args.metadataXml) form.set("encodedRawMetadata", Buffer.from(args.metadataXml, "utf8").toString("base64"));
else throw new SsoError("Provide the identity provider's metadata URL or XML.");
const res = await fetch(`${jacksonUrl()}/api/v1/sso`, {
method: "POST",
headers: { Authorization: apiKeyHeader(), "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!res.ok) throw new SsoError(`The SSO service rejected that metadata (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
return (await res.json().catch(() => ({}))) as SsoConnection;
}
export async function getConnection(facilityId: string): Promise<SsoConnection | null> {
const res = await fetch(`${jacksonUrl()}/api/v1/sso?tenant=${encodeURIComponent(facilityId)}&product=${PRODUCT}`, { headers: { Authorization: apiKeyHeader() }, cache: "no-store" });
if (!res.ok) throw new SsoError(`Could not read the SSO connection (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
const data = (await res.json().catch(() => [])) as SsoConnection[] | SsoConnection;
const list = Array.isArray(data) ? data : [data];
return list[0] && list[0].tenant ? list[0] : null;
}
export async function deleteConnection(facilityId: string): Promise<void> {
const res = await fetch(`${jacksonUrl()}/api/v1/sso?tenant=${encodeURIComponent(facilityId)}&product=${PRODUCT}`, { method: "DELETE", headers: { Authorization: apiKeyHeader() } });
if (!res.ok && res.status !== 404) throw new SsoError(`Could not remove the SSO connection (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
}
/* ---------- front door ---------- */
export function buildAuthorizeUrl(facilityId: string, state: string): string {
const q = new URLSearchParams({ client_id: OAUTH_CLIENT_ID, redirect_uri: ssoCallbackUrl(), response_type: "code", scope: "openid", state, tenant: facilityId, product: PRODUCT });
return `${jacksonUrl()}/api/oauth/authorize?${q.toString()}`;
}
export async function exchangeCode(code: string): Promise<string> {
const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: ssoCallbackUrl(), client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET });
const res = await fetch(`${jacksonUrl()}/api/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() });
if (!res.ok) throw new SsoError(`SSO token exchange failed (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
const data = (await res.json().catch(() => ({}))) as { access_token?: string };
if (!data.access_token) throw new SsoError("SSO token exchange returned no access token.");
return data.access_token;
}
export type SsoProfile = { email: string; name?: string };
export async function fetchProfile(accessToken: string): Promise<SsoProfile> {
const res = await fetch(`${jacksonUrl()}/api/oauth/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store" });
if (!res.ok) throw new SsoError(`Could not read the SSO profile (${res.status}). ${summarise(await res.text().catch(() => ""))}`);
const p = (await res.json().catch(() => ({}))) as { email?: string; firstName?: string; lastName?: string; name?: string };
const email = typeof p.email === "string" ? p.email.trim().toLowerCase() : "";
if (!email) throw new SsoError("The identity provider did not return an email address.");
const name = (p.name?.trim() || [p.firstName, p.lastName].filter(Boolean).join(" ").trim()) || undefined;
return { email, name };
}
// Jackson error bodies are sometimes JSON ({error:{message}}) and sometimes text; keep a short
// safe snippet for the server log and the admin, never a whole SAML payload.
function summarise(detail: string): string {
if (!detail) return "";
try {
const j = JSON.parse(detail) as { error?: { message?: string } | string };
const msg = typeof j.error === "string" ? j.error : j.error?.message;
if (msg) return msg.slice(0, 200);
} catch { /* not JSON */ }
return detail.slice(0, 200);
}
+80
View File
@@ -0,0 +1,80 @@
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import { fail, over } from "./ratelimit";
import { setStaffCookie } from "./staffsession";
import { recordAuthEvent } from "./audit";
/* Signing a wearer in, from either door.
*
* There are two ways into the staff app now: /my/signin, which is where the printed slip and the
* Play app send people, and the ordinary Log in box on the website, which a wearer reaches by
* clicking Log in on the home page like anybody else. Both end in the same session, so both go
* through here rather than through two copies of the same twenty lines — the refusal wording, the
* failure buckets and the audit events have to say the same thing whichever door was used, and the
* way that stops being true is by living in two places.
*/
// A constant to compare against when there is no account, so a missing email and a wrong password
// take the same time.
const DUMMY = "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u";
/** The trail names the person on the register, not the account — see lib/audit.ts. */
type Acc = { facilityId: string; staff: { id: string; first: string; last: string } };
const actorFor = (a: Acc, email: string) =>
({ facilityId: a.facilityId, userId: a.staff.id, userName: `${a.staff.first} ${a.staff.last}`.trim() || email });
/* Failures only, not attempts: every wearer in the hospital arrives from one NAT address at shift
* change, so a ceiling on attempts would lock out the ward it is meant to protect.
*
* Its own function because the staff door asks it *before* verifying Turnstile, and a token is good
* for one use — asking afterwards would spend somebody's check to tell them they are throttled.
* `over` only reads the bucket, so asking twice on one request costs nothing. */
export function staffThrottled(email: string, ip: string): boolean {
return over("staff-login-ip:" + ip, 40, 15 * 60 * 1000) || (!!email && over("staff-login-email:" + email, 25, 15 * 60 * 1000));
}
export type StaffSignIn =
/** No staff account with this address. The caller decides what that means. */
| { kind: "none" }
| { kind: "error"; error: string; status: number }
| { kind: "ok"; name: string };
/**
* Check an address and password against the staff register and, if they match, set the session.
*
* `ownDoor` says whether this is /api/staff/login itself. It decides one thing only: what happens
* when the address has no staff account at all. At the staff door that is a plain wrong answer, so
* it costs a dummy compare and a counted failure like any other. At the shared Log in box it is
* ordinary — most people typing there are coordinators — so it returns `none` having touched
* nothing, and the coordinator path does its own compare and counts its own failure. Counting in
* both places would spend two of somebody's eight attempts on one wrong password.
*/
export async function signInStaff(email: string, password: string, ip: string, ownDoor: boolean): Promise<StaffSignIn> {
const acc = await prisma.staffAccount.findUnique({
where: { email },
select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } },
});
if (!acc && !ownDoor) return { kind: "none" };
if (staffThrottled(email, ip)) {
return { kind: "error", error: "Too many attempts — try again in 15 minutes.", status: 429 };
}
const ok = await bcrypt.compare(password, acc?.passwordHash ?? DUMMY);
if (!acc || !ok) {
fail("staff-login-ip:" + ip, 15 * 60 * 1000);
if (email) fail("staff-login-email:" + email, 15 * 60 * 1000);
// An address with no account here goes unrecorded: nothing names a facility to file it under.
if (acc) recordAuthEvent(actorFor(acc, email), "staff:signin.failed", ip);
return { kind: "error", error: "Email or password doesnt match.", status: 401 };
}
if (acc.staff.inactive) {
recordAuthEvent(actorFor(acc, email), "staff:signin.refused", ip, "inactive");
return { kind: "error", error: "You're no longer on the register at this facility. Ask the linen room.", status: 403 };
}
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
await setStaffCookie(acc.id, acc.passwordHash);
recordAuthEvent(actorFor(acc, email), "staff:signin", ip, "password");
return { kind: "ok", name: `${acc.staff.first} ${acc.staff.last}` };
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { noteRev, useLiveRefresh } from "@/lib/live";
import { TRACKED_STAFF_OPS, failureKind, track } from "@/lib/analytics";
/* The staff app's client context.
*
* Deliberately not SnapshotProvider. That one hands the whole facility to the browser, which is
* right for a coordinator at the counter and wrong here: a wearer's device should never hold the
* register, so each staff screen is server-rendered from its own narrow query and this context
* carries only who you are and how to post a change.
*/
export type StaffMe = {
staffId: string;
name: string;
first: string;
num: string;
ward: string;
facility: string;
/* The facility's IANA zone, carried here so a screen can format a timestamp without a round trip.
*
* Every one of these screens is server-rendered and then hydrated, so a date formatted in whatever
* zone the process happens to sit in is wrong twice: wrong on the server, different again in the
* browser, and React logs a hydration mismatch in between. Pinning both to the facility's own zone
* is what makes "signed 08:14" the same string in both places — and the right one. */
tz: string;
/** They manage at least one person, so the approvals queue is theirs to see. */
isManager: boolean;
/** They are on the ward desk, so they sign for the bags that arrive on the round. */
wardDesk: boolean;
/** Nobody is recorded as their approver yet, so they cannot raise a request. */
hasManager: boolean;
};
type Ctx = {
me: StaffMe;
busy: boolean;
refresh: () => void;
mutate: <T = unknown>(op: string, payload?: unknown) => Promise<{ ok: true; result: T } | { ok: false; error: string }>;
};
const StaffContext = createContext<Ctx | null>(null);
export function StaffProvider({ me, children }: { me: StaffMe; children: React.ReactNode }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [inflight, setInflight] = useState(0);
const refresh = useCallback(() => startTransition(() => router.refresh()), [router]);
const mutate = useCallback(async <T,>(op: string, payload?: unknown) => {
setInflight((n) => n + 1);
try {
const r = await fetch("/api/staff/mutate", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ op, payload }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
// A dead session on a ward phone is common — the app sits open in a pocket for days.
if (r.status === 401) window.location.assign("/my/signin");
const err = (j && j.error) || "That didn't work.";
// Refusals are worth counting: a facility whose staff keep being told they have no
// manager recorded is telling us something. The op name and a coarse category only.
if (TRACKED_STAFF_OPS[op]) track("action_refused", { action: TRACKED_STAFF_OPS[op], reason: failureKind(String(err)) });
return { ok: false as const, error: err };
}
// Ours, so the watch above recognises the new revision instead of firing again.
noteRev(j.rev);
startTransition(() => router.refresh());
if (TRACKED_STAFF_OPS[op]) track(TRACKED_STAFF_OPS[op]);
return { ok: true as const, result: j.result as T };
} catch {
if (TRACKED_STAFF_OPS[op]) track("action_refused", { action: TRACKED_STAFF_OPS[op], reason: "network" });
/* "Nothing was saved" was a guess, and on ward wifi it was often the wrong one: the POST can
* reach the server and commit before the reply gets back, and none of the staff ops are
* idempotent — a retried request is raised twice and emails the manager twice. So the message
* says what is actually known, and points at the screen that settles it. */
return { ok: false as const, error: "No signal — we cant say whether that went through. Check your orders before trying again." };
} finally {
setInflight((n) => n - 1);
}
}, [router]);
/* Kept current, without re-reading the world to find out whether anything happened.
*
* Every screen here is server-rendered and none of them poll, so an app left open in a pocket
* used to show whatever was true when it was last looked at — somebody watching "Being picked"
* would never see it become "Ready to collect", and a garment the linen room added at the desk
* did not exist here until the app was reopened.
*
* The watch asks one question — the facility's revision number — and only reloads when it has
* moved. It stops entirely while the app is in the background, so a phone in a pocket on ward
* wifi costs nothing, and asks immediately on coming back to the front, so opening the app is
* up to date at once rather than a poll behind. It replaces an unconditional refresh on every
* glance at the screen, which paid for the whole record to find out that nothing had changed.
*
* It lives in the provider rather than in each screen, so a screen added later gets it without
* anyone remembering. */
useLiveRefresh(refresh);
const value = useMemo<Ctx>(() => ({ me, busy: pending || inflight > 0, refresh, mutate }), [me, pending, inflight, refresh, mutate]);
return <StaffContext.Provider value={value}>{children}</StaffContext.Provider>;
}
export function useStaff() {
const c = useContext(StaffContext);
if (!c) throw new Error("useStaff outside provider");
return c;
}
+506
View File
@@ -0,0 +1,506 @@
import { prisma } from "./db";
import {
addDays, facilityDate, facilityToday, fmtDate, garmentForGroup, garmentForStyle, isKitGroup, isNursingGroup, isPantItem, isTopItem, key, ledger,
onhand, reorderAt,
type Snapshot,
} from "./compute";
import { allowance, capState, garmentCounts, setsHeld } from "./sets";
import {
OPEN_REQUEST, approvedLines, decisionSummary, garmentCount, lineStatusLabel, roundWard, type StockWord,
stockWord,
} from "./staffreq";
import type { StaffSession } from "./staffsession";
/* Everything the staff screens read.
*
* No facility snapshot, anywhere. A wearer's screen has no business
* holding the register, and the availability queries below are the one place that touches
* facility-wide data — deliberately, narrowed to the garments being asked about, and only ever to
* turn a count into a word.
*
* The one other reach across is four columns of settings — the site's two lists of which groups
* take the FTE table and which the starting kit, its ceiling, and how many sets that kit is —
* selected alongside the wearer's own row where an allowance is being worked out. Without them this
* screen quotes the standing 3 at a site that issues 4, and cannot tell which route the wearer is
* on, so it tells somebody owed a starting kit that they start on nothing.
*/
export type Availability = { size: string; si: number; word: StockWord; countedOn: string };
/** Stock as a ward is allowed to see it: words, never numbers.
*
* The count itself never leaves this function. That is the product rule and it is also the only
* honest position — the linen room's count is the audited one, and a number on a ward screen just
* starts an argument at the counter about whether the shelf really holds four. */
export async function availability(facilityId: string, itemIds: string[]): Promise<Record<string, Availability[]>> {
if (!itemIds.length) return {};
const [fac, items, levels, issues, receiptLines, moves, takes] = await Promise.all([
prisma.facility.findUniqueOrThrow({ where: { id: facilityId }, select: { defaultReorder: true } }),
prisma.catalogItem.findMany({ where: { id: { in: itemIds }, facilityId }, select: { id: true, sizes: true } }),
prisma.stockLevel.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, opening: true, adj: true, reorder: true, preloved: true } }),
prisma.issue.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, qty: true, direct: true, preloved: true, returnedDate: true, returnedCond: true } }),
prisma.receiptLine.findMany({ where: { itemId: { in: itemIds }, receipt: { order: { facilityId } } }, select: { itemId: true, size: true, qty: true, dest: true } }),
prisma.stockMove.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, qty: true } }),
prisma.stocktake.findMany({ where: { facilityId }, orderBy: { date: "desc" }, take: 40, select: { date: true, lines: { select: { itemId: true, sizeIndex: true } } } }),
]);
const counted = new Map<string, string>();
for (const t of takes) for (const l of t.lines) {
const k = key(l.itemId, l.sizeIndex);
if (!counted.has(k)) counted.set(k, t.date); // takes are newest-first, so the first wins
}
/* On hand is the linen room's number or it is a fiction.
*
* There is one on-hand expression in ThreadCount — ledger() and onhand() in lib/compute.ts:
* opening plus adjustments, plus what shelf receipts and stock moves brought in, less what has
* been issued, plus what came back in good condition. The word a ward is shown has to come from
* that same arithmetic and not from a cheaper sum that happens to be easy to write here. A
* second formula does not drift a little: this one told wards "None on shelf" for full bays,
* because stock arriving against a supplier order never touches StockLevel at all.
*
* So rather than restate the sum, this loads the four things ledger() actually reads — the
* catalogue, receipt lines, issues and stock moves — narrowed to the garments being asked
* about, and hands them to the coordinator's own function. The cast is what that costs:
* ledger() and onhand() take a whole Snapshot, and a wearer's screen has no business building
* one. Neither function reads a field outside the ones set below.
*/
const stock: Snapshot["stock"] = {};
for (const l of levels) stock[key(l.itemId, l.sizeIndex)] = { opening: l.opening, adj: l.adj, reorder: l.reorder, preloved: l.preloved };
const scoped = {
settings: { defaultReorder: fac.defaultReorder },
catalog: items.map((i) => ({ id: i.id, sizes: i.sizes })),
stock,
issues: issues.map((i) => ({
itemId: i.itemId, si: i.sizeIndex, qty: i.qty, direct: i.direct, preloved: i.preloved,
returned: i.returnedDate ? { cond: i.returnedCond || "" } : null,
})),
orders: [{ receipts: [{ lines: receiptLines }] }],
moves: moves.map((m) => ({ itemId: m.itemId, si: m.sizeIndex, qty: m.qty })),
} as unknown as Snapshot;
const L = ledger(scoped);
const out: Record<string, Availability[]> = {};
for (const it of items) {
out[it.id] = it.sizes.map((size, si) => {
const k = key(it.id, si);
// reorderAt() falls back to the facility default, exactly as the linen room's own screens do.
// Without that fallback a size with no per-size reorder level goes straight from "In stock"
// to "None on shelf", and "Low" is a word the ward never once sees.
return { size: String(size), si, word: stockWord(onhand(scoped, L, k), reorderAt(scoped, k)), countedOn: counted.get(k) || "" };
});
}
return out;
}
/* ---------- a request, as a screen wants it ----------
*
* A request covers as many garments as the person needs, one line each, so every screen that used
* to print "2 × Tunic — 16" off the request itself now walks a list. The shaping is here rather
* than in each caller because the manager's queue, the wearer's orders, the ward round and the
* linen room's queue all have to describe the same bag the same way — the day they disagree is the
* day somebody signs for two garments and goes looking for a third.
*/
export type ReqLine = {
id: string; itemId: string; item: string; size: string; si: number; qty: number;
/** The cut, so a slip or a screen can tell two garments of the same name apart. Male | Female |
* Unisex, as stored — call genderLabel() for the words. */
gender: string;
/** awaiting | approved | declined, and the word for it. */
status: string; statusLabel: string;
declineReason: string | null;
};
type LineRecord = {
id: string; itemId: string; sizeIndex: number; qty: number; status: string; declineReason: string | null;
item: { item: string; gender: string; sizes: string[] };
};
export function reqLines(lines: readonly LineRecord[]): ReqLine[] {
return lines.map((l) => ({
id: l.id, itemId: l.itemId, item: l.item.item, gender: l.item.gender,
size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex), si: l.sizeIndex, qty: l.qty,
status: l.status, statusLabel: lineStatusLabel(l.status), declineReason: l.declineReason,
}));
}
/** The lines a row is actually about.
*
* Once the manager has left something to pick, that is the bag, and a declined fleece has no
* business padding out the linen room's pick list or the wearer's "ready to collect" card. Before
* a decision — and when every line was refused — there is no bag, so the whole ask is the subject.
* The refusals are never hidden: they stay in `lines` with their own word and reason. */
export function bagLines(lines: readonly ReqLine[]): ReqLine[] {
const picked = approvedLines(lines);
return picked.length ? picked : [...lines];
}
/** The one-liner for a collapsed row. A single-garment request reads exactly as it always did; a
* longer one leads with the total, because "5 garments" is the thing somebody picking or carrying
* a bag needs before the names. */
export function linesSummary(lines: readonly ReqLine[]): string {
const bag = bagLines(lines);
if (!bag.length) return "";
if (bag.length === 1) return `${bag[0].qty} × ${bag[0].item}${bag[0].size}`;
const names = bag.slice(0, 3).map((l) => l.item);
const rest = bag.length - names.length;
return `${garmentCount(bag)} garments · ${names.join(", ")}${rest ? ` +${rest} more` : ""}`;
}
export type ReqRow = {
id: string; code: string; status: string;
lines: ReqLine[];
/** The collapsed one-liner, and the totals behind it — all three about the bag, per bagLines(). */
summary: string; garments: number; lineCount: number;
/** "2 of 3 approved", or null while it is still with the manager. */
decision: string | null;
reason: string; note: string; managerName: string; declineReason: string | null;
collectCode: string | null; holdUntil: string; route: string | null;
signerName: string | null; signerRole: string | null;
subjectName: string; raisedByName: string;
/** The ward the bag was sent out to, per roundWard() — blank until it goes on a round. Never the
* wearer's current ward: that is what made a transferred nurse's order name the wrong desk. */
ward: string;
/** Is the viewer the wearer? False on a request they raised for somebody else. */
mine: boolean;
createdAt: string;
};
function reqRow(r: {
id: string; code: string; status: string; reason: string; note: string; subjectId: string;
managerName: string; declineReason: string | null; collectCode: string | null; holdUntil: string;
route: string | null; signerName: string | null; signerRole: string | null;
raisedByName: string; createdAt: Date;
lines: LineRecord[];
events: readonly { label: string; meta: string }[];
subject: { first: string; last: string };
}, viewerId: string): ReqRow {
const lines = reqLines(r.lines);
const bag = bagLines(lines);
return {
id: r.id, code: r.code, status: r.status,
lines,
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
decision: decisionSummary(lines),
reason: r.reason, note: r.note, managerName: r.managerName, declineReason: r.declineReason,
collectCode: r.collectCode, holdUntil: r.holdUntil, route: r.route,
signerName: r.signerName, signerRole: r.signerRole,
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
raisedByName: r.raisedByName, ward: roundWard(r.events),
mine: r.subjectId === viewerId,
createdAt: r.createdAt.toISOString(),
};
}
const REQ_INCLUDE = {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true } },
// The timeline comes along on every order row because it is the only record of which ward a bag
// was actually delivered to — see roundWard().
events: { select: { label: true, meta: true } },
} as const;
/** How long a decline stays eligible for the home screen's one live card. */
const DECLINE_HEADLINE_DAYS = 7;
/** 1A Home. One live thing at the top, then shortcuts. */
export async function homeData(sess: StaffSession) {
// The facility's own zone decides what "today" is here — which notice is still running, and how
// recent a decline still counts as. The server's ambient zone gets no say in either.
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: sess.facilityId }, select: { name: true, timezone: true } });
const today = facilityToday(fac.timezone);
const [staff, reqs, raised, notice, holdings] = await Promise.all([
prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { num: true, first: true, last: true, dept: true, top: true, pants: true, managerId: true, wardDesk: true },
}),
prisma.request.findMany({ where: { subjectId: sess.staffId }, orderBy: { createdAt: "desc" }, take: 25, include: REQ_INCLUDE }),
/* What they raised for somebody else — a manager, for one of their own reports.
*
* Every list on this app starts from `subjectId`, so a request they raised appeared on no
* screen they could reach. Keyed on `raisedByStaffId` instead, which also keeps the requests
* raised from the old desk route in front of the person who typed them. It is not theirs to
* collect, so it does not compete for the live card; it sits in its own list with the
* wearer's name on it. */
prisma.request.findMany({
where: {
facilityId: sess.facilityId, raisedByStaffId: sess.staffId,
subjectId: { not: sess.staffId }, status: { in: [...OPEN_REQUEST] },
},
orderBy: { createdAt: "desc" }, take: 25, include: REQ_INCLUDE,
}),
prisma.linenNotice.findFirst({
where: { facilityId: sess.facilityId, OR: [{ endsAt: "" }, { endsAt: { gte: today } }] },
orderBy: { createdAt: "desc" },
}),
prisma.issue.aggregate({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null }, _sum: { qty: true } }),
]);
// "Furthest along" — the one the person most likely wants to act on. Ready to collect beats a
// bag still out on the round, which beats something waiting on a manager.
const RANK: Record<string, number> = { ready: 6, round: 5, picking: 4, accepted: 3, awaiting: 2, declined: 1 };
// A decline is worth the top of the screen while it is news. Kept in the ranking forever it
// becomes the permanent headline the moment every later request has finished — a refusal from
// March greeting someone in September, with no way to dismiss it. After a week it lives in
// Orders with everything else that is over.
const declinedFrom = addDays(today, -DECLINE_HEADLINE_DAYS);
const open = reqs.filter((r) => (
OPEN_REQUEST.has(r.status as never)
|| (r.status === "declined" && facilityDate(r.decidedAt ?? r.createdAt, fac.timezone) >= declinedFrom)
));
const live = open.sort((a, b) => (RANK[b.status] || 0) - (RANK[a.status] || 0))[0];
return {
name: `${staff.first} ${staff.last}`.trim(),
num: staff.num,
ward: staff.dept,
facility: fac.name,
hasManager: !!staff.managerId,
wardDesk: staff.wardDesk,
holding: holdings._sum.qty || 0,
live: live ? reqRow(live, sess.staffId) : null,
openCount: open.filter((r) => OPEN_REQUEST.has(r.status as never)).length,
raisedOpen: raised.map((r) => reqRow(r, sess.staffId)),
notice: notice?.body || "",
};
}
/** 1B My kit. */
export async function kitData(sess: StaffSession) {
const staff = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { top: true, pants: true, facility: { select: { timezone: true } } },
});
const today = facilityToday(staff.facility.timezone);
const fyFrom = (+today.slice(0, 4) - (+today.slice(5, 7) >= 7 ? 0 : 1)) + "-07-01";
const issues = await prisma.issue.findMany({
where: { staffId: sess.staffId },
orderBy: [{ date: "desc" }],
select: { id: true, date: true, qty: true, sizeIndex: true, returnedDate: true, returnedCond: true, handedIn: true, item: { select: { id: true, item: true, sizes: true } } },
});
// Grouped by garment and size, which is how someone thinks about what they have — not as a
// list of issuing events.
const held = new Map<string, { itemId: string; item: string; size: string; si: number; qty: number; last: string; labelIds: string[] }>();
for (const i of issues) {
// A hand-in takes the garment off the person without ever setting returnedDate — it joins the
// pre-loved pool rather than coming back as a return — so skipping only returns left handed-in
// garments on someone's record here for good, and offered them up to Report damage.
if (i.returnedDate || i.handedIn) continue;
const k = `${i.item.id}:${i.sizeIndex}`;
const cur = held.get(k) || { itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, qty: 0, last: "", labelIds: [] };
cur.qty += i.qty;
if (i.date > cur.last) cur.last = i.date;
cur.labelIds.push(i.id);
held.set(k, cur);
}
/* Handed back is both routes off a person's record, and is neither of the two write-offs.
*
* A garment brought to the counter is booked as a return and stamped returnedDate; a hand-in is
* stamped handedIn and never returnedDate, because it joins the pre-loved pool rather than coming
* back as a return. Counting returnedDate alone therefore told somebody who had carried five
* garments in "Nothing handed back since 1 July" — the flat contradiction of what they had just
* done at the counter, on the one screen they would check before arguing about it.
*
* Lost and Written Off also stamp returnedDate, and neither is a garment anybody handed back: one
* never came home and the other was condemned. Crediting a person for them under that word would
* be the same untruth in the other direction, so they are left out here. Nothing is hidden by it
* — the write-off is on the linen room's record of the issue either way. */
const handedBackThisYear = issues.filter((i) => (
(i.handedIn && i.handedIn >= fyFrom)
|| (i.returnedDate && i.returnedDate >= fyFrom && i.returnedCond !== "Lost" && i.returnedCond !== "Written Off")
)).reduce((n, i) => n + i.qty, 0);
return {
held: [...held.values()].sort((a, b) => a.item.localeCompare(b.item) || a.size.localeCompare(b.size)),
total: [...held.values()].reduce((n, h) => n + h.qty, 0),
handedBackThisYear,
fyFrom,
sizes: { top: staff.top, pants: staff.pants },
};
}
/** 1C Orders. */
export async function ordersData(sess: StaffSession) {
const [reqs, raised] = await Promise.all([
prisma.request.findMany({
where: { subjectId: sess.staffId }, orderBy: { createdAt: "desc" }, include: REQ_INCLUDE,
}),
/* The requests this person raised for other people: a manager, for their own team, plus
* anything still on the record from the old desk route. Keying on `raisedByStaffId` rather
* than on a flag is what keeps those older ones reachable. They are deliberately not folded
* into `open`/`done`: what a wearer does with their own order (collect it, chase it, confirm
* they picked it up) is not what the person who typed it in does with it, and mixing the two
* lists is how somebody collects a bag that is not theirs. Every row carries `subjectName`, so
* the screen can say who it is for. */
prisma.request.findMany({
where: { facilityId: sess.facilityId, raisedByStaffId: sess.staffId, subjectId: { not: sess.staffId } },
orderBy: { createdAt: "desc" }, include: REQ_INCLUDE,
}),
]);
const rows = reqs.map((r) => reqRow(r, sess.staffId));
const raisedRows = raised.map((r) => reqRow(r, sess.staffId));
const isOpen = (r: ReqRow) => OPEN_REQUEST.has(r.status as never);
return {
open: rows.filter(isOpen),
done: rows.filter((r) => !isOpen(r)),
raised: { open: raisedRows.filter(isOpen), done: raisedRows.filter((r) => !isOpen(r)) },
};
}
/** 1D Order detail + 1F thread. Visible to the subject, their manager, or the clerk who raised it. */
export async function requestData(sess: StaffSession, id: string) {
const [r, me] = await Promise.all([
prisma.request.findFirst({
where: { id, facilityId: sess.facilityId },
include: { ...REQ_INCLUDE, events: { orderBy: { at: "asc" } }, messages: { orderBy: { createdAt: "asc" } } },
}),
prisma.staff.findUnique({ where: { id: sess.staffId }, select: { dept: true, wardDesk: true } }),
]);
if (!r) return null;
/* The subject, their manager and whoever raised it — plus the desk that is physically holding
* the bag. /my/round lists a delivered bag to the ward the trolley left it on and offers
* "Nudge", which opens this order's thread; without this clause that desk got the 404 and the
* nudge went nowhere. Same fence as round.sign and round.claim: the ward on the timeline, and a
* blank ward matches nobody. */
const party = r.subjectId === sess.staffId || r.managerId === sess.staffId || r.raisedByStaffId === sess.staffId;
const onMyDesk = !!me?.wardDesk && !!me.dept.trim() && (r.status === "round" || r.status === "delivered") && roundWard(r.events) === me.dept;
if (!party && !onMyDesk) return null;
return {
...reqRow(r, sess.staffId),
// Only this screen needs it: it is what decides whether the requester is still being asked to
// confirm they picked the bag up off the ward desk, and the desk's unclaimed list is exactly
// the delivered requests where it is still null.
claimedAt: r.claimedAt?.toISOString() ?? null,
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
};
}
/** 1E New request, and 1H Shelf check — both need the catalogue with words against it, and the
* request screen also needs to tell the person what they already hold and what they are allowed. */
export async function catalogueData(sess: StaffSession) {
const [catalog, staff, issues] = await Promise.all([
prisma.catalogItem.findMany({
where: { facilityId: sess.facilityId, archived: false },
orderBy: { sort: "asc" },
select: { id: true, item: true, type: true, gender: true, sizes: true, groups: true },
}),
prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { top: true, pants: true, group: true, uniformStyle: true, managerId: true, facility: { select: { nursingGroups: true, kitGroups: true, capSets: true, initialSets: true } } } }),
/* Every issue, not just the ones still out. The live ones are what they hold; the returned and
* handed-in ones are still the best evidence of what size fits them, which is the only thing
* the record knows about a jacket or a fleece. Newest first so the first row wins per garment. */
prisma.issue.findMany({
where: { staffId: sess.staffId },
orderBy: { date: "desc" },
select: { itemId: true, sizeIndex: true, qty: true, returnedDate: true, handedIn: true, item: { select: { type: true, item: true, sizes: true } } },
}),
]);
// Their own staff group's garments and those for every group, in the cut they are offered — the
// same two questions request.create asks before it refuses. Somebody set to Men's sees the men's
// range and the unisex one; somebody set to Either, or whom nobody has set, sees every cut, which
// is what everybody sees today. Nothing else, not even another group's or another cut's garment
// they are holding from before the rule: a request for it is refused, and a damage replacement is
// raised from the issue itself, not from this list.
const items = catalog.filter((i) => garmentForGroup(i, staff.group) && garmentForStyle(i, staff.uniformStyle));
const avail = await availability(sess.facilityId, items.map((i) => i.id));
const manager = staff.managerId
? await prisma.staff.findUnique({ where: { id: staff.managerId }, select: { first: true, last: true } })
: null;
/* What they are holding, per garment and per size, and the size the record last saw them in.
*
* A hand-in takes a garment off somebody without ever marking it returned, so both have to be
* excluded from the holdings or the screen tells a person they still have what they gave back. */
const held = new Map<string, Map<number, number>>();
const lastSize = new Map<string, string>();
const holdings: { item: { type: string; item: string }; qty: number }[] = [];
for (const i of issues) {
if (!lastSize.has(i.itemId)) lastSize.set(i.itemId, String(i.item.sizes[i.sizeIndex] ?? ""));
if (i.returnedDate || i.handedIn) continue;
const bySize = held.get(i.itemId) || new Map<number, number>();
bySize.set(i.sizeIndex, (bySize.get(i.sizeIndex) || 0) + i.qty);
held.set(i.itemId, bySize);
holdings.push({ item: i.item, qty: i.qty });
}
/* The same allowance sum the manager sees on the review screen, from the same function.
*
* Somebody can be declined "Over allowance" against a number their own app has never shown them,
* which is the sort of refusal that gets a linen room a phone call rather than an apology. The
* two screens have to agree, so neither one gets its own arithmetic. */
const sets = setsHeld(holdings);
const allow = allowance({
group: staff.group,
held: sets,
// The person reading this screen is the person being measured, so their own group answers it.
// Both answers are passed rather than left off: without them this screen and the manager's
// review describe two different allowances, and the wearer is declined against the one they
// were never shown. Leaving the starting-kit one off tells somebody owed a kit they start on
// nothing.
nursing: isNursingGroup(staff.facility.nursingGroups, staff.group),
kit: isKitGroup(staff.facility.kitGroups, staff.group),
capSets: staff.facility.capSets, startingSets: staff.facility.initialSets,
});
return {
items: items.map((i) => {
const bySize = held.get(i.id) || new Map<number, number>();
/* Only tops and trousers have a size on the register, so a dress, a fleece or a vest has
* always come up blank and the wearer guessed. The size of the last one they were issued is
* what the record does know, and it is a better opening bid than nothing — `recordedSource`
* says which it is so the screen can hint rather than assert. */
const fromRecord = isTopItem(i) ? staff.top : isPantItem(i) ? staff.pants : "";
const fromIssue = lastSize.get(i.id) || "";
const recorded = fromRecord || fromIssue;
const sizes: Availability[] = avail[i.id]
|| i.sizes.map((s, si) => ({ size: String(s), si, word: "none" as StockWord, countedOn: "" }));
return {
id: i.id, item: i.item, type: i.type, gender: i.gender,
sizes: sizes.map((s) => ({ ...s, held: bySize.get(s.si) || 0 })),
// Selecting a garment resets the size to the person's recorded size for that garment type.
recorded,
recordedSource: recorded ? (fromRecord ? ("record" as const) : ("issued" as const)) : ("" as const),
held: [...bySize.values()].reduce((n, q) => n + q, 0),
};
}),
managerName: manager ? `${manager.first} ${manager.last}`.trim() : "",
// Deliberately the same two keys the manager's review screen returns, so the wearer reads the
// sentence they will be judged on before they ask rather than in the decline.
holding: { total: holdings.reduce((n, h) => n + h.qty, 0), sets },
allowance: {
capped: allow.capped,
label: allow.capped ? `${allow.used} of ${allow.cap} sets` : `${staff.group || "This role"} — no fixed cap. Your manager's approval is the control.`,
note: allow.note,
/* Sets alone under-warn: the ceiling the hand-over applies (lib/sets.ts capState, the same
* one capCheck uses at the counter) bites per half, so six tops and two pairs is "2 of 6
* sets" here and yet the next top is refused or stamped as an override. Ask the ceiling's
* own question as well, so the wearer is warned before they ask rather than after. */
over: capState({ held: garmentCounts(holdings), capSets: staff.facility.capSets }).over
|| (allow.capped && allow.cap !== null && allow.used >= allow.cap),
},
};
}
/** 1G Report damage — their own holdings, each with the issue id the linen room can trace. */
export async function damageData(sess: StaffSession) {
const issues = await prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
orderBy: { date: "desc" },
select: { id: true, date: true, qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
});
const staff = await prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { num: true } });
const avail = await availability(sess.facilityId, [...new Set(issues.map((i) => i.item.id))]);
return {
holdings: issues.map((i) => ({
issueId: i.id, itemId: i.item.id, item: i.item.item,
size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, qty: i.qty,
// The label the linen room prints, so a garment in a hand can be matched to a row here.
labelId: `TC-${staff.num}-${i.id.slice(-4).toUpperCase()}`,
issued: fmtDate(i.date),
replacement: (avail[i.item.id] || []).find((a) => a.si === i.sizeIndex)?.word ?? ("none" as StockWord),
})),
};
}
export { fmtDate };
+733
View File
@@ -0,0 +1,733 @@
import { prisma } from "./db";
import { Prisma } from "@prisma/client";
import bcrypt from "bcryptjs";
import { setStaffCookie, type StaffSession } from "./staffsession";
import {
DAMAGE_KINDS, DECLINE_REASONS, REQUEST_REASONS, WAITLIST_HOLD_HOURS, canMove, decisionSummary,
holdExpired, requestCode, rollUpRequestStatus, roundWard, type LineStatus,
} from "./staffreq";
import { approvalEmail, approvalUrl, decisionEmail, signApprovalToken } from "./approvallink";
import { sendTo, transactionalConfigured } from "./mail";
import { OpError, handOverRequestStock, offGroupGarments, offStyleGarments, readRequestLines } from "./ops";
import { facilityToday, genderLabel, groupsLabel } from "./compute";
import { PLAN_COLS, entitlements } from "./plan";
/* Everything a staff member, ward manager or ward clerk can change.
*
* Separate from lib/ops.ts on purpose. That file's ops all begin from a coordinator session and
* may touch anything in the facility; these all begin from a *staff* session and must never be
* able to. Keeping them in one switch statement with one authorisation model each would mean the
* only thing standing between a wearer and the stock ledger was remembering which branch they
* came in through.
*
* The rule every op here obeys: **the facility is not the unit of authority, the person is.**
* A staff session may act on its own record; a manager may act on requests addressed to them and
* raise for the people who report to them; a ward clerk may sign for a bag coming to their own
* ward. Nothing here takes a facility id from the caller.
*/
export class StaffOpError extends Error {
status: number;
constructor(msg: string, status = 400) { super(msg); this.status = status; }
}
/** Same floor as activation, so a password change can't be a downgrade of the one they set. */
const MIN_PASSWORD = 8;
const str = (v: unknown, max = 500) => (v === undefined || v === null ? "" : String(v)).slice(0, max);
const int = (v: unknown, d = 0) => { const n = parseInt(String(v), 10); return Number.isFinite(n) ? n : d; };
/** Load a request the caller is allowed to see: their own, or one they approve, or one they
* raised for somebody else. Anything else is a 404 — not a 403, which would confirm it exists. */
async function visibleRequest(sess: StaffSession, id: string) {
const [r, me] = await Promise.all([
prisma.request.findFirst({
where: { id, facilityId: sess.facilityId },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, dept: true } },
events: { select: { label: true, meta: true } },
},
}),
prisma.staff.findUnique({ where: { id: sess.staffId }, select: { dept: true, wardDesk: true } }),
]);
/* The same three parties lib/staffdata.ts requestData admits, plus the desk holding the bag:
* /my/round offers that desk a "Nudge" into this thread, and the message it sends has to be
* accepted here or the button is a 404 with a different face. Ward from the timeline, and a
* blank ward matches nobody — the fence round.sign and round.claim use. */
const party = !!r && (r.subjectId === sess.staffId || r.managerId === sess.staffId || r.raisedByStaffId === sess.staffId);
const onMyDesk = !!r && !!me?.wardDesk && !!me.dept.trim() && (r.status === "round" || r.status === "delivered") && roundWard(r.events) === me.dept;
if (!r || (!party && !onMyDesk)) throw new StaffOpError("No such request", 404);
return r;
}
/** The staff app speaks one error type — /api/staff/mutate turns anything else into a bare
* "something went wrong", which tells the person on the ward nothing. The shared helpers in
* lib/ops.ts speak the coordinator's, so a call into one is translated rather than let through. */
function asStaffError(e: unknown): never {
if (e instanceof OpError) throw new StaffOpError(e.message, e.status);
throw e;
}
/** What one op calling another inside this file may say that a payload never can. The API route
* calls runStaffOp with three arguments, so nothing a phone sends reaches this.
*
* `replacing` is the garment a damage report is asking to replace (damage.report). It is the one
* garment outside somebody's staff group, or outside the cut they are offered, that a request may
* carry — see offGroupGarments() and offStyleGarments() in lib/ops.ts, which also check they are
* still holding it. */
type Internal = { replacing?: string };
/** The refusal: each garment with the groups it is for, then whose group it isn't. */
function offGroupMessage(off: readonly { item: string; groups: string[] }[], who: { first: string; group: string }, self: boolean): string {
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)} only`).join("; ");
const g = (who.group || "").trim();
const tail = self
? g ? `You're in ${g} — ask the linen room if you need ${off.length === 1 ? "it" : "them"}.` : "You have no staff group recorded — ask the linen room."
: g ? `${who.first} is in ${g}.` : `${who.first} has no staff group recorded — ask the linen room.`;
return `${each}. ${tail}`;
}
/** The same refusal for the wrong cut: each garment with the cut it is, then the style they are set
* to. There is no blank case — a blank style, and Either, are offered every cut, so this is only
* ever built for somebody a coordinator has set to Men's or Women's. */
function offStyleMessage(off: readonly { item: string; gender: string }[], who: { first: string; uniformStyle: string }, self: boolean): string {
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
const tail = self
? `You're set to ${who.uniformStyle} — ask the linen room if you need ${off.length === 1 ? "it" : "them"}.`
: `${who.first} is set to ${who.uniformStyle}.`;
return `${each}. ${tail}`;
}
async function ownStaffInFacility(sess: StaffSession, id: string) {
const s = await prisma.staff.findFirst({ where: { id, facilityId: sess.facilityId } });
if (!s) throw new StaffOpError("No such staff member", 404);
return s;
}
/** Append a timeline row. Every status change goes through here, so the history can't be
* half-written by a branch that forgot. */
async function event(tx: Prisma.TransactionClient, requestId: string, label: string, meta: string, actorName: string) {
await tx.requestEvent.create({ data: { requestId, label, meta: meta.slice(0, 200), actorName: actorName.slice(0, 120) } });
}
/** Fire-and-forget: a request must not fail because the mail server was slow. */
function mail(to: string | null | undefined, subject: string, text: string) {
if (!to || !transactionalConfigured()) return;
void sendTo(to, subject, text).catch((e) => console.error("[staff mail]", (e as Error).message));
}
/* ---------------------------------------------------------------- the decision
*
* The manager settles the whole request in one action. They may approve all of it, decline all of
* it, or approve some garments and knock others back — the tunic and the trousers yes, the fleece
* no, over allowance. Every line ends up `approved` or `declined`, and the request's own status is
* the rollup: something survived means `accepted` and there is a pick to do, nothing survived means
* `declined`. One code, one pick, one collection either way.
*
* It lives in one exported function because two doors reach it — the manager tapping Approve in
* the app, and the emailed link at /api/staff/decide, which has no session behind it. When the two
* were written separately they drifted, and a decision made by email recorded a different timeline
* row from the same decision made in the app.
*/
export type LineDecision = { id: string; decision: LineStatus; reason: string };
/** Read a per-line decision payload: `[{ id, decision: "approved" | "declined", reason? }]`.
* Null when the caller sent none, which means the whole-request shorthand applies. Every line of
* the request has to appear exactly once — a decision that leaves a garment undecided would move
* the request to the linen room with a line nobody has answered. */
function readLineDecisions(raw: unknown, lines: readonly { id: string }[]): LineDecision[] | null {
if (raw === undefined || raw === null) return null;
if (!Array.isArray(raw)) throw new StaffOpError("Say what you decided about each garment");
const seen = new Set<string>();
const out: LineDecision[] = [];
for (const row of raw) {
const r = (row || {}) as { id?: unknown; decision?: unknown; reason?: unknown };
const id = str(r.id);
if (!lines.some((l) => l.id === id) || seen.has(id)) throw new StaffOpError("That decision doesn't match the request — reopen it and try again");
seen.add(id);
const decision = str(r.decision, 20);
if (decision !== "approved" && decision !== "declined") throw new StaffOpError("Approve or decline each garment");
let reason = "";
if (decision === "declined") {
reason = str(r.reason, 60);
if (!DECLINE_REASONS.includes(reason as never)) throw new StaffOpError("Pick a reason for each garment you decline — the staff member is told what it was");
}
out.push({ id, decision, reason });
}
if (out.length !== lines.length) throw new StaffOpError("Decide every garment on the request before you send it");
return out;
}
/** Settle a request. `approveAll` is the whole-request shorthand the emailed link uses, where
* there is no room for a per-line answer; `lines` overrides it when the manager has decided
* garment by garment.
*
* The facility, the code and the manager's name come back with the decision so the emailed-link
* door can file its audit row without reading the request a second time. */
export async function decideRequest(d: {
requestId: string; managerId: string; facilityId?: string;
approveAll: boolean; reason?: unknown; lines?: unknown; actorName?: string;
}): Promise<{ ok: true; status: "accepted" | "declined"; summary: string; code: string; facilityId: string; managerName: string; selfApproved: boolean; notified: boolean }> {
const r = await prisma.request.findFirst({
where: { id: d.requestId, managerId: d.managerId, ...(d.facilityId ? { facilityId: d.facilityId } : {}) },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { id: true, first: true, inactive: true } },
facility: { select: { name: true } },
},
});
if (!r) throw new StaffOpError("No such request", 404);
/* Whoever is asking names a manager; that does not make them one. The session re-reads the
* register on every call, but an emailed link lives for a fortnight in a mailbox that may since
* have been closed or handed on, so the register is read here too — and the wearer with it,
* because approving garments for somebody who has left the register is the same mistake pointed
* the other way. Request.managerId carries no foreign key, so a deleted manager leaves an id
* that still satisfies the query above; this lookup is what turns that back into a refusal. */
const mgr = await prisma.staff.findFirst({ where: { id: d.managerId, facilityId: r.facilityId, inactive: false }, select: { id: true } });
if (!mgr) throw new StaffOpError("You're no longer recorded as a manager here — ask the linen room.", 403);
/* Anybody may approve their own request — the owner's decision, and it replaced a test that
* allowed it only to somebody with a report on the register. A self-approval is never mistaken
* later for an ordinary one: the timeline row written below says "Self-approved" in words, and
* the result carries selfApproved so both doors can say it too.
*
* What does stand is the raise rule: nobody approves a request they raised for somebody else.
* request.create and request.reassign both refuse to address one that way; this is the same
* rule at the moment it would matter, so a request addressed some other way — a restored backup
* — cannot slip past it. */
const selfApproval = r.subject.id === d.managerId;
if (r.raisedByStaffId && r.raisedByStaffId === d.managerId && !selfApproval) {
throw new StaffOpError("You raised this request, so somebody else has to approve it — ask the linen room to re-address it.", 403);
}
if (r.subject.inactive) throw new StaffOpError("That person is no longer on the register, so nothing can be approved for them.", 403);
if (!r.lines.length) throw new StaffOpError("That request has no garments on it — ask the linen room to raise it again.");
const perLine = readLineDecisions(d.lines, r.lines);
// The shorthand: the op name, or the emailed link, answers for every garment at once.
let wholeReason = "";
if (!perLine && !d.approveAll) {
wholeReason = str(d.reason, 60);
if (!DECLINE_REASONS.includes(wholeReason as never)) throw new StaffOpError("Pick a reason — the staff member is told what it was");
}
const decisions: LineDecision[] = perLine ?? r.lines.map((l) => ({
id: l.id, decision: d.approveAll ? "approved" : "declined", reason: d.approveAll ? "" : wholeReason,
}));
const to = rollUpRequestStatus(decisions.map((x) => ({ status: x.decision })));
// Every line has just been decided, so the rollup is never "awaiting" here; the check is the
// state machine's, and it is what makes the emailed link single-use — both links in the same
// message stop working the moment either one is spent.
if (to === "awaiting" || !canMove(r.status, to)) throw new StaffOpError("That request has already been decided", 409);
const refusals = decisions.filter((x) => x.decision === "declined");
// The request-level reason is the whole request's reason, so it is set only when one reason
// covers the whole refusal. Where the refusals differ, each line carries its own and the
// request has none — inventing one for it would put a reason on the record nobody gave.
const shared = refusals.length === decisions.length && refusals.every((x) => x.reason === refusals[0].reason) ? refusals[0].reason : "";
const actorName = d.actorName || r.managerName || "Your manager";
const sizeOf = (l: (typeof r.lines)[number]) => String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex);
// A request can carry the same garment in two sizes, so the size is named only when the garment
// alone would be ambiguous: "Fleece declined" beats "Fleece M declined" when there is one fleece.
const nameOf = (l: (typeof r.lines)[number]) => (r.lines.filter((x) => x.itemId === l.itemId).length > 1 ? `${l.item.item} ${sizeOf(l)}` : l.item.item);
const lineById = new Map(r.lines.map((l) => [l.id, l]));
const refusalList = refusals.map((x) => `${nameOf(lineById.get(x.id)!)} declined: ${x.reason}`).join("; ");
// One timeline row for the decision as a whole. A row per line would bury the request's own
// history under its garments, and the manager did one thing, once.
const yes = decisions.length - refusals.length;
// A manager deciding her own request is marked in the label, not tucked into the meta: the label
// is the line a timeline shows at a glance and the first thing anyone auditing the request
// reads, and it is the one thing about the decision nobody could otherwise tell from it. The
// meta then says it again in full, because the label is also quoted on screens that show no
// meta at all.
const own = selfApproval ? " — their own request" : "";
const label = yes === decisions.length ? `Approved by ${actorName}${own}`
: yes === 0 ? `Declined by ${actorName}${own}`
: `${yes} of ${decisions.length} approved by ${actorName}${own}`;
const plain = yes === decisions.length ? "Ward manager" : shared || refusalList;
// Worded for what actually happened: nothing was approved, so nothing was self-approved, but the
// decision was still the wearer's own and that is what the row has to show.
const selfNote = yes
? `Self-approved — ${actorName} is the manager on this request and the person it is for`
: `Decided by ${actorName}, who is the manager on this request and the person it is for`;
const meta = selfApproval
? [selfNote, yes === decisions.length ? "" : plain].filter(Boolean).join(" · ")
: plain;
await prisma.$transaction(async (tx) => {
// Conditional on the status we read, so two taps — or a tap and an email link — can't both win.
const moved = await tx.request.updateMany({
where: { id: r.id, status: "awaiting" },
data: { status: to, decidedAt: new Date(), declineReason: to === "declined" && shared ? shared : null },
});
if (moved.count !== 1) throw new StaffOpError("That request has already been decided", 409);
for (const x of decisions) {
await tx.requestLine.updateMany({
where: { id: x.id, requestId: r.id },
data: { status: x.decision, declineReason: x.decision === "declined" ? x.reason : null },
});
}
await event(tx, r.id, label, meta, actorName);
});
const subjAccount = await prisma.staffAccount.findUnique({ where: { staffId: r.subject.id }, select: { email: true } });
const decided = new Map(decisions.map((x) => [x.id, x]));
const em = decisionEmail({
staffFirst: r.subject.first, managerName: actorName, approved: to === "accepted",
reason: to === "declined" ? shared : "",
lines: r.lines.map((l) => ({
qty: l.qty, item: l.item.item, size: sizeOf(l),
status: decided.get(l.id)?.decision, declineReason: decided.get(l.id)?.reason || null,
})),
facility: r.facility.name,
});
mail(subjAccount?.email, em.subject, em.text);
return {
ok: true, status: to, summary: decisionSummary(decisions.map((x) => ({ status: x.decision }))) || label,
code: r.code, facilityId: r.facilityId, managerName: actorName,
// Whether the wearer was actually emailed — same meaning as request.create's flag. The
// emailed-link page said "has been told" regardless, which is how a wearer with no account
// waited on an approval nobody mentioned to them.
notified: !!subjAccount?.email && transactionalConfigured(),
// Handed back so the two doors can say it as well: the app's confirmation, and the audit row
// the emailed link files, which would otherwise read as an ordinary approval by a manager who
// happens to have the same name as the wearer.
selfApproved: selfApproval,
};
}
export async function runStaffOp(sess: StaffSession, op: string, p: Record<string, unknown>, internal: Internal = {}): Promise<unknown> {
p = p || {};
const me = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: {
id: true, first: true, last: true, dept: true, wardDesk: true, managerId: true, group: true, uniformStyle: true,
facility: { select: { id: true, name: true, timezone: true, ...PLAN_COLS } },
},
});
// A read-only room refuses a wearer's writes too — in the room's words, since the plan is the
// linen room's business and not the wearer's. Reading (my kit, the shelf) is untouched.
if (entitlements(me.facility).readOnly) throw new StaffOpError("The linen room's ThreadCount is read-only at the moment. Ask the linen room.", 403);
const myName = `${me.first} ${me.last}`.trim();
const fid = sess.facilityId;
switch (op) {
/* ---------------------------------------------------------------- requests */
case "request.create": {
/* Who it is for. One person other than the wearer may raise: a manager, for the people who
* report to them. That fence is the reporting line, which is the same relationship that
* makes them the approver — and the reason a raise of theirs goes up a level below.
*
* A ward clerk on the desk used to be able to raise for anyone on their own ward, on the
* grounds that half a ward will never install anything. That is gone: everyone in the
* building carries a phone, and the manager route covers the person who genuinely cannot.
* The desk flag itself stays — it is what signs for a bag on the ward round. */
const subjectId = str(p.subjectId) || me.id;
const other = me.id === subjectId ? null : await ownStaffInFacility(sess, subjectId);
if (other && other.managerId !== me.id) {
throw new StaffOpError("Only somebody's own manager can raise a request for them", 403);
}
const subject = other ?? (await ownStaffInFacility(sess, me.id));
if (subject.inactive) throw new StaffOpError("That person is no longer on the register");
// The approver is the subject's own manager, never the clerk's.
if (!subject.managerId) {
throw new StaffOpError(
subject.id === me.id
? "Your manager isn't set yet — the linen room has to record who approves your requests."
: `${subject.first} has no manager recorded, so there is nobody to approve this. Ask the linen room to set one.`,
);
}
// Off the register means off the register. A deactivated manager cannot sign in to approve
// anything, so addressing a request to one parks it where nobody can reach it — and it would
// mint an approval link that outlives their access by a fortnight.
let manager = await prisma.staff.findFirst({ where: { id: subject.managerId, facilityId: fid, inactive: false } });
if (!manager) throw new StaffOpError("The recorded manager is no longer on the register. Ask the linen room.");
/* Nobody approves a raise they made for somebody else. A manager raising for one of their
* own reports is the ordinary case, and the subject's approver is that same manager — so the
* request goes up a level instead: to the raiser's own manager, if they have one. If they do
* not, it is created with no approver at all and surfaces on the linen room's queue (Needs
* an approver), which already has request.reassign to give it one. Either way it never comes
* back to the person who raised it, and the timeline says where it went and why.
*
* Only a raise for somebody else. A person who is their own manager raising for themselves
* lands on their own desk, which is allowed and is marked Self-approved when they decide. */
let escalation = "";
if (other && manager.id === me.id) {
const found = me.managerId ? await prisma.staff.findFirst({ where: { id: me.managerId, facilityId: fid, inactive: false } }) : null;
/* A level up can be the raiser again: somebody who is their own manager has nobody above
* them but themselves. That is "nobody above" — Needs an approver — exactly as if no
* manager were recorded, because landing it back on them is the one thing this branch is
* here to prevent.
*
* A level up can also be the wearer. Two ward managers at the top of the tree are often
* recorded as each other's approver, and escalating a raise for one of them lands the
* request back on the person the garments are for. It goes to them: that is a person
* approving their own kit, which anybody may do, and the note below says so on the
* request's own timeline, so it is read as a self-approval rather than as a routine
* approval by somebody who happens to share the name. */
const above = found && found.id !== me.id ? found : null;
const aboveName = above ? `${above.first} ${above.last}`.trim() : "";
escalation = above
? above.id === subject.id
? `Raised by ${myName}, who approves ${subject.first}'s requests — sent up to ${aboveName}, the person it is for, to approve themselves`
: `Raised by ${myName}, who approves ${subject.first}'s requests — sent to ${aboveName} instead`
: `Raised by ${myName}, who approves ${subject.first}'s requests — the linen room will address it`;
manager = above;
}
const reason = str(p.reason, 40);
if (reason && !REQUEST_REASONS.includes(reason as never)) throw new StaffOpError("Unknown reason");
const lines = await readRequestLines(fid, p.lines).catch(asStaffError);
// Only the SUBJECT's own staff group's garments, plus those for every group — whoever raises it.
// A waitlist offer comes through here and is measured the same. So does a swap from the app's
// Swap a size screen, which sends nothing to tell it from a fresh request: holding another
// group's garment is no reason to be handed another, so a size swap of one is the counter's
// (issue.exchange). The one exception is a damage replacement for a garment they hold, which
// only damage.report can ask for.
const offGroup = await offGroupGarments(fid, subject.group, lines.map((l) => l.itemId),
internal.replacing ? { staffId: subject.id, itemId: internal.replacing } : undefined);
if (offGroup.length) throw new StaffOpError(offGroupMessage(offGroup, subject, subject.id === me.id));
// And only the cut the SUBJECT is offered, measured the same way and exempting the same one
// damage replacement. Blank — nobody has said which cut they wear — and Either take every
// garment, so nothing changes for a record no coordinator has set.
const offStyle = await offStyleGarments(fid, subject.uniformStyle, lines.map((l) => l.itemId),
internal.replacing ? { staffId: subject.id, itemId: internal.replacing } : undefined);
if (offStyle.length) throw new StaffOpError(offStyleMessage(offStyle, subject, subject.id === me.id));
const note = str(p.note, 400);
const managerName = manager ? `${manager.first} ${manager.last}`.trim() : "";
const created = await prisma.$transaction(async (tx) => {
const f = await tx.facility.update({ where: { id: fid }, data: { requestSeq: { increment: 1 } }, select: { requestSeq: true } });
const r = await tx.request.create({
data: {
facilityId: fid, code: requestCode(f.requestSeq),
subjectId: subject.id,
raisedByStaffId: subject.id === me.id ? null : me.id,
raisedByName: subject.id === me.id ? "" : myName,
reason, note,
status: "awaiting",
managerId: manager?.id ?? null, managerName,
lines: { create: lines.map((l, i) => ({ itemId: l.itemId, sizeIndex: l.sizeIndex, qty: l.qty, sort: i })) },
},
});
// The escalation note already names the raiser, so it stands in for the plain one.
const raised = subject.id === me.id ? "" : `Raised by ${myName}`;
await event(tx, r.id, "Requested", escalation || raised, subject.id === me.id ? "You" : myName);
return r;
});
// Nothing to send while the request has no approver — the linen room addresses it first.
const mgrAccount = manager ? await prisma.staffAccount.findUnique({ where: { staffId: manager.id }, select: { email: true } }) : null;
if (manager) {
const em = approvalEmail({
managerFirst: manager.first,
subjectName: `${subject.first} ${subject.last}`.trim(),
raisedByName: subject.id === me.id ? "" : myName,
lines, reason, note,
url: approvalUrl(signApprovalToken(created.id, manager.id)),
facility: me.facility.name,
});
mail(mgrAccount?.email, em.subject, em.text);
}
// `notified` says an email actually went out, not that the manager happens to have an
// account: mail() is a no-op with no transactional mail configured, and a screen that says
// "we've told them" when nothing was sent is the reason a request sits for three weeks.
const notified = !!mgrAccount?.email && transactionalConfigured();
// `selfApproves` is for the screen the raiser is standing at: their request has gone up to
// the person it is for, which is allowed and is the only way the top of the tree gets
// dressed, but they should be told that is where it went rather than find out later.
return { id: created.id, code: created.code, manager: managerName, escalated: !!escalation, notified, selfApproves: !!manager && manager.id === subject.id };
}
case "request.approve":
case "request.decline": {
// The manager's own door into the shared decision. `lines` decides garment by garment when
// the screen sends it; without it the op name settles the whole request, which is what the
// emailed link does through the same function.
return decideRequest({
requestId: str(p.id), managerId: me.id, facilityId: fid,
approveAll: op === "request.approve", reason: p.reason, lines: p.lines, actorName: myName,
});
}
case "request.message": {
const r = await visibleRequest(sess, str(p.id));
const body = str(p.body, 2000).trim();
if (!body) throw new StaffOpError("Write something first");
const m = await prisma.requestMessage.create({
data: { requestId: r.id, fromStaff: true, authorName: myName, body },
});
return { id: m.id, at: m.createdAt.toISOString() };
}
/* ---------------------------------------------------------------- ward round */
case "round.sign": {
// Anyone on the ward may sign, and whoever does is named on the requester's order — so a
// bag that never arrived has a name against it.
const r = await prisma.request.findFirst({ where: { id: str(p.id), facilityId: fid, status: "round" }, include: { lines: true, events: { select: { label: true, meta: true } } } });
if (!r) throw new StaffOpError("No such bag waiting on the round", 404);
// The ward the trolley left the bag on, off the timeline — not the ward the wearer is on
// today. Fencing on the wearer's ward made a bag follow a nurse who transferred: /my/round
// still lists it to the desk that is physically holding it (lib/deskdata.ts reads the same
// event), and this refused that desk. Since round.sign is the only way out of `round`, the
// bag was then stuck for good, and the ward it had moved to on screen could have signed for
// a handover that never happened.
//
// A blank ward is not a ward, so it matches nothing. Comparing the two sides as empty
// strings put everybody whose ward was never filled in on one ward together — a clerk with
// no ward could sign for any other ward-less person's bag in the facility. Both sides have
// to be a real ward name, and lib/ops.ts refuses to route a ward-less bag onto a round at all.
const bagWard = roundWard(r.events);
if (!me.dept || !bagWard || bagWard !== me.dept) throw new StaffOpError("That bag is for another ward", 403);
// Signing for the bag is the moment the garment leaves the linen room's shelf, and the ward
// round has to record that exactly as the counter does — an Issue against the wearer and a
// replenishment line — or the shelf count quietly loses a garment on every round while the
// counter's figures stay honest. Shared with request.collected so the two cannot drift.
try {
await handOverRequestStock(fid, facilityToday(me.facility.timezone), { subjectId: r.subjectId, lines: r.lines }, async (tx) => {
const moved = await tx.request.updateMany({
where: { id: r.id, status: "round" },
data: { status: "delivered", signerName: myName, signerRole: me.wardDesk ? "ward clerk" : (me.dept || "ward"), signedAt: new Date() },
});
if (moved.count !== 1) throw new StaffOpError("Someone has already signed for that bag");
await event(tx, r.id, `Delivered to ${me.dept || "the ward"}`, `Signed by ${myName}`, myName);
}, "ward"); // the bag has already left the shelf: record the hand-over, never refuse it here
} catch (e) {
// The shared helper speaks the coordinator's error type — an empty shelf has to reach the
// person on the ward as those words, not as a bare "something went wrong".
asStaffError(e);
}
return { ok: true };
}
case "round.claim": {
// The bag has left the desk. Either the requester says so — "I've got it" on their own
// order — or the clerk who is looking at the pile marks it collected, because the person who
// knows a bag is gone is usually the one standing next to where it was. A desk clerk may
// only do that for their own ward, the same fence round.sign works behind.
const r = await prisma.request.findFirst({
where: { id: str(p.id), facilityId: fid, status: "delivered" },
include: { subject: { select: { id: true } }, events: { select: { label: true, meta: true } } },
});
if (!r) throw new StaffOpError("No such delivery", 404);
if (r.subject.id !== me.id) {
if (!me.wardDesk) throw new StaffOpError("That delivery is somebody else's", 403);
// The ward the bag was delivered to, off the timeline, exactly as round.sign and
// /my/round read it: the clerk looking at the pile is the one who may clear it, and a
// wearer transferring afterwards does not move the bag off their desk.
//
// The same blank-ward rule as round.sign: no ward recorded on either side is no match, or
// a clerk whose ward was never filled in could claim for every other ward-less person.
const bagWard = roundWard(r.events);
if (!me.dept || !bagWard || bagWard !== me.dept) throw new StaffOpError("That bag is for another ward", 403);
}
// Conditional on it still being unclaimed, so the requester and the desk both tapping it
// leaves one claim with the first person's name on the timeline rather than two.
const claimed = await prisma.request.updateMany({ where: { id: r.id, claimedAt: null }, data: { claimedAt: new Date() } });
if (claimed.count === 1) {
await event(prisma, r.id, "Collected from the ward", r.subject.id === me.id ? "" : `Marked by ${myName}`, r.subject.id === me.id ? "You" : myName);
}
return { ok: true };
}
/* ---------------------------------------------------------------- damage */
case "damage.report": {
const kind = str(p.kind, 40);
if (!DAMAGE_KINDS.includes(kind as never)) throw new StaffOpError("Pick what happened");
// handedIn as well as returnedDate: a garment handed back at the counter is off the person
// even though nothing marks it returned, and the line below this one promises the damaged
// garment stays on the record only *until* it is handed in.
const issue = await prisma.issue.findFirst({ where: { id: str(p.issueId), facilityId: fid, staffId: me.id, returnedDate: null, handedIn: null } });
if (!issue) throw new StaffOpError("Pick something you're holding", 404);
// A replacement is a separate act with its own approval — reporting damage does not quietly
// issue anything, and the damaged garment stays on the record until it is handed in.
//
// It is also the half that can fail: somebody with no manager recorded cannot raise anything.
// So it goes first. Written the other way round, a nurse with no approver got an error over
// a report that had already been saved, and one more orphan for every time she tried again.
//
// A discontinued garment is still on somebody's back. A request may not carry an archived
// catalogue entry — nobody should be able to order from a withdrawn range — but that refusal
// used to take the damage report down with it: a nurse holding a torn tunic from last year's
// range was told "That garment isn't available" about the garment in her hand, and nothing at
// all was recorded. The report is the half that matters, so it is saved either way and what
// she gets instead becomes the linen room's to settle.
const cat = await prisma.catalogItem.findFirst({ where: { id: issue.itemId, facilityId: fid }, select: { archived: true } });
let replacement: { id: string } | null = null;
let replacementNote = "";
if (p.replace && cat?.archived) {
replacementNote = "That garment has been discontinued, so a replacement can't be asked for here. Your report has gone to the linen room, who will sort out what you get instead.";
} else if (p.replace) {
// Like for like, so a garment outside their staff group that they were issued (on the
// counter's override, or before the rule) can still be replaced — and nothing else can.
replacement = await runStaffOp(sess, "request.create", {
lines: [{ itemId: issue.itemId, si: issue.sizeIndex, qty: 1 }], reason: "Damaged",
note: `Replacement for a ${kind.toLowerCase()} garment`,
}, { replacing: issue.itemId }) as { id: string };
}
const rep = await prisma.damageReport.create({
data: {
facilityId: fid, staffId: me.id, issueId: issue.id, kind, note: str(p.note, 400),
photoId: str(p.photoId) || null, requestId: replacement?.id ?? null,
},
});
return { id: rep.id, replacement, replacementNote };
}
/* ---------------------------------------------------------------- the record is wrong */
case "dispute.raise": {
const body = str(p.body, 1000).trim();
if (!body) throw new StaffOpError("Say what doesn't look right");
const d = await prisma.recordDispute.create({
data: {
facilityId: fid, staffId: me.id, body,
itemId: str(p.itemId) || null,
sizeIndex: p.si === undefined || p.si === null || p.si === "" ? null : int(p.si),
},
});
return { id: d.id };
}
/* ---------------------------------------------------------------- waitlist */
case "waitlist.join": {
const item = await prisma.catalogItem.findFirst({ where: { id: str(p.itemId), facilityId: fid } });
const si = int(p.si, -1);
if (!item || si < 0 || si >= item.sizes.length) throw new StaffOpError("Pick a size", 404);
// A place in the queue ends in a request, which would be refused for a garment outside their
// staff group — so it is refused here, before they wait for an offer they cannot accept.
const offGroup = await offGroupGarments(fid, me.group, [item.id]);
if (offGroup.length) throw new StaffOpError(offGroupMessage(offGroup, me, true));
// The same for the cut, before they wait for an offer their own request would refuse.
const offStyle = await offStyleGarments(fid, me.uniformStyle, [item.id]);
if (offStyle.length) throw new StaffOpError(offStyleMessage(offStyle, me, true));
// Leaving is a soft delete — the row stays, with leftAt set — but one place in a queue per
// garment and size is a hard constraint, so a second create could never succeed and told
// whoever tried "you're already on the list", which was the opposite of true. Coming back
// revives the row she already has, at the back of the queue: leaving forfeits her place,
// which is the honest reading of having left.
const existing = await prisma.waitlistEntry.findUnique({
where: { staffId_itemId_sizeIndex: { staffId: me.id, itemId: item.id, sizeIndex: si } },
});
if (existing && !existing.leftAt && !existing.acceptedAt) throw new StaffOpError("You're already on the list for that size");
if (existing) {
const w = await prisma.waitlistEntry.update({
where: { id: existing.id },
data: { leftAt: null, offeredAt: null, acceptedAt: null, createdAt: new Date() },
});
return { id: w.id };
}
try {
// Joining needs no approval — a queue is not a request. Approval happens if and when the
// item lands and they accept it.
const w = await prisma.waitlistEntry.create({ data: { facilityId: fid, staffId: me.id, itemId: item.id, sizeIndex: si } });
return { id: w.id };
} catch (e) {
// Two taps racing each other past the read above — by then it is true.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") throw new StaffOpError("You're already on the list for that size");
throw e;
}
}
case "waitlist.leave": {
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), staffId: me.id, leftAt: null } });
if (!w) throw new StaffOpError("Not on that list", 404);
await prisma.waitlistEntry.update({ where: { id: w.id }, data: { leftAt: new Date() } });
return { ok: true };
}
case "waitlist.accept": {
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), staffId: me.id, leftAt: null, acceptedAt: null, offeredAt: { not: null } } });
if (!w) throw new StaffOpError("Nothing to accept", 404);
// The hold the screen and the offer email both promise. Enforced rather than described: the
// garment goes back on the shelf for whoever is next, and she keeps her place in the queue.
if (holdExpired(w.offeredAt)) {
throw new StaffOpError(`That was held for ${WAITLIST_HOLD_HOURS} hours and the hold has run out. You're still on the list — ask the linen room if it's still there.`);
}
// A withdrawn range can still have garments on the shelf, so an offer can outlive the
// catalogue entry — and a request may not carry an archived one. Refuse before the offer is
// claimed rather than after: rolling the acceptance back and saying "That garment isn't
// available" about a garment the linen room has physically held for her is a dead end she
// cannot tap her way out of. Her place on the list is untouched, and the counter can still
// hand it over by hand.
const offered = await prisma.catalogItem.findFirst({ where: { id: w.itemId, facilityId: fid }, select: { archived: true } });
if (offered?.archived) {
throw new StaffOpError("That garment has been discontinued since you joined the list, so it can't be requested here. Ask the linen room — they're holding it for you at the counter.");
}
// Claim the offer before raising the request, so two taps can't turn one held garment into
// two requests — and hand it straight back if the request can't be raised. An entry stamped
// accepted with no request behind it is a queue place that can never be used again: accept
// refuses it forever, and the linen room's waiting list has already dropped her.
const claimed = await prisma.waitlistEntry.updateMany({ where: { id: w.id, acceptedAt: null }, data: { acceptedAt: new Date() } });
if (claimed.count !== 1) throw new StaffOpError("Nothing to accept", 404);
try {
const req = await runStaffOp(sess, "request.create", { lines: [{ itemId: w.itemId, si: w.sizeIndex, qty: 1 }], reason: "Extra for shifts", note: "Accepted from the waitlist" });
return { ok: true, request: req };
} catch (e) {
await prisma.waitlistEntry.updateMany({ where: { id: w.id }, data: { acceptedAt: null } });
throw e;
}
}
/* ---------------------------------------------------------------- kit check */
case "kit.answer": {
const cycle = await prisma.kitCheck.findFirst({ where: { facilityId: fid, closedAt: null }, orderBy: { openedAt: "desc" } });
if (!cycle) throw new StaffOpError("No kit check is open", 404);
const item = await prisma.catalogItem.findFirst({ where: { id: str(p.itemId), facilityId: fid } });
const si = int(p.si, -1);
if (!item || si < 0 || si >= item.sizes.length) throw new StaffOpError("Unknown garment", 404);
// "On record" is the linen room's figure, so it is read from the record and not from the
// phone that is being asked about it. The screen is sent the same number and posts it back,
// which is convenient and worthless as evidence — these answers are what a coordinator
// adjusts the register against, and a shortfall the client chose is not a shortfall.
const held = await prisma.issue.aggregate({
where: { facilityId: fid, staffId: me.id, itemId: item.id, sizeIndex: si, returnedDate: null, handedIn: null },
_sum: { qty: true },
});
const onRecord = held._sum.qty || 0;
if (onRecord <= 0) throw new StaffOpError("You're not holding that garment", 404);
const confirmed = Math.max(0, Math.min(onRecord, int(p.confirmed, 0)));
const a = await prisma.kitCheckAnswer.upsert({
where: { kitCheckId_staffId_itemId_sizeIndex: { kitCheckId: cycle.id, staffId: me.id, itemId: item.id, sizeIndex: si } },
create: { kitCheckId: cycle.id, staffId: me.id, itemId: item.id, sizeIndex: si, onRecord, confirmed },
update: { onRecord, confirmed, answeredAt: new Date() },
});
// Nothing is written off here. The answers are the linen room's evidence; they adjust the
// record. A screen that silently removed garments from someone's name would be a screen
// people learn to lie to.
return { id: a.id, short: onRecord - confirmed };
}
/* ---------------------------------------------------------------- their own sign-in */
case "account.password": {
/* The one thing a staff member can change about their account, and the only revocation they
* have. A staff token carries a fingerprint of the password hash, so a new password ends
* every session signed against the old one — a phone left on a ward, a cookie copied off it
* — without a session table to keep. Signing out clears one cookie; this clears the lot.
*
* Deleting the account is deliberately not here. Access is the linen room's to grant and
* theirs to remove: a wearer who could delete their own account would take the record of
* what they were issued with it. */
const current = str(p.current, 200);
const next = str(p.next, 200);
if (next.length < MIN_PASSWORD) throw new StaffOpError(`Use at least ${MIN_PASSWORD} characters for your new password.`);
if (next === current) throw new StaffOpError("That's the password you already have.");
const acc = await prisma.staffAccount.findUnique({ where: { id: sess.accountId }, select: { id: true, passwordHash: true } });
if (!acc) throw new StaffOpError("No such account", 404);
if (!(await bcrypt.compare(current, acc.passwordHash))) throw new StaffOpError("That isn't your current password.", 403);
const passwordHash = await bcrypt.hash(next, 12);
await prisma.staffAccount.update({ where: { id: acc.id }, data: { passwordHash } });
// Re-issued in the same breath, so the person who just changed it is the one session that
// survives rather than the one that gets thrown out.
await setStaffCookie(acc.id, passwordHash);
return { ok: true };
}
default:
throw new StaffOpError("Unknown action", 400);
}
}
+238
View File
@@ -0,0 +1,238 @@
/* The request state machine, and the words a ward is allowed to see.
*
* Both live here rather than in the screens because both are stated once in the design and then
* relied on in six places. A status label that drifts between the home card, the order list and
* the notification email is the kind of bug nobody reports and everybody stops trusting.
*/
export type ReqStatus =
| "awaiting" | "declined" | "accepted" | "picking" | "ready" | "round" | "collected" | "delivered";
export const DECLINE_REASONS = ["Over allowance", "Not needed right now", "Wrong item for the role"] as const;
export type DeclineReason = (typeof DECLINE_REASONS)[number];
export const REQUEST_REASONS = ["Worn out", "Damaged", "Lost", "Extra for shifts"] as const;
/* `Contaminated` is deliberately absent from the damage list. Clinically it is a different
* pathway — red bag, no return to the counter, often an incident report — and an app that told
* someone to carry a contaminated garment to the linen room would be worse than one that says
* nothing. Wards use the route they already have. */
export const DAMAGE_KINDS = ["Torn", "Stained", "Worn thin"] as const;
/* ---------- the lines ----------
*
* A request covers as many garments as the person needs, one line each. The manager reads the
* whole ask on one screen and approves it in one action, but can knock back individual lines —
* the tunic and the trousers yes, the fleece no, over allowance. So a line carries its own status
* and its own decline reason, and the request's status is a rollup of them.
*/
export type LineStatus = "awaiting" | "approved" | "declined";
export const LINE_STATUSES: readonly LineStatus[] = ["awaiting", "approved", "declined"] as const;
/** The word against a single garment. Deliberately shorter than the request-level labels: it sits
* beside the garment on a list, where the request's own status is already stated above it. */
export function lineStatusLabel(status: string): string {
return status === "approved" ? "Approved" : status === "declined" ? "Declined" : "Awaiting approval";
}
/** The lines that are actually picked, bagged and collected. Nothing is picked before the manager
* has decided, and a declined line never reaches the linen room, so this is the one definition of
* "what is in the bag" and every screen that counts garments should start here. */
export function approvedLines<T extends { status: string }>(lines: readonly T[]): T[] {
return lines.filter((l) => l.status === "approved");
}
/** How many garments a set of lines comes to — three trousers on one line is three garments. */
export function garmentCount(lines: readonly { qty: number }[]): number {
return lines.reduce((n, l) => n + l.qty, 0);
}
/** The request status the lines add up to.
*
* A decision settles every line at once, so this is only ever asked of decided lines in practice;
* it still reports `awaiting` while any line is undecided rather than guessing, which is what
* keeps a half-written decision from moving an order to the linen room. Once decided: every line
* refused means the whole request was refused, and one surviving line means there is a pick to
* do, so the request is accepted and only the approved lines are fulfilled. A request with no
* lines has not been asked yet. */
export function rollUpRequestStatus(lines: readonly { status: string }[]): "awaiting" | "accepted" | "declined" {
if (lines.length === 0 || lines.some((l) => l.status === "awaiting")) return "awaiting";
return lines.some((l) => l.status === "approved") ? "accepted" : "declined";
}
/** What the manager decided, in one line: "2 of 3 approved". Null while it is still with them —
* there is nothing to summarise until someone has decided, and the request's own status already
* says so. A single-garment request just says Approved or Declined; "1 of 1 approved" is the
* sort of phrasing that makes a person read it twice. */
export function decisionSummary(lines: readonly { status: string }[]): string | null {
if (lines.length === 0 || lines.some((l) => l.status === "awaiting")) return null;
const yes = lines.filter((l) => l.status === "approved").length;
if (lines.length === 1) return yes === 1 ? "Approved" : "Declined";
if (yes === lines.length) return `All ${lines.length} approved`;
if (yes === 0) return `All ${lines.length} declined`;
return `${yes} of ${lines.length} approved`;
}
/** Which states the linen room still has work to do in. */
export const OPEN_REQUEST = new Set<ReqStatus>(["awaiting", "accepted", "picking", "ready", "round"]);
/** Every open status after a manager's decision and before the hand-over: the open statuses bar
* `awaiting`, which nobody has agreed to yet. Only a request in one of these has garments owed to
* somebody, so it is the one question the six-set ceiling asks of a request — on the server and in
* the snapshot alike, which is why it lives here rather than beside either of them. */
export const AWAITING_HANDOVER = [...OPEN_REQUEST].filter((st) => st !== "awaiting");
/** Which states need the *staff member* to do something — drives the accent left border. */
export const NEEDS_STAFF = new Set<ReqStatus>(["awaiting", "declined", "ready", "round"]);
/** Legal transitions. Anything not listed here is refused by the ops layer, so a stale phone
* screen can't drag an order backwards. */
export const TRANSITIONS: Record<ReqStatus, ReqStatus[]> = {
awaiting: ["accepted", "declined"],
declined: [],
accepted: ["picking"],
picking: ["ready", "round"],
ready: ["collected"],
round: ["delivered"],
collected: [],
delivered: [],
};
export function canMove(from: string, to: string): boolean {
return (TRANSITIONS[from as ReqStatus] || []).includes(to as ReqStatus);
}
/* ---------- which ward a bag on the round belongs to ----------
*
* Request has no column for it. The linen room routes a bag to the ward the wearer was on the
* moment the trolley loaded, and nothing rewrites that when she transfers — so the timeline row
* lib/ops.ts stamps on request.round is the only durable record of where the bag physically went.
*
* Every screen and every fence has to read the ward off that event rather than off subject.dept,
* and they all have to read it the same way. Asking the wearer's current ward instead makes a
* transferred nurse's bag follow her on screen: listed to the ward it never reached, refused by
* the desk that is actually holding it — and `round → delivered` is the only way out of `round`,
* so a bag fenced to the wrong desk is stuck there for good.
*/
export const ROUTED_TO_ROUND = "Out on the ward round";
const DUE_ON = "Due on ";
export const dueOnWard = (ward: string) => `${DUE_ON}${ward}`;
/** The ward a bag was sent out to, off its timeline. Empty when it never went on a round — a bag
* only ever routes once, since `round` has one way in and one way out, so there is no newest row
* to pick between. Callers treat "" as no match: a blank ward is not a ward. */
export function roundWard(events: readonly { label: string; meta: string }[]): string {
const routed = events.find((e) => e.label === ROUTED_TO_ROUND && e.meta.startsWith(DUE_ON));
return routed ? routed.meta.slice(DUE_ON.length) : "";
}
/** Status label and its supporting line, exactly as designed. `ink: "attention"` is the
* accent-700 text; everything else is neutral-700. Colour only ever reinforces the word. */
export function statusText(r: {
status: string; managerName?: string | null; declineReason?: string | null;
holdUntil?: string | null; signerName?: string | null; signerRole?: string | null;
/** Where the bag was sent, per roundWard() — not where the wearer works today. This line is how
* a nurse finds her bag, so after a transfer the two are different wards and only one of them
* has the bag on the desk. */
ward?: string | null;
}, view: {
/** False when somebody other than the wearer is reading — their manager, the clerk who raised
* it, the desk. "Your manager" and "your ward" are then somebody else's, so the line names them
* instead. Defaults to the wearer's own reading, which is what every list of "my orders" is. */
mine?: boolean;
/** The wearer's first name, for the third-person reading. */
first?: string;
} = {}): { label: string; note: string; ink: "attention" | "quiet" } {
const mine = view.mine !== false;
const whose = mine ? "your" : view.first ? `${view.first}s` : "their";
switch (r.status as ReqStatus) {
case "awaiting":
return { label: "Awaiting approval", note: r.managerName ? `With ${r.managerName}` : `With ${whose} manager`, ink: "attention" };
case "declined":
// A decline with a different reason on each line carries none on the request itself, and
// "Declined" with a blank line under it reads as a decision nobody explained.
return { label: "Declined", note: r.declineReason || "See each garment for the reason", ink: "attention" };
case "accepted":
return { label: "Approved — with linen room", note: "Waiting to be picked", ink: "quiet" };
case "picking":
return { label: "Being picked", note: "In the linen room", ink: "quiet" };
case "ready":
return { label: "Ready to collect", note: r.holdUntil ? `Linen room · until ${r.holdUntil}` : "Linen room", ink: "attention" };
case "round":
return { label: "On the ward round", note: r.ward ? `Arriving on ${r.ward}` : `Arriving on ${whose} ward`, ink: "attention" };
case "collected":
return { label: "Collected", note: "Signed off at the counter", ink: "quiet" };
case "delivered":
return {
label: mine ? "Delivered to your ward" : r.ward ? `Delivered to ${r.ward}` : "Delivered to the ward",
note: r.signerName ? `Signed by ${r.signerName}${r.signerRole ? `, ${r.signerRole}` : ""}` : "Signed for on the ward",
ink: "quiet",
};
default:
return { label: r.status, note: "", ink: "quiet" };
}
}
/* ---------- what a ward is told about stock ----------
*
* Wards see words, never counts. That is a product rule, not a display choice: a number invites
* an argument at the counter about whether the shelf really holds four, and the linen room's
* count is the only one that has been audited. The mapping reuses the reorder point the linen
* room already maintains per size, so "Low" means what it means to them.
*/
export type StockWord = "in_stock" | "low" | "none";
export function stockWord(onHand: number, reorderAt: number | null): StockWord {
if (onHand <= 0) return "none";
if (reorderAt !== null && reorderAt > 0 && onHand <= reorderAt) return "low";
return "in_stock";
}
export function stockLabel(w: StockWord): string {
return w === "in_stock" ? "In stock" : w === "low" ? "Low" : "None on shelf";
}
/* ---------- the waitlist hold ----------
*
* When a size finally lands, the linen room offers it to whoever is first in the queue, and both
* the waitlist screen and the offer email promise it is held for them for 48 hours. A promise
* nothing computes is just wording, so the number lives here and everything that depends on it —
* the deadline shown to the person, the refusal to accept an offer that has run out — is derived
* from this one constant rather than restated in each place.
*/
export const WAITLIST_HOLD_HOURS = 48;
/** When an offer made at `offeredAt` stops being held. Null when nothing has been offered. */
export function holdEndsAt(offeredAt: Date | string | null | undefined): Date | null {
if (!offeredAt) return null;
const at = offeredAt instanceof Date ? offeredAt : new Date(offeredAt);
return Number.isNaN(at.getTime()) ? null : new Date(at.getTime() + WAITLIST_HOLD_HOURS * 60 * 60 * 1000);
}
/** Has the hold run out? False when there is no offer at all — nothing has expired if nothing
* was ever held. */
export function holdExpired(offeredAt: Date | string | null | undefined, now: Date = new Date()): boolean {
const ends = holdEndsAt(offeredAt);
return !!ends && ends.getTime() <= now.getTime();
}
/** Four digits, shown at the counter. Not a secret — it is read aloud across a desk — so this is
* a convenience for matching a person to a bag, and the record is the audit.
*
* `taken` is the codes already on the bags this one will stand beside. Four random digits with
* nothing checked collide about one time in fifty once twenty bags are waiting, and two bags on
* the same counter both reading 4417 is somebody carrying home a stranger's uniform. The code
* stays four digits because it is read across a desk, so the fix is to draw again rather than to
* lengthen it — forty draws, which cannot plausibly all land on a code in use unless thousands of
* bags are open at once. Null when they do, and the caller has to fail on it: quietly handing back
* a number already on the counter is the whole bug this exists to stop. */
export function collectionCode(taken: ReadonlySet<string>): string | null {
for (let i = 0; i < 40; i++) {
const code = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
if (!taken.has(code)) return code;
}
return null;
}
export function requestCode(seq: number): string {
return `R-${String(seq).padStart(4, "0")}`;
}
+128
View File
@@ -0,0 +1,128 @@
import { cookies } from "next/headers";
import { createHash, createHmac, randomInt, timingSafeEqual } from "crypto";
import { prisma } from "./db";
/* Sessions for staff members looking at their own record.
*
* A wearer is not a coordinator, and this file is where that stops being a matter of remembering to
* check. Their session lives in its own cookie, signed with a key derived separately from the
* coordinator one, and its payload carries `sid` where a coordinator token carries `uid`. So a
* staff cookie pasted into `tc_session` fails signature verification; and even if it somehow
* didn't, readSessionToken() rejects a payload with no `uid`. There is no arrangement of a staff
* token that produces a coordinator session — not because a condition says no, but because the two
* are not the same shape.
*
* The reverse holds too: a coordinator's cookie is not a staff session, so /my shows a coordinator
* nothing until they activate their own staff record like anyone else.
*/
export const STAFF_COOKIE = "tc_staff";
const MAX_AGE = 60 * 60 * 24 * 30; // 30 days: read-only, and re-typing a password on a ward is a chore
function secret() {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET not set");
// Domain separation. The same master secret signs both kinds of token, and without this a
// signature valid for one would be valid for the other.
return createHash("sha256").update("threadcount:staff:v1:" + s).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function staffPwVersion(passwordHash: string) {
return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12);
}
/** `sso` marks a session the facility's identity provider signed in — see lib/session.ts. */
export function signStaffSession(sid: string, passwordHash: string, maxAge = MAX_AGE, sso = false) {
const payload = b64url(Buffer.from(JSON.stringify({ sid, pv: staffPwVersion(passwordHash), exp: Date.now() + maxAge * 1000, ...(sso ? { sso: true } : {}) })));
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
return `${payload}.${sig}`;
}
export function readStaffToken(raw: string | undefined): { sid: string; pv: string } | null {
if (!raw) return null;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", secret()).update(payload).digest());
const a = Buffer.from(sig), b = Buffer.from(expect);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
if (!data.sid || !data.exp || data.exp < Date.now()) return null;
return { sid: data.sid, pv: String(data.pv || "") };
} catch {
return null;
}
}
export async function setStaffCookie(sid: string, passwordHash: string, sso = false) {
const jar = await cookies();
jar.set(STAFF_COOKIE, signStaffSession(sid, passwordHash, MAX_AGE, sso), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: MAX_AGE,
});
}
export async function clearStaffCookie() {
const jar = await cookies();
jar.set(STAFF_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
}
export type StaffSession = {
accountId: string;
staffId: string;
facilityId: string;
email: string;
first: string;
last: string;
num: string;
};
export async function currentStaff(): Promise<StaffSession | null> {
const jar = await cookies();
const tok = readStaffToken(jar.get(STAFF_COOKIE)?.value);
if (!tok) return null;
const acc = await prisma.staffAccount.findUnique({
where: { id: tok.sid },
select: {
id: true, facilityId: true, email: true, passwordHash: true,
staff: { select: { id: true, first: true, last: true, num: true, inactive: true } },
},
});
if (!acc || acc.staff.inactive) return null; // a person taken off the register loses the view with it
if (tok.pv !== staffPwVersion(acc.passwordHash)) return null;
return {
accountId: acc.id,
staffId: acc.staff.id,
facilityId: acc.facilityId,
email: acc.email,
first: acc.staff.first,
last: acc.staff.last,
num: acc.staff.num,
};
}
/* ---------- activation codes ---------- */
// No I, L, O, U, 0 or 1: these get printed on a slip, read off it and typed by someone who is not
// looking closely, and every removed character is one fewer support call.
const ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
/** Twelve characters in three groups — about 58 bits, and the throttle on /api/staff/activate does
* the rest. Grouped because people type grouped codes more accurately than a run of twelve. */
export function newActivateCode(): string {
const g = () => Array.from({ length: 4 }, () => ALPHABET[randomInt(ALPHABET.length)]).join("");
return `${g()}-${g()}-${g()}`;
}
/** Accept it however it was typed: lower case, spaces, missing or extra dashes. */
export function normaliseCode(raw: string): string {
const clean = String(raw || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 12);
return clean.length === 12 ? `${clean.slice(0, 4)}-${clean.slice(4, 8)}-${clean.slice(8, 12)}` : "";
}
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "./db";
import { COMMUNITY } from "./edition";
/* The two platform switches: are sign-ups open, is the demo in service.
*
* Until the operations console existed these were environment variables, and flipping one meant
* editing the secrets file and restarting. Now they live in one database row the console can
* change, and the environment is an override rather than the source: SIGNUPS_DISABLED=1 or
* DEMO_DISABLED=1 closes the door whatever the row says, so a box can still be locked down by hand
* — and the console cannot reopen it until the variable goes, which is the point of an override.
*
* A missing row means open. A database error also means open here rather than closed: the only
* callers are the sign-up route, the sign-in page, the demo page and the demo entry, all of which
* fail on their own if the database is really down, and a transient error must not turn the
* public site into "sign-ups are closed" for the length of a blip. */
export type Switches = {
signupsOpen: boolean;
demoOpen: boolean;
/** Plans are live: sign-ups land on Hosted Small and Settings shows the Plan tab. Until then
* every new facility is grandfathered, because the page still says free. PLANS_LIVE=1 turns it
* on from the box whatever the row says — the one switch whose override opens rather than closes,
* because "on" is the state that costs a customer something and must be deliberate either way. */
plansLive: boolean;
/** True when the environment forced the switch — the console shows it as locked. */
signupsByEnv: boolean;
demoByEnv: boolean;
plansByEnv: boolean;
/** What the row says, before the environment has its say. */
row: { signupsDisabled: boolean; demoDisabled: boolean; plansLive: boolean; updatedAt: Date | null };
};
export const SWITCH_ROW = "platform";
export async function switches(): Promise<Switches> {
const signupsByEnv = process.env.SIGNUPS_DISABLED === "1";
// A Community instance has no demo facility and no plans, whatever its console row says.
const demoByEnv = process.env.DEMO_DISABLED === "1" || COMMUNITY;
const plansByEnv = process.env.PLANS_LIVE === "1" && !COMMUNITY;
let row: { signupsDisabled: boolean; demoDisabled: boolean; plansLive: boolean; updatedAt: Date } | null = null;
try {
row = await prisma.platformSwitch.findUnique({ where: { id: SWITCH_ROW } });
} catch (e) {
console.error("[switches] read failed — treating as open:", (e as Error).message);
}
return {
signupsOpen: !signupsByEnv && !row?.signupsDisabled,
demoOpen: !demoByEnv && !row?.demoDisabled,
// A read failure means "not live": the failure mode is a facility grandfathered by mistake,
// which is a gift, never a room capped or a tab shown by mistake.
plansLive: !COMMUNITY && (plansByEnv || !!row?.plansLive),
signupsByEnv,
demoByEnv,
plansByEnv,
row: { signupsDisabled: !!row?.signupsDisabled, demoDisabled: !!row?.demoDisabled, plansLive: !!row?.plansLive, updatedAt: row?.updatedAt ?? null },
};
}
+137
View File
@@ -0,0 +1,137 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
/* Time-based one-time passwords (RFC 6238), and the encryption that keeps the secrets from being
* useful in a database dump.
*
* Hand-rolled rather than pulled from a package because TOTP is small, exactly specified, and
* testable against the RFC's own vectors — scripts/check-totp.ts does precisely that. A dependency
* here would be more code, not less, and one that has to be trusted rather than checked.
*
* The secret is stored encrypted. A one-time-password secret sitting in plaintext is a second
* factor that a single leaked pg_dump quietly removes for every account at once. */
const STEP = 30; // seconds per code, per the RFC and every authenticator app
const DIGITS = 6;
/** Accept the neighbouring windows: phone clocks drift, and a code typed as it rolls over is not
* an attack. One step either way is the usual compromise — 90 seconds of validity in total. */
const SKEW = 1;
/* ---------- base32, because that is what authenticator apps consume ---------- */
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buf: Buffer): string {
let bits = 0, value = 0, out = "";
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s: string): Buffer {
const clean = s.toUpperCase().replace(/[^A-Z2-7]/g, "");
let bits = 0, value = 0;
const out: number[] = [];
for (const c of clean) {
const idx = B32.indexOf(c);
if (idx < 0) continue;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
}
return Buffer.from(out);
}
/* ---------- the algorithm ---------- */
/** HOTP: HMAC of the counter, then the RFC's dynamic truncation. */
export function hotp(secret: Buffer, counter: number, digits = DIGITS, algo: "sha1" | "sha256" | "sha512" = "sha1"): string {
const buf = Buffer.alloc(8);
// Counters exceed 32 bits eventually; write as two halves rather than lose the top bits.
buf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
buf.writeUInt32BE(counter >>> 0, 4);
const mac = createHmac(algo, secret).update(buf).digest();
const offset = mac[mac.length - 1] & 0x0f;
const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}
export function totp(secret: Buffer, at = Date.now(), digits = DIGITS, algo: "sha1" | "sha256" | "sha512" = "sha1"): string {
return hotp(secret, Math.floor(at / 1000 / STEP), digits, algo);
}
/** True if `code` is valid now or within one step either side. Constant-time per candidate. */
export function totpVerify(secretB32: string, code: string, at = Date.now()): boolean {
const cleaned = (code || "").replace(/\D/g, "");
if (cleaned.length !== DIGITS) return false;
const secret = base32Decode(secretB32);
if (!secret.length) return false;
const counter = Math.floor(at / 1000 / STEP);
const given = Buffer.from(cleaned, "utf8");
let match = false;
for (let w = -SKEW; w <= SKEW; w++) {
const expect = Buffer.from(hotp(secret, counter + w), "utf8");
// No early exit: every window is compared so the time taken says nothing about which matched.
if (expect.length === given.length && timingSafeEqual(expect, given)) match = true;
}
return match;
}
/** 20 bytes, the RFC's recommendation for SHA-1. */
export function newTotpSecret(): string {
return base32Encode(randomBytes(20));
}
/** The URI an authenticator app expects behind a QR code. */
export function otpauthUrl(secretB32: string, account: string, issuer = "ThreadCount"): string {
const label = encodeURIComponent(`${issuer}:${account}`);
const q = new URLSearchParams({ secret: secretB32, issuer, algorithm: "SHA1", digits: String(DIGITS), period: String(STEP) });
return `otpauth://totp/${label}?${q.toString()}`;
}
/* ---------- storage ---------- */
/** Key derived from SESSION_SECRET, so there is no new secret to manage or lose. Rotating
* SESSION_SECRET invalidates stored TOTP secrets as well as sessions — which is the correct
* blast radius for that action, and is why recovery codes exist. */
function key(): Buffer {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET is required to store a TOTP secret");
return createHash("sha256").update(`totp:${s}`).digest();
}
export function encryptSecret(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${enc.toString("base64url")}`;
}
export function decryptSecret(stored: string): string | null {
try {
const [v, iv, tag, enc] = stored.split(".");
if (v !== "v1") return null;
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([d.update(Buffer.from(enc, "base64url")), d.final()]).toString("utf8");
} catch {
return null;
}
}
/* ---------- recovery codes ---------- */
/** Ten codes, shown once. Without these, a lost phone means a locked-out admin and — because
* deleting the last admin deletes the facility — potentially a lost facility. */
export function newRecoveryCodes(n = 10): string[] {
return Array.from({ length: n }, () => {
const raw = randomBytes(5).toString("hex").toUpperCase(); // 10 hex characters
return `${raw.slice(0, 5)}-${raw.slice(5)}`;
});
}
export const hashRecoveryCode = (code: string) =>
createHash("sha256").update(code.toUpperCase().replace(/[^A-Z0-9]/g, "")).digest("hex");
+58
View File
@@ -0,0 +1,58 @@
// Cloudflare Turnstile server-side verification for sign-in / sign-up.
//
// Configured by two variables that have to agree: TURNSTILE_SECRET here, and
// NEXT_PUBLIC_TURNSTILE_SITEKEY in the browser, which is baked in at build time. Local dev and the
// e2e suites run with neither, and every check is skipped.
//
// In production it fails *closed*. A rebuilt secrets file that drops TURNSTILE_SECRET used to take
// bot protection off sign-in, sign-up, password reset, the contact form and the newsletter with no
// error, no log line and nothing visible — which is the worst shape a security control can fail in.
// Now the checks refuse instead, and instrumentation.ts stops the server from starting at all, so
// the missing variable is found on deploy rather than after it has been exploited.
export const turnstileEnabled = () => !!process.env.TURNSTILE_SECRET;
/* TURNSTILE_OPTIONAL=1 is the one way out, and it exists for a real case: a local `next start`
* smoke test runs with NODE_ENV=production against a machine that has no Cloudflare keys and no
* business having them. It is never set in /etc/threadcount/secrets.env, so it cannot quietly
* disarm the live site the way a *missing* variable used to. */
export const turnstileRequired = () =>
process.env.NODE_ENV === "production" && process.env.TURNSTILE_OPTIONAL !== "1"
// A Community instance has no Cloudflare account to lean on. Turnstile stays available to it —
// set both keys and it is enforced — but its absence is not a misconfiguration there; the
// per-address rate limits on every auth route are what stands in its place.
&& process.env.EDITION !== "community";
export async function verifyTurnstile(token: unknown, ip: string): Promise<string | null> {
const secret = process.env.TURNSTILE_SECRET;
if (!secret) {
if (!turnstileRequired()) return null;
console.error("[turnstile] TURNSTILE_SECRET is not set in production — refusing the request");
return "Security check unavailable — please try again in a moment.";
}
const t = typeof token === "string" ? token.slice(0, 2048) : "";
if (!t) return "Please complete the security check.";
try {
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ secret, response: t, remoteip: ip }),
signal: AbortSignal.timeout(6000),
});
const j = (await r.json()) as { success?: boolean; "error-codes"?: string[] };
if (!j.success) return "Security check failed — please try again.";
return null;
} catch {
return "Security check unavailable — please try again in a moment.";
}
}
/**
* The pair of variables the checks need, as they are at boot.
*
* The site key is read as a plain static reference on purpose: NEXT_PUBLIC_ values are substituted
* at build time, so this reports what the browser bundle actually got. That catches the mirror
* failure — a secret present but no site key compiled in, where no widget renders, no token is
* posted, and every sign-in answers "Please complete the security check."
*/
export function turnstileConfig(): { secret: boolean; sitekey: boolean } {
return { secret: !!process.env.TURNSTILE_SECRET, sitekey: !!process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY };
}
+44
View File
@@ -0,0 +1,44 @@
import { createHmac, timingSafeEqual } from "crypto";
/* The short-lived ticket that carries "this password was correct" from the first step of sign-in
* to the second.
*
* It is emphatically not a session: it grants nothing on its own, is only accepted by the
* second-factor endpoint, and dies in five minutes. Keeping it stateless means a half-finished
* sign-in leaves nothing behind to clean up, and there is no table for an attacker to fill.
*
* It carries the password version, so a password changed between the two steps invalidates the
* ticket for exactly the same reason it invalidates a session.
*/
const TICKET_TTL_MS = 5 * 60 * 1000;
function secret(): string {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET is required");
return s;
}
const b64 = (b: Buffer) => b.toString("base64url");
const sign = (payload: string) => b64(createHmac("sha256", secret()).update(`2fa.${payload}`).digest());
export function mintTicket(userId: string, pv: string): string {
const payload = b64(Buffer.from(JSON.stringify({ uid: userId, pv, exp: Date.now() + TICKET_TTL_MS })));
return `${payload}.${sign(payload)}`;
}
export function readTicket(ticket: string): { uid: string; pv: string } | null {
const [payload, mac] = String(ticket || "").split(".");
if (!payload || !mac) return null;
const expect = sign(payload);
const a = Buffer.from(mac, "utf8");
const b = Buffer.from(expect, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const t = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { uid?: string; pv?: string; exp?: number };
if (!t.uid || !t.pv || !t.exp || t.exp < Date.now()) return null;
return { uid: t.uid, pv: t.pv };
} catch {
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
/* Keep the screen on while a count is open.
*
* A shelf count is minutes of handling garments with the phone held low, and Android's display
* timeout is often fifteen seconds. Waking the phone, unlocking it and finding your place again
* every few garments is the difference between counting a bay and giving up on it.
*
* This is the Screen Wake Lock API rather than a Capacitor plugin, which means it works the same
* in the Android shell and in mobile Chrome, and needs no permission — only a secure context,
* which /m always has. Android drops the lock whenever the page is hidden, so it is re-taken on
* the way back from a phone call or the app switcher. */
import { useEffect } from "react";
type Sentinel = { released: boolean; release: () => Promise<void>; addEventListener: (t: string, f: () => void) => void };
type WakeLockNav = Navigator & { wakeLock?: { request: (type: "screen") => Promise<Sentinel> } };
/** Holds a screen wake lock for as long as `active` is true. A no-op where it isn't supported. */
export function useKeepAwake(active: boolean) {
useEffect(() => {
if (!active) return;
const nav = navigator as WakeLockNav;
if (!nav.wakeLock) return; // older WebView, or an insecure origin
let sentinel: Sentinel | null = null;
let dropped = false;
const take = async () => {
if (dropped || sentinel || document.visibilityState !== "visible") return;
try {
sentinel = await nav.wakeLock!.request("screen");
// The system releases it on its own terms too; forget the handle when it does.
sentinel.addEventListener("release", () => { sentinel = null; });
} catch { /* battery saver refuses it — the screen just times out as usual */ }
};
const onVisible = () => { if (document.visibilityState === "visible") void take(); };
void take();
document.addEventListener("visibilitychange", onVisible);
return () => {
dropped = true;
document.removeEventListener("visibilitychange", onVisible);
if (sentinel && !sentinel.released) sentinel.release().catch(() => {});
sentinel = null;
};
}, [active]);
}