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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 23:18:05 +10:00
commit c89010a4f1
424 changed files with 53588 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
/* The coordinator's account emails: the welcome after sign-up, and the password reset link.
* Plain text and HTML for each — the words of the text are the original ones; the HTML puts the
* same words in the layout every ThreadCount email shares (lib/mail-html.cjs). */
import { layout, siteUrl } from "@/lib/mail-html.cjs";
export type AccountMail = { subject: string; text: string; html: string };
/** Sent to the address a coordinator signs up with: it is the address a reset goes to, so it is
* exercised immediately, and the mail says in as many words why it matters. */
export function welcomeEmail(first: string, facility: string): AccountMail {
const base = siteUrl();
const subject = "Your ThreadCount facility is set up";
const text = [
`Hi ${first || "there"},`,
"",
`${facility} is set up on ThreadCount, and this address is the coordinator account for it.`,
"",
"Keep this message. This is the address a password reset is sent to, and it is the only way",
"back into the facility if the password is forgotten — so if it is wrong, sign in and add a",
"second admin with an address that works, under Settings → Users.",
"",
`${base}/app`,
"",
"— ThreadCount",
].join("\n");
const { html } = layout({
eyebrow: "Welcome",
title: `${facility} is set up`,
preheader: `${facility} is set up on ThreadCount. This address is its coordinator account.`,
intro: [`Hi ${first || "there"},`, `${facility} is set up on ThreadCount, and this address is the coordinator account for it.`],
rows: [["This address", "Where a password reset is sent — the only way back in if the password is forgotten"], ["If it is wrong", "Sign in and add a second admin with an address that works, under Settings → Users"]],
cta: { label: "Open ThreadCount", href: `${base}/app` },
closing: ["Keep this message."],
footer: { facility, links: [["Support", `${base}/support`]] },
});
return { subject, text, html };
}
+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";
}
+243
View File
@@ -0,0 +1,243 @@
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { layout, siteUrl } from "@/lib/mail-html.cjs";
/* 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 same lines for the HTML list slot: one garment per row, the refusal against itself. */
function garmentList(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;
});
}
function staffFooter(facility: string) {
return { facility, links: [["Support", `${siteUrl()}/support`]] as [string, string][] };
}
/** 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");
const rows: [string, string][] = [];
if (raisedBy) rows.push(["Raised by", raisedBy]);
if (opts.reason) rows.push(["Reason", opts.reason]);
if (opts.note) rows.push(["Note", opts.note]);
const { html } = layout({
eyebrow: "Uniform request", title: subject, preheader: asked,
intro: [`Hi ${opts.managerFirst || "there"},`, asked],
list: garmentList(opts.lines), rows,
cta: { label: "Review the request", href: opts.url },
closing: ["The link opens a page showing the request — nothing is decided until you choose. It works once."],
footer: staffFooter(opts.facility),
});
return { subject, text, html };
}
/** 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");
const { html } = layout({
eyebrow: "Uniform request", title: subject, preheader: opening,
intro: [`Hi ${opts.staffFirst || "there"},`, opening],
list: opts.reason ? garmentList(opts.lines.map((l) => ({ qty: l.qty, item: l.item, size: l.size }))) : garmentList(opts.lines),
closing: opts.approved ? ["You'll hear again when it's ready to collect or on its way to your ward."] : [],
footer: staffFooter(opts.facility),
});
return { subject, text, html };
}
/* ---------- the linen room's own notes to the wearer ----------
*
* Built here rather than inline in lib/ops.ts so the preview renders the real thing. `foot` is the
* linen room's sign-off (its name · ThreadCount), passed in because the room's name is what the
* caller has; the plain text below is word for word what these emails have always said. */
export type NoteLine = { qty: number; item: string; size: string };
/** The bag is at the counter. */
export function readyEmail(o: { first: string; code: string; lines: readonly NoteLine[]; collectCode?: string | null; holdUntil?: string | null; foot: string }) {
const subject = "Ready to collect";
const held = [
o.collectCode ? `Collection code: ${o.collectCode}` : null,
o.holdUntil ? `Held until ${o.holdUntil}.` : null,
].filter((l): l is string => l !== null).join("\n");
const text = [`Hi ${o.first},`, `Request ${o.code} is ready at the linen room.`, garmentBlock(o.lines), held || null, o.foot]
.filter((l): l is string => l !== null).join("\n\n");
const { html } = layout({
eyebrow: "Your uniform", title: subject, preheader: `Request ${o.code} is ready at the linen room.`,
intro: [`Hi ${o.first},`, `Request ${o.code} is ready at the linen room.`],
list: garmentList(o.lines),
rows: o.holdUntil ? [["Held until", o.holdUntil]] : [],
code: o.collectCode ? { label: "Collection code", value: o.collectCode } : undefined,
footer: { facility: o.foot.replace(/ · ThreadCount$/, ""), links: [["Support", `${siteUrl()}/support`]] },
});
return { subject, text, html };
}
/** The bag is on today's round. */
export function roundEmail(o: { first: string; code: string; lines: readonly NoteLine[]; dept?: string | null; foot: string }) {
const subject = "On the ward round";
const where = `Request ${o.code} is on today's round to ${o.dept || "your ward"}.`;
const text = [`Hi ${o.first},`, where, garmentBlock(o.lines), "Whoever signs for it at the desk will be named on your order.", o.foot].join("\n\n");
const { html } = layout({
eyebrow: "Your uniform", title: subject, preheader: where,
intro: [`Hi ${o.first},`, where],
list: garmentList(o.lines),
closing: ["Whoever signs for it at the desk will be named on your order."],
footer: { facility: o.foot.replace(/ · ThreadCount$/, ""), links: [["Support", `${siteUrl()}/support`]] },
});
return { subject, text, html };
}
/** A size somebody waited for has come in and is held for them. */
export function waitlistEmail(o: { item: string; size: string; until: string; foot: string }) {
const subject = "The size you were waiting for is in";
const line = `${o.item} — size ${o.size} has come in and is held for you until ${o.until}.`;
const text = `${line}\n\nOpen ThreadCount to accept it. After that it goes to the next person waiting.\n\n${o.foot}`;
const { html } = layout({
eyebrow: "Your uniform", title: subject, preheader: line,
intro: [line],
rows: [["Garment", `${o.item} — size ${o.size}`], ["Held until", o.until]],
cta: { label: "Open ThreadCount", href: `${siteUrl()}/my` },
closing: ["After that it goes to the next person waiting."],
footer: { facility: o.foot.replace(/ · ThreadCount$/, ""), links: [["Support", `${siteUrl()}/support`]] },
});
return { subject, text, html };
}
+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");
+20
View File
@@ -0,0 +1,20 @@
/* Community edition: there is no billing, so there is no billing mail. The sign-up route still
* calls this when a facility starts a trial, which never happens off the hosted service; every
* function here answers as if nothing was sent. */
import { PRICES } from "@/lib/plan";
export type Rendered = { subject: string; text: string; html: string };
export type FacilityCtx = { facility: string; contact: string };
export type Card = { brand: string; last4: string } | null;
export const PRICE_CENTS = { monthly: PRICES.hostedMonthly * 100, annual: PRICES.hostedAnnual * 100 } as const;
export async function billingRecipients(_facilityId: string): Promise<{ to: string[]; ctx: FacilityCtx }> {
return { to: [], ctx: { facility: "", contact: "" } };
}
export async function sendBillingMail(_facilityId: string, _render: (ctx: FacilityCtx) => Rendered): Promise<number> {
return 0;
}
const empty = (): Rendered => ({ subject: "", text: "", html: "" });
/** Every template name resolves to a renderer that produces an empty message. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const templates: Record<string, (...args: any[]) => Rendered> = new Proxy({}, { get: () => empty });
+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]);
}
+1329
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
/* ISO 3166-1 alpha-2 country codes and names, for the checkout page's country select and the
* server's validation of what it sent. The code is what Stripe Tax reads; the name is for people.
* Generated from the iso-codes package; the order is by name. */
export const COUNTRIES: ReadonlyArray<readonly [string, string]> = [
["AF", "Afghanistan"],
["AL", "Albania"],
["DZ", "Algeria"],
["AS", "American Samoa"],
["AD", "Andorra"],
["AO", "Angola"],
["AI", "Anguilla"],
["AQ", "Antarctica"],
["AG", "Antigua and Barbuda"],
["AR", "Argentina"],
["AM", "Armenia"],
["AW", "Aruba"],
["AU", "Australia"],
["AT", "Austria"],
["AZ", "Azerbaijan"],
["BS", "Bahamas"],
["BH", "Bahrain"],
["BD", "Bangladesh"],
["BB", "Barbados"],
["BY", "Belarus"],
["BE", "Belgium"],
["BZ", "Belize"],
["BJ", "Benin"],
["BM", "Bermuda"],
["BT", "Bhutan"],
["BO", "Bolivia"],
["BQ", "Bonaire, Sint Eustatius and Saba"],
["BA", "Bosnia and Herzegovina"],
["BW", "Botswana"],
["BV", "Bouvet Island"],
["BR", "Brazil"],
["IO", "British Indian Ocean Territory"],
["BN", "Brunei Darussalam"],
["BG", "Bulgaria"],
["BF", "Burkina Faso"],
["BI", "Burundi"],
["CV", "Cabo Verde"],
["KH", "Cambodia"],
["CM", "Cameroon"],
["CA", "Canada"],
["KY", "Cayman Islands"],
["CF", "Central African Republic"],
["TD", "Chad"],
["CL", "Chile"],
["CN", "China"],
["CX", "Christmas Island"],
["CC", "Cocos (Keeling) Islands"],
["CO", "Colombia"],
["KM", "Comoros"],
["CG", "Congo"],
["CD", "Congo, The Democratic Republic of the"],
["CK", "Cook Islands"],
["CR", "Costa Rica"],
["HR", "Croatia"],
["CU", "Cuba"],
["CW", "Cura\u00e7ao"],
["CY", "Cyprus"],
["CZ", "Czechia"],
["CI", "C\u00f4te d'Ivoire"],
["DK", "Denmark"],
["DJ", "Djibouti"],
["DM", "Dominica"],
["DO", "Dominican Republic"],
["EC", "Ecuador"],
["EG", "Egypt"],
["SV", "El Salvador"],
["GQ", "Equatorial Guinea"],
["ER", "Eritrea"],
["EE", "Estonia"],
["SZ", "Eswatini"],
["ET", "Ethiopia"],
["FK", "Falkland Islands (Malvinas)"],
["FO", "Faroe Islands"],
["FJ", "Fiji"],
["FI", "Finland"],
["FR", "France"],
["GF", "French Guiana"],
["PF", "French Polynesia"],
["TF", "French Southern Territories"],
["GA", "Gabon"],
["GM", "Gambia"],
["GE", "Georgia"],
["DE", "Germany"],
["GH", "Ghana"],
["GI", "Gibraltar"],
["GR", "Greece"],
["GL", "Greenland"],
["GD", "Grenada"],
["GP", "Guadeloupe"],
["GU", "Guam"],
["GT", "Guatemala"],
["GG", "Guernsey"],
["GN", "Guinea"],
["GW", "Guinea-Bissau"],
["GY", "Guyana"],
["HT", "Haiti"],
["HM", "Heard Island and McDonald Islands"],
["VA", "Holy See (Vatican City State)"],
["HN", "Honduras"],
["HK", "Hong Kong"],
["HU", "Hungary"],
["IS", "Iceland"],
["IN", "India"],
["ID", "Indonesia"],
["IR", "Iran"],
["IQ", "Iraq"],
["IE", "Ireland"],
["IM", "Isle of Man"],
["IL", "Israel"],
["IT", "Italy"],
["JM", "Jamaica"],
["JP", "Japan"],
["JE", "Jersey"],
["JO", "Jordan"],
["KZ", "Kazakhstan"],
["KE", "Kenya"],
["KI", "Kiribati"],
["KW", "Kuwait"],
["KG", "Kyrgyzstan"],
["LA", "Laos"],
["LV", "Latvia"],
["LB", "Lebanon"],
["LS", "Lesotho"],
["LR", "Liberia"],
["LY", "Libya"],
["LI", "Liechtenstein"],
["LT", "Lithuania"],
["LU", "Luxembourg"],
["MO", "Macao"],
["MG", "Madagascar"],
["MW", "Malawi"],
["MY", "Malaysia"],
["MV", "Maldives"],
["ML", "Mali"],
["MT", "Malta"],
["MH", "Marshall Islands"],
["MQ", "Martinique"],
["MR", "Mauritania"],
["MU", "Mauritius"],
["YT", "Mayotte"],
["MX", "Mexico"],
["FM", "Micronesia, Federated States of"],
["MD", "Moldova"],
["MC", "Monaco"],
["MN", "Mongolia"],
["ME", "Montenegro"],
["MS", "Montserrat"],
["MA", "Morocco"],
["MZ", "Mozambique"],
["MM", "Myanmar"],
["NA", "Namibia"],
["NR", "Nauru"],
["NP", "Nepal"],
["NL", "Netherlands"],
["NC", "New Caledonia"],
["NZ", "New Zealand"],
["NI", "Nicaragua"],
["NE", "Niger"],
["NG", "Nigeria"],
["NU", "Niue"],
["NF", "Norfolk Island"],
["KP", "North Korea"],
["MK", "North Macedonia"],
["MP", "Northern Mariana Islands"],
["NO", "Norway"],
["OM", "Oman"],
["PK", "Pakistan"],
["PW", "Palau"],
["PS", "Palestine, State of"],
["PA", "Panama"],
["PG", "Papua New Guinea"],
["PY", "Paraguay"],
["PE", "Peru"],
["PH", "Philippines"],
["PN", "Pitcairn"],
["PL", "Poland"],
["PT", "Portugal"],
["PR", "Puerto Rico"],
["QA", "Qatar"],
["RO", "Romania"],
["RU", "Russian Federation"],
["RW", "Rwanda"],
["RE", "R\u00e9union"],
["BL", "Saint Barth\u00e9lemy"],
["SH", "Saint Helena, Ascension and Tristan da Cunha"],
["KN", "Saint Kitts and Nevis"],
["LC", "Saint Lucia"],
["MF", "Saint Martin (French part)"],
["PM", "Saint Pierre and Miquelon"],
["VC", "Saint Vincent and the Grenadines"],
["WS", "Samoa"],
["SM", "San Marino"],
["ST", "Sao Tome and Principe"],
["SA", "Saudi Arabia"],
["SN", "Senegal"],
["RS", "Serbia"],
["SC", "Seychelles"],
["SL", "Sierra Leone"],
["SG", "Singapore"],
["SX", "Sint Maarten (Dutch part)"],
["SK", "Slovakia"],
["SI", "Slovenia"],
["SB", "Solomon Islands"],
["SO", "Somalia"],
["ZA", "South Africa"],
["GS", "South Georgia and the South Sandwich Islands"],
["KR", "South Korea"],
["SS", "South Sudan"],
["ES", "Spain"],
["LK", "Sri Lanka"],
["SD", "Sudan"],
["SR", "Suriname"],
["SJ", "Svalbard and Jan Mayen"],
["SE", "Sweden"],
["CH", "Switzerland"],
["SY", "Syria"],
["TW", "Taiwan"],
["TJ", "Tajikistan"],
["TZ", "Tanzania"],
["TH", "Thailand"],
["TL", "Timor-Leste"],
["TG", "Togo"],
["TK", "Tokelau"],
["TO", "Tonga"],
["TT", "Trinidad and Tobago"],
["TN", "Tunisia"],
["TM", "Turkmenistan"],
["TC", "Turks and Caicos Islands"],
["TV", "Tuvalu"],
["TR", "T\u00fcrkiye"],
["UG", "Uganda"],
["UA", "Ukraine"],
["AE", "United Arab Emirates"],
["GB", "United Kingdom"],
["US", "United States"],
["UM", "United States Minor Outlying Islands"],
["UY", "Uruguay"],
["UZ", "Uzbekistan"],
["VU", "Vanuatu"],
["VE", "Venezuela"],
["VN", "Vietnam"],
["VG", "Virgin Islands, British"],
["VI", "Virgin Islands, U.S."],
["WF", "Wallis and Futuna"],
["EH", "Western Sahara"],
["YE", "Yemen"],
["ZM", "Zambia"],
["ZW", "Zimbabwe"],
["AX", "\u00c5land Islands"],
];
const CODES = new Set(COUNTRIES.map(([c]) => c));
export function isCountryCode(x: unknown): x is string { return typeof x === "string" && CODES.has(x); }
export function countryName(code: string): string { return COUNTRIES.find(([c]) => c === code)?.[1] || code; }
+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 → Issuing rules, 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 */ }
}
+47
View File
@@ -0,0 +1,47 @@
/* Helpdesk (Chatwoot, self-hosted).
*
* Two halves. The browser half is the chat widget (components/Helpdesk.tsx); the server half,
* here, files a contact-form message as a helpdesk conversation so it lands in the same queue as
* chats and emails instead of a table nobody opens. Fire-and-forget: the form's own record and
* email are the system of record; a helpdesk outage must never fail a contact submission.
*
* Configuration (environment): CHATWOOT_URL, CHATWOOT_API_TOKEN (an agent access token),
* CHATWOOT_ACCOUNT_ID, CHATWOOT_INBOX_ID (the ThreadCount website inbox). Absent = off. */
const URL_ = process.env.CHATWOOT_URL || "";
const TOKEN = process.env.CHATWOOT_API_TOKEN || "";
const ACCOUNT = process.env.CHATWOOT_ACCOUNT_ID || "";
const INBOX = process.env.CHATWOOT_INBOX_ID || "";
export const helpdeskOn = () => !!(URL_ && TOKEN && ACCOUNT && INBOX);
async function api(path: string, body: unknown): Promise<Record<string, unknown>> {
const res = await fetch(`${URL_}/api/v1/accounts/${ACCOUNT}${path}`, {
method: "POST",
headers: { "content-type": "application/json", api_access_token: TOKEN },
body: JSON.stringify(body),
signal: AbortSignal.timeout(6000),
});
if (!res.ok) throw new Error(`helpdesk ${path} ${res.status}`);
return (await res.json()) as Record<string, unknown>;
}
export type HelpdeskMessage = { name: string; email: string; message: string; subject?: string; facility?: string };
/** Files one conversation. Resolves true when the helpdesk accepted it; never throws. */
export async function fileHelpdeskMessage(m: HelpdeskMessage): Promise<boolean> {
if (!helpdeskOn()) return false;
try {
const created = await api("/contacts", { inbox_id: Number(INBOX), name: m.name, email: m.email });
const payload = (created.payload as Record<string, unknown>) || created;
const contact = (payload.contact as Record<string, unknown>) || payload;
const inboxes = (contact.contact_inboxes as Array<Record<string, unknown>>) || [];
const sourceId = inboxes.find((ci) => (ci.inbox as Record<string, unknown>)?.id === Number(INBOX))?.source_id || inboxes[0]?.source_id;
const content = [m.subject ? `**${m.subject}**` : "", m.facility ? `Facility: ${m.facility}` : "", m.message].filter(Boolean).join("\n\n");
await api("/conversations", { source_id: sourceId, inbox_id: Number(INBOX), contact_id: contact.id, message: { content } });
return true;
} catch (e) {
console.error("[helpdesk] file failed:", e instanceof Error ? e.message : e);
return false;
}
}
+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]);
}
+139
View File
@@ -0,0 +1,139 @@
/* The one HTML layout every ThreadCount email is built from.
*
* Lifted out of lib/billing-mail.cjs so the coordinator and staff emails read the same way as the
* billing ones: the same colours, the same text wordmark, one button, a footer. CommonJS with no
* dependencies so the app, the timers and the previews all render the same file.
*
* Colour is hard-coded from app/globals.css (`--color-*`): email clients do not read CSS
* variables. Tables, inline styles, a single 600px column, no images the wordmark is text, so the
* mail looks the same with images blocked.
*
* Slots: `eyebrow` (the small label above the headline), `title`, `preheader`, `intro` (paragraphs),
* `rows` (a details table of [label, value]), `list` (garment lines or bullets), `code` (a big
* collection code), `cta` ({label, href}), `closing` (paragraphs in the quieter colour), `footer`
* ({facility, contact, links: [[label, href]], entity, textLinks}). `text` is built from the same
* parts for callers that have no hand-written plain-text body. */
const C = {
bg: "#f3f2f2", surface: "#eae9e9", text: "#201e1d", accent: "#ec3013", accent700: "#b8240e", accent300: "#ffc4b8",
divider: "#cfcccb", n600: "#6c6764", n700: "#57534f", n800: "#3a3735", white: "#ffffff",
};
const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function money(cents, currency) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: (currency || "AUD").toUpperCase() }).format((cents || 0) / 100);
}
function longDate(d) {
const x = d instanceof Date ? d : new Date(d);
return x.toLocaleDateString("en-AU", { day: "numeric", month: "long", year: "numeric", timeZone: "Australia/Brisbane" });
}
function siteUrl() {
return (process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech").replace(/\/+$/, "");
}
/**
* @param {{ eyebrow?: string, title: string, preheader?: string, intro?: string | string[],
* rows?: [string, string][], list?: string[], code?: { label?: string, value: string },
* cta?: { label: string, href: string }, closing?: string | string[],
* footer?: { facility?: string, contact?: string, links?: [string, string][], entity?: string, textLinks?: [string, string][] } }} o
* @returns {{ html: string, text: string }}
*/
function layout(o) {
const { eyebrow, title, preheader, intro, rows, list, code, cta, closing, footer } = o;
const base = siteUrl();
const f = footer || {};
const entity = f.entity === undefined ? (process.env.INVOICE_ENTITY || "ThreadCount") : f.entity;
const abn = f.entity === undefined && process.env.INVOICE_ABN ? ` · ABN ${process.env.INVOICE_ABN}` : "";
const links = f.links || [["Support", `${base}/support`]];
const paras = (Array.isArray(intro) ? intro : [intro]).filter(Boolean);
const closingParas = (Array.isArray(closing) ? closing : [closing]).filter(Boolean);
const rowsHtml = rows && rows.length
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:22px 0 4px;border-top:2px solid ${C.text}">
${rows.map(([k, v]) => `<tr>
<td style="padding:10px 12px;font:700 11px/1.4 ${FONT};letter-spacing:.06em;text-transform:uppercase;color:${C.n600};background:${C.surface};border-bottom:1px solid ${C.divider};width:38%;vertical-align:top">${esc(k)}</td>
<td style="padding:10px 12px;font:15px/1.4 ${FONT};color:${C.text};border-bottom:1px solid ${C.divider};font-variant-numeric:tabular-nums">${esc(v)}</td>
</tr>`).join("")}
</table>`
: "";
const listHtml = list && list.length
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:20px 0 4px;border-top:2px solid ${C.text};border-bottom:1px solid ${C.divider}">
${list.map((l) => `<tr><td style="padding:10px 12px;font:15px/1.45 ${FONT};color:${C.text};border-bottom:1px solid ${C.divider};font-variant-numeric:tabular-nums">${esc(l)}</td></tr>`).join("")}
</table>`
: "";
const codeHtml = code
? `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:22px 0 4px"><tr>
<td style="padding:14px 22px;background:${C.surface};border:2px solid ${C.text}">
<div style="font:700 11px/1.4 ${FONT};letter-spacing:.14em;text-transform:uppercase;color:${C.n600}">${esc(code.label || "Collection code")}</div>
<div style="font:800 34px/1.1 ${MONO};letter-spacing:.08em;color:${C.text};margin-top:4px">${esc(code.value)}</div>
</td></tr></table>`
: "";
const ctaHtml = cta
? `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:26px 0 6px"><tr><td style="background:${C.accent};border-radius:0">
<a href="${esc(cta.href)}" style="display:inline-block;padding:13px 22px;font:700 15px/1 ${FONT};color:${C.white};text-decoration:none;letter-spacing:.01em">${esc(cta.label)}</a>
</td></tr></table>`
: "";
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="color-scheme" content="light"><title>${esc(title)}</title></head>
<body style="margin:0;padding:0;background:${C.bg};-webkit-text-size-adjust:100%">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${esc(preheader || title)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${C.bg}"><tr><td align="center" style="padding:28px 12px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%">
<tr><td style="padding:0 0 14px">
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
<td style="width:14px;height:14px;background:${C.accent};font-size:0;line-height:0">&nbsp;</td>
<td style="padding-left:10px;font:800 17px/1 ${FONT};letter-spacing:-.02em;color:${C.text}">ThreadCount</td>
</tr></table>
</td></tr>
<tr><td style="background:${C.white};border:2px solid ${C.text}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr><td style="background:${C.text};padding:26px 32px 22px">
<div style="font:700 11px/1.4 ${FONT};letter-spacing:.14em;text-transform:uppercase;color:${C.accent300}">${esc(eyebrow || "ThreadCount")}</div>
<div style="font:800 30px/1.1 ${FONT};letter-spacing:-.03em;color:${C.white};margin-top:8px">${esc(title)}</div>
</td></tr>
<tr><td style="padding:26px 32px 30px">
${paras.map((p) => `<p style="margin:0 0 14px;font:16px/1.55 ${FONT};color:${C.text}">${esc(p)}</p>`).join("")}
${listHtml}
${rowsHtml}
${codeHtml}
${ctaHtml}
${closingParas.map((p) => `<p style="margin:18px 0 0;font:15px/1.55 ${FONT};color:${C.n700}">${esc(p)}</p>`).join("")}
</td></tr>
</table>
</td></tr>
<tr><td style="padding:18px 8px 0;font:12.5px/1.6 ${FONT};color:${C.n600}">
${f.facility ? `${esc(f.facility)}${f.contact ? ` · ${esc(f.contact)}` : ""}<br>` : ""}
${links.map(([label, href]) => `<a href="${esc(href)}" style="color:${C.accent700};text-decoration:underline">${esc(label)}</a>`).join(" · ")}${links.length ? "<br>" : ""}
${esc(entity)}${esc(abn)}
</td></tr>
</table>
</td></tr></table>
</body></html>`;
const textLinks = f.textLinks || links;
const text = [
title.toUpperCase(),
"",
...paras,
"",
...(list && list.length ? [...list, ""] : []),
...(rows && rows.length ? rows.map(([k, v]) => `${k}: ${v}`) : []),
...(rows && rows.length ? [""] : []),
...(code ? [`${code.label || "Collection code"}: ${code.value}`, ""] : []),
...(cta ? [`${cta.label}: ${cta.href}`, ""] : []),
...closingParas,
...(closingParas.length ? [""] : []),
f.facility ? `${f.facility}${f.contact ? ` · ${f.contact}` : ""}` : "",
...textLinks.map(([label, href]) => `${label}: ${href}`),
`${entity}${abn}`,
].filter((l, i, a) => !(l === "" && a[i - 1] === "")).join("\n");
return { html, text };
}
module.exports = { layout, esc, money, longDate, siteUrl, C, FONT };
+62
View File
@@ -0,0 +1,62 @@
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, html?: string, replyTo?: 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! },
});
// `html` is optional: the billing emails send both parts, everything else stays plain text.
await t.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER!, to, subject, text, ...(html ? { html } : {}), ...(replyTo ? { replyTo } : {}) });
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) };
}
+79
View File
@@ -0,0 +1,79 @@
/* The manual's fixed shape, and which page is about which app screen. Client-safe: no file system
* here, so the help mark on every screen can import it. The pages themselves are Markdown under
* docs/manual/<section>/<slug>.md and are read by lib/manual.ts on the server. */
export const SECTIONS = [
{ id: "start", title: "Start here", blurb: "The whole product in one page, setting a room up, the two roles and a first order." },
{ id: "counter", title: "The counter", blurb: "Issuing, returns, requests, approvals, the pickup call list, rounds and slips." },
{ id: "stock", title: "Stock", blurb: "The catalogue, barcodes, reorder levels, stocktakes, ordering and receiving." },
{ id: "people", title: "People", blurb: "The staff register, groups and routes, the entitlement rule and managers." },
{ id: "reports", title: "Reports and finance", blurb: "Every report, cost centres, the journal and the month-end pack." },
{ id: "apps", title: "Apps", blurb: "The counter app, the staff app, and scanning in each browser." },
{ id: "account", title: "Account and plan", blurb: "Users, two-factor, single sign-on, billing, backups and deleting." },
{ id: "selfhost", title: "Self-hosting", blurb: "Installing the Community edition, updating, backups, email and configuration." },
{ id: "reference", title: "Reference", blurb: "CSV templates, the glossary, and the keyboard and scanner." },
] as const;
export type SectionId = (typeof SECTIONS)[number]["id"];
export const sectionTitle = (id: string) => SECTIONS.find((s) => s.id === id)?.title ?? id;
/** The public site, for links out of the in-app manual to pages only the website has. */
export const SITE_ORIGIN = "https://threadcount.tech";
/** Old Settings tab ids, and the section each now lives in. */
export const LEGACY_SETTINGS_TAB: Record<string, string> = {
general: "facility",
groups: "issuing",
suppliers: "catalogue",
locations: "places",
departments: "places",
account: "people",
"sign-in": "people",
sso: "people",
security: "people",
activity: "audit",
};
const SETTINGS_PAGE: Record<string, string> = {
facility: "reports/journal-export",
issuing: "people/groups-and-routes",
catalogue: "stock/suppliers",
places: "reports/cost-centres",
people: "account/users",
data: "account/export-and-backup",
audit: "account/export-and-backup",
plan: "account/plan-and-billing",
};
/** The manual page about the screen at `path` (and its `?tab=`). First match wins; falls back to
* the manual's first page. */
export function helpFor(path: string, tab?: string | null): string {
const p = path.replace(/\/+$/, "") || "/app";
const t = tab || "";
if (p === "/app/settings") {
const section = LEGACY_SETTINGS_TAB[t] || t || "facility";
return SETTINGS_PAGE[section] || SETTINGS_PAGE.facility;
}
const rules: [RegExp, string, string?][] = [
[/^\/app$/, "counter/pickup-call-list"],
[/^\/app\/rounds(\/|$)/, "counter/delivery-rounds"],
[/^\/app\/(counter|issue)(\/|$)/, "counter/issue-a-garment"],
[/^\/app\/stocktake(\/|$)/, "stock/stocktakes"],
[/^\/app\/stock$/, "stock/stocktakes", "count"],
[/^\/app\/stock$/, "stock/catalogue-sizes-and-cuts", "locations"],
[/^\/app\/stock\/[^/]+/, "stock/reorder-levels"],
[/^\/app\/stock$/, "stock/catalogue-sizes-and-cuts"],
[/^\/app\/orders\/all$/, "stock/order-list"],
[/^\/app\/orders\/[^/]+/, "stock/receiving-and-back-orders"],
[/^\/app\/orders$/, "stock/order-list"],
[/^\/app\/staff\/[^/]+/, "counter/requests-from-staff", "requests"],
[/^\/app\/staff\/[^/]+/, "people/managers", "details"],
[/^\/app\/staff\/[^/]+/, "people/entitlement-rule"],
[/^\/app\/staff$/, "people/staff-register"],
[/^\/app\/requests(\/|$)/, "counter/requests-from-staff"],
[/^\/app\/report$/, "reports/the-nine-reports"],
[/^\/app\/checkout(\/|$)/, "account/plan-and-billing"],
[/^\/app\/activity(\/|$)/, "account/export-and-backup"],
];
return rules.find(([re, , onTab]) => re.test(p) && (onTab === undefined || onTab === t))?.[1] ?? "start/threadcount-in-one-page";
}
+236
View File
@@ -0,0 +1,236 @@
import fs from "node:fs";
import path from "node:path";
import { SECTIONS, sectionTitle, type SectionId } from "@/lib/manual-links";
/* The manual: Markdown files under docs/manual/<section>/<slug>.md, read once per server process.
*
* One file serves the website (/docs/...), the app (/app/help/...) and the Community edition,
* which ships the folder. The Markdown is a deliberate subset (docs/manual/AUTHORING.md), parsed
* here into blocks rather than turned into HTML, so the renderer controls every element and no
* page can inject markup. No dependency: the subset is small enough that a parser is shorter than
* the configuration a general one would need. */
export type Inline = string | { t: "code"; v: string } | { t: "b"; c: Inline[] } | { t: "a"; href: string; c: Inline[] };
export type Block =
| { t: "h2"; id: string; n: string; text: string }
| { t: "p"; c: Inline[] }
| { t: "ul"; items: Inline[][] }
| { t: "ol"; items: Inline[][] }
| { t: "table"; head: Inline[][]; rows: Inline[][][] }
| { t: "code"; v: string }
| { t: "callout"; tone: "plain" | "careful"; label: string; c: Inline[] };
export type Heading = { id: string; n: string; text: string };
export type ManualPage = {
section: SectionId;
slug: string;
title: string;
order: number;
summary: string;
screen?: string;
role?: string;
keywords: string[];
blocks: Block[];
headings: Heading[];
words: number;
};
/** The date the manual as a whole was last checked against the code. */
export const MANUAL_REVIEWED = "15 Sep 2026";
const ROOT = path.join(process.cwd(), "docs", "manual");
export const slugify = (s: string) =>
s.toLowerCase().replace(/['`]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
const INLINE = /`([^`]+)`|\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
export function inline(s: string): Inline[] {
const out: Inline[] = [];
let last = 0;
for (const m of s.matchAll(INLINE)) {
const at = m.index ?? 0;
if (at > last) out.push(s.slice(last, at));
if (m[1] !== undefined) out.push({ t: "code", v: m[1] });
else if (m[2] !== undefined) out.push({ t: "b", c: inline(m[2]) });
else out.push({ t: "a", href: m[4], c: inline(m[3]) });
last = at + m[0].length;
}
if (last < s.length) out.push(s.slice(last));
return out;
}
export const plain = (c: Inline[]): string =>
c.map((x) => (typeof x === "string" ? x : x.t === "code" ? x.v : plain(x.c))).join("");
/** Split a table row on pipes that are not inside backticks. */
function cells(row: string): string[] {
const r = row.trim().replace(/^\|/, "").replace(/\|$/, "");
const out: string[] = [];
let cur = "", code = false;
for (const ch of r) {
if (ch === "`") code = !code;
if (ch === "|" && !code) { out.push(cur.trim()); cur = ""; } else cur += ch;
}
out.push(cur.trim());
return out;
}
const BULLET = /^\s*[-*] /;
const NUMBER = /^\s*\d+[.)] /;
const starts = (l: string) => l.startsWith("```") || l.startsWith("#") || l.startsWith(">") || l.trim().startsWith("|") || BULLET.test(l) || NUMBER.test(l);
export function parseBody(src: string): Block[] {
const lines = src.replace(/\r/g, "").split("\n");
const blocks: Block[] = [];
const used = new Set<string>();
let n = 0;
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (line.startsWith("```")) {
const buf: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) buf.push(lines[i++]);
i++;
blocks.push({ t: "code", v: buf.join("\n") });
continue;
}
if (/^#{1,6} /.test(line)) {
const text = line.replace(/^#{1,6} /, "").trim();
if (line.startsWith("## ")) {
n++;
let id = slugify(text);
while (used.has(id)) id += "-2";
used.add(id);
blocks.push({ t: "h2", id, n: String(n).padStart(2, "0"), text });
} else {
// The subset has one heading level. Anything else is kept as a bold line rather than dropped.
blocks.push({ t: "p", c: [{ t: "b", c: inline(text) }] });
}
i++;
continue;
}
if (line.startsWith(">")) {
const buf: string[] = [];
while (i < lines.length && lines[i].startsWith(">")) buf.push(lines[i++].replace(/^>\s?/, ""));
const text = buf.join(" ").trim();
const m = /^\*\*(.+?)\*\*[:.]?\s*(.*)$/.exec(text);
const label = m ? m[1] : "Note";
blocks.push({ t: "callout", tone: /careful|warning|caution|important|before you/i.test(label) ? "careful" : "plain", label, c: inline(m ? m[2] : text) });
continue;
}
if (line.trim().startsWith("|")) {
const rows: string[][] = [];
while (i < lines.length && lines[i].trim().startsWith("|")) {
const r = lines[i++].trim();
if (/^\|[\s:|-]+\|?$/.test(r)) continue;
rows.push(cells(r));
}
const [head = [], ...body] = rows;
blocks.push({ t: "table", head: head.map(inline), rows: body.map((r) => r.map(inline)) });
continue;
}
if (BULLET.test(line) || NUMBER.test(line)) {
const ordered = NUMBER.test(line);
const marker = ordered ? NUMBER : BULLET;
const items: string[] = [];
while (i < lines.length) {
const l = lines[i];
if (marker.test(l)) items.push(l.replace(marker, ""));
else if (items.length && /^\s{2,}\S/.test(l)) items[items.length - 1] += " " + l.trim();
else break;
i++;
}
blocks.push({ t: ordered ? "ol" : "ul", items: items.map(inline) });
continue;
}
const buf: string[] = [];
while (i < lines.length && lines[i].trim() && !starts(lines[i])) buf.push(lines[i++].trim());
if (buf.length) blocks.push({ t: "p", c: inline(buf.join(" ")) });
else i++;
}
return blocks;
}
function frontMatter(src: string): [Record<string, string>, string] {
const m = /^---\n([\s\S]*?)\n---\n?/.exec(src.replace(/\r/g, ""));
if (!m) return [{}, src];
const meta: Record<string, string> = {};
for (const l of m[1].split("\n")) {
const k = /^([a-z]+):\s*(.*)$/.exec(l.trim());
if (k) meta[k[1]] = k[2].replace(/^["']|["']$/g, "").trim();
}
return [meta, src.replace(/\r/g, "").slice(m[0].length)];
}
const blockText = (b: Block): string =>
b.t === "h2" ? b.text : b.t === "code" ? b.v : b.t === "p" || b.t === "callout" ? plain(b.c) : b.t === "table" ? [...b.head, ...b.rows.flat()].map(plain).join(" ") : b.items.map(plain).join(" ");
export function parsePage(section: SectionId, slug: string, src: string): ManualPage {
const [meta, body] = frontMatter(src);
const blocks = parseBody(body);
const words = blocks.map(blockText).join(" ").split(/\s+/).filter(Boolean).length;
return {
section,
slug,
title: meta.title || slug,
order: Number(meta.order) || 99,
summary: meta.summary || "",
screen: meta.screen || undefined,
role: meta.role || undefined,
keywords: (meta.keywords || "").split(",").map((k) => k.trim()).filter(Boolean),
blocks,
headings: blocks.filter((b): b is Extract<Block, { t: "h2" }> => b.t === "h2").map(({ id, n, text }) => ({ id, n, text })),
words,
};
}
let cache: ManualPage[] | null = null;
/** Every page, in reading order: section by section, then by `order`. */
export function manual(): ManualPage[] {
if (cache && process.env.NODE_ENV === "production") return cache;
const pages: ManualPage[] = [];
for (const s of SECTIONS) {
const dir = path.join(ROOT, s.id);
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) {
pages.push(parsePage(s.id, f.slice(0, -3), fs.readFileSync(path.join(dir, f), "utf8")));
}
}
const at = (id: string) => SECTIONS.findIndex((s) => s.id === id);
pages.sort((a, b) => at(a.section) - at(b.section) || a.order - b.order || a.title.localeCompare(b.title));
cache = pages;
return pages;
}
export const findPage = (section: string, slug: string) => manual().find((p) => p.section === section && p.slug === slug);
export function neighbours(p: ManualPage): { prev?: ManualPage; next?: ManualPage } {
const all = manual();
const i = all.findIndex((x) => x.section === p.section && x.slug === p.slug);
return { prev: all[i - 1], next: all[i + 1] };
}
export type TreeSection = { id: SectionId; title: string; blurb: string; pages: { slug: string; title: string; summary: string }[] };
export function tree(): TreeSection[] {
const all = manual();
return SECTIONS.map((s) => ({ id: s.id, title: s.title, blurb: s.blurb, pages: all.filter((p) => p.section === s.id).map(({ slug, title, summary }) => ({ slug, title, summary })) }))
.filter((s) => s.pages.length > 0);
}
/** What the search box knows about each page: small enough to ship inline with every manual page. */
export type SearchEntry = { h: string; t: string; s: string; sec: string; k: string; hd: { id: string; text: string }[] };
export function searchIndex(): SearchEntry[] {
return manual().map((p) => ({
h: `${p.section}/${p.slug}`,
t: p.title,
s: p.summary,
sec: sectionTitle(p.section),
k: [...p.keywords, p.screen ?? ""].join(" ").toLowerCase(),
hd: p.headings.map(({ id, text }) => ({ id, text })),
}));
}
export const readMinutes = (words: number) => Math.max(1, Math.ceil(words / 220));
+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 */ }
}
+3483
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})`); }
+79
View File
@@ -0,0 +1,79 @@
/* One purchase order, loaded the way the sheet, the email and the CSV all need it: the supplier's
* own product codes against each line, a unit cost, the facility's delivery details, and the
* person it was ordered for when it was. Server only reads the tables directly. */
import { prisma } from "./db";
import { garmentGroups, groupsLabel, label, money, fmtDate } from "./compute";
export type OrderDocLine = { code: string; description: string; size: string; qty: number; unit: number; total: number };
export type OrderDoc = {
order: { id: string; code: string; date: string; status: string; ref: string; expected: string; notes: string; cc: string; emailedAt: Date | null; printedAt: Date | null };
facility: { name: string; location: string; coordinator: string; coordinatorEmail: string; coordinatorPhone: string; slipOrg: string };
supplier: { name: string; contact: string; phone: string; account: string; email: string; lead: number | null };
staff: { name: string; dept: string; cc: string } | null;
lines: OrderDocLine[];
total: number;
};
export async function loadOrderDoc(facilityId: string, orderId: string): Promise<OrderDoc> {
const o = await prisma.order.findFirstOrThrow({
where: { id: orderId, facilityId },
include: { lines: { orderBy: { sort: "asc" }, include: { item: true } }, staff: { select: { first: true, last: true, dept: true, ccOverride: true } } },
});
const [fac, sup, levels] = await Promise.all([
prisma.facility.findUniqueOrThrow({ where: { id: facilityId }, select: { name: true, location: true, coordinator: true, coordinatorEmail: true, coordinatorPhone: true, slipOrg: true } }),
prisma.supplier.findFirst({ where: { facilityId, name: o.supplier } }),
prisma.stockLevel.findMany({ where: { itemId: { in: o.lines.map((l) => l.itemId) } }, select: { itemId: true, sizeIndex: true, supplierCode: true } }),
]);
const codeOf = (itemId: string, si: number) => levels.find((l) => l.itemId === itemId && l.sizeIndex === si)?.supplierCode || "";
const lines: OrderDocLine[] = o.lines.map((l) => {
const it = l.item;
const si = it.sizes.map(String).indexOf(l.size);
const item = { id: it.id, sort: it.sort, item: it.item, gender: it.gender, type: it.type, sku: it.sku, supplier: it.supplier, cost: it.cost, groups: garmentGroups(it.groups), group: groupsLabel(it.groups), notes: it.notes, sizes: it.sizes, archived: it.archived };
const unit = it.cost || 0;
return { code: codeOf(it.id, si) || it.sku || "", description: label(item), size: l.size, qty: l.qty, unit, total: unit * l.qty };
});
return {
order: { id: o.id, code: o.code, date: o.date, status: o.status, ref: o.ref, expected: o.expected, notes: o.notes, cc: o.cc, emailedAt: o.emailedAt, printedAt: o.printedAt },
facility: fac,
supplier: { name: o.supplier, contact: sup?.contact || "", phone: sup?.phone || "", account: sup?.account || "", email: sup?.email || "", lead: sup?.lead ?? null },
staff: o.staff ? { name: `${o.staff.first} ${o.staff.last}`.trim(), dept: o.staff.dept, cc: o.cc || o.staff.ccOverride || "" } : null,
lines,
total: lines.reduce((t, l) => t + l.total, 0),
};
}
/** The email a supplier receives: the same figures as the printed sheet, in the site's mail layout. */
export function supplierOrderEmail(d: OrderDoc): { subject: string; text: string; html: string } {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { layout } = require("./mail-html.cjs") as { layout: (o: Record<string, unknown>) => string };
const refBit = d.order.ref ? ` (${d.order.ref})` : "";
const subject = `Purchase order ${d.order.code}${refBit}${d.facility.name}`;
const rows: [string, string][] = [
["Order no.", d.order.code + refBit],
["Date", fmtDate(d.order.date)],
...(d.supplier.account ? [["Account", d.supplier.account] as [string, string]] : []),
["Deliver to", `${d.facility.name} · ${d.facility.location}`],
...(d.staff ? [["Ordered for", `${d.staff.name}${d.staff.dept ? ` · ${d.staff.dept}` : ""}${d.staff.cc ? ` · cost centre ${d.staff.cc}` : ""}`] as [string, string]] : []),
...(d.order.expected ? [["Expected", fmtDate(d.order.expected)] as [string, string]] : []),
];
const textLines = d.lines.map((l) => ` ${l.code || "(no code)"} ${l.description} · ${l.size} · qty ${l.qty}`);
const text = [
`Hello ${d.supplier.contact || d.supplier.name},`,
`Please supply the following to ${d.facility.name}, ${d.facility.location}.`,
rows.map(([k, v]) => `${k}: ${v}`).join("\n"),
textLines.join("\n"),
`Please quote order ${d.order.code} on the invoice.`,
d.facility.coordinator ? `${d.facility.coordinator}${d.facility.coordinatorPhone ? ` · ${d.facility.coordinatorPhone}` : ""}\n${d.facility.name}` : d.facility.name,
].join("\n\n");
const html = layout({
eyebrow: "Purchase order",
title: `Order ${d.order.code}${refBit}`,
preheader: `${d.facility.name}${d.lines.length} line${d.lines.length === 1 ? "" : "s"}`,
intro: [`Hello ${d.supplier.contact || d.supplier.name},`, `Please supply the following to ${d.facility.name}, ${d.facility.location}.`],
rows,
list: d.lines.map((l) => `${l.code || "no code"} · ${l.description} · ${l.size} · qty ${l.qty}`),
closing: [`Please quote order ${d.order.code} on the invoice.${d.total ? ` Estimated value ${money(d.total)} ex tax at our last known unit costs.` : ""}`],
footer: { facility: d.facility.name, contact: d.facility.coordinatorEmail || "", links: [] },
});
return { subject, text, html };
}
+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 = 30;
/** 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.`;
}
+222
View File
@@ -0,0 +1,222 @@
/* The single source of queue membership for the coordinator portal (/app).
*
* Today's queue, the rail badges, the command panel and the Reports strip all read from here, so a
* number on the rail and the head count on the screen it opens are the same number. Pure functions
* over the snapshot, plus a small context the shell fills with the four counts only the server can
* make (requests live outside the snapshot). */
import { createContext, useContext, useMemo } from "react";
import {
addDays, ccOf, countsAsIssued, daysBetween, forecastFor, isOverdue, isPlacedOpen, locSubtree, onhand, reorderAt, splitKey, touched, variantList,
type Forecast, type Item, type Ledger, type LocationRec, type OrderRec, type PickupRec, type Snapshot, type StaffRec, type StocktakeRec, type Variant,
} from "./compute";
import { useDerived, useSnap } from "./client";
export const PICKUP_LATE_DAYS = 14;
export const RECEIVE_SOON_DAYS = 2;
export const COUNT_DUE_DAYS = 30;
export type ServerCounts = { pick: number; stranded: number; queries: number; damage: number };
export const EMPTY_SERVER_COUNTS: ServerCounts = { pick: 0, stranded: 0, queries: 0, damage: 0 };
/* Wards with at least one active ward-desk person, cached per snapshot object. */
const deskCache = new WeakMap<Snapshot, Set<string>>();
function deskWards(s: Snapshot): Set<string> {
let w = deskCache.get(s);
if (!w) {
w = new Set(s.staff.filter((x) => !x.inactive && x.wardDesk && x.dept).map((x) => x.dept));
deskCache.set(s, w);
}
return w;
}
/** C14: a bag goes on the round when nobody has called about it and its person's ward has a desk. */
export function isRoundPickup(s: Snapshot, p: PickupRec, staffById: Record<string, StaffRec>): boolean {
if (p.contacted) return false;
const st = staffById[p.staffId];
return !!st && !!st.dept && deskWards(s).has(st.dept);
}
export function waitingPickups(s: Snapshot, staffById: Record<string, StaffRec>, opts?: { includeRound?: boolean }): { p: PickupRec; days: number; late: boolean }[] {
return s.pickups
.filter((p) => !p.pickedUp && (opts?.includeRound || !isRoundPickup(s, p, staffById)))
.map((p) => {
const days = p.received ? daysBetween(p.received, s.today) : 0;
return { p, days, late: days >= PICKUP_LATE_DAYS };
})
.sort((a, b) => b.days - a.days);
}
export function roundWards(s: Snapshot, staffById: Record<string, StaffRec>): { ward: string; pickups: PickupRec[]; garments: number; people: StaffRec[]; desk: StaffRec[] }[] {
const by = new Map<string, PickupRec[]>();
for (const p of s.pickups) {
if (p.pickedUp || !isRoundPickup(s, p, staffById)) continue;
const ward = staffById[p.staffId].dept;
const list = by.get(ward) || [];
list.push(p);
by.set(ward, list);
}
return [...by.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ward, pickups]) => {
const people: StaffRec[] = [];
for (const p of pickups) { const st = staffById[p.staffId]; if (st && !people.includes(st)) people.push(st); }
return {
ward, pickups, people,
garments: pickups.reduce((t, p) => t + p.lines.reduce((u, l) => u + l.qty, 0), 0),
desk: s.staff.filter((x) => !x.inactive && x.wardDesk && x.dept === ward),
};
});
}
export function receiveOrders(s: Snapshot): { o: OrderRec; overdueDays: number; dueInDays: number | null }[] {
const soon = addDays(s.today, RECEIVE_SOON_DAYS);
return s.orders
.filter((o) => isPlacedOpen(o) && !!o.expected && o.expected <= soon)
.map((o) => {
const overdue = o.expected < s.today;
return { o, overdueDays: overdue ? daysBetween(o.expected, s.today) : 0, dueInDays: overdue ? null : daysBetween(s.today, o.expected) };
})
.sort((a, b) => {
const ao = a.dueInDays === null, bo = b.dueInDays === null;
if (ao !== bo) return ao ? -1 : 1;
if (ao) return b.overdueDays - a.overdueDays;
return (a.dueInDays ?? 0) - (b.dueInDays ?? 0);
});
}
/** Locations due a shelf count (A2). A whole-room shelf count (no location) counts every location. */
export function countsDue(s: Snapshot): { loc: LocationRec; lastCounted: string | null; days: number | null; sizes: number; itemIds: string[] }[] {
const byId: Record<string, LocationRec> = Object.fromEntries(s.locations.map((l) => [l.id, l]));
const liveItems = new Set(s.catalog.filter((i) => !i.archived).map((i) => i.id));
const shelf = s.stocktakes.filter((t) => t.mode !== "preloved");
const ancestors = (id: string): Set<string> => {
const out = new Set<string>();
for (let cur: LocationRec | undefined = byId[id]; cur && !out.has(cur.id); cur = cur.parentId ? byId[cur.parentId] : undefined) out.add(cur.id);
return out;
};
const rows = s.locations.filter((l) => !l.archived).map((loc) => {
const sub = locSubtree(s, loc.id);
const itemIds = new Set<string>();
let sizes = 0;
for (const [k, locId] of Object.entries(s.placed)) {
if (!sub.has(locId)) continue;
const { itemId } = splitKey(k);
if (!liveItems.has(itemId)) continue;
sizes++;
itemIds.add(itemId);
}
const up = ancestors(loc.id);
let lastCounted: string | null = null;
for (const t of shelf) if ((t.locationId === null || up.has(t.locationId)) && (!lastCounted || t.date > lastCounted)) lastCounted = t.date;
const days = lastCounted ? daysBetween(lastCounted, s.today) : null;
return { loc, lastCounted, days, sizes, itemIds: [...itemIds], due: sizes > 0 && (lastCounted === null || (days ?? 0) >= COUNT_DUE_DAYS) };
});
const dueIds = new Set(rows.filter((r) => r.due).map((r) => r.loc.id));
return rows
.filter((r) => r.due && ![...ancestors(r.loc.id)].some((a) => a !== r.loc.id && dueIds.has(a)))
.sort((a, b) => {
if (a.lastCounted === null && b.lastCounted !== null) return -1;
if (b.lastCounted === null && a.lastCounted !== null) return 1;
return (b.days ?? 0) - (a.days ?? 0);
})
.map(({ loc, lastCounted, days, sizes, itemIds }) => ({ loc, lastCounted, days, sizes, itemIds }));
}
/** The only "at reorder" rule: live, touched, on hand at or below its reorder level. */
export function atReorderVariants(s: Snapshot, L: Ledger): Variant[] {
return variantList(s).filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key));
}
export function runsOut(s: Snapshot, L: Ledger, byId: Record<string, Item>): { v: Variant; oh: number; ro: number; f: Forecast; coverDays: number | null }[] {
const out: { v: Variant; oh: number; ro: number; f: Forecast; coverDays: number | null }[] = [];
for (const v of atReorderVariants(s, L)) {
const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key);
const f = forecastFor(s, L, byId, v.key);
if (!(oh <= 0 || f.runsOutBeforeDelivery)) continue;
out.push({ v, oh, ro, f, coverDays: f.weeksOfCover === null ? null : Math.round(f.weeksOfCover * 7) });
}
return out.sort((a, b) => {
const ao = a.oh <= 0 ? 0 : 1, bo = b.oh <= 0 ? 0 : 1;
if (ao !== bo) return ao - bo;
return (a.coverDays ?? Infinity) - (b.coverDays ?? Infinity);
});
}
function lastDayOfMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10);
}
export function monthEnd(s: Snapshot, L: Ledger, byId: Record<string, Item>, staffById: Record<string, StaffRec>, month: string): {
deliveriesOverdue: number;
stocktakeFiled: StocktakeRec | null;
unsignedReceipts: number;
unallocated: number;
journalReady: boolean;
daysLeft: number;
} {
const current = s.today.slice(0, 7) === month;
const inMonth = (d: string) => !!d && d.slice(0, 7) === month;
const deliveriesOverdue = current
? s.orders.filter((o) => isOverdue(o, s.today)).length
: s.orders.filter((o) => isPlacedOpen(o) && inMonth(o.date)).length;
let stocktakeFiled: StocktakeRec | null = null;
for (const t of s.stocktakes) if (t.mode !== "preloved" && inMonth(t.date) && (!stocktakeFiled || t.date > stocktakeFiled.date)) stocktakeFiled = t;
const unsignedReceipts = s.issues.filter((i) => !i.receipt && !i.returned && !i.handedIn).length;
const unallocated = s.issues.filter((i) => inMonth(i.date) && countsAsIssued(i) && !i.preloved && ccOf(s, staffById[i.staffId]) === "").length;
const end = lastDayOfMonth(month);
void L; void byId;
return {
deliveriesOverdue, stocktakeFiled, unsignedReceipts, unallocated,
journalReady: !!stocktakeFiled && unallocated === 0,
daysLeft: end < s.today ? 0 : daysBetween(s.today, end),
};
}
export type PortalCounts = {
today: { total: number; overdue: number; groups: { collect: number; round: number; pick: number; receive: number; counts: number } };
stock: { garmentsAtReorder: number; out: number; onOrder: number; noBarcode: number };
orders: { overdue: number; toOrderLines: number };
people: { attention: number; stranded: number };
};
export function portalCounts(s: Snapshot, d: { L: Ledger; byId: Record<string, Item>; staffById: Record<string, StaffRec> }, server: ServerCounts, isAdmin: boolean): PortalCounts {
const { L, staffById } = d;
const collect = waitingPickups(s, staffById).length;
const round = roundWards(s, staffById).reduce((t, w) => t + w.pickups.length, 0);
const receive = receiveOrders(s).length;
const counts = countsDue(s).length;
const groups = { collect, round, pick: server.pick, receive, counts };
const overdueOrders = s.orders.filter((o) => isOverdue(o, s.today)).length;
const latePickups = waitingPickups(s, staffById, { includeRound: true }).filter((w) => w.late).length;
const reorder = atReorderVariants(s, L);
const bound = new Set(Object.values(s.barcodes));
let out = 0, noBarcode = 0;
for (const v of variantList(s)) {
if (!touched(s, L, v.key)) continue;
if (onhand(s, L, v.key) <= 0) out++;
if (!bound.has(v.key)) noBarcode++;
}
let onOrder = 0;
for (const o of s.orders) {
if (!isPlacedOpen(o)) continue;
const got = o.receipts.reduce((t, r) => t + r.lines.reduce((u, l) => u + l.qty, 0), 0);
onOrder += Math.max(0, o.lines.reduce((t, l) => t + l.qty, 0) - got);
}
return {
today: { total: collect + round + server.pick + receive + counts, overdue: latePickups + overdueOrders, groups },
stock: { garmentsAtReorder: new Set(reorder.map((v) => v.itemId)).size, out, onOrder, noBarcode },
orders: { overdue: overdueOrders, toOrderLines: isAdmin ? reorder.length : 0 },
people: { attention: server.stranded + server.queries + server.damage, stranded: server.stranded },
};
}
export const PortalCountsContext = createContext<ServerCounts>(EMPTY_SERVER_COUNTS);
export function useServerCounts(): ServerCounts { return useContext(PortalCountsContext); }
export function usePortalCounts(): PortalCounts {
const { s, isAdmin } = useSnap();
const d = useDerived();
const server = useServerCounts();
return useMemo(() => portalCounts(s, d, server, isAdmin), [s, d, server, isAdmin]);
}
+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";
}
+65
View File
@@ -0,0 +1,65 @@
import { createHash, randomBytes, timingSafeEqual } from "crypto";
import { layout, siteUrl } from "@/lib/mail-html.cjs";
/* 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");
const { html } = layout({
eyebrow: "Your account",
title: "Reset your password",
preheader: "A password reset was asked for on your ThreadCount account. The link works once and expires in an hour.",
intro: [`Hi ${firstName || "there"},`, "Someone asked to reset the password on your ThreadCount account. If that was you, use the button below."],
cta: { label: "Reset my password", href: url },
closing: ["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."],
footer: { links: [["Support", `${siteUrl()}/support`]] },
});
return { subject, text, html };
}
+79
View File
@@ -0,0 +1,79 @@
/* Search and scan for the portal's command panel. Pure and client-safe. */
import { bcParse, daysBetween, heldByStaff, key, label, longLabel, onhand, setsCap, type GarmentCounts, type Item, type Ledger, type OrderRec, type PickupRec, type Snapshot, type StaffRec } from "./compute";
/** The fields of a /api/requests row the search reads. RequestRow (components/requests/RequestList) is assignable to it. */
export type SearchRequest = { id: string; code: string; status: string; staffName: string; managerId: string | null; managerName: string; decidedAt: string | null; createdAt: string };
export type SearchHit =
| { kind: "person"; staff: StaffRec; held: GarmentCounts; cap: number; waitingBags: number; oldestWaitDays: number | null }
| { kind: "waiting"; pickup: PickupRec; staff: StaffRec | undefined; days: number }
| { kind: "approves"; request: SearchRequest }
| { kind: "garment"; item: Item; onhand: number }
| { kind: "order"; order: OrderRec };
export type SearchGroups = { person: SearchHit[]; waiting: SearchHit[]; approves: SearchHit[]; garment: SearchHit[]; order: SearchHit[] };
const LIMIT = 5;
const norm = (t: string) => String(t || "").toLowerCase().replace(/\s+/g, " ").trim();
export function searchPortal(s: Snapshot, d: { L: Ledger; byId: Record<string, Item> }, q: string, requests?: readonly SearchRequest[]): SearchGroups {
const n = norm(q);
if (!n) return { person: [], waiting: [], approves: [], garment: [], order: [] };
const terms = n.split(" ");
const hit = (hay: string) => { const h = norm(hay); return terms.every((t) => h.includes(t)); };
const days = (p: PickupRec) => (p.received ? daysBetween(p.received, s.today) : 0);
const people = s.staff
.filter((st) => hit(`${st.first} ${st.last} ${st.num}`))
.sort((a, b) =>
Number(a.inactive) - Number(b.inactive)
|| Number(norm(b.num) === n) - Number(norm(a.num) === n)
|| `${a.last} ${a.first}`.localeCompare(`${b.last} ${b.first}`))
.slice(0, LIMIT);
const cap = setsCap(s.settings.capSets);
const held = people.length ? heldByStaff(s) : {};
const person: SearchHit[] = people.map((st) => {
const w = s.pickups.filter((p) => !p.pickedUp && p.staffId === st.id);
return { kind: "person", staff: st, held: held[st.id] || { tops: 0, pants: 0, other: 0, sets: 0 }, cap, waitingBags: w.length, oldestWaitDays: w.length ? Math.max(...w.map(days)) : null };
});
const ids = new Set(people.map((p) => p.id));
const staffById: Record<string, StaffRec> = Object.fromEntries(s.staff.map((st) => [st.id, st]));
const waiting: SearchHit[] = s.pickups
.filter((p) => !p.pickedUp && (ids.has(p.staffId) || (!!p.orderCode && norm(p.orderCode).includes(n))))
.map((p) => ({ kind: "waiting" as const, pickup: p, staff: staffById[p.staffId], days: days(p) }))
.sort((a, b) => b.days - a.days)
.slice(0, LIMIT);
const approves: SearchHit[] = (requests || [])
.filter((r) => r.status === "accepted" && !!r.managerId && ids.has(r.managerId))
.slice(0, LIMIT)
.map((r) => ({ kind: "approves" as const, request: r }));
const garment: SearchHit[] = s.catalog
.filter((it) => !it.archived && hit(`${longLabel(it)} ${label(it)} ${it.sku}`))
.slice(0, LIMIT)
.map((it) => ({ kind: "garment" as const, item: it, onhand: it.sizes.reduce((t, _, si) => t + Math.max(0, onhand(s, d.L, key(it.id, si))), 0) }));
const order: SearchHit[] = s.orders
.filter((o) => hit(`${o.code} ${o.ref} ${o.invoice} ${o.supplier}`))
.sort((a, b) => (b.date || "").localeCompare(a.date || ""))
.slice(0, LIMIT)
.map((o) => ({ kind: "order" as const, order: o }));
return { person, waiting, approves, garment, order };
}
export type ScanTarget = { kind: "staff"; staffId: string } | { kind: "garment"; itemId: string; si: number } | { kind: "unknown"; code: string };
/** A staff badge carries the plain staff number (A1); anything else is a garment barcode, or unknown. */
export function resolveScan(s: Snapshot, raw: string): ScanTarget {
const code = String(raw || "").trim();
if (code) {
const c = code.toLowerCase();
const st = s.staff.find((x) => !x.inactive && !!x.num && x.num.trim().toLowerCase() === c);
if (st) return { kind: "staff", staffId: st.id };
const g = bcParse(s, code);
if (g) return { kind: "garment", itemId: g.itemId, si: g.si };
}
return { kind: "unknown", code };
}
+94
View File
@@ -0,0 +1,94 @@
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;
}
}
/** "Keep me signed in on this computer": the longer life a person asks for on the sign-in screen. */
export const REMEMBER_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
export async function setSessionCookie(uid: string, passwordHash: string, sso = false, maxAge = MAX_AGE) {
const jar = await cookies();
jar.set(COOKIE_NAME, signSession(uid, passwordHash, maxAge, sso), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge,
});
}
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.`,
};
}
+185
View File
@@ -0,0 +1,185 @@
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; billingLegalName: string; billingCountry: string; billingCadence: 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 shows the Billing section instead of "Subscribe by card".
cardsOn: stripeConfigured(), card: !!fac.stripeSubscriptionId,
// What the checkout recorded, for the Plan screen's rows; admins only, like the contact.
billingLegalName: admin ? fac.billingLegalName : "", billingCountry: admin ? fac.billingCountry : "", billingCadence: fac.billingCadence,
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, noticeRow] = 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 } }),
// The ward notice posted from Settings, so the screen can show what is up right now.
db.linenNotice.findFirst({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, select: { body: true, endsAt: 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; supplierCode: string }> = {};
for (const s of stock) st[key(s.itemId, s.sizeIndex)] = { opening: s.opening, adj: s.adj, reorder: s.reorder, preloved: s.preloved, supplierCode: s.supplierCode };
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,
checklistDismissed: fac.checklistDismissed,
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, email: x.email, 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,
notice: noticeRow && (noticeRow.endsAt === "" || noticeRow.endsAt >= facilityToday(fac.timezone)) ? { body: noticeRow.body, endsAt: noticeRow.endsAt } : 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),
// When the facility was created: the dashboard checklist stops nagging a room that has been
// running for two months.
createdAt: fac.createdAt.toISOString(),
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, supplierCode: "" };
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, html?: string) {
if (!to || !transactionalConfigured()) return;
void sendTo(to, subject, text, html).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, em.html);
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, em.html);
}
// `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 @@
/* Display rows for the Today queue (/app) and the delivery rounds sheet (/app/rounds).
*
* Membership comes only from lib/portalcounts.ts: this file shapes the rows those selectors return
* into the words and figures a row prints, and adds no rule of its own about what belongs where. */
import {
addDays, ccOf, facilityDate, formatInZone, label, leadDaysOf, locTrail, staffName, supplierInfo, telHref,
type Item, type Ledger, type LocationRec, type OrderRec, type PickupRec, type Snapshot, type StaffRec,
} from "./compute";
import { PICKUP_LATE_DAYS, countsDue, monthEnd, receiveOrders, roundWards, runsOut, waitingPickups } from "./portalcounts";
/** "15 Sep" */
export function dayMonth(iso: string, tz: string): string {
return iso ? formatInZone(iso, tz, { day: "numeric", month: "short" }) : "";
}
/** "Fri 11 Sep" */
export function weekdayDayMonth(iso: string, tz: string): string {
return iso ? formatInZone(iso, tz, { weekday: "short", day: "numeric", month: "short" }) : "";
}
/** "today", "yesterday" or "9 Sep" for an instant or a date, in the facility's zone. */
export function relativeDay(iso: string | null | undefined, s: Snapshot): string {
if (!iso) return "";
const d = facilityDate(iso, s.tz);
if (!d) return "";
if (d === s.today) return "today";
if (d === addDays(s.today, -1)) return "yesterday";
return dayMonth(d, s.tz);
}
/** The head's count line. */
export function todayHeadLine(total: number, overdue: number): string {
const base = total === 0 ? "Nothing needs a person" : total === 1 ? "1 thing needs a person" : `${total} things need a person`;
return overdue > 0 ? `${base} · ${overdue} overdue` : base;
}
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
export type PickupLine = { garment: string; size: string; qty: number };
export function pickupLines(p: PickupRec, byId: Record<string, Item>): PickupLine[] {
return p.lines.map((l) => ({ garment: label(byId[l.itemId]), size: l.size, qty: l.qty }));
}
/** The collection slip payload, exactly as the dashboard built it. */
export function collectionSlip(s: Snapshot, p: PickupRec, st: StaffRec | undefined): Record<string, string | number | boolean | undefined> {
const ord = s.orders.find((o) => o.id === p.orderId);
return { staffName: staffName(st), dept: st?.dept, sets: p.lines.reduce((t, l) => t + l.qty, 0), po: ord?.ref || ord?.code || "", dateReceived: p.received, notifiedPhone: p.contacted, dateNotified: "" };
}
export type CollectRow = { p: PickupRec; st: StaffRec | undefined; days: number; late: boolean; name: string; phone: string; tel: string; lines: PickupLine[] };
export function collectRows(s: Snapshot, byId: Record<string, Item>, staffById: Record<string, StaffRec>): CollectRow[] {
return waitingPickups(s, staffById).map(({ p, days, late }) => {
const st = staffById[p.staffId];
return { p, st, days, late, name: staffName(st, "Staff"), phone: st?.phone || "", tel: telHref(st?.phone), lines: pickupLines(p, byId) };
});
}
export type RoundRow = { ward: string; bags: number; garments: number; names: string[]; moreNames: number; desk: boolean; href: string };
export function roundRows(s: Snapshot, staffById: Record<string, StaffRec>): RoundRow[] {
return roundWards(s, staffById).map((w) => ({
ward: w.ward,
bags: w.pickups.length,
garments: w.garments,
names: w.people.slice(0, 3).map((p) => staffName(p)),
moreNames: Math.max(0, w.people.length - 3),
desk: w.desk.length > 0,
href: `/app/rounds?ward=${encodeURIComponent(w.ward)}`,
}));
}
export type ReceiveRow = { o: OrderRec; overdue: boolean; age: string; ageLabel: string; lines: number; expected: string; forName: string; chase: string };
export function receiveRows(s: Snapshot, staffById: Record<string, StaffRec>): ReceiveRow[] {
return receiveOrders(s).map(({ o, overdueDays, dueInDays }) => {
const overdue = dueInDays === null;
const sup = supplierInfo(s, o.supplier);
const chase = sup?.email ? `mailto:${sup.email}?subject=${encodeURIComponent(o.code)}` : telHref(sup?.phone);
return {
o, overdue,
age: overdue ? `${overdueDays}d` : dueInDays === 0 ? "today" : `${dueInDays}d`,
ageLabel: overdue ? "overdue" : "due",
lines: o.lines.length,
expected: weekdayDayMonth(o.expected, s.tz),
forName: o.staffId ? staffName(staffById[o.staffId]) : "",
chase,
};
});
}
export type CountRow = { loc: LocationRec; trail: string; age: string; ageLabel: string; garments: string[]; last: string; sizes: number; href: string };
export function countRows(s: Snapshot, byId: Record<string, Item>): CountRow[] {
const locById: Record<string, LocationRec> = Object.fromEntries(s.locations.map((l) => [l.id, l]));
return countsDue(s).map(({ loc, lastCounted, days, sizes, itemIds }) => ({
loc,
trail: locTrail(locById, loc.id, 0) || loc.name,
age: days === null ? "—" : `${days}d`,
ageLabel: days === null ? "never" : "since",
garments: itemIds.slice(0, 3).map((id) => label(byId[id])),
last: lastCounted ? `last counted ${dayMonth(lastCounted, s.tz)}` : "never counted",
sizes,
href: `/app/stock?tab=count&location=${encodeURIComponent(loc.id)}`,
}));
}
export type RunsOutRow = { key: string; itemId: string; name: string; size: string; oh: number; ro: number; leadDays: number; coverDays: number | null; out: boolean; short: boolean };
export function runsOutRows(s: Snapshot, L: Ledger, byId: Record<string, Item>): RunsOutRow[] {
return runsOut(s, L, byId).map(({ v, oh, ro, f, coverDays }) => {
const leadDays = leadDaysOf(s, v.item.supplier) || Math.round(f.leadWeeks * 7);
return {
key: v.key, itemId: v.itemId, name: label(v.item), size: v.size, oh, ro, leadDays, coverDays,
out: oh <= 0,
short: coverDays !== null && coverDays < leadDays,
};
});
}
export type MonthEndView = ReturnType<typeof monthEnd> & { month: string; monthName: string; stocktakeDate: string };
export function monthEndView(s: Snapshot, L: Ledger, byId: Record<string, Item>, staffById: Record<string, StaffRec>): MonthEndView {
const month = s.today.slice(0, 7);
const m = monthEnd(s, L, byId, staffById, month);
return { ...m, month, monthName: formatInZone(`${month}-01`, s.tz, { month: "long" }), stocktakeDate: m.stocktakeFiled ? dayMonth(m.stocktakeFiled.date, s.tz) : "" };
}
/** The delivery rounds sheet: every uncollected pickup (round or not), by ward, wards AZ. */
export type RoundSheetRow = { p: PickupRec; st: StaffRec | undefined; name: string; phone: string; tel: string; days: number; late: boolean; lines: PickupLine[] };
export type RoundSheetWard = { ward: string; cc: string; rows: RoundSheetRow[]; garments: number };
export function roundSheet(s: Snapshot, byId: Record<string, Item>, staffById: Record<string, StaffRec>): RoundSheetWard[] {
const wards = new Map<string, RoundSheetRow[]>();
for (const { p, days } of waitingPickups(s, staffById, { includeRound: true })) {
const st = staffById[p.staffId];
const w = st?.dept || "Unknown";
const list = wards.get(w) || [];
list.push({ p, st, name: staffName(st, "Staff"), phone: st?.phone || "", tel: telHref(st?.phone), days, late: days >= PICKUP_LATE_DAYS, lines: pickupLines(p, byId) });
wards.set(w, list);
}
return [...wards.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ward, rows]) => {
const st0 = rows.find((r) => r.st)?.st;
return { ward, rows, cc: st0 ? ccOf(s, st0) : "", garments: rows.reduce((t, r) => t + r.lines.reduce((u, l) => u + l.qty, 0), 0) };
});
}
+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 };
}
+66
View File
@@ -0,0 +1,66 @@
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;
}
}
/* "Trust this computer for 30 days" a second cookie beside the session, set only after a second
* factor was actually entered on this browser. It carries the user id and the password version,
* signed like the ticket, so a password change ends every trusted browser at once. Sign-in reads it
* before asking for a code; a browser without it, or with somebody else's, is asked as before. */
export const TRUST_COOKIE = "tc_trust";
export const TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const signTrust = (payload: string) => b64(createHmac("sha256", secret()).update(`trust.${payload}`).digest());
export function mintTrust(userId: string, pv: string): string {
const payload = b64(Buffer.from(JSON.stringify({ uid: userId, pv, exp: Date.now() + TRUST_TTL_MS })));
return `${payload}.${signTrust(payload)}`;
}
export function readTrust(token: string | undefined, userId: string, pv: string): boolean {
const [payload, mac] = String(token || "").split(".");
if (!payload || !mac) return false;
const a = Buffer.from(mac, "utf8"), b = Buffer.from(signTrust(payload), "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
try {
const t = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { uid?: string; pv?: string; exp?: number };
return t.uid === userId && t.pv === pv && !!t.exp && t.exp > Date.now();
} catch { return false; }
}
+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]);
}