ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 12:12:58 +10:00
commit cbf533d90b
275 changed files with 43340 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
"use client";
/* Analytics.
*
* Self-hosted Umami; the tracker address and site ids come from lib/hosted-defaults.ts or the
* NEXT_PUBLIC_UMAMI_* variables.
* 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";
import { HOSTED_UMAMI_APP_ID, HOSTED_UMAMI_MARKETING_ID, HOSTED_UMAMI_SRC } from "@/lib/hosted-defaults";
/* The tracker script address. Its origin is admitted by the content-security policy in
* next.config.ts, which derives it from the same two sources. */
export const UMAMI_SRC = process.env.NEXT_PUBLIC_UMAMI_SRC || HOSTED_UMAMI_SRC;
/** Umami website ids. Overridable; the hosted defaults live in lib/hosted-defaults.ts, which the
* Community edition replaces with blanks, so a self-hosted instance reports nowhere by default. */
export const MARKETING_ID = process.env.NEXT_PUBLIC_UMAMI_SITE_ID || HOSTED_UMAMI_MARKETING_ID;
export const APP_ID = process.env.NEXT_PUBLIC_UMAMI_APP_ID || HOSTED_UMAMI_APP_ID;
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]);
}
+1243
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;
+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 demo, the public site, and its own error reports and
* usage statistics. Community is the product run by a
* facility on its own server, from the Dockerfile, with EDITION=community in the environment:
* every feature a room uses, no plans, no ceiling, nothing reported anywhere.
*
* 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);
}
+158
View File
@@ -0,0 +1,158 @@
/* Error reporting to a GlitchTip (Sentry-protocol) project.
*
* 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_.
*/
/* The default lives in lib/hosted-defaults.ts (blank in the Community edition); an explicit
NEXT_PUBLIC_GLITCHTIP_DSN wins. The key is public by protocol design. */
import { HOSTED_GLITCHTIP_DSN } from "./hosted-defaults";
const DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN || HOSTED_GLITCHTIP_DSN;
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 default DSN is the hosted service's. It 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);
// On the server only a real deploy reports: a deploy compiles NEXT_PUBLIC_RELEASE into the
// build, local `next start`/e2e runs never set it. Without this, every suite run at home
// filed its scaffolding failures as production issues (44 of them, none from a customer).
if (!process.env.NEXT_PUBLIC_RELEASE) return false;
return process.env.EDITION !== "community";
}
/* Browser "Script error." is the cross-origin placeholder the browser substitutes for an error
thrown by a third-party script (Turnstile, the analytics beacon): no message, no stack, no
location. It cannot be acted on, so it is dropped rather than paged. */
function ignorable(err: Error): boolean {
return /^Script error\.?$/.test((err.message || "").trim());
}
/* Prisma errors begin with a blank line and an engine id, which the scrubber turns into "[id]:",
so the issue title says nothing. Use the first line that carries words instead. */
function headline(value: string): string {
const lines = value.split("\n").map((l) => l.trim()).filter(Boolean);
return lines.find((l) => !/^\[id\]:?$/.test(l) && !/^Invalid `.*` invocation:?$/.test(l)) || lines[0] || value;
}
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));
if (ignorable(err)) return;
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: headline(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 */ }
}
+14
View File
@@ -0,0 +1,14 @@
/* Community edition: no telemetry goes anywhere unless the operator points it somewhere with
* NEXT_PUBLIC_UMAMI_SITE_ID / NEXT_PUBLIC_UMAMI_APP_ID / NEXT_PUBLIC_UMAMI_SRC and
* NEXT_PUBLIC_GLITCHTIP_DSN, and the screens link to no documents but the operator's own
* (NEXT_PUBLIC_TERMS_URL / NEXT_PUBLIC_PRIVACY_URL). */
export const HOSTED_UMAMI_SRC = "";
export const HOSTED_UMAMI_MARKETING_ID = "";
export const HOSTED_UMAMI_APP_ID = "";
export const HOSTED_GLITCHTIP_DSN = "";
export const HOSTED_TELEMETRY_HOSTS: string[] = [];
export const HOSTED_TERMS_URL = "";
export const HOSTED_PRIVACY_URL = "";
export const HOSTED_DELETE_ACCOUNT_URL = "";
export const HOSTED_PRIVACY_EMAIL = "";
export const HOSTED_SITE = false;
+14
View File
@@ -0,0 +1,14 @@
/* Where the product's own screens send people for the terms, the privacy notice and the rest.
*
* The defaults are the hosted service's pages and live in lib/hosted-defaults.ts, which the
* Community edition replaces with blanks: a self-hosted instance is somebody else's service with
* somebody else's privacy officer, so its operator sets NEXT_PUBLIC_TERMS_URL and
* NEXT_PUBLIC_PRIVACY_URL to their own documents, and until they do the screens show no link
* rather than the wrong one. Compiled in at build time. */
import { HOSTED_DELETE_ACCOUNT_URL, HOSTED_PRIVACY_EMAIL, HOSTED_PRIVACY_URL, HOSTED_SITE, HOSTED_TERMS_URL } from "./hosted-defaults";
export const TERMS_URL = process.env.NEXT_PUBLIC_TERMS_URL || HOSTED_TERMS_URL;
export const PRIVACY_URL = process.env.NEXT_PUBLIC_PRIVACY_URL || HOSTED_PRIVACY_URL;
export const DELETE_ACCOUNT_URL = HOSTED_DELETE_ACCOUNT_URL;
export const PRIVACY_EMAIL = HOSTED_PRIVACY_EMAIL;
/** True when a public website (support page, demo) sits in front of this build. */
export const HAS_SITE = HOSTED_SITE;
+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 */ }
}
+3416
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
/* Community edition: notices about a facility go to the server log. The hosted edition emails
* them to the people who run the service. */
export async function ownerAddresses(): Promise<string[]> { return []; }
export function notifyOwners(subject: string, text: string): void { console.log(`[notice] ${subject}\n${text}`); }
export function alertNewSignup(facility: { id: string; name: string }): void { console.log(`[notice] facility created: ${facility.name}`); }
export function alertInvoiceRequested(f: { id: string; name: string; plan: string; wants: string }): void { console.log(`[notice] invoice requested by ${f.name} (${f.plan}${f.wants})`); }
export function alertFacilityDeleted(args: { name: string; by: string; ip: string; counts: string }): void { console.log(`[notice] facility deleted: ${args.name} (${args.counts})`); }
+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);
+142
View File
@@ -0,0 +1,142 @@
/* 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 a payment is recorded.
*
* 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 set by ThreadCount, kept for abuse.
*/
import { COMMUNITY } from "./edition";
export type PlanCode = "hosted_small" | "hosted_facility" | "health_service" | "private";
/** What was 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 read-only was set by hand
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. A member of a health
* service carries its organisation's plan row too, and that row is the one that counts. */
export const PLAN_COLS = {
plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true, isDemo: true, stripeSubscriptionId: true,
org: { select: { id: true, name: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true } },
} as const;
export type OrgPlanRow = { id: string; name: string; plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null };
export type PlanRow = {
plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean; isDemo?: boolean;
stripeSubscriptionId?: string; org?: OrgPlanRow | null;
};
export function entitlements(f: PlanRow, now: Date = new Date()): Entitlements {
/* A facility inside a health service is on the health service's plan: its own columns are
ignored while it is a member. Grandfathering stays the facility's own — a room that was free
before plans keeps that if it ever leaves — and an explicit read-only on the facility still
wins, because that is the tool for one room, not the whole service. */
if (f.org && f.planStatus !== "read_only" && !f.grandfathered) {
return entitlements({ plan: f.org.plan, planStatus: f.org.planStatus, trialEndsAt: f.org.trialEndsAt, paidUntil: f.org.paidUntil, grandfathered: false, isDemo: f.isDemo }, now);
}
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 an explicit 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 that has been 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 a period was started 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 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.`;
}
+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 };
}
+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.`,
};
}
+176
View File
@@ -0,0 +1,176 @@
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 { stripeConfigured } from "./stripe";
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; stripeSubscriptionId: string; org?: { id: string; name: string } | null }, 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,
// The card path: shown only when Stripe is configured on this instance; `card` says a
// subscription exists, so the screen offers "Manage card" instead of "Pay by card".
cardsOn: stripeConfigured(), card: !!fac.stripeSubscriptionId,
org: fac.org ? { name: fac.org.name } : null,
// Who invoices, once the entity exists (INVOICE_ENTITY / INVOICE_ABN); blank until then.
invoicer: process.env.INVOICE_ENTITY ? `${process.env.INVOICE_ENTITY}${process.env.INVOICE_ABN ? ` · ABN ${process.env.INVOICE_ABN}` : ""}` : "",
};
}
/** `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 }, include: { org: { select: { id: true, name: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true } } } }),
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,
};
}
+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)}` : "";
}
+2
View File
@@ -0,0 +1,2 @@
/* Community edition: no card payments. The Plan screen reads this and never offers a card. */
export function stripeConfigured(): boolean { return false; }
+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.
*
* These used to be environment variables, and flipping one meant editing the secrets file and
* restarting. Now they live in one database row ThreadCount's own administration 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 nothing
* can 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. */
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 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 a production secrets file, 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]);
}