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 d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 18:27:45 +10:00
commit 7d650e4c10
298 changed files with 45868 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");
+264
View File
@@ -0,0 +1,264 @@
/* The billing emails: one layout, eight templates, plain text and HTML for each.
*
* Written as CommonJS so the app (lib/billing-mail.ts), the reminder timer
* (scripts/plan-reminders.cjs) and the preview (scripts/preview-billing-mail.cjs) all render the
* same words from the same file. No dependencies: strings in, strings out.
*
* Voice: a person who runs the service writing to the person who runs the linen room. Short,
* plain, Australian English, first person plural, no exclamation marks. Every mail says what
* happened, what it means for the room, and the one thing to do next if there is one.
*
* The layout itself lives in lib/mail-html.cjs, shared with the coordinator and staff emails. */
const { layout, esc, money, longDate, siteUrl } = require("./mail-html.cjs");
/** The billing variant of the shared layout (lib/mail-html.cjs): "Your plan" above the headline,
* and a footer that links Settings Plan, Support and the SLA. The plain-text twin names only the
* Settings Plan link, as it always has. */
function billingLayout({ title, preheader, intro, rows, cta, footer, closing, list }) {
const base = siteUrl();
const planUrl = `${base}/app/settings?tab=plan`;
return layout({
eyebrow: "Your plan", title, preheader, intro, rows, list, cta, closing,
footer: {
facility: footer.facility, contact: footer.contact,
links: [["Settings Plan", planUrl], ["Support", `${base}/support`], ["Service Level Agreement", `${base}/sla`]],
textLinks: [["Settings Plan", planUrl]],
},
});
}
function sign() { return "— ThreadCount"; }
// ---------- the templates. Each returns { subject, text, html }. `f` = { facility, contact }.
function trialStarted(f, o) {
const base = siteUrl();
const title = "Your 30-day trial has started";
const { html, text } = billingLayout({
title, preheader: `${f.facility} is on a Hosted Facility trial until ${longDate(o.endsAt)}. Nothing is charged.`,
intro: [
`Hello${o.first ? ` ${o.first}` : ""},`,
`${f.facility} is set up on the Hosted Facility plan for the next 30 days: every feature, no ceiling on staff records, nightly backups kept for 35 days, and support with a response time. Nothing is charged, and no card was taken.`,
],
rows: [["Plan", "Hosted Facility · trial"], ["Trial ends", longDate(o.endsAt)], ["After that", "14 days still writable, then read-only until a plan is paid"]],
cta: { label: "Open your facility", href: `${base}/app` },
closing: [
"When you are ready, Settings Plan is where you subscribe by card, monthly or yearly, or ask for an invoice. Whatever you enter during the trial stays yours: the backup file and every export are always available.",
"If anything is unclear, reply to this email and a person answers.",
sign(),
],
footer: f,
});
return { subject: `Your ThreadCount trial has started — ${f.facility}`, text, html };
}
/** The five notes a room gets during its trial, by day: what to set up, the counter app, the
* reports, what a plan includes, and the last reminder. `o.day` is 1, 3, 7, 21 or 28. */
function trialTip(f, o) {
const base = siteUrl();
const ends = longDate(o.endsAt);
const T = {
1: {
title: "Get your register in", preheader: `Day one of ${f.facility}'s trial: staff, garments, reorder levels.`,
intro: [
"Hello,",
"The quickest way to see ThreadCount do something useful is to give it your people and your shelves. Three things, in this order:",
],
list: [
"Import the staff register from a CSV, or add people one at a time — Settings Data has the template.",
"Add the garments you stock, with sizes. Settings Data imports a catalogue CSV too.",
"Set a reorder level on each size from the Inventory screen, so the Order list can tell you what to buy.",
],
cta: { label: "Open Settings Data", href: `${base}/app/settings?tab=data` },
closing: [`The getting-started guide walks through the same three steps with screenshots: ${base}/getting-started`, `Your trial runs until ${ends}.`],
subject: `Day one: get your register into ThreadCount`,
},
3: {
title: "The counter app", preheader: "Issue, return and receive from a phone at the counter, with the camera as the scanner.",
intro: [
"Hello,",
"The linen room's counter runs on a phone. Sign in at the address below on any Android phone, or install ThreadCount Counter from Google Play, and the camera reads the barcodes on your garments.",
],
list: [
"Issue a garment to a person by scanning it, or by name.",
"Receive a delivery against the order it came from.",
"Print labels for garments that arrived without a barcode — the Inventory screen prints a sheet.",
],
cta: { label: "Open the counter", href: `${base}/m/login` },
closing: ["Counter accounts are the same accounts as the web app, so nobody needs a second password.", `Your trial runs until ${ends}.`],
subject: `Day three: the counter app`,
},
7: {
title: "Reports and the month-end pack", preheader: "Nine report tabs, a journal CSV for finance, and one button for the month-end pack.",
intro: [
"Hello,",
"A week in, there is enough recorded to look at the reports. Reports has nine tabs: Overview, Journal, Top stock, Valuation, Shrinkage, Exceptions, Suppliers, Approvals and Pre-loved. Every one exports to CSV.",
],
list: [
"Journal: every issue with its cost centre, the file finance asks for.",
"Shrinkage and Exceptions: what left the shelf without a record, and who is over the ceiling.",
"Month-end pack: one button prints the month's figures across every tab.",
],
cta: { label: "Open Reports", href: `${base}/app/report` },
closing: [`Your trial runs until ${ends}.`],
subject: `Day seven: reports and the month-end pack`,
},
21: {
title: "What a plan includes", preheader: `Hosting, backups, support with a response time. ${money(o.monthlyCents, "AUD")} a month or ${money(o.annualCents, "AUD")} a year.`,
intro: [
"Hello,",
`Nine days of trial left. What you are using is the Hosted Facility plan, and this is what it pays for after ${ends}:`,
],
rows: [
["Hosting", "threadcount.tech, in Australia, behind Cloudflare"],
["Backups", "continuous, encrypted, kept 35 days"],
["Support", "email and chat, first response by severity — 4 business hours for an outage"],
["Monthly", `${money(o.monthlyCents, "AUD")} ex tax`],
["Yearly", `${money(o.annualCents, "AUD")} ex tax — two months free`],
],
cta: { label: "Read the Service Level Agreement", href: `${base}/sla` },
closing: ["Subscribe by card from Settings Plan whenever suits, monthly or yearly, and the trial simply continues into the plan. Health services with more than one facility can ask for an invoice instead."],
subject: `What a ThreadCount plan includes — ${f.facility}`,
},
28: {
title: "Two days left", preheader: `${f.facility}'s trial ends ${ends}. Nothing is deleted either way.`,
intro: [
"Hello,",
`${f.facility}'s trial ends on ${ends}. Settings Plan takes a card, monthly or yearly, and the room carries on without a break.`,
],
rows: [["Trial ends", ends], ["Then", "14 days still writable, then read-only"], ["Read-only means", "reports, exports, printing and the backup all keep working; nothing is deleted"]],
cta: { label: "Open Settings Plan", href: `${base}/app/settings?tab=plan` },
closing: ["If the timing is wrong for your facility, reply to this email and say so."],
subject: `Two days left on your ThreadCount trial — ${f.facility}`,
},
}[o.day];
const { html, text } = billingLayout({ title: T.title, preheader: T.preheader, intro: T.intro, rows: T.rows, cta: T.cta, closing: [...(T.closing || []), sign()], footer: f, list: T.list });
return { subject: T.subject, text, html };
}
function trialEndingSoon(f, o) {
const base = siteUrl();
const days = o.daysLeft;
const title = days === 1 ? "Your trial ends tomorrow" : `${days} days left on your trial`;
const { html, text } = billingLayout({
title, preheader: `${f.facility}'s Hosted Facility trial ends ${longDate(o.endsAt)}.`,
intro: [
`${f.facility}'s trial of the Hosted Facility plan ends on ${longDate(o.endsAt)}.`,
"After that there are 14 days of grace when everything still works as it does today. Then the room becomes read-only: sign-in, reports, exports, printing and the backup keep working, and nothing is deleted. Writing starts again the moment a plan is paid.",
],
rows: [["Trial ends", longDate(o.endsAt)], ["Read-only from", longDate(o.graceEndsAt)], ["Monthly", `${money(o.monthlyCents, "AUD")} a month, ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} a year, two months free`]],
cta: { label: "Choose a plan", href: `${base}/app/checkout` },
closing: ["To pay by invoice instead, ask for one from Settings Plan and we will send it to your billing contact.", sign()],
footer: f,
});
return { subject: days === 1 ? `Your ThreadCount trial ends tomorrow — ${f.facility}` : `${days} days left on your ThreadCount trial — ${f.facility}`, text, html };
}
function trialEnded(f, o) {
const base = siteUrl();
const title = "Your trial has ended";
const { html, text } = billingLayout({
title, preheader: `${f.facility} stays writable until ${longDate(o.graceEndsAt)}.`,
intro: [
`${f.facility}'s trial ended on ${longDate(o.endsAt)}. Nothing has changed on your screens yet: there are 14 days of grace, so the room stays writable until ${longDate(o.graceEndsAt)}.`,
"After that it becomes read-only. Sign-in, every report, every export, printing and the full backup keep working, and nothing is deleted. Writing starts again the moment a plan is paid.",
],
rows: [["Writable until", longDate(o.graceEndsAt)], ["Monthly", `${money(o.monthlyCents, "AUD")} a month, ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} a year, two months free`]],
cta: { label: "Choose a plan", href: `${base}/app/checkout` },
closing: ["If ThreadCount is not for you, the backup file under Settings Data takes everything out in one go, and the room can simply be left.", sign()],
footer: f,
});
return { subject: `Your ThreadCount trial has ended — ${f.facility}`, text, html };
}
function paymentReceived(f, o) {
const base = siteUrl();
const title = "Thanks, payment received";
const rows = [
["Plan", `Hosted Facility · ${o.cadence === "year" ? "yearly" : "monthly"}`],
["Period", `${longDate(o.periodStart)} to ${longDate(o.periodEnd)}`],
["Amount", `${money(o.subtotalCents, o.currency)} ex tax`],
["Tax", money(o.taxCents, o.currency)],
["Total paid", money(o.totalCents, o.currency)],
];
if (o.card) rows.push(["Card", `${o.card.brand} ···· ${o.card.last4}`]);
if (o.invoiceNumber) rows.push(["Invoice", o.invoiceNumber]);
if (o.nextChargeAt) rows.push(["Next charge", `${longDate(o.nextChargeAt)} · ${money(o.nextChargeCents != null ? o.nextChargeCents : o.totalCents, o.currency)}`]);
const { html, text } = billingLayout({
title, preheader: `${money(o.totalCents, o.currency)} for ${f.facility}, ${longDate(o.periodStart)} to ${longDate(o.periodEnd)}.`,
intro: [`This is your receipt for ${f.facility}. The plan is paid up to ${longDate(o.periodEnd)}; it renews on its own and we email you each time.`],
rows,
cta: o.invoiceUrl ? { label: "Download the tax invoice (PDF)", href: o.invoiceUrl } : { label: "See your plan", href: `${base}/app/settings?tab=plan` },
closing: ["To change the card or stop the renewal, use Settings Plan. Nothing there needs a phone call.", sign()],
footer: f,
});
return { subject: `Your ThreadCount receipt — ${money(o.totalCents, o.currency)}`, text, html };
}
function paymentFailed(f, o) {
const base = siteUrl();
const title = "A payment did not go through";
const { html, text } = billingLayout({
title, preheader: `${money(o.totalCents, o.currency)} for ${f.facility} could not be charged. Nothing changes yet.`,
intro: [
`The ${o.cadence === "year" ? "yearly" : "monthly"} payment of ${money(o.totalCents, o.currency)} for ${f.facility} could not be taken from the card on file${o.card ? ` (${o.card.brand} ···· ${o.card.last4})` : ""}${o.reason ? `: ${o.reason}` : "."}`,
"Nothing changes today. The card is tried again over the next few days, and the room stays writable through the 14 days of grace after the paid period ends. If it is still unpaid then, the room becomes read-only until a payment goes through, and nothing is deleted.",
],
rows: [["Amount", money(o.totalCents, o.currency)], ["Paid until", longDate(o.paidUntil)], ["Read-only from", longDate(o.graceEndsAt)]],
cta: { label: "Update the card", href: `${base}/app/settings?tab=plan` },
closing: ["The most common causes are an expired card or a bank blocking an online charge. Updating the card on the Plan screen retries the payment straight away.", sign()],
footer: f,
});
return { subject: `Payment for ThreadCount did not go through — ${f.facility}`, text, html };
}
function subscriptionCancelled(f, o) {
const base = siteUrl();
const scheduled = !!o.scheduled;
const title = scheduled ? "Your plan will end" : "Your plan has ended";
const { html, text } = billingLayout({
title, preheader: scheduled ? `${f.facility}'s card plan stops renewing on ${longDate(o.endsAt)}.` : `${f.facility}'s card plan has ended.`,
intro: scheduled
? [`The card plan for ${f.facility} has been set to stop at the end of the paid period, ${longDate(o.endsAt)}. Nothing more is charged. Until then everything works as it does today.`]
: [`The card plan for ${f.facility} has ended${o.endsAt ? `; the last paid period ran to ${longDate(o.endsAt)}` : ""}. Nothing more is charged.`],
rows: [["Writable until", longDate(o.graceEndsAt)], ["After that", "Read-only: reports, exports, printing and the backup keep working"]],
cta: scheduled ? { label: "Keep my plan", href: `${base}/app/settings?tab=plan` } : { label: "Subscribe again", href: `${base}/app/checkout` },
closing: ["Everything you entered stays yours: the backup file and every export are available at any time from Settings Data. If something made you leave, reply and tell us; it is read by the person who builds this.", sign()],
footer: f,
});
return { subject: scheduled ? `Your ThreadCount plan ends ${longDate(o.endsAt)}${f.facility}` : `Your ThreadCount plan has ended — ${f.facility}`, text, html };
}
function readOnlyNow(f, o) {
const base = siteUrl();
const title = "Your facility is now read-only";
const { html, text } = billingLayout({
title, preheader: `${f.facility} is read-only until a plan is paid. Nothing is deleted.`,
intro: [
`${f.facility}'s ${o.wasTrial ? "trial" : "paid period"} ended on ${longDate(o.endsAt)} and the 14 days of grace are over, so the room is now read-only.`,
"Read-only means exactly that: sign-in, every report, every export, printing and the full backup keep working; only changes are refused, and the screen says why. Nothing is deleted, now or later.",
],
rows: [["Monthly", `${money(o.monthlyCents, "AUD")} a month, ex tax`], ["Yearly", `${money(o.annualCents, "AUD")} a year, two months free`], ["Writing resumes", "the moment a payment is recorded"]],
cta: { label: "Choose a plan", href: `${base}/app/checkout` },
closing: ["To leave instead, download the backup from Settings Data. It has everything.", sign()],
footer: f,
});
return { subject: `${f.facility} is now read-only on ThreadCount`, text, html };
}
function cardUpdated(f, o) {
const base = siteUrl();
const title = "Card updated";
const { html, text } = billingLayout({
title, preheader: `The card on file for ${f.facility} was changed.`,
intro: [`The card on file for ${f.facility} is now ${o.card ? `${o.card.brand} ···· ${o.card.last4}` : "the one you entered"}. The next charge${o.nextChargeAt ? ` on ${longDate(o.nextChargeAt)}` : ""} uses it.`],
rows: o.card ? [["Card", `${o.card.brand} ···· ${o.card.last4}`], ...(o.nextChargeAt ? [["Next charge", longDate(o.nextChargeAt)]] : [])] : [],
cta: { label: "See your plan", href: `${base}/app/settings?tab=plan` },
closing: ["If you did not make this change, reply to this email straight away.", sign()],
footer: f,
});
return { subject: `Card updated for ${f.facility} on ThreadCount`, text, html };
}
module.exports = { billingLayout, trialStarted, trialTip, trialEndingSoon, trialEnded, paymentReceived, paymentFailed, subscriptionCancelled, readOnlyNow, cardUpdated, money, longDate };
+41
View File
@@ -0,0 +1,41 @@
/* Sending the billing emails from the app: who gets them, and the fire-and-forget send.
*
* The words live in lib/billing-mail.cjs so the reminder timer and the preview script render the
* same templates without a TypeScript build. Recipients are the billing contact when one is set,
* otherwise every active admin of the facility — never a staff account, never a wearer. */
import { prisma } from "@/lib/db";
import { sendTo } from "@/lib/mail";
import * as T from "@/lib/billing-mail.cjs";
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;
/** Where a facility's billing mail goes. */
export async function billingRecipients(facilityId: string): Promise<{ to: string[]; ctx: FacilityCtx }> {
const fac = await prisma.facility.findUnique({ where: { id: facilityId }, select: { name: true, billingEmail: true } });
if (!fac) return { to: [], ctx: { facility: "", contact: "" } };
const contact = fac.billingEmail.trim().toLowerCase();
if (contact) return { to: [contact], ctx: { facility: fac.name, contact } };
const admins = await prisma.user.findMany({ where: { facilityId, role: "ADMIN", inactive: false }, select: { email: true } });
return { to: admins.map((a) => a.email.toLowerCase()), ctx: { facility: fac.name, contact: "" } };
}
/** Render with a template and send to every recipient. Never throws, never awaited by callers. */
export async function sendBillingMail(facilityId: string, render: (ctx: FacilityCtx) => Rendered): Promise<number> {
try {
const { to, ctx } = await billingRecipients(facilityId);
if (!to.length) return 0;
const m = render(ctx);
const results = await Promise.all(to.map((a) => sendTo(a, m.subject, m.text, m.html)));
return results.filter(Boolean).length;
} catch (e) {
console.error("[billing mail] failed:", e instanceof Error ? e.message : e);
return 0;
}
}
export const templates = T;
+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]);
}
+1327
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 → General, the same for every group." },
depts: { name: "Departments & cost centres", headers: "dept,cc", example: `"Emergency Department",RGH-4040`, note: "One row per department/ward with its cost centre code." },
barcodes: { name: "Supplier barcodes", headers: "sku,gender,item,size,barcode", example: `NL-RN-TOP-01,Unisex,"RN Active Scrub Top",M,9357732548036`, note: "Match the catalogue row by sku (+ gender/item if a SKU is shared), then size. EAN-13 or any scannable code." },
reorder: { name: "Reorder levels", headers: "sku,gender,item,size,reorder", example: `NL-RN-TOP-01,Unisex,"RN Active Scrub Top",M,3`, note: "Sets the per-size reorder level (overwrites). Sizes without one use the facility default." },
opening: { name: "Opening balances", headers: "sku,gender,item,size,opening,reorder", example: `NL-RN-TOP-01,Unisex,"RN Active Scrub Top",M,12,3`, note: "Sets the opening count per size (overwrites). reorder is optional and sets the per-line reorder level." },
};
+107
View File
@@ -0,0 +1,107 @@
import { prisma } from "./db";
import { availability } from "./staffdata";
import { facilityDate } from "./compute";
import { holdEndsAt, holdExpired } from "./staffreq";
import type { StaffSession } from "./staffsession";
/* The two periodic surfaces: the kit check cycle and the waitlist. */
/** 2A. Null when there is no cycle open — the screen shouldn't exist between rounds. */
export async function kitCheckData(sess: StaffSession) {
const cycle = await prisma.kitCheck.findFirst({
where: { facilityId: sess.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true, facility: { select: { timezone: true } } },
});
if (!cycle) return null;
const [issues, answers, previous] = await Promise.all([
// handedIn, not just returnedDate: a garment handed back at the counter joins the pre-loved
// pool without ever being marked returned, and asking someone to confirm they still hold it
// is how a kit check teaches people the record is wrong.
prisma.issue.findMany({
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
select: { qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
}),
prisma.kitCheckAnswer.findMany({
where: { kitCheckId: cycle.id, staffId: sess.staffId },
select: { itemId: true, sizeIndex: true, confirmed: true },
}),
// "Last confirmed" comes from the previous cycle they actually answered, not from the cycle
// before this one — somebody who missed the last round should be told the truth.
prisma.kitCheckAnswer.findFirst({
where: { staffId: sess.staffId, kitCheckId: { not: cycle.id } },
orderBy: { answeredAt: "desc" },
select: { answeredAt: true },
}),
]);
const byKey = new Map<string, { itemId: string; item: string; size: string; si: number; onRecord: number }>();
for (const i of issues) {
const k = `${i.item.id}:${i.sizeIndex}`;
const cur = byKey.get(k) || { itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, onRecord: 0 };
cur.onRecord += i.qty;
byKey.set(k, cur);
}
const answered = new Map(answers.map((a) => [`${a.itemId}:${a.sizeIndex}`, a.confirmed]));
return {
dueBy: cycle.dueBy,
lastConfirmed: previous ? facilityDate(previous.answeredAt, cycle.facility.timezone) : "",
rows: [...byKey.values()]
.sort((a, b) => a.item.localeCompare(b.item) || a.size.localeCompare(b.size))
.map((r) => ({ ...r, answered: answered.has(`${r.itemId}:${r.si}`) ? answered.get(`${r.itemId}:${r.si}`)! : null })),
};
}
/** 2B. Position is FIFO on join time, counting only people still waiting. */
export async function waitlistData(sess: StaffSession, itemId: string, si: number) {
const item = await prisma.catalogItem.findFirst({
where: { id: itemId, facilityId: sess.facilityId },
select: { id: true, item: true, sizes: true },
});
if (!item || si < 0 || si >= item.sizes.length) return null;
const [queue, mine, avail] = await Promise.all([
prisma.waitlistEntry.findMany({
where: { facilityId: sess.facilityId, itemId, sizeIndex: si, leftAt: null, acceptedAt: null },
orderBy: { createdAt: "asc" },
select: { id: true, staffId: true, createdAt: true, offeredAt: true },
}),
prisma.waitlistEntry.findFirst({ where: { staffId: sess.staffId, itemId, sizeIndex: si, leftAt: null }, select: { id: true, offeredAt: true, acceptedAt: true } }),
availability(sess.facilityId, [itemId]),
]);
const sizes = avail[itemId] || [];
const mineIndex = queue.findIndex((q) => q.staffId === sess.staffId);
const joined = mineIndex >= 0;
return {
itemId: item.id,
item: item.item,
size: String(item.sizes[si]),
si,
lastRestocked: sizes.find((s) => s.si === si)?.countedOn || "",
// Already on the list: their real place. Not on it: the place they would take.
position: joined ? mineIndex + 1 : queue.length + 1,
ahead: joined ? mineIndex : queue.length,
joined,
entryId: mine?.id ?? null,
offeredAt: mine?.offeredAt?.toISOString() ?? null,
// The screen and the offer email both promise the garment is held for 48 hours, so the
// deadline is computed rather than implied, and the screen is told when it has passed —
// an offer bar that can no longer be accepted is worse than none.
holdUntil: holdEndsAt(mine?.offeredAt ?? null)?.toISOString() ?? null,
offerExpired: holdExpired(mine?.offeredAt ?? null),
// Accepting raises a request and the entry stays put, so without this the screen keeps
// offering "Accept it" for something already accepted and every tap is refused.
acceptedAt: mine?.acceptedAt?.toISOString() ?? null,
// Nearest sizes either way that are actually on the shelf — most people would rather have
// something that fits approximately today.
alternatives: sizes
.filter((s) => s.si !== si && s.word !== "none")
.sort((a, b) => Math.abs(a.si - si) - Math.abs(b.si - si))
.slice(0, 3)
.map((s) => ({ si: s.si, size: s.size, word: s.word })),
};
}
+18
View File
@@ -0,0 +1,18 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
function makeClient() {
const max = parseInt(process.env.DB_POOL_MAX || "", 10);
// DB_POOL_MAX=1 is only for the local `prisma dev` embedded server, which can't handle concurrent queries. Never set it in production.
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, ...(Number.isFinite(max) && max > 0 ? { max } : {}) });
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
}
export const prisma = globalForPrisma.prisma ?? makeClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+188
View File
@@ -0,0 +1,188 @@
import { prisma } from "./db";
import { availability, bagLines, linesSummary, reqLines } from "./staffdata";
import { facilityDate, facilityToday, garmentForGroup, garmentForStyle, isPantItem, isTopItem } from "./compute";
import { garmentCount } from "./staffreq";
import type { StaffSession } from "./staffsession";
/* What a manager raising for one of their own reports sees, and what the ward desk sees when the
* trolley arrives.
*
* Neither list is the facility. The raise list is the manager's reporting line, and the round is
* one ward's bags. Both queries start from that scope rather than filtering down to it, which is
* the difference that matters the day somebody adds a search box.
*/
/** Who this manager raises for: their active reports, never themselves. */
const reportsOf = (sess: StaffSession) => ({ facilityId: sess.facilityId, managerId: sess.staffId, inactive: false, id: { not: sess.staffId } });
/** Everyone the raise screen may pick from: the people who name this manager as theirs, wherever
* those people happen to sit. That is the same relationship `request.create` enforces on the
* server and the same one that makes this person the approver — which is why the request they
* raise here goes up a level rather than back to them.
*
* Each row carries the two things whoever is typing is least likely to know: what that person is
* holding right now, and the size the record last saw them in.
*
* The awkward part is the hand-in: a garment handed back is gone from the person even though the
* issue row never says returned, and a copy of this that forgot it would tell a manager a nurse
* still holds three tunics she gave back a fortnight ago, and the request she needs would be
* declined as over allowance.
*
* Every issue is read, not just the live ones: a returned garment is spent, but it is still the
* best evidence of what fits. Newest first, so the first row wins per garment. */
export async function teamPeople(sess: StaffSession) {
const staff = await prisma.staff.findMany({
where: reportsOf(sess),
orderBy: [{ last: "asc" }, { first: "asc" }],
select: {
id: true, first: true, last: true, num: true, group: true, top: true, pants: true,
account: { select: { id: true } },
},
});
const ids = staff.map((s) => s.id);
const issues = ids.length
? await prisma.issue.findMany({
where: { staffId: { in: ids } },
orderBy: { date: "desc" },
select: { staffId: true, itemId: true, sizeIndex: true, qty: true, returnedDate: true, handedIn: true, item: { select: { sizes: true } } },
})
: [];
const held = new Map<string, Record<string, number>>();
const lastSizes = new Map<string, Record<string, string>>();
for (const i of issues) {
const sizes = lastSizes.get(i.staffId) || {};
if (!(i.itemId in sizes)) sizes[i.itemId] = String(i.item.sizes[i.sizeIndex] ?? "");
lastSizes.set(i.staffId, sizes);
// Nobody has a locker — the uniform lives at their house — so "still holding it" is the only
// thing this can mean, and a hand-in ends it just as surely as a return does.
if (i.returnedDate || i.handedIn) continue;
const mine = held.get(i.staffId) || {};
mine[i.itemId] = (mine[i.itemId] || 0) + i.qty;
held.set(i.staffId, mine);
}
return staff.map((s) => ({
id: s.id,
name: `${s.first} ${s.last}`.trim(),
num: s.num,
group: s.group,
// The design surfaces this on the selected person: whoever is raising needs to know whether the
// outcome will reach them directly or has to be passed on by hand.
hasApp: !!s.account,
recordedTop: s.top,
recordedPants: s.pants,
/** Garment id → how many they are holding right now. */
held: held.get(s.id) || {},
/** Garment id → the size of the last one they were issued, for everything the register has
* no recorded size for. */
lastSizes: lastSizes.get(s.id) || {},
}));
}
/** The garments the raise screen offers. The person is picked on the phone, after this list is
* drawn, so it cannot be one person's group: it is every garment that is for at least one of the
* people in teamPeople() — the same reporting line, so the two lists agree — plus the garments
* for every group. The same is asked of the cut: a garment is on the list if one of those people
* is offered it — which, for anybody blank or set to Either, is every cut. request.create still
* asks both questions of the one person picked, and refuses anything outside their group or their
* style. */
export async function deskCatalogue(sess: StaffSession) {
const [all, reports] = await Promise.all([
prisma.catalogItem.findMany({
where: { facilityId: sess.facilityId, archived: false },
orderBy: { sort: "asc" },
select: { id: true, item: true, type: true, gender: true, sizes: true, groups: true },
}),
prisma.staff.findMany({ where: reportsOf(sess), select: { group: true, uniformStyle: true }, distinct: ["group", "uniformStyle"] }),
]);
// A blank group is on nobody's list, so garmentForGroup(it, "") is true of an every-group garment
// alone — which keeps those on the screen when this manager has no reports. The blank style beside
// it is the one nobody has set, which is offered every cut, so that fallback hides nothing.
//
// Group and cut are asked of the SAME person, one pair at a time, rather than of two lists: a
// manager with a man in Security and a woman in Nursing must not be offered the women's Nursing
// tunic for him because somebody on their list is in Nursing and somebody is set to Women's.
const people = [{ group: "", uniformStyle: "" }, ...reports];
const items = all.filter((i) => people.some((r) => garmentForGroup(i, r.group) && garmentForStyle(i, r.uniformStyle)));
const avail = await availability(sess.facilityId, items.map((i) => i.id));
return items.map((i) => ({
id: i.id, item: i.item, type: i.type, gender: i.gender,
sizes: avail[i.id] || i.sizes.map((s, si) => ({ size: String(s), si, word: "none" as const, countedOn: "" })),
recorded: "",
isTop: isTopItem(i),
isPant: isPantItem(i),
}));
}
/** The label and meta `request.round` stamps on a bag when it goes out (lib/ops.ts). Request has
* no column saying which ward the trolley was sent to, so that event is the only durable record
* of it, and this screen is fenced on it rather than on where the wearer sits today. */
const ROUTED_TO_ROUND = "Out on the ward round";
const dueOn = (ward: string) => `Due on ${ward}`;
/** 2D. Three lists: what to sign, what nobody collected, and what has been signed today. */
export async function roundData(sess: StaffSession) {
const me = await prisma.staff.findUniqueOrThrow({
where: { id: sess.staffId },
select: { dept: true, wardDesk: true, facility: { select: { timezone: true } } },
});
// A blank ward is not a ward. `dept` defaults to an empty string, so querying on it as-is hands
// a clerk whose ward was never filled in the bags of every other ward-less person in the
// facility — and the round they could sign for is fenced the same way in lib/staffops.ts, so the
// screen would only be listing work it then refuses. There is no round without a ward.
if (!me.wardDesk || !me.dept) return null;
const rows = await prisma.request.findMany({
where: {
facilityId: sess.facilityId,
// The ward the bag was left on, not the ward the wearer is on now. Selecting on
// `subject.dept` made the bag follow the person: a nurse who transfers between the trolley
// leaving and the desk signing took her bag with her on screen — off the round of the ward
// it is physically sitting on, and onto a ward it never reached, where signing would stamp a
// delivery, name a real signer and issue the garments against her for a handover that never
// happened. Nothing edits a staff member's ward through this path, so it changes silently.
// round.sign fences the same bag in lib/staffops.ts and the two have to agree on the ward,
// or a bag is either signable by the wrong desk or signable by nobody.
events: { some: { label: ROUTED_TO_ROUND, meta: dueOn(me.dept) } },
OR: [{ status: "round" }, { status: "delivered", claimedAt: null }],
},
orderBy: { createdAt: "asc" },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true } },
},
});
// A bag on the round holds the approved lines and nothing else, which is exactly what bagLines()
// hands back — a declined garment must never appear on a sheet somebody is about to sign for.
const shape = (r: (typeof rows)[number]) => {
const lines = bagLines(reqLines(r.lines));
return {
id: r.id, code: r.code,
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
lines, summary: linesSummary(lines), garments: garmentCount(lines), lineCount: lines.length,
status: r.status, signerName: r.signerName, signedAt: r.signedAt?.toISOString() ?? null,
claimedAt: r.claimedAt?.toISOString() ?? null,
since: r.createdAt.toISOString(),
};
};
// Which day a bag was signed for is a question about the ward's day, not the server's. Comparing
// against the process's local midnight put every bag signed before the server's rollover into
// "unclaimed from earlier rounds" — on a UTC host that is an entire Australian morning round,
// filed as abandoned on the one screen whose job is telling this morning's work from stale bags.
const tz = me.facility.timezone;
const today = facilityToday(tz);
const delivered = rows.filter((r) => r.status === "delivered");
const signedOn = (r: (typeof rows)[number]) => (r.signedAt ? facilityDate(r.signedAt, tz) : "");
return {
ward: me.dept,
toSign: rows.filter((r) => r.status === "round").map(shape),
// Signed for on the ward on an earlier day and still nobody has taken it away.
unclaimed: delivered.filter((r) => signedOn(r) !== "" && signedOn(r) < today).map(shape),
signedToday: delivered.filter((r) => signedOn(r) >= today).map(shape),
};
}
+11
View File
@@ -0,0 +1,11 @@
/* Which edition this process is.
*
* Hosted is threadcount.tech: plans, the demo, the public site, and its own error reports and
* usage statistics. Community is the product run by a
* facility on its own server, from the Dockerfile, with EDITION=community in the environment:
* every feature a room uses, no plans, no ceiling, nothing reported anywhere.
*
* Server-side only — the browser bundle never sees EDITION. Client code that must behave
* differently off threadcount.tech decides by hostname instead (components/Analytics.tsx,
* lib/glitchtip.ts), which has the same effect and needs no build-time flag. */
export const COMMUNITY = process.env.EDITION === "community";
+47
View File
@@ -0,0 +1,47 @@
"use client";
/* One place the app reports a crash from.
*
* The error boundaries call this rather than talking to a reporting SDK directly, so that what
* gets sent — and what gets stripped first — is decided in a single file rather than at each
* boundary. ThreadCount's URLs carry staff, location and order ids, exactly as they do for
* analytics, and a crash report is if anything more likely to drag one along: the page URL, the
* referrer and the breadcrumb trail all contain them.
*
* Until the reporter is wired this is a no-op in production and a console line in development,
* which is deliberately better than the previous behaviour of losing the error entirely. */
import { scrubPath } from "@/lib/analytics";
type Reporter = {
captureException: (e: unknown, ctx?: Record<string, unknown>) => void;
};
/** Set by the reporting bootstrap once it has initialised. Absent = reporting is off. */
function reporter(): Reporter | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { __tcReporter?: Reporter }).__tcReporter;
}
/** The current location with record ids removed, safe to attach to a report. */
export function safeLocation(): string {
if (typeof location === "undefined") return "";
return scrubPath(location.pathname);
}
/**
* Report a crash.
* `where` distinguishes a route boundary from the root one — a global error means the layout
* itself failed, which is a different and more serious shape of problem.
*/
export function reportError(error: unknown, where: "route" | "global" | "client") {
const r = reporter();
if (r) {
try {
r.captureException(error, { tags: { boundary: where }, extra: { path: safeLocation() } });
return;
} catch { /* a reporter that throws must not take the page with it */ }
}
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.error(`[threadcount:${where}]`, error);
}
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
/* What a scan feels like.
*
* Counting a shelf is done looking at garments, not at the phone, so every scan has to confirm
* itself physically. There are two things worth saying, and they have to feel different:
*
* tick — read one, keep going.
* reject — that code isn't on this shelf; look up.
*
* On the web that's `navigator.vibrate`. Inside the Android shell it isn't: the WebView only
* honours navigator.vibrate when the app declares android.permission.VIBRATE, which arrives with
* @capacitor/haptics — until this plugin was added the tick was silent on a real phone and only
* the beep survived. Native also gets the platform's own haptic engine, which is a cleaner tap
* than a raw motor pulse.
*
* Settings can turn the lot off; the beep and the buzz share one switch because they are one
* signal wearing two coats. */
import { isNative } from "@/lib/nativescan";
type HapticsModule = {
Haptics: {
impact: (o: { style: string }) => Promise<void>;
notification: (o: { type: string }) => Promise<void>;
};
ImpactStyle: { Light: string; Medium: string; Heavy: string };
NotificationType: { Success: string; Warning: string; Error: string };
};
let mod: HapticsModule | null = null;
let loading: Promise<HapticsModule | null> | null = null;
/** Dynamically imported: most people meet /m in a browser, where none of this ships. */
function load(): Promise<HapticsModule | null> {
if (!isNative()) return Promise.resolve(null);
if (mod) return Promise.resolve(mod);
if (!loading) {
loading = import("@capacitor/haptics")
.then((m) => (mod = m as unknown as HapticsModule))
.catch(() => null); // shell built without the plugin — the web path still works
}
return loading;
}
/** Honours the Settings switch, and survives a browser that blocks storage entirely. */
function wanted(): boolean {
try { return localStorage.getItem("tc.beep") !== "0"; } catch { return true; }
}
function buzz(pattern: number | number[]) {
try { navigator.vibrate?.(pattern); } catch { /* not every device has a motor */ }
}
function beep(hz: number, seconds: number, gain = 0.05) {
try {
const AC = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
const Ctor = AC.AudioContext || AC.webkitAudioContext;
if (!Ctor) return;
const ctx = new Ctor();
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = hz; g.gain.value = gain;
o.connect(g); g.connect(ctx.destination);
o.start(); o.stop(ctx.currentTime + seconds);
setTimeout(() => ctx.close().catch(() => {}), seconds * 1000 + 200);
} catch { /* audio is blocked until a gesture — the haptic still fires */ }
}
/** One garment read. Short and high, so a shelf of them doesn't become a drone. */
export function scanTick() {
if (!wanted()) return;
void load().then((m) => {
if (m) m.Haptics.impact({ style: m.ImpactStyle.Light }).catch(() => {});
else buzz(35);
});
beep(1180, 0.06);
}
/** That code doesn't belong here. Two longer pulses and a lower note — unmistakably not a tick. */
export function scanReject() {
if (!wanted()) return;
void load().then((m) => {
if (m) m.Haptics.notification({ type: m.NotificationType.Warning }).catch(() => {});
else buzz([50, 60, 50]);
});
beep(320, 0.16, 0.06);
}
+158
View File
@@ -0,0 +1,158 @@
/* Error reporting to a GlitchTip (Sentry-protocol) project.
*
* GlitchTip speaks Sentry's protocol, so this posts a Sentry "store" event by hand rather than
* pulling in @sentry/nextjs. Three reasons that's the right trade here:
* - the SDK is tens of kilobytes on a phone that a nurse uses in a linen room;
* - it hooks the build (withSentryConfig) on a very new Next, which is a compatibility risk the
* project doesn't need;
* - and everything that leaves this file has to be scrubbed first, which is far easier to
* guarantee when there is exactly one function doing the sending.
*
* What it costs: no source maps, so client stack frames are minified, and no automatic
* breadcrumbs. The message, the scrubbed path and the stack are still vastly better than the
* nothing that was here before.
*
* The DSN's public key is not a secret — Sentry-protocol keys are designed to sit in client
* bundles — so it is safe in NEXT_PUBLIC_.
*/
/* The default lives in lib/hosted-defaults.ts (blank in the Community edition); an explicit
NEXT_PUBLIC_GLITCHTIP_DSN wins. The key is public by protocol design. */
import { HOSTED_GLITCHTIP_DSN } from "./hosted-defaults";
const DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN || HOSTED_GLITCHTIP_DSN;
type Parsed = { url: string; key: string };
/** `https://<key>@host/<projectId>` -> the store endpoint and the auth key. */
function parseDsn(dsn: string): Parsed | null {
try {
const u = new URL(dsn);
const projectId = u.pathname.replace(/^\//, "");
if (!u.username || !projectId) return null;
return { url: `${u.protocol}//${u.host}/api/${projectId}/store/`, key: u.username };
} catch {
return null;
}
}
const PARSED = DSN ? parseDsn(DSN) : null;
/* The default DSN is the hosted service's. It is used only where it belongs: on the server, when
this is not the community edition; in the browser, when the page is served from threadcount.tech.
An explicit DSN is honoured anywhere. */
const EXPLICIT = !!process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
function allowed(): boolean {
if (!PARSED) return false;
if (EXPLICIT) return true;
if (typeof window !== "undefined") return /(^|\.)threadcount\.tech$/.test(window.location.hostname);
// On the server only a real deploy reports: a deploy compiles NEXT_PUBLIC_RELEASE into the
// build, local `next start`/e2e runs never set it. Without this, every suite run at home
// filed its scaffolding failures as production issues (44 of them, none from a customer).
if (!process.env.NEXT_PUBLIC_RELEASE) return false;
return process.env.EDITION !== "community";
}
/* Browser "Script error." is the cross-origin placeholder the browser substitutes for an error
thrown by a third-party script (Turnstile, the analytics beacon): no message, no stack, no
location. It cannot be acted on, so it is dropped rather than paged. */
function ignorable(err: Error): boolean {
return /^Script error\.?$/.test((err.message || "").trim());
}
/* Prisma errors begin with a blank line and an engine id, which the scrubber turns into "[id]:",
so the issue title says nothing. Use the first line that carries words instead. */
function headline(value: string): string {
const lines = value.split("\n").map((l) => l.trim()).filter(Boolean);
return lines.find((l) => !/^\[id\]:?$/.test(l) && !/^Invalid `.*` invocation:?$/.test(l)) || lines[0] || value;
}
export const errorReportingOn = () => allowed();
/* Redaction.
*
* An error message is not written by us. A Prisma failure quotes the row it choked on, a
* constraint violation quotes the value, and a validation error quotes what someone typed. Any of
* those can carry a staff name, a work email, a payroll number or a record id straight into the
* error tracker, which would undo the care taken everywhere else. */
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
const ID_RE = /\b(?:[a-z0-9]{20,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
const LONG_NUM_RE = /\b\d{6,}\b/g;
export function scrubText(s: string, max = 1000): string {
return String(s || "")
.replace(EMAIL_RE, "[email]")
.replace(ID_RE, "[id]")
.replace(LONG_NUM_RE, "[number]")
.slice(0, max);
}
/** Paths carry record ids; query strings carry more. Keep the shape, drop the specifics. */
export function scrubUrl(raw: string): string {
try {
const u = new URL(raw, "https://threadcount.tech");
const path = u.pathname
.split("/")
.map((seg) => (ID_RE.test(seg) ? ":id" : seg))
.join("/");
ID_RE.lastIndex = 0;
return u.origin + path;
} catch {
return scrubText(raw, 200);
}
}
function uuid(): string {
try {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, "");
} catch { /* fall through */ }
let s = "";
for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
return s;
}
export type ReportInput = {
error: unknown;
where: string;
url?: string;
tags?: Record<string, string>;
extra?: Record<string, unknown>;
};
/** Fire-and-forget. Reporting must never delay, block or break the thing that failed. */
export function report({ error, where, url, tags, extra }: ReportInput): void {
if (!PARSED || !allowed()) return;
try {
const err = error instanceof Error ? error : new Error(String(error));
if (ignorable(err)) return;
const digest = (error as { digest?: string })?.digest;
const event = {
event_id: uuid(),
timestamp: new Date().toISOString(),
platform: "javascript",
level: "error",
logger: where,
release: process.env.NEXT_PUBLIC_RELEASE || undefined,
environment: process.env.NODE_ENV === "production" ? "production" : "development",
exception: {
values: [{
type: scrubText(err.name || "Error", 120),
value: headline(scrubText(err.message || String(error), 600)),
// Sent as a single scrubbed string: without source maps a structured frame list adds
// nothing a reader can use, and each frame is another place a path could leak.
stacktrace: undefined,
}],
},
request: url ? { url: scrubUrl(url) } : undefined,
tags: { boundary: where, ...(digest ? { digest } : {}), ...(tags || {}) },
extra: { ...extra, stack: scrubText(err.stack || "", 4000) },
};
const body = JSON.stringify(event);
const headers: Record<string, string> = {
"content-type": "application/json",
"x-sentry-auth": `Sentry sentry_version=7, sentry_client=threadcount/1.0, sentry_key=${PARSED.key}`,
};
void fetch(PARSED.url, { method: "POST", headers, body, keepalive: true }).catch(() => {});
} catch { /* a reporter that throws is worse than one that stays quiet */ }
}
+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) };
}
+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.`;
}
+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 };
}
+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.`,
};
}
+182
View File
@@ -0,0 +1,182 @@
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] = await Promise.all([
db.facility.findUniqueOrThrow({ where: { id: fid }, include: { org: { select: { id: true, name: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true } } } }),
db.catalogItem.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
db.barcode.findMany({ where: { facilityId: fid } }),
db.stockLevel.findMany({ where: { facilityId: fid } }),
db.stockMove.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
db.department.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
db.supplier.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
db.staff.findMany({ where: { facilityId: fid }, orderBy: [{ last: "asc" }, { first: "asc" }], include: { account: { select: { email: true } } } }),
db.approval.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
db.alteration.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, take: HISTORY_TAKE.alterations }),
db.issue.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
db.order.findMany({
where: { facilityId: fid },
orderBy: { createdAt: "desc" },
include: { lines: { orderBy: { sort: "asc" } }, receipts: { orderBy: { createdAt: "asc" }, include: { lines: true } } },
}),
// Only the pickups still waiting. Every screen that reads them — the call list, the rounds
// screen, a staff record, both home pages — filters on `!pickedUp`, and one that has been
// collected is already on the record as the Issue it created.
db.pickup.findMany({ where: { facilityId: fid, pickedUp: null }, orderBy: { createdAt: "asc" }, include: { lines: true, order: { select: { code: true } } } }),
db.stocktake.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, include: { lines: true }, take: HISTORY_TAKE.stocktakes }),
user.role === "ADMIN"
? db.user.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" }, select: { id: true, email: true, first: true, last: true, title: true, role: true, inactive: true, ssoBreakGlass: true } })
: Promise.resolve([]),
db.handIn.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, include: { lines: true }, take: HISTORY_TAKE.handins }),
db.location.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
db.costChange.findMany({
where: { facilityId: fid },
orderBy: { at: "desc" },
take: 400,
}),
// Garments a manager has approved that nobody has handed over yet. They are owed to that person,
// so they count toward the six sets held on every screen exactly as the counter counts them.
// Without them a staff record shows room the counter then refuses.
db.requestLine.findMany({
where: { status: "approved", request: { facilityId: fid, status: { in: AWAITING_HANDOVER } } },
select: { itemId: true, qty: true, request: { select: { subjectId: true } } },
}),
// Read through `db`, not lib/switches.ts: callers inside lockedTx hold the one connection a
// single-connection pool has, and a query on the global client would wait for it until the
// transaction timed out. The environment's override is applied below, as switches() applies it.
db.platformSwitch.findUnique({ where: { id: SWITCH_ROW }, select: { plansLive: true } }),
]);
const plansLive = process.env.PLANS_LIVE === "1" || !!switchRow?.plansLive;
const bc: Record<string, string> = {};
for (const b of barcodes) bc[b.code] = key(b.itemId, b.sizeIndex);
const st: Record<string, { opening: number; adj: number; reorder: number | null; preloved: number; 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,
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 @@
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]);
}