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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
/* The link a ward manager taps in an email to approve or decline, without signing in.
|
||||
*
|
||||
* Three properties matter, and each is bought a specific way.
|
||||
*
|
||||
* **It cannot be forged.** HMAC over the payload, with a key derived separately from the session
|
||||
* and staff-session keys, so a valid approval link is not a valid anything else.
|
||||
*
|
||||
* **It cannot be used twice.** There is no table of spent tokens: the link is only honoured while
|
||||
* the request is still `awaiting`, and approving or declining moves it. Both links in the same
|
||||
* email therefore die together the moment either is used — which is exactly the behaviour you
|
||||
* want when a manager taps Approve and then wonders about Decline.
|
||||
*
|
||||
* **It cannot be spent by a machine.** The link is a GET that renders a page; the decision is a
|
||||
* POST from that page. This is not ceremony. Corporate mail scanners and link-preview crawlers
|
||||
* fetch every URL in every message, and a GET that approved a uniform request would be approved
|
||||
* by Outlook before the manager saw it.
|
||||
*/
|
||||
|
||||
const TTL_MS = 14 * 24 * 60 * 60 * 1000; // a fortnight: leave covers most of it, and stale is safe here
|
||||
|
||||
function key() {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s) throw new Error("SESSION_SECRET not set");
|
||||
return createHash("sha256").update("threadcount:approval:v1:" + s).digest();
|
||||
}
|
||||
|
||||
function b64url(buf: Buffer) {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export type ApprovalClaim = { rid: string; mid: string };
|
||||
|
||||
export function signApprovalToken(requestId: string, managerStaffId: string): string {
|
||||
const payload = b64url(Buffer.from(JSON.stringify({ rid: requestId, mid: managerStaffId, exp: Date.now() + TTL_MS })));
|
||||
const sig = b64url(createHmac("sha256", key()).update(payload).digest());
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
export function readApprovalToken(raw: string | undefined): ApprovalClaim | null {
|
||||
if (!raw) return null;
|
||||
const [payload, sig] = raw.split(".");
|
||||
if (!payload || !sig) return null;
|
||||
const expect = b64url(createHmac("sha256", key()).update(payload).digest());
|
||||
const a = Buffer.from(sig), b = Buffer.from(expect);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const d = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
|
||||
if (!d.rid || !d.mid || !d.exp || d.exp < Date.now()) return null;
|
||||
return { rid: String(d.rid), mid: String(d.mid) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function approvalUrl(token: string) {
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
|
||||
return `${base}/my/approve?t=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/* ---------- the garments, as an email reads them ----------
|
||||
*
|
||||
* A request covers as many garments as the person needed, so every email about one has to list
|
||||
* them rather than name a single item. Both emails below print the same block, and so do the
|
||||
* "ready to collect" and "on the round" notes in lib/ops.ts, which is why it lives here and not
|
||||
* inside one of them: a manager approving three garments and the wearer collecting them should be
|
||||
* reading the same three lines.
|
||||
*/
|
||||
export type EmailLine = { qty: number; item: string; size: string; status?: string; declineReason?: string | null };
|
||||
|
||||
/** One garment to a line, indented so it sits in a plain-text email as its own block. A refused
|
||||
* garment says so against itself — a bag that turns up two garments short with no explanation is
|
||||
* exactly what this flow exists to prevent. */
|
||||
export function garmentBlock(lines: readonly EmailLine[]): string {
|
||||
return lines
|
||||
.map((l) => {
|
||||
const g = ` ${l.qty} × ${l.item} — size ${l.size}`;
|
||||
return l.status === "declined" ? `${g} — declined: ${l.declineReason || "not approved"}` : g;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** The email a manager gets when one of their staff asks for uniform. */
|
||||
export function approvalEmail(opts: {
|
||||
managerFirst: string; subjectName: string; raisedByName?: string;
|
||||
lines: readonly EmailLine[]; reason: string; note: string; url: string; facility: string;
|
||||
}) {
|
||||
/* Who actually asked. A manager, or the counter, raising on somebody's behalf used to be
|
||||
* invisible here, so the approver read "Ali has asked for uniform" about a request Ali had never
|
||||
* seen. The wearer is still named, because it is her allowance being spent; the raiser is named
|
||||
* as well, because they are the one who can answer a question about it. */
|
||||
const raisedBy = (opts.raisedByName || "").trim();
|
||||
const subject = raisedBy ? `Uniform request for ${opts.subjectName}` : `Uniform request from ${opts.subjectName}`;
|
||||
const asked = raisedBy
|
||||
? `${raisedBy} has raised a uniform request for ${opts.subjectName}, and it needs your approval before the linen room can act on it.`
|
||||
: `${opts.subjectName} has asked for uniform and needs your approval before the linen room can act on it.`;
|
||||
/* Paragraphs, joined by blank lines — not a list of lines with blanks written in among them.
|
||||
* These are plain-text emails with no HTML alternative, so the blank lines are the only
|
||||
* formatting there is, and a filter that drops empty strings drops the spacing along with the
|
||||
* optional lines it was aimed at. Written this way the optional slots are `null`, which cannot
|
||||
* be confused with a separator. */
|
||||
const detail = [
|
||||
garmentBlock(opts.lines),
|
||||
opts.reason ? ` Reason: ${opts.reason}` : null,
|
||||
opts.note ? ` Note: ${opts.note}` : null,
|
||||
].filter((l): l is string => l !== null).join("\n");
|
||||
const text = [
|
||||
`Hi ${opts.managerFirst || "there"},`,
|
||||
asked,
|
||||
detail,
|
||||
`Approve or decline here:\n${opts.url}`,
|
||||
"The link opens a page showing the request — nothing is decided until you choose. It works once.",
|
||||
`${opts.facility} · ThreadCount`,
|
||||
].join("\n\n");
|
||||
return { subject, text };
|
||||
}
|
||||
|
||||
/** Told to the staff member once their manager has decided.
|
||||
*
|
||||
* `approved` is the whole request's answer rather than one garment's: true when at least one line
|
||||
* survived, which is the moment the linen room has a pick to do. `reason` is set only when a
|
||||
* single reason covers the whole refusal — a partly approved request, or one refused for two
|
||||
* different reasons, carries the reason against the garment it belongs to instead. */
|
||||
export function decisionEmail(opts: {
|
||||
staffFirst: string; managerName: string; approved: boolean; reason?: string;
|
||||
lines: readonly EmailLine[]; facility: string;
|
||||
}) {
|
||||
const partly = opts.approved && opts.lines.some((l) => l.status === "declined");
|
||||
const subject = partly
|
||||
? "Part of your uniform request was approved"
|
||||
: opts.approved ? "Your uniform request was approved" : "Your uniform request was declined";
|
||||
const opening = partly
|
||||
? `${opts.managerName} approved part of your request. The approved garments are with the linen room now; the rest are below, with the reason.`
|
||||
: opts.approved
|
||||
? `${opts.managerName} approved your request. It's with the linen room now.`
|
||||
: opts.reason
|
||||
? `${opts.managerName} declined your request — ${opts.reason.toLowerCase()}.`
|
||||
: `${opts.managerName} declined your request. The reason against each garment is below.`;
|
||||
// A single reason is stated once, in the sentence above, and not repeated against every garment:
|
||||
// that reads like a form letter. Where the reasons differ, the block carries them.
|
||||
const detail = opts.reason
|
||||
? garmentBlock(opts.lines.map((l) => ({ qty: l.qty, item: l.item, size: l.size })))
|
||||
: garmentBlock(opts.lines);
|
||||
const text = [
|
||||
`Hi ${opts.staffFirst || "there"},`,
|
||||
opening,
|
||||
detail,
|
||||
opts.approved ? "You'll hear again when it's ready to collect or on its way to your ward." : null,
|
||||
`${opts.facility} · ThreadCount`,
|
||||
].filter((l): l is string => l !== null).join("\n\n");
|
||||
return { subject, text };
|
||||
}
|
||||
+117
@@ -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
@@ -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 32–127, 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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");
|
||||
@@ -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 can’t say whether that saved. Check the record before trying again." };
|
||||
} finally {
|
||||
setInflight((n) => n - 1);
|
||||
}
|
||||
}, [router]);
|
||||
const value = useMemo<Ctx>(() => ({ s: snap, isAdmin: snap.session.role === "Admin", busy: pending || inflight > 0, refresh, mutate }), [snap, pending, inflight, refresh, mutate]);
|
||||
return <SnapshotContext.Provider value={value}>{children}</SnapshotContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSnap() {
|
||||
const c = useContext(SnapshotContext);
|
||||
if (!c) throw new Error("useSnap outside provider");
|
||||
return c;
|
||||
}
|
||||
|
||||
export function useDerived() {
|
||||
const { s } = useSnap();
|
||||
return useMemo(() => ({ L: ledger(s), byId: itemMap(s), staffById: staffMap(s), variants: variantList(s) }), [s]);
|
||||
}
|
||||
+1243
File diff suppressed because it is too large
Load Diff
+15
@@ -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
@@ -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." },
|
||||
};
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/* Keeping a screen honest about changes it did not make.
|
||||
*
|
||||
* Every mutation in this product already ends in router.refresh(), so a screen is never stale about
|
||||
* its OWN work. What it had no way to learn was that somebody else had changed something: a phone
|
||||
* left open on a ward went on showing the catalogue as it stood when it was opened, and a
|
||||
* coordinator adding a garment at the desk had to go and tell the counter to reload.
|
||||
*
|
||||
* Polling the snapshot itself to find out would mean every open device re-reading the facility's
|
||||
* catalogue, register, stock and history every few seconds to discover, nearly always, that nothing
|
||||
* had happened. So the server keeps a counter and bumps it once per mutation; this asks for that
|
||||
* one number, and only pays for the real reload when it has moved — with one exception, the first
|
||||
* answer this tab has no baseline for, for the reason set out in tick().
|
||||
*
|
||||
* Two things keep it quiet. It stops entirely while the tab is hidden — a phone in a pocket costs
|
||||
* nothing, and the first thing it does on becoming visible again is ask, so coming back to the app
|
||||
* is immediate rather than up to a poll late. And a mutation made HERE records the revision it
|
||||
* produced, so your own save never bounces the screen a second time a few seconds later.
|
||||
*/
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
/** The last revision this tab knows about, from a poll or from its own mutation. */
|
||||
let lastRev: number | null = null;
|
||||
|
||||
/* Bumped every time lastRev moves, so a poll can tell whether its answer was already out of date
|
||||
* by the moment it arrived. A question asked before lastRev changed may have been read on the
|
||||
* server before that change landed; one asked after it cannot have been. That is the whole of how
|
||||
* we tell "an answer from before my own save" from "the world really is at a smaller number" —
|
||||
* see the lower-revision branch in tick(). */
|
||||
let revGen = 0;
|
||||
|
||||
function setRev(rev: number) {
|
||||
lastRev = rev;
|
||||
revGen++;
|
||||
}
|
||||
|
||||
/** Called by mutate() with the revision its own write produced, so the poll does not re-fire it. */
|
||||
export function noteRev(rev: unknown) {
|
||||
if (typeof rev === "number") setRev(rev);
|
||||
}
|
||||
|
||||
export function useLiveRefresh(refresh: () => void) {
|
||||
const busy = useRef(false);
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!stopped) timer = setTimeout(tick, POLL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped) return schedule();
|
||||
// Hidden tabs ask nothing at all; visibilitychange below wakes them.
|
||||
if (document.visibilityState !== "visible" || busy.current) return schedule();
|
||||
busy.current = true;
|
||||
// Taken before the question goes out, so the answer can be judged against what we knew when
|
||||
// we asked rather than against what we have learned while waiting.
|
||||
const genAsked = revGen;
|
||||
try {
|
||||
const r = await fetch("/api/rev", { cache: "no-store" });
|
||||
if (r.ok) {
|
||||
const { rev } = (await r.json()) as { rev?: unknown };
|
||||
if (typeof rev === "number") {
|
||||
if (lastRev === null) {
|
||||
/* Nothing to compare against, so we cannot prove this screen is current.
|
||||
*
|
||||
* The server rendered the page at some revision nobody told the browser, and the
|
||||
* linen room can mark a bag ready in the gap between that render and this first
|
||||
* question. Quietly adopting the answer as a baseline loses that change for good:
|
||||
* the ward phone goes on saying "Being picked" until somebody else in the facility
|
||||
* happens to move the number again, and the nurse never walks down for the bag. So
|
||||
* the first answer always reloads. It costs one extra render per page load, and the
|
||||
* loop below is started at mount rather than a poll later so that render lands at
|
||||
* launch, before anyone has begun counting into the screen.
|
||||
*
|
||||
* Once per fresh load of the app, then — not once per screen. lastRev belongs to the
|
||||
* tab, so moving between screens inside the app still has a baseline to compare
|
||||
* with, and anything that happened around that later render leaves the counter above
|
||||
* the baseline, which the ordinary branch below picks up on its own.
|
||||
*/
|
||||
setRev(rev);
|
||||
refresh();
|
||||
} else if (rev > lastRev) {
|
||||
// The ordinary case: somebody else moved the counter on.
|
||||
setRev(rev);
|
||||
refresh();
|
||||
} else if (rev < lastRev && revGen === genAsked) {
|
||||
/* The counter has genuinely gone backwards, and the screen has to follow it down.
|
||||
*
|
||||
* Restoring a backup rebuilds the facility row, and the revision it comes back with
|
||||
* can be lower than a number this tab has already seen. A tab that only ever accepted
|
||||
* higher numbers would then refuse every answer for as long as it stayed open — going
|
||||
* on showing a catalogue and a stock position that no longer exist, with nothing on
|
||||
* screen to say so. That is worse than the double refresh guarded against below,
|
||||
* because nothing ever ends it.
|
||||
*
|
||||
* What separates the two is whether anything moved lastRev while this question was in
|
||||
* the air. Nothing did, so this read cannot be an echo of the world before our own
|
||||
* save: it was issued after we already held the newer number, and the server still
|
||||
* answered with a smaller one. That is news, not a straggler, so we take it.
|
||||
*/
|
||||
setRev(rev);
|
||||
refresh();
|
||||
}
|
||||
/* Anything left is our own save coming back to haunt us: this poll's read ran before
|
||||
* mutate() bumped the facility, so it answers with the old number while noteRev has
|
||||
* already recorded the new one. Taking it would walk lastRev backwards and reload the
|
||||
* screen for a change it had already applied — then again five seconds later when the
|
||||
* real number reappeared. And the same number twice was never news to begin with. */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Offline, asleep, or the server restarting mid-deploy. The next tick asks again; a missed
|
||||
// poll is a few seconds of staleness, not an error worth putting in front of anybody.
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
schedule();
|
||||
};
|
||||
|
||||
const onVisible = () => { if (document.visibilityState === "visible") void tick(); };
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
void tick();
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
/** SMTP is optional. With nothing configured the app still records what it needs to — it just
|
||||
* doesn't post a notification, and says so in the logs rather than failing the caller's request. */
|
||||
export function mailConfigured() {
|
||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS && process.env.CONTACT_TO);
|
||||
}
|
||||
|
||||
/** Is transactional mail (password resets, staff activation) possible?
|
||||
* Deliberately separate from mailConfigured(): the contact form additionally needs CONTACT_TO,
|
||||
* and a missing CONTACT_TO must not silently disable password resets. */
|
||||
export function transactionalConfigured() {
|
||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS);
|
||||
}
|
||||
|
||||
/** Send to a specific person. Returns false rather than throwing: a caller deciding what to tell
|
||||
* the user should never be handed an SMTP stack trace. */
|
||||
export async function sendTo(to: string, subject: string, text: string): Promise<boolean> {
|
||||
if (!transactionalConfigured()) {
|
||||
console.warn("[mail] no SMTP configured — not sending:", subject);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const port = parseInt(process.env.SMTP_PORT || "587", 10);
|
||||
const t = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
|
||||
});
|
||||
await t.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER!, to, subject, text });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[mail] send failed:", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMail(subject: string, text: string, replyTo?: string): Promise<boolean> {
|
||||
if (!mailConfigured()) return false;
|
||||
try {
|
||||
const port = parseInt(process.env.SMTP_PORT || "587", 10);
|
||||
const t = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
|
||||
});
|
||||
await t.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER!,
|
||||
to: process.env.CONTACT_TO!,
|
||||
replyTo: replyTo || undefined,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[mail] send failed:", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 can’t 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 wouldn’t 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(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* A part-finished shelf count, parked in the browser while it is being taken.
|
||||
*
|
||||
* The counting screen writes on every tap and the variance screen reads it back, so the two have to
|
||||
* agree on the key and the shape. They used to agree by importing a helper out of the page module,
|
||||
* which worked but put the contract in the wrong place; it lives here now so sign-out can reach it
|
||||
* too.
|
||||
*
|
||||
* Scoped to the person as well as the shelf. A counter phone sits on a bench and is shared: keyed
|
||||
* on the location alone, an abandoned half-count was pre-filled straight into the next person's
|
||||
* screen, and they would commit somebody else's tally under their own name without ever being told
|
||||
* a count was already open. The user id keeps them apart, and gives sign-out something it can
|
||||
* clear.
|
||||
*
|
||||
* This is a scratchpad, not a cache. ThreadCount is online-only; nothing here is ever the record,
|
||||
* and it is deleted the moment the count commits.
|
||||
*/
|
||||
|
||||
const PREFIX = "tc.count.";
|
||||
|
||||
/** Where one person's open count of one shelf lives. */
|
||||
export const countKey = (userId: string, locationId: string) => `${PREFIX}${userId}.${locationId}`;
|
||||
|
||||
export type OpenCount = {
|
||||
/** Counted quantity by variant key. */
|
||||
n: Record<string, number>;
|
||||
/** When the tally was last touched, so variance can say how old it is — a count resumed the next
|
||||
* morning is a different thing from one still in your hand, and the screen should say which. */
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
export function readCount(userId: string, locationId: string): OpenCount | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(countKey(userId, locationId));
|
||||
if (!raw) return null;
|
||||
const v = JSON.parse(raw) as unknown;
|
||||
if (!v || typeof v !== "object") return null;
|
||||
const o = v as Partial<OpenCount>;
|
||||
// Anything that isn't the current shape is treated as no count at all rather than half-read:
|
||||
// a tally is only worth restoring if it is whole, and starting from zero is honest.
|
||||
if (!o.n || typeof o.n !== "object") return null;
|
||||
const n: Record<string, number> = {};
|
||||
for (const k of Object.keys(o.n)) {
|
||||
const q = Math.floor(Number((o.n as Record<string, unknown>)[k]));
|
||||
if (Number.isFinite(q) && q > 0) n[k] = q;
|
||||
}
|
||||
return { n, savedAt: typeof o.savedAt === "string" ? o.savedAt : "" };
|
||||
} catch {
|
||||
// A cleared, blocked or full store just means there is no count to resume.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCount(userId: string, locationId: string, n: Record<string, number>) {
|
||||
try {
|
||||
localStorage.setItem(countKey(userId, locationId), JSON.stringify({ n, savedAt: new Date().toISOString() } satisfies OpenCount));
|
||||
} catch { /* nothing to do if the store is full or blocked — the count carries on in memory */ }
|
||||
}
|
||||
|
||||
export function clearCount(userId: string, locationId: string) {
|
||||
try { localStorage.removeItem(countKey(userId, locationId)); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/** Everything this person has part-counted, on this device. Sign-out calls it: their tallies are
|
||||
* theirs, and leaving them behind on a shared phone is the leak the per-person key exists to stop
|
||||
* — the keys would otherwise sit there until the browser storage was cleared by hand. */
|
||||
export function clearAllCounts(userId: string) {
|
||||
try {
|
||||
const mine = `${PREFIX}${userId}.`;
|
||||
const doomed: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(mine)) doomed.push(k);
|
||||
}
|
||||
for (const k of doomed) localStorage.removeItem(k);
|
||||
} catch { /* blocked store — there was nothing written to clear either */ }
|
||||
}
|
||||
+3416
File diff suppressed because it is too large
Load Diff
@@ -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})`); }
|
||||
@@ -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"); }
|
||||
@@ -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
@@ -0,0 +1,142 @@
|
||||
/* What a facility's plan lets it do today.
|
||||
*
|
||||
* Six columns on Facility go in (plan, planStatus, trialEndsAt, paidUntil, grandfathered, and
|
||||
* isDemo for the one facility that has no plan at all) and one plain object comes out. Every
|
||||
* write door asks the object — never the plan's name — so a new plan is a row in PLANS here and
|
||||
* nothing anywhere else. There is no nightly job: the state is worked out from the dates each
|
||||
* time it is read, so a lapsed trial is read-only the moment the grace runs out and paid-again is
|
||||
* writable the moment a payment is recorded.
|
||||
*
|
||||
* The rules, which are also the pricing page's promises:
|
||||
* - Nothing that exists today is fenced. Plans differ in the staff-record ceiling on the free
|
||||
* hosted tier, how long backups are kept, and whether the room is read-only for non-payment.
|
||||
* - Read-only never deletes and never hides. Reports, exports, printing, sign-in and the whole
|
||||
* backup keep working; only writes are refused, with the reason and where to go.
|
||||
* - Grandfathered means free with everything, for good — a change of plan cannot take it away.
|
||||
* The one thing above it is an explicit read_only set by ThreadCount, kept for abuse.
|
||||
*/
|
||||
|
||||
import { COMMUNITY } from "./edition";
|
||||
|
||||
export type PlanCode = "hosted_small" | "hosted_facility" | "health_service" | "private";
|
||||
|
||||
/** What was last recorded. What it means today is `Entitlements.state`. */
|
||||
export type PlanStatus = "free" | "trial" | "active" | "read_only";
|
||||
|
||||
export type PlanState =
|
||||
| "grandfathered" // free with everything; the promise
|
||||
| "free" // Hosted Small: free, capped
|
||||
| "trial" // inside the trial
|
||||
| "active" // paid, inside the year
|
||||
| "grace" // trial or year has ended; a fortnight to sort the invoice, still writable
|
||||
| "read_only"; // grace ran out, or read-only was set by hand
|
||||
|
||||
export type Entitlements = {
|
||||
code: PlanCode;
|
||||
label: string;
|
||||
state: PlanState;
|
||||
readOnly: boolean;
|
||||
/** Staff records the register may hold, or null for no ceiling. */
|
||||
maxStaff: number | null;
|
||||
/** How long the hosted backups are kept, for the Plan screen. */
|
||||
backupDays: number;
|
||||
/** When the current period ends — the trial, or the paid year. */
|
||||
endsAt: Date | null;
|
||||
/** When grace ends and writes stop, when the facility is in grace. */
|
||||
graceEndsAt: Date | null;
|
||||
grandfathered: boolean;
|
||||
};
|
||||
|
||||
export const GRACE_DAYS = 14;
|
||||
export const TRIAL_DAYS = 60;
|
||||
|
||||
/** The prices, in Australian dollars before GST — one place, read by the pricing page, the
|
||||
* structured data, the sign-up choice and the notice to existing rooms. */
|
||||
export const PRICES = {
|
||||
hostedMonthly: 129,
|
||||
hostedAnnual: 1290,
|
||||
healthServiceAnnual: 4990,
|
||||
healthServiceFacilities: 5,
|
||||
healthServiceExtra: 890,
|
||||
/** Staff records a room may hold and stay on the free hosted plan. */
|
||||
freeStaff: 60,
|
||||
} as const;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
export const PLANS: Record<PlanCode, { label: string; maxStaff: number | null; backupDays: number }> = {
|
||||
hosted_small: { label: "Hosted Small", maxStaff: 60, backupDays: 14 },
|
||||
hosted_facility: { label: "Hosted Facility", maxStaff: null, backupDays: 35 },
|
||||
health_service: { label: "Health Service", maxStaff: null, backupDays: 35 },
|
||||
private: { label: "Private", maxStaff: null, backupDays: 35 },
|
||||
};
|
||||
|
||||
export const PLAN_CODES = Object.keys(PLANS) as PlanCode[];
|
||||
export const PLAN_STATUSES: PlanStatus[] = ["free", "trial", "active", "read_only"];
|
||||
|
||||
export function isPlanCode(x: unknown): x is PlanCode { return typeof x === "string" && x in PLANS; }
|
||||
export function isPlanStatus(x: unknown): x is PlanStatus { return PLAN_STATUSES.includes(x as PlanStatus); }
|
||||
|
||||
/** The columns this reads — one select to share between every caller. A member of a health
|
||||
* service carries its organisation's plan row too, and that row is the one that counts. */
|
||||
export const PLAN_COLS = {
|
||||
plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true, isDemo: true, stripeSubscriptionId: true,
|
||||
org: { select: { id: true, name: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true } },
|
||||
} as const;
|
||||
export type OrgPlanRow = { id: string; name: string; plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null };
|
||||
export type PlanRow = {
|
||||
plan: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean; isDemo?: boolean;
|
||||
stripeSubscriptionId?: string; org?: OrgPlanRow | null;
|
||||
};
|
||||
|
||||
export function entitlements(f: PlanRow, now: Date = new Date()): Entitlements {
|
||||
/* A facility inside a health service is on the health service's plan: its own columns are
|
||||
ignored while it is a member. Grandfathering stays the facility's own — a room that was free
|
||||
before plans keeps that if it ever leaves — and an explicit read-only on the facility still
|
||||
wins, because that is the tool for one room, not the whole service. */
|
||||
if (f.org && f.planStatus !== "read_only" && !f.grandfathered) {
|
||||
return entitlements({ plan: f.org.plan, planStatus: f.org.planStatus, trialEndsAt: f.org.trialEndsAt, paidUntil: f.org.paidUntil, grandfathered: false, isDemo: f.isDemo }, now);
|
||||
}
|
||||
const code: PlanCode = isPlanCode(f.plan) ? f.plan : "hosted_small";
|
||||
const def = PLANS[code];
|
||||
const base = { code, label: def.label, backupDays: def.backupDays, grandfathered: f.grandfathered, endsAt: null as Date | null, graceEndsAt: null as Date | null };
|
||||
|
||||
// A Community instance has no plans at all: everything, no ceiling, never read-only, whatever
|
||||
// its columns say. Then an explicit read-only beats everything, the demo has no plan, and the
|
||||
// promise beats the rest.
|
||||
if (COMMUNITY) return { ...base, label: "Community", state: "grandfathered", readOnly: false, maxStaff: null, grandfathered: true };
|
||||
if (f.planStatus === "read_only") return { ...base, state: "read_only", readOnly: true, maxStaff: null };
|
||||
if (f.isDemo) return { ...base, label: "Demo", state: "grandfathered", readOnly: false, maxStaff: null };
|
||||
// A grandfathered room with no plan recorded is simply "Free"; one that has been put on a
|
||||
// named plan (a pilot, a health service that later bought) keeps that plan's name.
|
||||
if (f.grandfathered) return { ...base, label: isPlanCode(f.plan) ? def.label : "Free", state: "grandfathered", readOnly: false, maxStaff: null };
|
||||
|
||||
if (f.planStatus === "trial" || f.planStatus === "active") {
|
||||
const endsAt = f.planStatus === "trial" ? f.trialEndsAt : f.paidUntil;
|
||||
// No date recorded means a period was started without an end: writable, and shown as
|
||||
// such, rather than read-only because somebody forgot a field.
|
||||
if (!endsAt) return { ...base, state: f.planStatus, readOnly: false, maxStaff: null };
|
||||
const graceEndsAt = new Date(endsAt.getTime() + GRACE_DAYS * DAY);
|
||||
if (now < endsAt) return { ...base, state: f.planStatus, readOnly: false, maxStaff: null, endsAt };
|
||||
if (now < graceEndsAt) return { ...base, state: "grace", readOnly: false, maxStaff: null, endsAt, graceEndsAt };
|
||||
return { ...base, state: "read_only", readOnly: true, maxStaff: null, endsAt, graceEndsAt };
|
||||
}
|
||||
|
||||
// "free": Hosted Small, capped — or a bigger plan set free on purpose (a pilot, an
|
||||
// internal room), which keeps that plan's ceiling.
|
||||
return { ...base, state: "free", readOnly: false, maxStaff: def.maxStaff };
|
||||
}
|
||||
|
||||
/** The whole days until `d`, never below zero. */
|
||||
export function daysUntil(d: Date | null, now: Date = new Date()): number | null {
|
||||
return d ? Math.max(0, Math.ceil((d.getTime() - now.getTime()) / DAY)) : null;
|
||||
}
|
||||
|
||||
/** What a refused write says. Where to go, never a price. */
|
||||
export const READ_ONLY_REFUSAL = "Read-only: this facility's plan has lapsed. Reports, exports and the backup still work — see Settings › Plan.";
|
||||
|
||||
/** Ops that stay open in a read-only room: sorting the plan out, and leaving. */
|
||||
export const READ_ONLY_ALLOWED = new Set(["plan.billing", "plan.invoice", "me.password", "me.profile", "me.deleteAccount"]);
|
||||
|
||||
export function staffRefusal(max: number): string {
|
||||
return `The register is full for this plan — ${max} staff records. Settings › Plan has the next step.`;
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||
|
||||
/* Password reset tokens.
|
||||
*
|
||||
* The raw token is shown to exactly one person, once, in one email, and is never stored: the
|
||||
* database keeps only its SHA-256. That matters because this table lands in every pg_dump, and a
|
||||
* plaintext token in a leaked backup is a working key to an account until it expires.
|
||||
*
|
||||
* SHA-256 rather than bcrypt is the right call here, unusually: the token is 32 bytes of CSPRNG
|
||||
* output, so there is no dictionary to attack and no need to be slow — and a reset lookup happens
|
||||
* before the user is authenticated, where a deliberately slow hash is a denial-of-service lever. */
|
||||
|
||||
/** One hour. Long enough to walk back to a desk, short enough that a forwarded email goes stale. */
|
||||
export const RESET_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
export function newResetToken() {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
return { token, tokenHash: hashResetToken(token) };
|
||||
}
|
||||
|
||||
export function hashResetToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** Constant-time compare, so a mismatched token can't be found a character at a time. */
|
||||
export function tokenMatches(a: string, b: string) {
|
||||
const ab = Buffer.from(a, "utf8");
|
||||
const bb = Buffer.from(b, "utf8");
|
||||
if (ab.length !== bb.length) return false;
|
||||
return timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
/** The link a person clicks. Absolute, because it is going into an email client. */
|
||||
export function resetUrl(token: string) {
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
|
||||
return `${base}/reset?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function resetEmail(firstName: string, url: string) {
|
||||
const subject = "Reset your ThreadCount password";
|
||||
const text = [
|
||||
`Hi ${firstName || "there"},`,
|
||||
"",
|
||||
"Someone asked to reset the password on your ThreadCount account. If that was you, open this link:",
|
||||
"",
|
||||
url,
|
||||
"",
|
||||
"The link works once and expires in an hour.",
|
||||
"",
|
||||
"If it wasn't you, you can ignore this — your password hasn't changed, and nobody can get in without this link.",
|
||||
"",
|
||||
"— ThreadCount",
|
||||
].join("\n");
|
||||
return { subject, text };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||
import { prisma } from "./db";
|
||||
|
||||
export const COOKIE_NAME = "tc_session";
|
||||
const MAX_AGE = 60 * 60 * 24 * 14; // 14 days
|
||||
|
||||
function secret() {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s) throw new Error("SESSION_SECRET not set");
|
||||
return s;
|
||||
}
|
||||
|
||||
function b64url(buf: Buffer) {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** Short fingerprint of the password hash: a password change (or admin reset) invalidates every older token. */
|
||||
export function pwVersion(passwordHash: string) { return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12); }
|
||||
|
||||
/** `sso` marks a session the facility's own identity provider signed in — no password crossed here. */
|
||||
export function signSession(uid: string, passwordHash: string, maxAge = MAX_AGE, sso = false) {
|
||||
const payload = b64url(Buffer.from(JSON.stringify({ uid, pv: pwVersion(passwordHash), exp: Date.now() + maxAge * 1000, ...(sso ? { sso: true } : {}) })));
|
||||
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
export function readSessionToken(raw: string | undefined): { uid: string; pv: string; sso: boolean } | null {
|
||||
if (!raw) return null;
|
||||
const [payload, sig] = raw.split(".");
|
||||
if (!payload || !sig) return null;
|
||||
const expect = b64url(createHmac("sha256", secret()).update(payload).digest());
|
||||
const a = Buffer.from(sig), b = Buffer.from(expect);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
|
||||
if (!data.uid || !data.exp || data.exp < Date.now()) return null;
|
||||
return { uid: data.uid, pv: String(data.pv || ""), sso: data.sso === true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSessionCookie(uid: string, passwordHash: string, sso = false) {
|
||||
const jar = await cookies();
|
||||
jar.set(COOKIE_NAME, signSession(uid, passwordHash, MAX_AGE, sso), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionCookie() {
|
||||
const jar = await cookies();
|
||||
jar.set(COOKIE_NAME, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
|
||||
}
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
facilityId: string;
|
||||
email: string;
|
||||
first: string;
|
||||
last: string;
|
||||
title: string;
|
||||
role: "ADMIN" | "ISSUER";
|
||||
isDemo: boolean;
|
||||
/** Signed in through the facility's identity provider rather than a password. */
|
||||
viaSso: boolean;
|
||||
};
|
||||
|
||||
export async function currentUser(): Promise<SessionUser | null> {
|
||||
const jar = await cookies();
|
||||
const tok = readSessionToken(jar.get(COOKIE_NAME)?.value);
|
||||
if (!tok) return null;
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: tok.uid },
|
||||
select: { id: true, facilityId: true, email: true, first: true, last: true, title: true, role: true, inactive: true, passwordHash: true, facility: { select: { isDemo: true } } },
|
||||
});
|
||||
if (!u || u.inactive) return null;
|
||||
if (tok.pv !== pwVersion(u.passwordHash)) return null; // password changed since this token was minted
|
||||
const { inactive: _i, passwordHash: _p, facility, ...rest } = u; void _i; void _p;
|
||||
return { ...rest, isDemo: facility.isDemo, viaSso: tok.sso };
|
||||
}
|
||||
|
||||
export async function requireUser(): Promise<SessionUser> {
|
||||
const u = await currentUser();
|
||||
if (!u) throw new Error("UNAUTHENTICATED");
|
||||
return u;
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
/* Set allowances, and what a set is.
|
||||
*
|
||||
* One ceiling, and it is a ceiling on what somebody is HOLDING: six sets, at any time, for every
|
||||
* group in the building. Not six a year — nothing here resets in July, and there is no date in this
|
||||
* file at all. Six sets is what a person has on their back and in their locker, so the only way past
|
||||
* a full six is to hand something in — a swap, not an addition — or a coordinator's override,
|
||||
* recorded as the exception it is.
|
||||
*
|
||||
* Each facility names its own staff groups and puts each one on one of three routes. The routes
|
||||
* differ in how somebody gets up to that ceiling, never in whether they have one:
|
||||
*
|
||||
* - The FTE table. The hours worked propose the number — a full-timer five sets, a half-timer
|
||||
* three, a casual whatever the manager thinks right — and a manager may sign above the proposal,
|
||||
* up to the ceiling. The proposal lives in lib/compute with the rest of the FTE table; what lives
|
||||
* here is the ceiling it is signed up to.
|
||||
* - The starting kit. A fixed number of sets on the first day, then more as they need them, up to
|
||||
* the same ceiling. Nothing has to be handed back first: asked whether the first set had to come
|
||||
* back before the next was issued, the owner said no.
|
||||
* - Manager approval. No starting kit; their manager approves a set at a time.
|
||||
*
|
||||
* Which group is on which route is the facility's own answer, two lists of names on its settings
|
||||
* (Facility.nursingGroups for the FTE table, Facility.kitGroups for the starting kit). Nothing here
|
||||
* guesses it from the letters in a group's name: the same job is "Housekeeping" in one building and
|
||||
* "Support Services" in the next, and a guess that misses one takes a whole team's first kit away.
|
||||
* lib/compute reads the lists; this file is told the answer.
|
||||
*
|
||||
* A **set is one top and one pair of trousers**. That matters because ThreadCount's entitlement used
|
||||
* to count individual garments, and counting garments would let someone take three tops and no
|
||||
* trousers and be "fully issued". Sets held is therefore the smaller of the two counts, which is
|
||||
* also how anybody would describe it out loud.
|
||||
*
|
||||
* The smaller of the two counts is not a ceiling on its own, though: twenty tops and one pair of
|
||||
* trousers is "one set" by that measure. So the ceiling bites on each half — at most six tops AND at
|
||||
* most six pairs — which is what capState() below works out, and why it answers in halves rather
|
||||
* than with a single number.
|
||||
*
|
||||
* This file is the one place that answers "how many sets may this person hold". The starting figure
|
||||
* and the ceiling are facility settings (Facility.initialSets, Facility.capSets) and are passed in —
|
||||
* but the sums they go into, and the figures a facility that has never been asked falls back to,
|
||||
* live here and nowhere else. The counter screens ask through lib/compute, which defers to these;
|
||||
* that is why nothing here may import compute, or the two would be a cycle.
|
||||
*/
|
||||
|
||||
/** The two fields any of these predicates read of a garment. Callers holding a narrow select (the
|
||||
* staff app reads the catalogue without costs) don't have to fake a whole Item to ask. */
|
||||
export type Garment = { type?: string; item?: string };
|
||||
|
||||
/** The garment types that make up the two halves of a set. lib/compute re-exports them and builds
|
||||
* the catalogue's full type vocabulary around them. */
|
||||
export const TOP_TYPES = ["Shirt", "Polo", "Tunic", "Scrub top", "Blouse"];
|
||||
export const PANT_TYPES = ["Pants", "Trousers", "Cargo pants", "Shorts", "Skort", "Skirt"];
|
||||
|
||||
/** Case-insensitive match against one of those vocabularies. Exported only so lib/compute's own
|
||||
* blocks — maternity, outerwear — can ask the same question the same way. */
|
||||
export const isTypeIn = (types: readonly string[], type: string) =>
|
||||
types.some((t) => t.toLowerCase() === type.toLowerCase());
|
||||
|
||||
// An explicit type wins; items saved before the field existed keep the old name-based guess.
|
||||
//
|
||||
// The trap worth knowing: type is a free-text field with a datalist behind it, not a closed list,
|
||||
// and both of these read it as an exact (case-insensitive) match against the vocabulary above. A
|
||||
// hand-typed "scrubs" or "Scrub Tops" therefore answers false everywhere — worse than leaving type
|
||||
// blank, which at least falls back to the garment name. Ask through these helpers rather than
|
||||
// comparing `it.type` yourself, or one typo in the catalogue quietly stops a garment counting as a
|
||||
// top, and a wearer's holdings stop pairing into sets.
|
||||
export const isTopItem = (it: Garment | undefined) =>
|
||||
it?.type ? isTypeIn(TOP_TYPES, it.type) : /top|shirt|polo|tunic|blouse/i.test(it?.item || "");
|
||||
export const isPantItem = (it: Garment | undefined) =>
|
||||
it?.type ? isTypeIn(PANT_TYPES, it.type) : /pant|bottom|trouser|skort|short|cargo/i.test(it?.item || "");
|
||||
|
||||
/** A set is a top and a bottom. Two garments, for every stream, everywhere the word "set" is used —
|
||||
* named rather than written as a bare 2, because a bare 2 in a sum is indistinguishable from a
|
||||
* rounding fudge six months later. */
|
||||
export const SET_GARMENTS = 2;
|
||||
|
||||
/** The starting allocation for a facility that has never been asked. A facility sets its own figure
|
||||
* (Settings.initialSets, seeded with this one); this is the fallback, not a second rule. */
|
||||
export const SETS_ON_START = 3;
|
||||
/** The ceiling, for a facility that has never been asked (Facility.capSets). Six sets is twelve
|
||||
* garments, and it is everybody's: every group ends at the same place, whichever of the three
|
||||
* routes it took to get there. */
|
||||
export const SETS_CAP = 6;
|
||||
|
||||
/** The starting sets this facility issues *to the groups on the starting-kit route*. Takes the
|
||||
* configured figure and stands in for it when there isn't one: a blank or a nonsense number must not
|
||||
* become an offer of zero sets to somebody starting on Monday, so the standing figure holds until a
|
||||
* coordinator says otherwise.
|
||||
*
|
||||
* Whose number it is matters as much as what it is. Nobody on the other two routes starts on it —
|
||||
* the FTE table proposes its own number, and manager approval starts on nothing at all — so asking
|
||||
* this about somebody on manager approval answers a question that was never put, and quoting the
|
||||
* answer to them promises a kit the counter would turn them away for.
|
||||
*
|
||||
* Fractions are floored — the counter can only hand over whole tops and whole trousers, so half a
|
||||
* set is a loose garment, not an entitlement. */
|
||||
export function setsOnStart(configured?: number | null): number {
|
||||
const n = Number(configured);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : SETS_ON_START;
|
||||
}
|
||||
|
||||
/** This facility's ceiling, in sets held at any one time, for anybody at all. Same treatment as the
|
||||
* starting figure: a blank or a nonsense number must not turn into a ceiling of zero that declines
|
||||
* the whole hospital, so the standing figure holds until a coordinator says otherwise, and
|
||||
* fractions are floored because half a set is a loose garment. */
|
||||
export function setsCap(configured?: number | null): number {
|
||||
const n = Number(configured);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : SETS_CAP;
|
||||
}
|
||||
|
||||
/** The three routes to the ceiling: the FTE table, the starting kit, and manager approval. */
|
||||
export type AllowanceRoute = "fte" | "kit" | "approval";
|
||||
|
||||
/** Which route somebody is on, given the facility's two answers about their group — is it on the FTE
|
||||
* table list, is it on the starting-kit list. On neither is manager approval.
|
||||
*
|
||||
* The server refuses a group on both lists, but a backup restored from a file somebody edited could
|
||||
* still carry one, and every screen has to give the same answer when it does. The FTE table wins.
|
||||
* The order form always asked it first, so a group caught on both goes on getting what it got
|
||||
* before; and it is the route where a manager's signature stands behind anything past the table's
|
||||
* proposal, which is the safer of the two to land on than a fixed kit handed over the counter with
|
||||
* nobody signing. Decided here, once, so the order form, the counter, the wearer's app and the
|
||||
* manager's review cannot each settle it differently. */
|
||||
export function allowanceRoute(on: { nursing?: boolean; kit?: boolean }): AllowanceRoute {
|
||||
if (on.nursing) return "fte";
|
||||
if (on.kit) return "kit";
|
||||
return "approval";
|
||||
}
|
||||
|
||||
/** Which half of a set a garment is, or null when it is no part of one — outerwear, maternity wear,
|
||||
* a hat. One question asked in one place, so that the counter, the wearer's app and the manager's
|
||||
* review screen never disagree about whether a fleece paired with a pair of trousers.
|
||||
*
|
||||
* A maternity garment entered with its proper type answers null here, because a maternity tunic is
|
||||
* never swapped for a standard one and the two of them are not the two-piece uniform. One saved
|
||||
* with no type at all is read by its name, exactly as every other untyped garment is. */
|
||||
export function setHalf(it: Garment | undefined): "top" | "pants" | null {
|
||||
if (isTopItem(it)) return "top";
|
||||
if (isPantItem(it)) return "pants";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A pile of garments counted the way the ceiling reads it: tops, trousers, whatever is in no set at
|
||||
* all, and the complete sets the first two make between them. */
|
||||
export type GarmentCounts = { tops: number; pants: number; other: number; sets: number };
|
||||
export function garmentCounts(holdings: { item: Garment; qty: number }[]): GarmentCounts {
|
||||
let tops = 0, pants = 0, other = 0;
|
||||
for (const h of holdings) {
|
||||
const half = setHalf(h.item);
|
||||
if (half === "top") tops += h.qty;
|
||||
else if (half === "pants") pants += h.qty;
|
||||
else other += h.qty;
|
||||
}
|
||||
return { tops, pants, other, sets: Math.min(tops, pants) };
|
||||
}
|
||||
|
||||
/** How many complete sets a person is holding, from their current holdings. */
|
||||
export function setsHeld(holdings: { item: Garment; qty: number }[]): number {
|
||||
return garmentCounts(holdings).sets;
|
||||
}
|
||||
|
||||
/** Loose garments that don't yet pair into a set — useful for saying "3 sets and a spare top". */
|
||||
export function looseGarments(holdings: { item: Garment; qty: number }[]): { tops: number; pants: number } {
|
||||
const c = garmentCounts(holdings);
|
||||
return { tops: c.tops - c.sets, pants: c.pants - c.sets };
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** The ceiling, worked out for one person and whatever is about to be handed over, in the parts a
|
||||
* screen or a coordinator needs to see the reason.
|
||||
*
|
||||
* `cap` is sets. The two halves are counted separately against it — at most six tops and at most
|
||||
* six pairs — because the smaller of the two counts is not a ceiling: twenty tops and one pair is
|
||||
* "one set" by that measure, and a locker with twenty tops in it is exactly what the ceiling is
|
||||
* there to prevent.
|
||||
*
|
||||
* `other` is everything that is no part of a set — a fleece, a jacket, maternity wear. The six-set
|
||||
* ceiling says nothing about those, so they carry the same number as a ceiling of their own,
|
||||
* counted in garments: nobody needs seven jackets at once either, and with no ceiling at all they
|
||||
* would be the one thing in the building nothing ever asked about. The same figure as the sets
|
||||
* ceiling deliberately, so a coordinator has one number to remember and nobody has to keep two
|
||||
* settings in step.
|
||||
*
|
||||
* `over` is the counter's question — after this hand-over, is this person still inside what one
|
||||
* person holds. `breach` says which of the three ceilings it is and `overBy` how many garments past
|
||||
* it, because a coordinator asked to tick an override is owed something they can check against the
|
||||
* person in front of them: "holds six tops and six pairs already" is checkable, a bare refusal is
|
||||
* not. Past it, the way on is a hand-in — a swap rather than an addition — or that override. */
|
||||
export type CapState = {
|
||||
cap: number;
|
||||
otherCap: number;
|
||||
tops: number; pants: number; other: number; sets: number;
|
||||
addTops: number; addPants: number; addOther: number;
|
||||
afterTops: number; afterPants: number; afterOther: number; afterSets: number;
|
||||
overTops: number; overPants: number; overOther: number;
|
||||
over: boolean;
|
||||
overBy: number;
|
||||
breach: "tops" | "pants" | "both" | "other" | null;
|
||||
note: string;
|
||||
};
|
||||
export function capState(opts: {
|
||||
held: { tops: number; pants: number; other?: number };
|
||||
adding?: { tops?: number; pants?: number; other?: number };
|
||||
capSets?: number | null;
|
||||
}): CapState {
|
||||
const cap = setsCap(opts.capSets);
|
||||
const tops = Math.max(0, opts.held.tops), pants = Math.max(0, opts.held.pants), other = Math.max(0, opts.held.other || 0);
|
||||
const addTops = Math.max(0, opts.adding?.tops || 0), addPants = Math.max(0, opts.adding?.pants || 0), addOther = Math.max(0, opts.adding?.other || 0);
|
||||
const afterTops = tops + addTops, afterPants = pants + addPants, afterOther = other + addOther;
|
||||
const overTops = Math.max(0, afterTops - cap), overPants = Math.max(0, afterPants - cap), overOther = Math.max(0, afterOther - cap);
|
||||
const breach = overTops && overPants ? "both" : overTops ? "tops" : overPants ? "pants" : overOther ? "other" : null;
|
||||
const over = breach !== null;
|
||||
const overBy = Math.max(overTops, overPants, overOther);
|
||||
const sets = Math.min(tops, pants), afterSets = Math.min(afterTops, afterPants);
|
||||
// Said in garments as well as in sets, because a hand-over is garments: "6 sets" on its own tells
|
||||
// somebody holding six tops and two pairs nothing about why they are being turned away.
|
||||
const holds = `${plural(afterTops, "top", "tops")} and ${plural(afterPants, "pair", "pairs")}`;
|
||||
const note = !over
|
||||
? `${afterSets} of ${cap} sets — ${holds}.${afterOther ? ` Plus ${plural(afterOther, "garment", "garments")} outside a set.` : ""}`
|
||||
: breach === "other"
|
||||
? `That would be ${plural(afterOther, "garment", "garments")} outside a set, and ${cap} outside a set is the most anyone holds. Hand one in to make room, or a coordinator can record an override.`
|
||||
: `That would be ${holds}, and the most anyone holds is ${cap} sets — ${cap} tops and ${cap} pairs. Hand ${breach === "both" ? "a top and a pair" : breach === "tops" ? "a top" : "a pair"} in to make room, or a coordinator can record an override.`;
|
||||
return {
|
||||
cap, otherCap: cap,
|
||||
tops, pants, other, sets,
|
||||
addTops, addPants, addOther,
|
||||
afterTops, afterPants, afterOther, afterSets,
|
||||
overTops, overPants, overOther,
|
||||
over, overBy, breach, note,
|
||||
};
|
||||
}
|
||||
|
||||
/** What this person may hold, and how they get there.
|
||||
*
|
||||
* `cap` and `max` are both the facility's ceiling, for every route. The starting-kit route used to be
|
||||
* the exception — its second allocation waited on a hand-in — until the owner said nothing has to
|
||||
* come back before the next set is issued. So there is one figure, and it is the one the counter
|
||||
* refuses on; `cap` is kept beside `max` only so nothing calling this had to change the same day.
|
||||
*
|
||||
* `start` is the starting-kit route's figure — what they are handed on the first day — and null on
|
||||
* the other two routes, whose starting number comes from the FTE table or a signature.
|
||||
*
|
||||
* The note is written about nobody in particular, because it lands in three places — the wearer's own
|
||||
* app, the manager's review, and the counter — and "your manager" means a different person in each.
|
||||
*
|
||||
* `nursing` and `kit` are the facility's own answers about this person's group — is it on the FTE
|
||||
* table list, is it on the starting-kit list — from lib/compute's isNursingGroup() and isKitGroup(),
|
||||
* which read the facility's lists of names. They have to be passed in because this file has no
|
||||
* facility to ask, and allowanceRoute() above turns the pair into one route. Neither decides
|
||||
* whether there is a ceiling, only which sentence describes the way to it and whether there is a
|
||||
* starting figure. Deciding either here from the group name would mean a second test for who is on
|
||||
* which route, and two tests drift: the last one cost a whole group their allowance when somebody
|
||||
* renamed a label. Leave `kit` off and the person is read as being on manager approval — told they
|
||||
* start on nothing — so every caller that can reach the facility's lists has to pass both.
|
||||
*
|
||||
* `group` is no longer read: the route comes from the two answers above. It stays in the signature
|
||||
* so that nothing calling this had to change the day the letters in a name stopped deciding it.
|
||||
*
|
||||
* `capped` is always true now and `cap` is never null. Both are kept so that nothing calling this
|
||||
* had to change on the same day the rule did, and both can go once the screens have.
|
||||
*
|
||||
* `startingSets` and `capSets` are the facility's configured figures. Both are optional because the
|
||||
* staff app holds no facility register, and a wearer's screen quoting the standing figure beats it
|
||||
* quoting nothing — but any caller that can reach settings should pass them, or a site that issues
|
||||
* four will go on telling its wearers three. */
|
||||
export function allowance(opts: {
|
||||
group?: string | null;
|
||||
held: number;
|
||||
startingSets?: number | null;
|
||||
nursing?: boolean;
|
||||
kit?: boolean;
|
||||
capSets?: number | null;
|
||||
}): { capped: boolean; cap: number | null; max: number; start: number | null; used: number; note: string } {
|
||||
const max = setsCap(opts.capSets);
|
||||
const garments = max * SET_GARMENTS;
|
||||
const route = allowanceRoute(opts);
|
||||
if (route !== "kit") {
|
||||
// The FTE table and manager approval both end at the ceiling; what differs is what proposes the
|
||||
// number on the way up. On the table somebody's hours propose theirs and a manager may sign
|
||||
// above it; on approval nothing is proposed at all and each set comes with a signature.
|
||||
const note = route === "fte"
|
||||
? `Up to ${max} sets — ${garments} garments — at any time. The hours worked propose the starting number, and a manager can sign for more, up to that.`
|
||||
: `Up to ${max} sets — ${garments} garments — at any time, each one approved by a manager.`;
|
||||
return { capped: true, cap: max, max, start: null, used: opts.held, note };
|
||||
}
|
||||
// Never above the ceiling: a site that set its starting figure to seven would otherwise print an
|
||||
// offer the counter then declines.
|
||||
const start = Math.min(max, setsOnStart(opts.startingSets));
|
||||
return {
|
||||
capped: true, cap: max, max, start, used: opts.held,
|
||||
note: `${start} sets on starting, then more as needed, up to ${max} sets — ${garments} garments — at any time. Nothing has to be handed back first.`,
|
||||
};
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { prisma } from "./db";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { facilityToday, garmentGroups, groupsLabel, key, type Snapshot } from "./compute";
|
||||
import { AWAITING_HANDOVER } from "./staffreq";
|
||||
import type { SessionUser } from "./session";
|
||||
import { daysUntil, entitlements } from "./plan";
|
||||
import { stripeConfigured } from "./stripe";
|
||||
import { SWITCH_ROW } from "./switches";
|
||||
|
||||
/* How much history the snapshot carries.
|
||||
*
|
||||
* Both /app and /m rebuild this on every navigation and hand the whole thing to a client
|
||||
* component, so anything unbounded here is a payload that grows for the life of the facility and
|
||||
* is downloaded again on a ward phone at every tap. The collections below are read newest-first
|
||||
* and capped: each is shown as a list and nothing derives a balance from it, so an older row
|
||||
* falling off the end costs a line on a history screen, never a wrong number.
|
||||
*
|
||||
* Issues, stock movements and orders are deliberately NOT capped. On-hand is derived by replaying
|
||||
* every one of them (ledger() in lib/compute.ts), and a garment issued three years ago and never
|
||||
* handed back is still on that person's record — so a window over any of the three would quietly
|
||||
* misstate the one figure this product exists to keep.
|
||||
*
|
||||
* The numbers clear several years of a busy linen room, and hand-ins in particular are generous:
|
||||
* entUsed() credits a hand-in back against the allowance for the whole financial year, so that
|
||||
* window has to comfortably outlast one. */
|
||||
const HISTORY_TAKE = { stocktakes: 200, handins: 5000, alterations: 500 };
|
||||
|
||||
/* The plan as the screens see it: the entitlements plus the dates, days and the billing contact —
|
||||
* the last for admins only, since it is a contact. `live` is the platform switch: until plans are
|
||||
* live the Plan tab stays out of Settings and the banner stays quiet, whatever the columns say. */
|
||||
function planBlock(fac: Parameters<typeof entitlements>[0] & { billingEmail: string; lastBackup: string; stripeSubscriptionId: string; org?: { id: string; name: string } | null }, staffCount: number, live: boolean, admin: boolean): Snapshot["plan"] {
|
||||
const e = entitlements(fac);
|
||||
return {
|
||||
code: e.code, label: e.label, state: e.state, readOnly: e.readOnly, maxStaff: e.maxStaff, staff: staffCount, backupDays: e.backupDays,
|
||||
endsAt: e.endsAt ? e.endsAt.toISOString() : null, daysLeft: daysUntil(e.endsAt),
|
||||
graceEndsAt: e.graceEndsAt ? e.graceEndsAt.toISOString() : null, graceDaysLeft: daysUntil(e.graceEndsAt),
|
||||
grandfathered: e.grandfathered, billingEmail: admin ? fac.billingEmail : "", live,
|
||||
// The card path: shown only when Stripe is configured on this instance; `card` says a
|
||||
// subscription exists, so the screen offers "Manage card" instead of "Pay by card".
|
||||
cardsOn: stripeConfigured(), card: !!fac.stripeSubscriptionId,
|
||||
org: fac.org ? { name: fac.org.name } : null,
|
||||
// Who invoices, once the entity exists (INVOICE_ENTITY / INVOICE_ABN); blank until then.
|
||||
invoicer: process.env.INVOICE_ENTITY ? `${process.env.INVOICE_ENTITY}${process.env.INVOICE_ABN ? ` · ABN ${process.env.INVOICE_ABN}` : ""}` : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** `db` may be a transaction client so callers holding a lock read through the same connection. */
|
||||
export async function buildSnapshot(user: SessionUser, db: Prisma.TransactionClient | typeof prisma = prisma): Promise<Snapshot> {
|
||||
const fid = user.facilityId;
|
||||
const [fac, catalog, barcodes, stock, moves, depts, suppliers, staff, approvals, alterations, issues, orders, pickups, stocktakes, users, handins, locations, costs, owedLines, switchRow] = await Promise.all([
|
||||
db.facility.findUniqueOrThrow({ where: { id: fid }, include: { org: { select: { id: true, name: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true } } } }),
|
||||
db.catalogItem.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
|
||||
db.barcode.findMany({ where: { facilityId: fid } }),
|
||||
db.stockLevel.findMany({ where: { facilityId: fid } }),
|
||||
db.stockMove.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
|
||||
db.department.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
|
||||
db.supplier.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
|
||||
db.staff.findMany({ where: { facilityId: fid }, orderBy: [{ last: "asc" }, { first: "asc" }], include: { account: { select: { email: true } } } }),
|
||||
db.approval.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
|
||||
db.alteration.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, take: HISTORY_TAKE.alterations }),
|
||||
db.issue.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" } }),
|
||||
db.order.findMany({
|
||||
where: { facilityId: fid },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { lines: { orderBy: { sort: "asc" } }, receipts: { orderBy: { createdAt: "asc" }, include: { lines: true } } },
|
||||
}),
|
||||
// Only the pickups still waiting. Every screen that reads them — the call list, the rounds
|
||||
// screen, a staff record, both home pages — filters on `!pickedUp`, and one that has been
|
||||
// collected is already on the record as the Issue it created.
|
||||
db.pickup.findMany({ where: { facilityId: fid, pickedUp: null }, orderBy: { createdAt: "asc" }, include: { lines: true, order: { select: { code: true } } } }),
|
||||
db.stocktake.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, include: { lines: true }, take: HISTORY_TAKE.stocktakes }),
|
||||
user.role === "ADMIN"
|
||||
? db.user.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" }, select: { id: true, email: true, first: true, last: true, title: true, role: true, inactive: true, ssoBreakGlass: true } })
|
||||
: Promise.resolve([]),
|
||||
db.handIn.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "desc" }, include: { lines: true }, take: HISTORY_TAKE.handins }),
|
||||
db.location.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
|
||||
db.costChange.findMany({
|
||||
where: { facilityId: fid },
|
||||
orderBy: { at: "desc" },
|
||||
take: 400,
|
||||
}),
|
||||
// Garments a manager has approved that nobody has handed over yet. They are owed to that person,
|
||||
// so they count toward the six sets held on every screen exactly as the counter counts them.
|
||||
// Without them a staff record shows room the counter then refuses.
|
||||
db.requestLine.findMany({
|
||||
where: { status: "approved", request: { facilityId: fid, status: { in: AWAITING_HANDOVER } } },
|
||||
select: { itemId: true, qty: true, request: { select: { subjectId: true } } },
|
||||
}),
|
||||
// Read through `db`, not lib/switches.ts: callers inside lockedTx hold the one connection a
|
||||
// single-connection pool has, and a query on the global client would wait for it until the
|
||||
// transaction timed out. The environment's override is applied below, as switches() applies it.
|
||||
db.platformSwitch.findUnique({ where: { id: SWITCH_ROW }, select: { plansLive: true } }),
|
||||
]);
|
||||
const plansLive = process.env.PLANS_LIVE === "1" || !!switchRow?.plansLive;
|
||||
|
||||
const bc: Record<string, string> = {};
|
||||
for (const b of barcodes) bc[b.code] = key(b.itemId, b.sizeIndex);
|
||||
const st: Record<string, { opening: number; adj: number; reorder: number | null; preloved: number }> = {};
|
||||
for (const s of stock) st[key(s.itemId, s.sizeIndex)] = { opening: s.opening, adj: s.adj, reorder: s.reorder, preloved: s.preloved };
|
||||
const placed: Record<string, string> = {};
|
||||
for (const s of stock) if (s.locationId) placed[key(s.itemId, s.sizeIndex)] = s.locationId;
|
||||
|
||||
return {
|
||||
session: { userId: user.id, name: `${user.first} ${user.last}`, first: user.first, last: user.last, title: user.title, role: user.role === "ADMIN" ? "Admin" : "Issuer", email: user.email },
|
||||
settings: {
|
||||
facility: fac.name, location: fac.location, coordinator: fac.coordinator,
|
||||
// The order form prints the linen room's own contacts in its footer, the way the collection
|
||||
// slip prints the facility's own name. Nothing about a customer's identity is written down in
|
||||
// the product, so if these are blank the form's footer is blank.
|
||||
coordinatorEmail: fac.coordinatorEmail, coordinatorPhone: fac.coordinatorPhone,
|
||||
defaultEntitlement: fac.defaultEntitlement, defaultReorder: fac.defaultReorder,
|
||||
// capSets travels with initialSets because they are the two halves of the same question — what
|
||||
// a facility hands out to start with, and the most anybody may hold. Leave it out and the
|
||||
// coordinator's ceiling is saved but never reaches the screens that have to honour it. The two
|
||||
// route lists travel with them: which groups the FTE table proposes for, and which start on the
|
||||
// kit. Drop kitGroups and every group on it reads as manager approval, told it starts on
|
||||
// nothing while the counter still hands it the kit.
|
||||
initialSets: fac.initialSets, capSets: fac.capSets, nursingGroups: fac.nursingGroups, kitGroups: fac.kitGroups,
|
||||
exceptionHigh: fac.exceptionHigh, varianceReason: fac.varianceReason, glAccount: fac.glAccount, journalDesc: fac.journalDesc, lastBackup: fac.lastBackup, hasLogo: !!fac.logoData,
|
||||
suppliers: suppliers.map((x) => x.name), staffGroups: fac.staffGroups,
|
||||
slipCollectionFooter: fac.slipCollectionFooter, slipDeliveryFooter: fac.slipDeliveryFooter, slipOrg: fac.slipOrg,
|
||||
barcodeLookup: fac.barcodeLookup,
|
||||
timezone: fac.timezone,
|
||||
// The switches only; the IdP metadata lives in the SSO service and is read by its own route.
|
||||
sso: { enabled: fac.ssoEnabled, required: fac.ssoRequired, staff: fac.ssoStaff, domains: fac.ssoDomains },
|
||||
},
|
||||
// groups decides; group is only its label, kept because the phone counter's catalogue screens read it.
|
||||
catalog: catalog.map((c) => ({ id: c.id, sort: c.sort, item: c.item, gender: c.gender, type: c.type, sku: c.sku, supplier: c.supplier, cost: c.cost, groups: garmentGroups(c.groups), group: groupsLabel(c.groups), notes: c.notes, sizes: c.sizes, archived: c.archived })),
|
||||
barcodes: bc,
|
||||
stock: st,
|
||||
locations: locations.map((l) => ({ id: l.id, name: l.name, kind: l.kind, parentId: l.parentId, sort: l.sort, archived: l.archived })),
|
||||
placed,
|
||||
moves: moves.map((m) => ({ id: m.id, date: m.date, type: m.type, itemId: m.itemId, si: m.sizeIndex, qty: m.qty, reason: m.reason, byName: m.byName })),
|
||||
costs: costs.map((c) => ({ id: c.id, itemId: c.itemId, cost: c.cost, previous: c.previous, at: c.at.toISOString(), byName: c.byName })),
|
||||
depts: depts.map((d) => ({ id: d.id, name: d.name, cc: d.cc })),
|
||||
supplierDir: suppliers.map((x) => ({ id: x.id, name: x.name, contact: x.contact, phone: x.phone, account: x.account, lead: x.lead })),
|
||||
// selfCode is a boolean, never the code itself: an outstanding activation code is a credential,
|
||||
// and the snapshot is downloaded whole into every coordinator's browser. The code is shown once,
|
||||
// in the response to the op that made it, and then only exists on the printed slip.
|
||||
//
|
||||
// When it was printed is not a secret, and a screen needs it: slips expire (see the staleness
|
||||
// check in app/api/staff/activate/route.ts), so without the date a coordinator looking at an
|
||||
// outstanding code cannot tell a slip somebody will use tomorrow from one that died weeks ago
|
||||
// and is only ever going to send that person back to the counter.
|
||||
staff: staff.map((s) => ({ id: s.id, num: s.num, first: s.first, last: s.last, phone: s.phone, group: s.group, dept: s.dept, top: s.top, pants: s.pants, ccOverride: s.ccOverride, inactive: s.inactive, ent: s.ent, fte: s.fte, uniformStyle: s.uniformStyle, start: s.start, notes: s.notes, selfCode: !!s.activateCode, selfCodeAt: s.activateCodeAt ? s.activateCodeAt.toISOString() : null, selfEmail: s.account?.email ?? "", managerId: s.managerId, wardDesk: s.wardDesk })),
|
||||
// Both halves of "approved by" travel: the name as signed, and the link to the register row
|
||||
// it was picked from. The link is null on every approval recorded before the search existed and
|
||||
// on any approver who was never on the register, so nothing may assume it is there.
|
||||
approvals: approvals.map((a) => ({ id: a.id, staffId: a.staffId, date: a.date, by: a.byName, byStaffId: a.byStaffId, sets: a.sets, fte: a.fte, notes: a.notes, used: a.used, photoId: a.photoId })),
|
||||
alterations: alterations.map((a) => ({ id: a.id, staffId: a.staffId, date: a.date, garment: a.garment, desc: a.desc, status: a.status })),
|
||||
issues: issues.map((i) => ({
|
||||
id: i.id, date: i.date, staffId: i.staffId, itemId: i.itemId, si: i.sizeIndex, qty: i.qty, cond: i.cond, cost: i.cost, orderCode: i.orderCode,
|
||||
receipt: i.receipt, returned: i.returnedDate ? { date: i.returnedDate, cond: i.returnedCond || "", photoId: i.returnPhotoId } : null, override: i.override, direct: i.direct,
|
||||
preloved: i.preloved, handedIn: i.handedIn, createdAt: i.createdAt.toISOString(), offGroup: i.offGroup, offStyle: i.offStyle,
|
||||
})),
|
||||
orders: orders.map((o) => ({
|
||||
id: o.id, code: o.code, date: o.date, source: o.source, orderFor: o.orderFor, staffId: o.staffId, supplier: o.supplier, status: o.status,
|
||||
ref: o.ref, invoice: o.invoice, tracking: o.tracking, expected: o.expected, received: o.received, cc: o.cc, notes: o.notes, replenish: o.replenish, parentId: o.parentId,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
lines: o.lines.map((l) => ({ id: l.id, itemId: l.itemId, size: l.size, qty: l.qty })),
|
||||
receipts: o.receipts.map((r) => ({ id: r.id, date: r.date, invoice: r.invoice, note: r.note, photoId: r.photoId, lines: r.lines.map((x) => ({ itemId: x.itemId, size: x.size, qty: x.qty, dest: x.dest, cost: x.cost })) })),
|
||||
})),
|
||||
pickups: pickups.map((p) => ({ id: p.id, orderId: p.orderId, orderCode: p.order.code, staffId: p.staffId, received: p.received, contacted: p.contacted, pickedUp: p.pickedUp, deliveredTo: p.deliveredTo, sigId: p.sigId, proofId: p.proofId, deliveredRound: p.deliveredRound, lines: p.lines.map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty })) })),
|
||||
owedRequestLines: owedLines.map((l) => ({ staffId: l.request.subjectId, itemId: l.itemId, qty: l.qty })),
|
||||
handins: handins.map((h) => ({ id: h.id, date: h.date, staffId: h.staffId, by: h.byName, credit: h.credit, lines: h.lines.map((l) => ({ itemId: l.itemId, si: l.sizeIndex, qty: l.qty, cond: l.cond, laundered: l.laundered, credited: l.credited })) })),
|
||||
stocktakes: stocktakes.map((t) => ({ id: t.id, date: t.date, by: t.byName, counted: t.counted, variances: t.variances, mode: t.mode, locationId: t.locationId, lines: t.lines.map((l) => ({ itemId: l.itemId, si: l.sizeIndex, sys: l.sys, counted: l.counted, reason: l.reason })) })),
|
||||
users: users.map((u) => ({ id: u.id, email: u.email, first: u.first, last: u.last, title: u.title, role: u.role, inactive: u.inactive, ssoBreakGlass: u.ssoBreakGlass })),
|
||||
demo: fac.isDemo ? { resetAt: fac.demoResetAt ? fac.demoResetAt.toISOString() : null } : null,
|
||||
plan: planBlock(fac, staff.length, plansLive, user.role === "ADMIN"),
|
||||
// Everything downstream measures "today" against the facility's own zone, not the server's, so
|
||||
// the snapshot settles it once here and hands the zone out alongside it. `tz` is a copy of
|
||||
// settings.timezone, hoisted so a client component formatting a date need not thread settings in.
|
||||
today: facilityToday(fac.timezone),
|
||||
tz: fac.timezone,
|
||||
};
|
||||
}
|
||||
@@ -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 doesn’t 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}` };
|
||||
}
|
||||
@@ -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 can’t 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;
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { prisma } from "./db";
|
||||
import {
|
||||
addDays, facilityDate, facilityToday, fmtDate, garmentForGroup, garmentForStyle, isKitGroup, isNursingGroup, isPantItem, isTopItem, key, ledger,
|
||||
onhand, reorderAt,
|
||||
type Snapshot,
|
||||
} from "./compute";
|
||||
import { allowance, capState, garmentCounts, setsHeld } from "./sets";
|
||||
import {
|
||||
OPEN_REQUEST, approvedLines, decisionSummary, garmentCount, lineStatusLabel, roundWard, type StockWord,
|
||||
stockWord,
|
||||
} from "./staffreq";
|
||||
import type { StaffSession } from "./staffsession";
|
||||
|
||||
/* Everything the staff screens read.
|
||||
*
|
||||
* No facility snapshot, anywhere. A wearer's screen has no business
|
||||
* holding the register, and the availability queries below are the one place that touches
|
||||
* facility-wide data — deliberately, narrowed to the garments being asked about, and only ever to
|
||||
* turn a count into a word.
|
||||
*
|
||||
* The one other reach across is four columns of settings — the site's two lists of which groups
|
||||
* take the FTE table and which the starting kit, its ceiling, and how many sets that kit is —
|
||||
* selected alongside the wearer's own row where an allowance is being worked out. Without them this
|
||||
* screen quotes the standing 3 at a site that issues 4, and cannot tell which route the wearer is
|
||||
* on, so it tells somebody owed a starting kit that they start on nothing.
|
||||
*/
|
||||
|
||||
export type Availability = { size: string; si: number; word: StockWord; countedOn: string };
|
||||
|
||||
/** Stock as a ward is allowed to see it: words, never numbers.
|
||||
*
|
||||
* The count itself never leaves this function. That is the product rule and it is also the only
|
||||
* honest position — the linen room's count is the audited one, and a number on a ward screen just
|
||||
* starts an argument at the counter about whether the shelf really holds four. */
|
||||
export async function availability(facilityId: string, itemIds: string[]): Promise<Record<string, Availability[]>> {
|
||||
if (!itemIds.length) return {};
|
||||
const [fac, items, levels, issues, receiptLines, moves, takes] = await Promise.all([
|
||||
prisma.facility.findUniqueOrThrow({ where: { id: facilityId }, select: { defaultReorder: true } }),
|
||||
prisma.catalogItem.findMany({ where: { id: { in: itemIds }, facilityId }, select: { id: true, sizes: true } }),
|
||||
prisma.stockLevel.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, opening: true, adj: true, reorder: true, preloved: true } }),
|
||||
prisma.issue.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, qty: true, direct: true, preloved: true, returnedDate: true, returnedCond: true } }),
|
||||
prisma.receiptLine.findMany({ where: { itemId: { in: itemIds }, receipt: { order: { facilityId } } }, select: { itemId: true, size: true, qty: true, dest: true } }),
|
||||
prisma.stockMove.findMany({ where: { facilityId, itemId: { in: itemIds } }, select: { itemId: true, sizeIndex: true, qty: true } }),
|
||||
prisma.stocktake.findMany({ where: { facilityId }, orderBy: { date: "desc" }, take: 40, select: { date: true, lines: { select: { itemId: true, sizeIndex: true } } } }),
|
||||
]);
|
||||
|
||||
const counted = new Map<string, string>();
|
||||
for (const t of takes) for (const l of t.lines) {
|
||||
const k = key(l.itemId, l.sizeIndex);
|
||||
if (!counted.has(k)) counted.set(k, t.date); // takes are newest-first, so the first wins
|
||||
}
|
||||
|
||||
/* On hand is the linen room's number or it is a fiction.
|
||||
*
|
||||
* There is one on-hand expression in ThreadCount — ledger() and onhand() in lib/compute.ts:
|
||||
* opening plus adjustments, plus what shelf receipts and stock moves brought in, less what has
|
||||
* been issued, plus what came back in good condition. The word a ward is shown has to come from
|
||||
* that same arithmetic and not from a cheaper sum that happens to be easy to write here. A
|
||||
* second formula does not drift a little: this one told wards "None on shelf" for full bays,
|
||||
* because stock arriving against a supplier order never touches StockLevel at all.
|
||||
*
|
||||
* So rather than restate the sum, this loads the four things ledger() actually reads — the
|
||||
* catalogue, receipt lines, issues and stock moves — narrowed to the garments being asked
|
||||
* about, and hands them to the coordinator's own function. The cast is what that costs:
|
||||
* ledger() and onhand() take a whole Snapshot, and a wearer's screen has no business building
|
||||
* one. Neither function reads a field outside the ones set below.
|
||||
*/
|
||||
const stock: Snapshot["stock"] = {};
|
||||
for (const l of levels) stock[key(l.itemId, l.sizeIndex)] = { opening: l.opening, adj: l.adj, reorder: l.reorder, preloved: l.preloved };
|
||||
const scoped = {
|
||||
settings: { defaultReorder: fac.defaultReorder },
|
||||
catalog: items.map((i) => ({ id: i.id, sizes: i.sizes })),
|
||||
stock,
|
||||
issues: issues.map((i) => ({
|
||||
itemId: i.itemId, si: i.sizeIndex, qty: i.qty, direct: i.direct, preloved: i.preloved,
|
||||
returned: i.returnedDate ? { cond: i.returnedCond || "" } : null,
|
||||
})),
|
||||
orders: [{ receipts: [{ lines: receiptLines }] }],
|
||||
moves: moves.map((m) => ({ itemId: m.itemId, si: m.sizeIndex, qty: m.qty })),
|
||||
} as unknown as Snapshot;
|
||||
const L = ledger(scoped);
|
||||
|
||||
const out: Record<string, Availability[]> = {};
|
||||
for (const it of items) {
|
||||
out[it.id] = it.sizes.map((size, si) => {
|
||||
const k = key(it.id, si);
|
||||
// reorderAt() falls back to the facility default, exactly as the linen room's own screens do.
|
||||
// Without that fallback a size with no per-size reorder level goes straight from "In stock"
|
||||
// to "None on shelf", and "Low" is a word the ward never once sees.
|
||||
return { size: String(size), si, word: stockWord(onhand(scoped, L, k), reorderAt(scoped, k)), countedOn: counted.get(k) || "" };
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- a request, as a screen wants it ----------
|
||||
*
|
||||
* A request covers as many garments as the person needs, one line each, so every screen that used
|
||||
* to print "2 × Tunic — 16" off the request itself now walks a list. The shaping is here rather
|
||||
* than in each caller because the manager's queue, the wearer's orders, the ward round and the
|
||||
* linen room's queue all have to describe the same bag the same way — the day they disagree is the
|
||||
* day somebody signs for two garments and goes looking for a third.
|
||||
*/
|
||||
export type ReqLine = {
|
||||
id: string; itemId: string; item: string; size: string; si: number; qty: number;
|
||||
/** The cut, so a slip or a screen can tell two garments of the same name apart. Male | Female |
|
||||
* Unisex, as stored — call genderLabel() for the words. */
|
||||
gender: string;
|
||||
/** awaiting | approved | declined, and the word for it. */
|
||||
status: string; statusLabel: string;
|
||||
declineReason: string | null;
|
||||
};
|
||||
|
||||
type LineRecord = {
|
||||
id: string; itemId: string; sizeIndex: number; qty: number; status: string; declineReason: string | null;
|
||||
item: { item: string; gender: string; sizes: string[] };
|
||||
};
|
||||
|
||||
export function reqLines(lines: readonly LineRecord[]): ReqLine[] {
|
||||
return lines.map((l) => ({
|
||||
id: l.id, itemId: l.itemId, item: l.item.item, gender: l.item.gender,
|
||||
size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex), si: l.sizeIndex, qty: l.qty,
|
||||
status: l.status, statusLabel: lineStatusLabel(l.status), declineReason: l.declineReason,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The lines a row is actually about.
|
||||
*
|
||||
* Once the manager has left something to pick, that is the bag, and a declined fleece has no
|
||||
* business padding out the linen room's pick list or the wearer's "ready to collect" card. Before
|
||||
* a decision — and when every line was refused — there is no bag, so the whole ask is the subject.
|
||||
* The refusals are never hidden: they stay in `lines` with their own word and reason. */
|
||||
export function bagLines(lines: readonly ReqLine[]): ReqLine[] {
|
||||
const picked = approvedLines(lines);
|
||||
return picked.length ? picked : [...lines];
|
||||
}
|
||||
|
||||
/** The one-liner for a collapsed row. A single-garment request reads exactly as it always did; a
|
||||
* longer one leads with the total, because "5 garments" is the thing somebody picking or carrying
|
||||
* a bag needs before the names. */
|
||||
export function linesSummary(lines: readonly ReqLine[]): string {
|
||||
const bag = bagLines(lines);
|
||||
if (!bag.length) return "";
|
||||
if (bag.length === 1) return `${bag[0].qty} × ${bag[0].item} — ${bag[0].size}`;
|
||||
const names = bag.slice(0, 3).map((l) => l.item);
|
||||
const rest = bag.length - names.length;
|
||||
return `${garmentCount(bag)} garments · ${names.join(", ")}${rest ? ` +${rest} more` : ""}`;
|
||||
}
|
||||
|
||||
export type ReqRow = {
|
||||
id: string; code: string; status: string;
|
||||
lines: ReqLine[];
|
||||
/** The collapsed one-liner, and the totals behind it — all three about the bag, per bagLines(). */
|
||||
summary: string; garments: number; lineCount: number;
|
||||
/** "2 of 3 approved", or null while it is still with the manager. */
|
||||
decision: string | null;
|
||||
reason: string; note: string; managerName: string; declineReason: string | null;
|
||||
collectCode: string | null; holdUntil: string; route: string | null;
|
||||
signerName: string | null; signerRole: string | null;
|
||||
subjectName: string; raisedByName: string;
|
||||
/** The ward the bag was sent out to, per roundWard() — blank until it goes on a round. Never the
|
||||
* wearer's current ward: that is what made a transferred nurse's order name the wrong desk. */
|
||||
ward: string;
|
||||
/** Is the viewer the wearer? False on a request they raised for somebody else. */
|
||||
mine: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function reqRow(r: {
|
||||
id: string; code: string; status: string; reason: string; note: string; subjectId: string;
|
||||
managerName: string; declineReason: string | null; collectCode: string | null; holdUntil: string;
|
||||
route: string | null; signerName: string | null; signerRole: string | null;
|
||||
raisedByName: string; createdAt: Date;
|
||||
lines: LineRecord[];
|
||||
events: readonly { label: string; meta: string }[];
|
||||
subject: { first: string; last: string };
|
||||
}, viewerId: string): ReqRow {
|
||||
const lines = reqLines(r.lines);
|
||||
const bag = bagLines(lines);
|
||||
return {
|
||||
id: r.id, code: r.code, status: r.status,
|
||||
lines,
|
||||
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
|
||||
decision: decisionSummary(lines),
|
||||
reason: r.reason, note: r.note, managerName: r.managerName, declineReason: r.declineReason,
|
||||
collectCode: r.collectCode, holdUntil: r.holdUntil, route: r.route,
|
||||
signerName: r.signerName, signerRole: r.signerRole,
|
||||
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
|
||||
raisedByName: r.raisedByName, ward: roundWard(r.events),
|
||||
mine: r.subjectId === viewerId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const REQ_INCLUDE = {
|
||||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||||
subject: { select: { first: true, last: true } },
|
||||
// The timeline comes along on every order row because it is the only record of which ward a bag
|
||||
// was actually delivered to — see roundWard().
|
||||
events: { select: { label: true, meta: true } },
|
||||
} as const;
|
||||
|
||||
/** How long a decline stays eligible for the home screen's one live card. */
|
||||
const DECLINE_HEADLINE_DAYS = 7;
|
||||
|
||||
/** 1A Home. One live thing at the top, then shortcuts. */
|
||||
export async function homeData(sess: StaffSession) {
|
||||
// The facility's own zone decides what "today" is here — which notice is still running, and how
|
||||
// recent a decline still counts as. The server's ambient zone gets no say in either.
|
||||
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: sess.facilityId }, select: { name: true, timezone: true } });
|
||||
const today = facilityToday(fac.timezone);
|
||||
const [staff, reqs, raised, notice, holdings] = await Promise.all([
|
||||
prisma.staff.findUniqueOrThrow({
|
||||
where: { id: sess.staffId },
|
||||
select: { num: true, first: true, last: true, dept: true, top: true, pants: true, managerId: true, wardDesk: true },
|
||||
}),
|
||||
prisma.request.findMany({ where: { subjectId: sess.staffId }, orderBy: { createdAt: "desc" }, take: 25, include: REQ_INCLUDE }),
|
||||
/* What they raised for somebody else — a manager, for one of their own reports.
|
||||
*
|
||||
* Every list on this app starts from `subjectId`, so a request they raised appeared on no
|
||||
* screen they could reach. Keyed on `raisedByStaffId` instead, which also keeps the requests
|
||||
* raised from the old desk route in front of the person who typed them. It is not theirs to
|
||||
* collect, so it does not compete for the live card; it sits in its own list with the
|
||||
* wearer's name on it. */
|
||||
prisma.request.findMany({
|
||||
where: {
|
||||
facilityId: sess.facilityId, raisedByStaffId: sess.staffId,
|
||||
subjectId: { not: sess.staffId }, status: { in: [...OPEN_REQUEST] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" }, take: 25, include: REQ_INCLUDE,
|
||||
}),
|
||||
prisma.linenNotice.findFirst({
|
||||
where: { facilityId: sess.facilityId, OR: [{ endsAt: "" }, { endsAt: { gte: today } }] },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.issue.aggregate({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null }, _sum: { qty: true } }),
|
||||
]);
|
||||
|
||||
// "Furthest along" — the one the person most likely wants to act on. Ready to collect beats a
|
||||
// bag still out on the round, which beats something waiting on a manager.
|
||||
const RANK: Record<string, number> = { ready: 6, round: 5, picking: 4, accepted: 3, awaiting: 2, declined: 1 };
|
||||
// A decline is worth the top of the screen while it is news. Kept in the ranking forever it
|
||||
// becomes the permanent headline the moment every later request has finished — a refusal from
|
||||
// March greeting someone in September, with no way to dismiss it. After a week it lives in
|
||||
// Orders with everything else that is over.
|
||||
const declinedFrom = addDays(today, -DECLINE_HEADLINE_DAYS);
|
||||
const open = reqs.filter((r) => (
|
||||
OPEN_REQUEST.has(r.status as never)
|
||||
|| (r.status === "declined" && facilityDate(r.decidedAt ?? r.createdAt, fac.timezone) >= declinedFrom)
|
||||
));
|
||||
const live = open.sort((a, b) => (RANK[b.status] || 0) - (RANK[a.status] || 0))[0];
|
||||
|
||||
return {
|
||||
name: `${staff.first} ${staff.last}`.trim(),
|
||||
num: staff.num,
|
||||
ward: staff.dept,
|
||||
facility: fac.name,
|
||||
hasManager: !!staff.managerId,
|
||||
wardDesk: staff.wardDesk,
|
||||
holding: holdings._sum.qty || 0,
|
||||
live: live ? reqRow(live, sess.staffId) : null,
|
||||
openCount: open.filter((r) => OPEN_REQUEST.has(r.status as never)).length,
|
||||
raisedOpen: raised.map((r) => reqRow(r, sess.staffId)),
|
||||
notice: notice?.body || "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 1B My kit. */
|
||||
export async function kitData(sess: StaffSession) {
|
||||
const staff = await prisma.staff.findUniqueOrThrow({
|
||||
where: { id: sess.staffId },
|
||||
select: { top: true, pants: true, facility: { select: { timezone: true } } },
|
||||
});
|
||||
const today = facilityToday(staff.facility.timezone);
|
||||
const fyFrom = (+today.slice(0, 4) - (+today.slice(5, 7) >= 7 ? 0 : 1)) + "-07-01";
|
||||
const issues = await prisma.issue.findMany({
|
||||
where: { staffId: sess.staffId },
|
||||
orderBy: [{ date: "desc" }],
|
||||
select: { id: true, date: true, qty: true, sizeIndex: true, returnedDate: true, returnedCond: true, handedIn: true, item: { select: { id: true, item: true, sizes: true } } },
|
||||
});
|
||||
|
||||
// Grouped by garment and size, which is how someone thinks about what they have — not as a
|
||||
// list of issuing events.
|
||||
const held = new Map<string, { itemId: string; item: string; size: string; si: number; qty: number; last: string; labelIds: string[] }>();
|
||||
for (const i of issues) {
|
||||
// A hand-in takes the garment off the person without ever setting returnedDate — it joins the
|
||||
// pre-loved pool rather than coming back as a return — so skipping only returns left handed-in
|
||||
// garments on someone's record here for good, and offered them up to Report damage.
|
||||
if (i.returnedDate || i.handedIn) continue;
|
||||
const k = `${i.item.id}:${i.sizeIndex}`;
|
||||
const cur = held.get(k) || { itemId: i.item.id, item: i.item.item, size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, qty: 0, last: "", labelIds: [] };
|
||||
cur.qty += i.qty;
|
||||
if (i.date > cur.last) cur.last = i.date;
|
||||
cur.labelIds.push(i.id);
|
||||
held.set(k, cur);
|
||||
}
|
||||
/* Handed back is both routes off a person's record, and is neither of the two write-offs.
|
||||
*
|
||||
* A garment brought to the counter is booked as a return and stamped returnedDate; a hand-in is
|
||||
* stamped handedIn and never returnedDate, because it joins the pre-loved pool rather than coming
|
||||
* back as a return. Counting returnedDate alone therefore told somebody who had carried five
|
||||
* garments in "Nothing handed back since 1 July" — the flat contradiction of what they had just
|
||||
* done at the counter, on the one screen they would check before arguing about it.
|
||||
*
|
||||
* Lost and Written Off also stamp returnedDate, and neither is a garment anybody handed back: one
|
||||
* never came home and the other was condemned. Crediting a person for them under that word would
|
||||
* be the same untruth in the other direction, so they are left out here. Nothing is hidden by it
|
||||
* — the write-off is on the linen room's record of the issue either way. */
|
||||
const handedBackThisYear = issues.filter((i) => (
|
||||
(i.handedIn && i.handedIn >= fyFrom)
|
||||
|| (i.returnedDate && i.returnedDate >= fyFrom && i.returnedCond !== "Lost" && i.returnedCond !== "Written Off")
|
||||
)).reduce((n, i) => n + i.qty, 0);
|
||||
|
||||
return {
|
||||
held: [...held.values()].sort((a, b) => a.item.localeCompare(b.item) || a.size.localeCompare(b.size)),
|
||||
total: [...held.values()].reduce((n, h) => n + h.qty, 0),
|
||||
handedBackThisYear,
|
||||
fyFrom,
|
||||
sizes: { top: staff.top, pants: staff.pants },
|
||||
};
|
||||
}
|
||||
|
||||
/** 1C Orders. */
|
||||
export async function ordersData(sess: StaffSession) {
|
||||
const [reqs, raised] = await Promise.all([
|
||||
prisma.request.findMany({
|
||||
where: { subjectId: sess.staffId }, orderBy: { createdAt: "desc" }, include: REQ_INCLUDE,
|
||||
}),
|
||||
/* The requests this person raised for other people: a manager, for their own team, plus
|
||||
* anything still on the record from the old desk route. Keying on `raisedByStaffId` rather
|
||||
* than on a flag is what keeps those older ones reachable. They are deliberately not folded
|
||||
* into `open`/`done`: what a wearer does with their own order (collect it, chase it, confirm
|
||||
* they picked it up) is not what the person who typed it in does with it, and mixing the two
|
||||
* lists is how somebody collects a bag that is not theirs. Every row carries `subjectName`, so
|
||||
* the screen can say who it is for. */
|
||||
prisma.request.findMany({
|
||||
where: { facilityId: sess.facilityId, raisedByStaffId: sess.staffId, subjectId: { not: sess.staffId } },
|
||||
orderBy: { createdAt: "desc" }, include: REQ_INCLUDE,
|
||||
}),
|
||||
]);
|
||||
const rows = reqs.map((r) => reqRow(r, sess.staffId));
|
||||
const raisedRows = raised.map((r) => reqRow(r, sess.staffId));
|
||||
const isOpen = (r: ReqRow) => OPEN_REQUEST.has(r.status as never);
|
||||
return {
|
||||
open: rows.filter(isOpen),
|
||||
done: rows.filter((r) => !isOpen(r)),
|
||||
raised: { open: raisedRows.filter(isOpen), done: raisedRows.filter((r) => !isOpen(r)) },
|
||||
};
|
||||
}
|
||||
|
||||
/** 1D Order detail + 1F thread. Visible to the subject, their manager, or the clerk who raised it. */
|
||||
export async function requestData(sess: StaffSession, id: string) {
|
||||
const [r, me] = await Promise.all([
|
||||
prisma.request.findFirst({
|
||||
where: { id, facilityId: sess.facilityId },
|
||||
include: { ...REQ_INCLUDE, events: { orderBy: { at: "asc" } }, messages: { orderBy: { createdAt: "asc" } } },
|
||||
}),
|
||||
prisma.staff.findUnique({ where: { id: sess.staffId }, select: { dept: true, wardDesk: true } }),
|
||||
]);
|
||||
if (!r) return null;
|
||||
/* The subject, their manager and whoever raised it — plus the desk that is physically holding
|
||||
* the bag. /my/round lists a delivered bag to the ward the trolley left it on and offers
|
||||
* "Nudge", which opens this order's thread; without this clause that desk got the 404 and the
|
||||
* nudge went nowhere. Same fence as round.sign and round.claim: the ward on the timeline, and a
|
||||
* blank ward matches nobody. */
|
||||
const party = r.subjectId === sess.staffId || r.managerId === sess.staffId || r.raisedByStaffId === sess.staffId;
|
||||
const onMyDesk = !!me?.wardDesk && !!me.dept.trim() && (r.status === "round" || r.status === "delivered") && roundWard(r.events) === me.dept;
|
||||
if (!party && !onMyDesk) return null;
|
||||
return {
|
||||
...reqRow(r, sess.staffId),
|
||||
// Only this screen needs it: it is what decides whether the requester is still being asked to
|
||||
// confirm they picked the bag up off the ward desk, and the desk's unclaimed list is exactly
|
||||
// the delivered requests where it is still null.
|
||||
claimedAt: r.claimedAt?.toISOString() ?? null,
|
||||
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
|
||||
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 1E New request, and 1H Shelf check — both need the catalogue with words against it, and the
|
||||
* request screen also needs to tell the person what they already hold and what they are allowed. */
|
||||
export async function catalogueData(sess: StaffSession) {
|
||||
const [catalog, staff, issues] = await Promise.all([
|
||||
prisma.catalogItem.findMany({
|
||||
where: { facilityId: sess.facilityId, archived: false },
|
||||
orderBy: { sort: "asc" },
|
||||
select: { id: true, item: true, type: true, gender: true, sizes: true, groups: true },
|
||||
}),
|
||||
prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { top: true, pants: true, group: true, uniformStyle: true, managerId: true, facility: { select: { nursingGroups: true, kitGroups: true, capSets: true, initialSets: true } } } }),
|
||||
/* Every issue, not just the ones still out. The live ones are what they hold; the returned and
|
||||
* handed-in ones are still the best evidence of what size fits them, which is the only thing
|
||||
* the record knows about a jacket or a fleece. Newest first so the first row wins per garment. */
|
||||
prisma.issue.findMany({
|
||||
where: { staffId: sess.staffId },
|
||||
orderBy: { date: "desc" },
|
||||
select: { itemId: true, sizeIndex: true, qty: true, returnedDate: true, handedIn: true, item: { select: { type: true, item: true, sizes: true } } },
|
||||
}),
|
||||
]);
|
||||
// Their own staff group's garments and those for every group, in the cut they are offered — the
|
||||
// same two questions request.create asks before it refuses. Somebody set to Men's sees the men's
|
||||
// range and the unisex one; somebody set to Either, or whom nobody has set, sees every cut, which
|
||||
// is what everybody sees today. Nothing else, not even another group's or another cut's garment
|
||||
// they are holding from before the rule: a request for it is refused, and a damage replacement is
|
||||
// raised from the issue itself, not from this list.
|
||||
const items = catalog.filter((i) => garmentForGroup(i, staff.group) && garmentForStyle(i, staff.uniformStyle));
|
||||
const avail = await availability(sess.facilityId, items.map((i) => i.id));
|
||||
const manager = staff.managerId
|
||||
? await prisma.staff.findUnique({ where: { id: staff.managerId }, select: { first: true, last: true } })
|
||||
: null;
|
||||
|
||||
/* What they are holding, per garment and per size, and the size the record last saw them in.
|
||||
*
|
||||
* A hand-in takes a garment off somebody without ever marking it returned, so both have to be
|
||||
* excluded from the holdings or the screen tells a person they still have what they gave back. */
|
||||
const held = new Map<string, Map<number, number>>();
|
||||
const lastSize = new Map<string, string>();
|
||||
const holdings: { item: { type: string; item: string }; qty: number }[] = [];
|
||||
for (const i of issues) {
|
||||
if (!lastSize.has(i.itemId)) lastSize.set(i.itemId, String(i.item.sizes[i.sizeIndex] ?? ""));
|
||||
if (i.returnedDate || i.handedIn) continue;
|
||||
const bySize = held.get(i.itemId) || new Map<number, number>();
|
||||
bySize.set(i.sizeIndex, (bySize.get(i.sizeIndex) || 0) + i.qty);
|
||||
held.set(i.itemId, bySize);
|
||||
holdings.push({ item: i.item, qty: i.qty });
|
||||
}
|
||||
|
||||
/* The same allowance sum the manager sees on the review screen, from the same function.
|
||||
*
|
||||
* Somebody can be declined "Over allowance" against a number their own app has never shown them,
|
||||
* which is the sort of refusal that gets a linen room a phone call rather than an apology. The
|
||||
* two screens have to agree, so neither one gets its own arithmetic. */
|
||||
const sets = setsHeld(holdings);
|
||||
const allow = allowance({
|
||||
group: staff.group,
|
||||
held: sets,
|
||||
// The person reading this screen is the person being measured, so their own group answers it.
|
||||
// Both answers are passed rather than left off: without them this screen and the manager's
|
||||
// review describe two different allowances, and the wearer is declined against the one they
|
||||
// were never shown. Leaving the starting-kit one off tells somebody owed a kit they start on
|
||||
// nothing.
|
||||
nursing: isNursingGroup(staff.facility.nursingGroups, staff.group),
|
||||
kit: isKitGroup(staff.facility.kitGroups, staff.group),
|
||||
capSets: staff.facility.capSets, startingSets: staff.facility.initialSets,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((i) => {
|
||||
const bySize = held.get(i.id) || new Map<number, number>();
|
||||
/* Only tops and trousers have a size on the register, so a dress, a fleece or a vest has
|
||||
* always come up blank and the wearer guessed. The size of the last one they were issued is
|
||||
* what the record does know, and it is a better opening bid than nothing — `recordedSource`
|
||||
* says which it is so the screen can hint rather than assert. */
|
||||
const fromRecord = isTopItem(i) ? staff.top : isPantItem(i) ? staff.pants : "";
|
||||
const fromIssue = lastSize.get(i.id) || "";
|
||||
const recorded = fromRecord || fromIssue;
|
||||
const sizes: Availability[] = avail[i.id]
|
||||
|| i.sizes.map((s, si) => ({ size: String(s), si, word: "none" as StockWord, countedOn: "" }));
|
||||
return {
|
||||
id: i.id, item: i.item, type: i.type, gender: i.gender,
|
||||
sizes: sizes.map((s) => ({ ...s, held: bySize.get(s.si) || 0 })),
|
||||
// Selecting a garment resets the size to the person's recorded size for that garment type.
|
||||
recorded,
|
||||
recordedSource: recorded ? (fromRecord ? ("record" as const) : ("issued" as const)) : ("" as const),
|
||||
held: [...bySize.values()].reduce((n, q) => n + q, 0),
|
||||
};
|
||||
}),
|
||||
managerName: manager ? `${manager.first} ${manager.last}`.trim() : "",
|
||||
// Deliberately the same two keys the manager's review screen returns, so the wearer reads the
|
||||
// sentence they will be judged on before they ask rather than in the decline.
|
||||
holding: { total: holdings.reduce((n, h) => n + h.qty, 0), sets },
|
||||
allowance: {
|
||||
capped: allow.capped,
|
||||
label: allow.capped ? `${allow.used} of ${allow.cap} sets` : `${staff.group || "This role"} — no fixed cap. Your manager's approval is the control.`,
|
||||
note: allow.note,
|
||||
/* Sets alone under-warn: the ceiling the hand-over applies (lib/sets.ts capState, the same
|
||||
* one capCheck uses at the counter) bites per half, so six tops and two pairs is "2 of 6
|
||||
* sets" here and yet the next top is refused or stamped as an override. Ask the ceiling's
|
||||
* own question as well, so the wearer is warned before they ask rather than after. */
|
||||
over: capState({ held: garmentCounts(holdings), capSets: staff.facility.capSets }).over
|
||||
|| (allow.capped && allow.cap !== null && allow.used >= allow.cap),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 1G Report damage — their own holdings, each with the issue id the linen room can trace. */
|
||||
export async function damageData(sess: StaffSession) {
|
||||
const issues = await prisma.issue.findMany({
|
||||
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
|
||||
orderBy: { date: "desc" },
|
||||
select: { id: true, date: true, qty: true, sizeIndex: true, item: { select: { id: true, item: true, sizes: true } } },
|
||||
});
|
||||
const staff = await prisma.staff.findUniqueOrThrow({ where: { id: sess.staffId }, select: { num: true } });
|
||||
const avail = await availability(sess.facilityId, [...new Set(issues.map((i) => i.item.id))]);
|
||||
return {
|
||||
holdings: issues.map((i) => ({
|
||||
issueId: i.id, itemId: i.item.id, item: i.item.item,
|
||||
size: String(i.item.sizes[i.sizeIndex] ?? i.sizeIndex), si: i.sizeIndex, qty: i.qty,
|
||||
// The label the linen room prints, so a garment in a hand can be matched to a row here.
|
||||
labelId: `TC-${staff.num}-${i.id.slice(-4).toUpperCase()}`,
|
||||
issued: fmtDate(i.date),
|
||||
replacement: (avail[i.item.id] || []).find((a) => a.si === i.sizeIndex)?.word ?? ("none" as StockWord),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export { fmtDate };
|
||||
+733
@@ -0,0 +1,733 @@
|
||||
import { prisma } from "./db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { setStaffCookie, type StaffSession } from "./staffsession";
|
||||
import {
|
||||
DAMAGE_KINDS, DECLINE_REASONS, REQUEST_REASONS, WAITLIST_HOLD_HOURS, canMove, decisionSummary,
|
||||
holdExpired, requestCode, rollUpRequestStatus, roundWard, type LineStatus,
|
||||
} from "./staffreq";
|
||||
import { approvalEmail, approvalUrl, decisionEmail, signApprovalToken } from "./approvallink";
|
||||
import { sendTo, transactionalConfigured } from "./mail";
|
||||
import { OpError, handOverRequestStock, offGroupGarments, offStyleGarments, readRequestLines } from "./ops";
|
||||
import { facilityToday, genderLabel, groupsLabel } from "./compute";
|
||||
import { PLAN_COLS, entitlements } from "./plan";
|
||||
|
||||
/* Everything a staff member, ward manager or ward clerk can change.
|
||||
*
|
||||
* Separate from lib/ops.ts on purpose. That file's ops all begin from a coordinator session and
|
||||
* may touch anything in the facility; these all begin from a *staff* session and must never be
|
||||
* able to. Keeping them in one switch statement with one authorisation model each would mean the
|
||||
* only thing standing between a wearer and the stock ledger was remembering which branch they
|
||||
* came in through.
|
||||
*
|
||||
* The rule every op here obeys: **the facility is not the unit of authority, the person is.**
|
||||
* A staff session may act on its own record; a manager may act on requests addressed to them and
|
||||
* raise for the people who report to them; a ward clerk may sign for a bag coming to their own
|
||||
* ward. Nothing here takes a facility id from the caller.
|
||||
*/
|
||||
|
||||
export class StaffOpError extends Error {
|
||||
status: number;
|
||||
constructor(msg: string, status = 400) { super(msg); this.status = status; }
|
||||
}
|
||||
|
||||
/** Same floor as activation, so a password change can't be a downgrade of the one they set. */
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
const str = (v: unknown, max = 500) => (v === undefined || v === null ? "" : String(v)).slice(0, max);
|
||||
const int = (v: unknown, d = 0) => { const n = parseInt(String(v), 10); return Number.isFinite(n) ? n : d; };
|
||||
|
||||
/** Load a request the caller is allowed to see: their own, or one they approve, or one they
|
||||
* raised for somebody else. Anything else is a 404 — not a 403, which would confirm it exists. */
|
||||
async function visibleRequest(sess: StaffSession, id: string) {
|
||||
const [r, me] = await Promise.all([
|
||||
prisma.request.findFirst({
|
||||
where: { id, facilityId: sess.facilityId },
|
||||
include: {
|
||||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||||
subject: { select: { first: true, last: true, dept: true } },
|
||||
events: { select: { label: true, meta: true } },
|
||||
},
|
||||
}),
|
||||
prisma.staff.findUnique({ where: { id: sess.staffId }, select: { dept: true, wardDesk: true } }),
|
||||
]);
|
||||
/* The same three parties lib/staffdata.ts requestData admits, plus the desk holding the bag:
|
||||
* /my/round offers that desk a "Nudge" into this thread, and the message it sends has to be
|
||||
* accepted here or the button is a 404 with a different face. Ward from the timeline, and a
|
||||
* blank ward matches nobody — the fence round.sign and round.claim use. */
|
||||
const party = !!r && (r.subjectId === sess.staffId || r.managerId === sess.staffId || r.raisedByStaffId === sess.staffId);
|
||||
const onMyDesk = !!r && !!me?.wardDesk && !!me.dept.trim() && (r.status === "round" || r.status === "delivered") && roundWard(r.events) === me.dept;
|
||||
if (!r || (!party && !onMyDesk)) throw new StaffOpError("No such request", 404);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** The staff app speaks one error type — /api/staff/mutate turns anything else into a bare
|
||||
* "something went wrong", which tells the person on the ward nothing. The shared helpers in
|
||||
* lib/ops.ts speak the coordinator's, so a call into one is translated rather than let through. */
|
||||
function asStaffError(e: unknown): never {
|
||||
if (e instanceof OpError) throw new StaffOpError(e.message, e.status);
|
||||
throw e;
|
||||
}
|
||||
|
||||
/** What one op calling another inside this file may say that a payload never can. The API route
|
||||
* calls runStaffOp with three arguments, so nothing a phone sends reaches this.
|
||||
*
|
||||
* `replacing` is the garment a damage report is asking to replace (damage.report). It is the one
|
||||
* garment outside somebody's staff group, or outside the cut they are offered, that a request may
|
||||
* carry — see offGroupGarments() and offStyleGarments() in lib/ops.ts, which also check they are
|
||||
* still holding it. */
|
||||
type Internal = { replacing?: string };
|
||||
|
||||
/** The refusal: each garment with the groups it is for, then whose group it isn't. */
|
||||
function offGroupMessage(off: readonly { item: string; groups: string[] }[], who: { first: string; group: string }, self: boolean): string {
|
||||
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)} only`).join("; ");
|
||||
const g = (who.group || "").trim();
|
||||
const tail = self
|
||||
? g ? `You're in ${g} — ask the linen room if you need ${off.length === 1 ? "it" : "them"}.` : "You have no staff group recorded — ask the linen room."
|
||||
: g ? `${who.first} is in ${g}.` : `${who.first} has no staff group recorded — ask the linen room.`;
|
||||
return `${each}. ${tail}`;
|
||||
}
|
||||
|
||||
/** The same refusal for the wrong cut: each garment with the cut it is, then the style they are set
|
||||
* to. There is no blank case — a blank style, and Either, are offered every cut, so this is only
|
||||
* ever built for somebody a coordinator has set to Men's or Women's. */
|
||||
function offStyleMessage(off: readonly { item: string; gender: string }[], who: { first: string; uniformStyle: string }, self: boolean): string {
|
||||
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
|
||||
const tail = self
|
||||
? `You're set to ${who.uniformStyle} — ask the linen room if you need ${off.length === 1 ? "it" : "them"}.`
|
||||
: `${who.first} is set to ${who.uniformStyle}.`;
|
||||
return `${each}. ${tail}`;
|
||||
}
|
||||
|
||||
async function ownStaffInFacility(sess: StaffSession, id: string) {
|
||||
const s = await prisma.staff.findFirst({ where: { id, facilityId: sess.facilityId } });
|
||||
if (!s) throw new StaffOpError("No such staff member", 404);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Append a timeline row. Every status change goes through here, so the history can't be
|
||||
* half-written by a branch that forgot. */
|
||||
async function event(tx: Prisma.TransactionClient, requestId: string, label: string, meta: string, actorName: string) {
|
||||
await tx.requestEvent.create({ data: { requestId, label, meta: meta.slice(0, 200), actorName: actorName.slice(0, 120) } });
|
||||
}
|
||||
|
||||
/** Fire-and-forget: a request must not fail because the mail server was slow. */
|
||||
function mail(to: string | null | undefined, subject: string, text: string) {
|
||||
if (!to || !transactionalConfigured()) return;
|
||||
void sendTo(to, subject, text).catch((e) => console.error("[staff mail]", (e as Error).message));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- the decision
|
||||
*
|
||||
* The manager settles the whole request in one action. They may approve all of it, decline all of
|
||||
* it, or approve some garments and knock others back — the tunic and the trousers yes, the fleece
|
||||
* no, over allowance. Every line ends up `approved` or `declined`, and the request's own status is
|
||||
* the rollup: something survived means `accepted` and there is a pick to do, nothing survived means
|
||||
* `declined`. One code, one pick, one collection either way.
|
||||
*
|
||||
* It lives in one exported function because two doors reach it — the manager tapping Approve in
|
||||
* the app, and the emailed link at /api/staff/decide, which has no session behind it. When the two
|
||||
* were written separately they drifted, and a decision made by email recorded a different timeline
|
||||
* row from the same decision made in the app.
|
||||
*/
|
||||
export type LineDecision = { id: string; decision: LineStatus; reason: string };
|
||||
|
||||
/** Read a per-line decision payload: `[{ id, decision: "approved" | "declined", reason? }]`.
|
||||
* Null when the caller sent none, which means the whole-request shorthand applies. Every line of
|
||||
* the request has to appear exactly once — a decision that leaves a garment undecided would move
|
||||
* the request to the linen room with a line nobody has answered. */
|
||||
function readLineDecisions(raw: unknown, lines: readonly { id: string }[]): LineDecision[] | null {
|
||||
if (raw === undefined || raw === null) return null;
|
||||
if (!Array.isArray(raw)) throw new StaffOpError("Say what you decided about each garment");
|
||||
const seen = new Set<string>();
|
||||
const out: LineDecision[] = [];
|
||||
for (const row of raw) {
|
||||
const r = (row || {}) as { id?: unknown; decision?: unknown; reason?: unknown };
|
||||
const id = str(r.id);
|
||||
if (!lines.some((l) => l.id === id) || seen.has(id)) throw new StaffOpError("That decision doesn't match the request — reopen it and try again");
|
||||
seen.add(id);
|
||||
const decision = str(r.decision, 20);
|
||||
if (decision !== "approved" && decision !== "declined") throw new StaffOpError("Approve or decline each garment");
|
||||
let reason = "";
|
||||
if (decision === "declined") {
|
||||
reason = str(r.reason, 60);
|
||||
if (!DECLINE_REASONS.includes(reason as never)) throw new StaffOpError("Pick a reason for each garment you decline — the staff member is told what it was");
|
||||
}
|
||||
out.push({ id, decision, reason });
|
||||
}
|
||||
if (out.length !== lines.length) throw new StaffOpError("Decide every garment on the request before you send it");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Settle a request. `approveAll` is the whole-request shorthand the emailed link uses, where
|
||||
* there is no room for a per-line answer; `lines` overrides it when the manager has decided
|
||||
* garment by garment.
|
||||
*
|
||||
* The facility, the code and the manager's name come back with the decision so the emailed-link
|
||||
* door can file its audit row without reading the request a second time. */
|
||||
export async function decideRequest(d: {
|
||||
requestId: string; managerId: string; facilityId?: string;
|
||||
approveAll: boolean; reason?: unknown; lines?: unknown; actorName?: string;
|
||||
}): Promise<{ ok: true; status: "accepted" | "declined"; summary: string; code: string; facilityId: string; managerName: string; selfApproved: boolean; notified: boolean }> {
|
||||
const r = await prisma.request.findFirst({
|
||||
where: { id: d.requestId, managerId: d.managerId, ...(d.facilityId ? { facilityId: d.facilityId } : {}) },
|
||||
include: {
|
||||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||||
subject: { select: { id: true, first: true, inactive: true } },
|
||||
facility: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!r) throw new StaffOpError("No such request", 404);
|
||||
|
||||
/* Whoever is asking names a manager; that does not make them one. The session re-reads the
|
||||
* register on every call, but an emailed link lives for a fortnight in a mailbox that may since
|
||||
* have been closed or handed on, so the register is read here too — and the wearer with it,
|
||||
* because approving garments for somebody who has left the register is the same mistake pointed
|
||||
* the other way. Request.managerId carries no foreign key, so a deleted manager leaves an id
|
||||
* that still satisfies the query above; this lookup is what turns that back into a refusal. */
|
||||
const mgr = await prisma.staff.findFirst({ where: { id: d.managerId, facilityId: r.facilityId, inactive: false }, select: { id: true } });
|
||||
if (!mgr) throw new StaffOpError("You're no longer recorded as a manager here — ask the linen room.", 403);
|
||||
/* Anybody may approve their own request — the owner's decision, and it replaced a test that
|
||||
* allowed it only to somebody with a report on the register. A self-approval is never mistaken
|
||||
* later for an ordinary one: the timeline row written below says "Self-approved" in words, and
|
||||
* the result carries selfApproved so both doors can say it too.
|
||||
*
|
||||
* What does stand is the raise rule: nobody approves a request they raised for somebody else.
|
||||
* request.create and request.reassign both refuse to address one that way; this is the same
|
||||
* rule at the moment it would matter, so a request addressed some other way — a restored backup
|
||||
* — cannot slip past it. */
|
||||
const selfApproval = r.subject.id === d.managerId;
|
||||
if (r.raisedByStaffId && r.raisedByStaffId === d.managerId && !selfApproval) {
|
||||
throw new StaffOpError("You raised this request, so somebody else has to approve it — ask the linen room to re-address it.", 403);
|
||||
}
|
||||
if (r.subject.inactive) throw new StaffOpError("That person is no longer on the register, so nothing can be approved for them.", 403);
|
||||
if (!r.lines.length) throw new StaffOpError("That request has no garments on it — ask the linen room to raise it again.");
|
||||
|
||||
const perLine = readLineDecisions(d.lines, r.lines);
|
||||
// The shorthand: the op name, or the emailed link, answers for every garment at once.
|
||||
let wholeReason = "";
|
||||
if (!perLine && !d.approveAll) {
|
||||
wholeReason = str(d.reason, 60);
|
||||
if (!DECLINE_REASONS.includes(wholeReason as never)) throw new StaffOpError("Pick a reason — the staff member is told what it was");
|
||||
}
|
||||
const decisions: LineDecision[] = perLine ?? r.lines.map((l) => ({
|
||||
id: l.id, decision: d.approveAll ? "approved" : "declined", reason: d.approveAll ? "" : wholeReason,
|
||||
}));
|
||||
|
||||
const to = rollUpRequestStatus(decisions.map((x) => ({ status: x.decision })));
|
||||
// Every line has just been decided, so the rollup is never "awaiting" here; the check is the
|
||||
// state machine's, and it is what makes the emailed link single-use — both links in the same
|
||||
// message stop working the moment either one is spent.
|
||||
if (to === "awaiting" || !canMove(r.status, to)) throw new StaffOpError("That request has already been decided", 409);
|
||||
|
||||
const refusals = decisions.filter((x) => x.decision === "declined");
|
||||
// The request-level reason is the whole request's reason, so it is set only when one reason
|
||||
// covers the whole refusal. Where the refusals differ, each line carries its own and the
|
||||
// request has none — inventing one for it would put a reason on the record nobody gave.
|
||||
const shared = refusals.length === decisions.length && refusals.every((x) => x.reason === refusals[0].reason) ? refusals[0].reason : "";
|
||||
|
||||
const actorName = d.actorName || r.managerName || "Your manager";
|
||||
const sizeOf = (l: (typeof r.lines)[number]) => String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex);
|
||||
// A request can carry the same garment in two sizes, so the size is named only when the garment
|
||||
// alone would be ambiguous: "Fleece declined" beats "Fleece M declined" when there is one fleece.
|
||||
const nameOf = (l: (typeof r.lines)[number]) => (r.lines.filter((x) => x.itemId === l.itemId).length > 1 ? `${l.item.item} ${sizeOf(l)}` : l.item.item);
|
||||
const lineById = new Map(r.lines.map((l) => [l.id, l]));
|
||||
const refusalList = refusals.map((x) => `${nameOf(lineById.get(x.id)!)} declined: ${x.reason}`).join("; ");
|
||||
|
||||
// One timeline row for the decision as a whole. A row per line would bury the request's own
|
||||
// history under its garments, and the manager did one thing, once.
|
||||
const yes = decisions.length - refusals.length;
|
||||
// A manager deciding her own request is marked in the label, not tucked into the meta: the label
|
||||
// is the line a timeline shows at a glance and the first thing anyone auditing the request
|
||||
// reads, and it is the one thing about the decision nobody could otherwise tell from it. The
|
||||
// meta then says it again in full, because the label is also quoted on screens that show no
|
||||
// meta at all.
|
||||
const own = selfApproval ? " — their own request" : "";
|
||||
const label = yes === decisions.length ? `Approved by ${actorName}${own}`
|
||||
: yes === 0 ? `Declined by ${actorName}${own}`
|
||||
: `${yes} of ${decisions.length} approved by ${actorName}${own}`;
|
||||
const plain = yes === decisions.length ? "Ward manager" : shared || refusalList;
|
||||
// Worded for what actually happened: nothing was approved, so nothing was self-approved, but the
|
||||
// decision was still the wearer's own and that is what the row has to show.
|
||||
const selfNote = yes
|
||||
? `Self-approved — ${actorName} is the manager on this request and the person it is for`
|
||||
: `Decided by ${actorName}, who is the manager on this request and the person it is for`;
|
||||
const meta = selfApproval
|
||||
? [selfNote, yes === decisions.length ? "" : plain].filter(Boolean).join(" · ")
|
||||
: plain;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Conditional on the status we read, so two taps — or a tap and an email link — can't both win.
|
||||
const moved = await tx.request.updateMany({
|
||||
where: { id: r.id, status: "awaiting" },
|
||||
data: { status: to, decidedAt: new Date(), declineReason: to === "declined" && shared ? shared : null },
|
||||
});
|
||||
if (moved.count !== 1) throw new StaffOpError("That request has already been decided", 409);
|
||||
for (const x of decisions) {
|
||||
await tx.requestLine.updateMany({
|
||||
where: { id: x.id, requestId: r.id },
|
||||
data: { status: x.decision, declineReason: x.decision === "declined" ? x.reason : null },
|
||||
});
|
||||
}
|
||||
await event(tx, r.id, label, meta, actorName);
|
||||
});
|
||||
|
||||
const subjAccount = await prisma.staffAccount.findUnique({ where: { staffId: r.subject.id }, select: { email: true } });
|
||||
const decided = new Map(decisions.map((x) => [x.id, x]));
|
||||
const em = decisionEmail({
|
||||
staffFirst: r.subject.first, managerName: actorName, approved: to === "accepted",
|
||||
reason: to === "declined" ? shared : "",
|
||||
lines: r.lines.map((l) => ({
|
||||
qty: l.qty, item: l.item.item, size: sizeOf(l),
|
||||
status: decided.get(l.id)?.decision, declineReason: decided.get(l.id)?.reason || null,
|
||||
})),
|
||||
facility: r.facility.name,
|
||||
});
|
||||
mail(subjAccount?.email, em.subject, em.text);
|
||||
|
||||
return {
|
||||
ok: true, status: to, summary: decisionSummary(decisions.map((x) => ({ status: x.decision }))) || label,
|
||||
code: r.code, facilityId: r.facilityId, managerName: actorName,
|
||||
// Whether the wearer was actually emailed — same meaning as request.create's flag. The
|
||||
// emailed-link page said "has been told" regardless, which is how a wearer with no account
|
||||
// waited on an approval nobody mentioned to them.
|
||||
notified: !!subjAccount?.email && transactionalConfigured(),
|
||||
// Handed back so the two doors can say it as well: the app's confirmation, and the audit row
|
||||
// the emailed link files, which would otherwise read as an ordinary approval by a manager who
|
||||
// happens to have the same name as the wearer.
|
||||
selfApproved: selfApproval,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runStaffOp(sess: StaffSession, op: string, p: Record<string, unknown>, internal: Internal = {}): Promise<unknown> {
|
||||
p = p || {};
|
||||
const me = await prisma.staff.findUniqueOrThrow({
|
||||
where: { id: sess.staffId },
|
||||
select: {
|
||||
id: true, first: true, last: true, dept: true, wardDesk: true, managerId: true, group: true, uniformStyle: true,
|
||||
facility: { select: { id: true, name: true, timezone: true, ...PLAN_COLS } },
|
||||
},
|
||||
});
|
||||
// A read-only room refuses a wearer's writes too — in the room's words, since the plan is the
|
||||
// linen room's business and not the wearer's. Reading (my kit, the shelf) is untouched.
|
||||
if (entitlements(me.facility).readOnly) throw new StaffOpError("The linen room's ThreadCount is read-only at the moment. Ask the linen room.", 403);
|
||||
const myName = `${me.first} ${me.last}`.trim();
|
||||
const fid = sess.facilityId;
|
||||
|
||||
switch (op) {
|
||||
/* ---------------------------------------------------------------- requests */
|
||||
case "request.create": {
|
||||
/* Who it is for. One person other than the wearer may raise: a manager, for the people who
|
||||
* report to them. That fence is the reporting line, which is the same relationship that
|
||||
* makes them the approver — and the reason a raise of theirs goes up a level below.
|
||||
*
|
||||
* A ward clerk on the desk used to be able to raise for anyone on their own ward, on the
|
||||
* grounds that half a ward will never install anything. That is gone: everyone in the
|
||||
* building carries a phone, and the manager route covers the person who genuinely cannot.
|
||||
* The desk flag itself stays — it is what signs for a bag on the ward round. */
|
||||
const subjectId = str(p.subjectId) || me.id;
|
||||
const other = me.id === subjectId ? null : await ownStaffInFacility(sess, subjectId);
|
||||
if (other && other.managerId !== me.id) {
|
||||
throw new StaffOpError("Only somebody's own manager can raise a request for them", 403);
|
||||
}
|
||||
const subject = other ?? (await ownStaffInFacility(sess, me.id));
|
||||
if (subject.inactive) throw new StaffOpError("That person is no longer on the register");
|
||||
|
||||
// The approver is the subject's own manager, never the clerk's.
|
||||
if (!subject.managerId) {
|
||||
throw new StaffOpError(
|
||||
subject.id === me.id
|
||||
? "Your manager isn't set yet — the linen room has to record who approves your requests."
|
||||
: `${subject.first} has no manager recorded, so there is nobody to approve this. Ask the linen room to set one.`,
|
||||
);
|
||||
}
|
||||
// Off the register means off the register. A deactivated manager cannot sign in to approve
|
||||
// anything, so addressing a request to one parks it where nobody can reach it — and it would
|
||||
// mint an approval link that outlives their access by a fortnight.
|
||||
let manager = await prisma.staff.findFirst({ where: { id: subject.managerId, facilityId: fid, inactive: false } });
|
||||
if (!manager) throw new StaffOpError("The recorded manager is no longer on the register. Ask the linen room.");
|
||||
|
||||
/* Nobody approves a raise they made for somebody else. A manager raising for one of their
|
||||
* own reports is the ordinary case, and the subject's approver is that same manager — so the
|
||||
* request goes up a level instead: to the raiser's own manager, if they have one. If they do
|
||||
* not, it is created with no approver at all and surfaces on the linen room's queue (Needs
|
||||
* an approver), which already has request.reassign to give it one. Either way it never comes
|
||||
* back to the person who raised it, and the timeline says where it went and why.
|
||||
*
|
||||
* Only a raise for somebody else. A person who is their own manager raising for themselves
|
||||
* lands on their own desk, which is allowed and is marked Self-approved when they decide. */
|
||||
let escalation = "";
|
||||
if (other && manager.id === me.id) {
|
||||
const found = me.managerId ? await prisma.staff.findFirst({ where: { id: me.managerId, facilityId: fid, inactive: false } }) : null;
|
||||
/* A level up can be the raiser again: somebody who is their own manager has nobody above
|
||||
* them but themselves. That is "nobody above" — Needs an approver — exactly as if no
|
||||
* manager were recorded, because landing it back on them is the one thing this branch is
|
||||
* here to prevent.
|
||||
*
|
||||
* A level up can also be the wearer. Two ward managers at the top of the tree are often
|
||||
* recorded as each other's approver, and escalating a raise for one of them lands the
|
||||
* request back on the person the garments are for. It goes to them: that is a person
|
||||
* approving their own kit, which anybody may do, and the note below says so on the
|
||||
* request's own timeline, so it is read as a self-approval rather than as a routine
|
||||
* approval by somebody who happens to share the name. */
|
||||
const above = found && found.id !== me.id ? found : null;
|
||||
const aboveName = above ? `${above.first} ${above.last}`.trim() : "";
|
||||
escalation = above
|
||||
? above.id === subject.id
|
||||
? `Raised by ${myName}, who approves ${subject.first}'s requests — sent up to ${aboveName}, the person it is for, to approve themselves`
|
||||
: `Raised by ${myName}, who approves ${subject.first}'s requests — sent to ${aboveName} instead`
|
||||
: `Raised by ${myName}, who approves ${subject.first}'s requests — the linen room will address it`;
|
||||
manager = above;
|
||||
}
|
||||
|
||||
const reason = str(p.reason, 40);
|
||||
if (reason && !REQUEST_REASONS.includes(reason as never)) throw new StaffOpError("Unknown reason");
|
||||
const lines = await readRequestLines(fid, p.lines).catch(asStaffError);
|
||||
// Only the SUBJECT's own staff group's garments, plus those for every group — whoever raises it.
|
||||
// A waitlist offer comes through here and is measured the same. So does a swap from the app's
|
||||
// Swap a size screen, which sends nothing to tell it from a fresh request: holding another
|
||||
// group's garment is no reason to be handed another, so a size swap of one is the counter's
|
||||
// (issue.exchange). The one exception is a damage replacement for a garment they hold, which
|
||||
// only damage.report can ask for.
|
||||
const offGroup = await offGroupGarments(fid, subject.group, lines.map((l) => l.itemId),
|
||||
internal.replacing ? { staffId: subject.id, itemId: internal.replacing } : undefined);
|
||||
if (offGroup.length) throw new StaffOpError(offGroupMessage(offGroup, subject, subject.id === me.id));
|
||||
// And only the cut the SUBJECT is offered, measured the same way and exempting the same one
|
||||
// damage replacement. Blank — nobody has said which cut they wear — and Either take every
|
||||
// garment, so nothing changes for a record no coordinator has set.
|
||||
const offStyle = await offStyleGarments(fid, subject.uniformStyle, lines.map((l) => l.itemId),
|
||||
internal.replacing ? { staffId: subject.id, itemId: internal.replacing } : undefined);
|
||||
if (offStyle.length) throw new StaffOpError(offStyleMessage(offStyle, subject, subject.id === me.id));
|
||||
const note = str(p.note, 400);
|
||||
const managerName = manager ? `${manager.first} ${manager.last}`.trim() : "";
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const f = await tx.facility.update({ where: { id: fid }, data: { requestSeq: { increment: 1 } }, select: { requestSeq: true } });
|
||||
const r = await tx.request.create({
|
||||
data: {
|
||||
facilityId: fid, code: requestCode(f.requestSeq),
|
||||
subjectId: subject.id,
|
||||
raisedByStaffId: subject.id === me.id ? null : me.id,
|
||||
raisedByName: subject.id === me.id ? "" : myName,
|
||||
reason, note,
|
||||
status: "awaiting",
|
||||
managerId: manager?.id ?? null, managerName,
|
||||
lines: { create: lines.map((l, i) => ({ itemId: l.itemId, sizeIndex: l.sizeIndex, qty: l.qty, sort: i })) },
|
||||
},
|
||||
});
|
||||
// The escalation note already names the raiser, so it stands in for the plain one.
|
||||
const raised = subject.id === me.id ? "" : `Raised by ${myName}`;
|
||||
await event(tx, r.id, "Requested", escalation || raised, subject.id === me.id ? "You" : myName);
|
||||
return r;
|
||||
});
|
||||
|
||||
// Nothing to send while the request has no approver — the linen room addresses it first.
|
||||
const mgrAccount = manager ? await prisma.staffAccount.findUnique({ where: { staffId: manager.id }, select: { email: true } }) : null;
|
||||
if (manager) {
|
||||
const em = approvalEmail({
|
||||
managerFirst: manager.first,
|
||||
subjectName: `${subject.first} ${subject.last}`.trim(),
|
||||
raisedByName: subject.id === me.id ? "" : myName,
|
||||
lines, reason, note,
|
||||
url: approvalUrl(signApprovalToken(created.id, manager.id)),
|
||||
facility: me.facility.name,
|
||||
});
|
||||
mail(mgrAccount?.email, em.subject, em.text);
|
||||
}
|
||||
|
||||
// `notified` says an email actually went out, not that the manager happens to have an
|
||||
// account: mail() is a no-op with no transactional mail configured, and a screen that says
|
||||
// "we've told them" when nothing was sent is the reason a request sits for three weeks.
|
||||
const notified = !!mgrAccount?.email && transactionalConfigured();
|
||||
// `selfApproves` is for the screen the raiser is standing at: their request has gone up to
|
||||
// the person it is for, which is allowed and is the only way the top of the tree gets
|
||||
// dressed, but they should be told that is where it went rather than find out later.
|
||||
return { id: created.id, code: created.code, manager: managerName, escalated: !!escalation, notified, selfApproves: !!manager && manager.id === subject.id };
|
||||
}
|
||||
|
||||
case "request.approve":
|
||||
case "request.decline": {
|
||||
// The manager's own door into the shared decision. `lines` decides garment by garment when
|
||||
// the screen sends it; without it the op name settles the whole request, which is what the
|
||||
// emailed link does through the same function.
|
||||
return decideRequest({
|
||||
requestId: str(p.id), managerId: me.id, facilityId: fid,
|
||||
approveAll: op === "request.approve", reason: p.reason, lines: p.lines, actorName: myName,
|
||||
});
|
||||
}
|
||||
|
||||
case "request.message": {
|
||||
const r = await visibleRequest(sess, str(p.id));
|
||||
const body = str(p.body, 2000).trim();
|
||||
if (!body) throw new StaffOpError("Write something first");
|
||||
const m = await prisma.requestMessage.create({
|
||||
data: { requestId: r.id, fromStaff: true, authorName: myName, body },
|
||||
});
|
||||
return { id: m.id, at: m.createdAt.toISOString() };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- ward round */
|
||||
case "round.sign": {
|
||||
// Anyone on the ward may sign, and whoever does is named on the requester's order — so a
|
||||
// bag that never arrived has a name against it.
|
||||
const r = await prisma.request.findFirst({ where: { id: str(p.id), facilityId: fid, status: "round" }, include: { lines: true, events: { select: { label: true, meta: true } } } });
|
||||
if (!r) throw new StaffOpError("No such bag waiting on the round", 404);
|
||||
// The ward the trolley left the bag on, off the timeline — not the ward the wearer is on
|
||||
// today. Fencing on the wearer's ward made a bag follow a nurse who transferred: /my/round
|
||||
// still lists it to the desk that is physically holding it (lib/deskdata.ts reads the same
|
||||
// event), and this refused that desk. Since round.sign is the only way out of `round`, the
|
||||
// bag was then stuck for good, and the ward it had moved to on screen could have signed for
|
||||
// a handover that never happened.
|
||||
//
|
||||
// A blank ward is not a ward, so it matches nothing. Comparing the two sides as empty
|
||||
// strings put everybody whose ward was never filled in on one ward together — a clerk with
|
||||
// no ward could sign for any other ward-less person's bag in the facility. Both sides have
|
||||
// to be a real ward name, and lib/ops.ts refuses to route a ward-less bag onto a round at all.
|
||||
const bagWard = roundWard(r.events);
|
||||
if (!me.dept || !bagWard || bagWard !== me.dept) throw new StaffOpError("That bag is for another ward", 403);
|
||||
|
||||
// Signing for the bag is the moment the garment leaves the linen room's shelf, and the ward
|
||||
// round has to record that exactly as the counter does — an Issue against the wearer and a
|
||||
// replenishment line — or the shelf count quietly loses a garment on every round while the
|
||||
// counter's figures stay honest. Shared with request.collected so the two cannot drift.
|
||||
try {
|
||||
await handOverRequestStock(fid, facilityToday(me.facility.timezone), { subjectId: r.subjectId, lines: r.lines }, async (tx) => {
|
||||
const moved = await tx.request.updateMany({
|
||||
where: { id: r.id, status: "round" },
|
||||
data: { status: "delivered", signerName: myName, signerRole: me.wardDesk ? "ward clerk" : (me.dept || "ward"), signedAt: new Date() },
|
||||
});
|
||||
if (moved.count !== 1) throw new StaffOpError("Someone has already signed for that bag");
|
||||
await event(tx, r.id, `Delivered to ${me.dept || "the ward"}`, `Signed by ${myName}`, myName);
|
||||
}, "ward"); // the bag has already left the shelf: record the hand-over, never refuse it here
|
||||
} catch (e) {
|
||||
// The shared helper speaks the coordinator's error type — an empty shelf has to reach the
|
||||
// person on the ward as those words, not as a bare "something went wrong".
|
||||
asStaffError(e);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
case "round.claim": {
|
||||
// The bag has left the desk. Either the requester says so — "I've got it" on their own
|
||||
// order — or the clerk who is looking at the pile marks it collected, because the person who
|
||||
// knows a bag is gone is usually the one standing next to where it was. A desk clerk may
|
||||
// only do that for their own ward, the same fence round.sign works behind.
|
||||
const r = await prisma.request.findFirst({
|
||||
where: { id: str(p.id), facilityId: fid, status: "delivered" },
|
||||
include: { subject: { select: { id: true } }, events: { select: { label: true, meta: true } } },
|
||||
});
|
||||
if (!r) throw new StaffOpError("No such delivery", 404);
|
||||
if (r.subject.id !== me.id) {
|
||||
if (!me.wardDesk) throw new StaffOpError("That delivery is somebody else's", 403);
|
||||
// The ward the bag was delivered to, off the timeline, exactly as round.sign and
|
||||
// /my/round read it: the clerk looking at the pile is the one who may clear it, and a
|
||||
// wearer transferring afterwards does not move the bag off their desk.
|
||||
//
|
||||
// The same blank-ward rule as round.sign: no ward recorded on either side is no match, or
|
||||
// a clerk whose ward was never filled in could claim for every other ward-less person.
|
||||
const bagWard = roundWard(r.events);
|
||||
if (!me.dept || !bagWard || bagWard !== me.dept) throw new StaffOpError("That bag is for another ward", 403);
|
||||
}
|
||||
// Conditional on it still being unclaimed, so the requester and the desk both tapping it
|
||||
// leaves one claim with the first person's name on the timeline rather than two.
|
||||
const claimed = await prisma.request.updateMany({ where: { id: r.id, claimedAt: null }, data: { claimedAt: new Date() } });
|
||||
if (claimed.count === 1) {
|
||||
await event(prisma, r.id, "Collected from the ward", r.subject.id === me.id ? "" : `Marked by ${myName}`, r.subject.id === me.id ? "You" : myName);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- damage */
|
||||
case "damage.report": {
|
||||
const kind = str(p.kind, 40);
|
||||
if (!DAMAGE_KINDS.includes(kind as never)) throw new StaffOpError("Pick what happened");
|
||||
// handedIn as well as returnedDate: a garment handed back at the counter is off the person
|
||||
// even though nothing marks it returned, and the line below this one promises the damaged
|
||||
// garment stays on the record only *until* it is handed in.
|
||||
const issue = await prisma.issue.findFirst({ where: { id: str(p.issueId), facilityId: fid, staffId: me.id, returnedDate: null, handedIn: null } });
|
||||
if (!issue) throw new StaffOpError("Pick something you're holding", 404);
|
||||
// A replacement is a separate act with its own approval — reporting damage does not quietly
|
||||
// issue anything, and the damaged garment stays on the record until it is handed in.
|
||||
//
|
||||
// It is also the half that can fail: somebody with no manager recorded cannot raise anything.
|
||||
// So it goes first. Written the other way round, a nurse with no approver got an error over
|
||||
// a report that had already been saved, and one more orphan for every time she tried again.
|
||||
//
|
||||
// A discontinued garment is still on somebody's back. A request may not carry an archived
|
||||
// catalogue entry — nobody should be able to order from a withdrawn range — but that refusal
|
||||
// used to take the damage report down with it: a nurse holding a torn tunic from last year's
|
||||
// range was told "That garment isn't available" about the garment in her hand, and nothing at
|
||||
// all was recorded. The report is the half that matters, so it is saved either way and what
|
||||
// she gets instead becomes the linen room's to settle.
|
||||
const cat = await prisma.catalogItem.findFirst({ where: { id: issue.itemId, facilityId: fid }, select: { archived: true } });
|
||||
let replacement: { id: string } | null = null;
|
||||
let replacementNote = "";
|
||||
if (p.replace && cat?.archived) {
|
||||
replacementNote = "That garment has been discontinued, so a replacement can't be asked for here. Your report has gone to the linen room, who will sort out what you get instead.";
|
||||
} else if (p.replace) {
|
||||
// Like for like, so a garment outside their staff group that they were issued (on the
|
||||
// counter's override, or before the rule) can still be replaced — and nothing else can.
|
||||
replacement = await runStaffOp(sess, "request.create", {
|
||||
lines: [{ itemId: issue.itemId, si: issue.sizeIndex, qty: 1 }], reason: "Damaged",
|
||||
note: `Replacement for a ${kind.toLowerCase()} garment`,
|
||||
}, { replacing: issue.itemId }) as { id: string };
|
||||
}
|
||||
const rep = await prisma.damageReport.create({
|
||||
data: {
|
||||
facilityId: fid, staffId: me.id, issueId: issue.id, kind, note: str(p.note, 400),
|
||||
photoId: str(p.photoId) || null, requestId: replacement?.id ?? null,
|
||||
},
|
||||
});
|
||||
return { id: rep.id, replacement, replacementNote };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- the record is wrong */
|
||||
case "dispute.raise": {
|
||||
const body = str(p.body, 1000).trim();
|
||||
if (!body) throw new StaffOpError("Say what doesn't look right");
|
||||
const d = await prisma.recordDispute.create({
|
||||
data: {
|
||||
facilityId: fid, staffId: me.id, body,
|
||||
itemId: str(p.itemId) || null,
|
||||
sizeIndex: p.si === undefined || p.si === null || p.si === "" ? null : int(p.si),
|
||||
},
|
||||
});
|
||||
return { id: d.id };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- waitlist */
|
||||
case "waitlist.join": {
|
||||
const item = await prisma.catalogItem.findFirst({ where: { id: str(p.itemId), facilityId: fid } });
|
||||
const si = int(p.si, -1);
|
||||
if (!item || si < 0 || si >= item.sizes.length) throw new StaffOpError("Pick a size", 404);
|
||||
// A place in the queue ends in a request, which would be refused for a garment outside their
|
||||
// staff group — so it is refused here, before they wait for an offer they cannot accept.
|
||||
const offGroup = await offGroupGarments(fid, me.group, [item.id]);
|
||||
if (offGroup.length) throw new StaffOpError(offGroupMessage(offGroup, me, true));
|
||||
// The same for the cut, before they wait for an offer their own request would refuse.
|
||||
const offStyle = await offStyleGarments(fid, me.uniformStyle, [item.id]);
|
||||
if (offStyle.length) throw new StaffOpError(offStyleMessage(offStyle, me, true));
|
||||
|
||||
// Leaving is a soft delete — the row stays, with leftAt set — but one place in a queue per
|
||||
// garment and size is a hard constraint, so a second create could never succeed and told
|
||||
// whoever tried "you're already on the list", which was the opposite of true. Coming back
|
||||
// revives the row she already has, at the back of the queue: leaving forfeits her place,
|
||||
// which is the honest reading of having left.
|
||||
const existing = await prisma.waitlistEntry.findUnique({
|
||||
where: { staffId_itemId_sizeIndex: { staffId: me.id, itemId: item.id, sizeIndex: si } },
|
||||
});
|
||||
if (existing && !existing.leftAt && !existing.acceptedAt) throw new StaffOpError("You're already on the list for that size");
|
||||
if (existing) {
|
||||
const w = await prisma.waitlistEntry.update({
|
||||
where: { id: existing.id },
|
||||
data: { leftAt: null, offeredAt: null, acceptedAt: null, createdAt: new Date() },
|
||||
});
|
||||
return { id: w.id };
|
||||
}
|
||||
try {
|
||||
// Joining needs no approval — a queue is not a request. Approval happens if and when the
|
||||
// item lands and they accept it.
|
||||
const w = await prisma.waitlistEntry.create({ data: { facilityId: fid, staffId: me.id, itemId: item.id, sizeIndex: si } });
|
||||
return { id: w.id };
|
||||
} catch (e) {
|
||||
// Two taps racing each other past the read above — by then it is true.
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") throw new StaffOpError("You're already on the list for that size");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
case "waitlist.leave": {
|
||||
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), staffId: me.id, leftAt: null } });
|
||||
if (!w) throw new StaffOpError("Not on that list", 404);
|
||||
await prisma.waitlistEntry.update({ where: { id: w.id }, data: { leftAt: new Date() } });
|
||||
return { ok: true };
|
||||
}
|
||||
case "waitlist.accept": {
|
||||
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), staffId: me.id, leftAt: null, acceptedAt: null, offeredAt: { not: null } } });
|
||||
if (!w) throw new StaffOpError("Nothing to accept", 404);
|
||||
// The hold the screen and the offer email both promise. Enforced rather than described: the
|
||||
// garment goes back on the shelf for whoever is next, and she keeps her place in the queue.
|
||||
if (holdExpired(w.offeredAt)) {
|
||||
throw new StaffOpError(`That was held for ${WAITLIST_HOLD_HOURS} hours and the hold has run out. You're still on the list — ask the linen room if it's still there.`);
|
||||
}
|
||||
// A withdrawn range can still have garments on the shelf, so an offer can outlive the
|
||||
// catalogue entry — and a request may not carry an archived one. Refuse before the offer is
|
||||
// claimed rather than after: rolling the acceptance back and saying "That garment isn't
|
||||
// available" about a garment the linen room has physically held for her is a dead end she
|
||||
// cannot tap her way out of. Her place on the list is untouched, and the counter can still
|
||||
// hand it over by hand.
|
||||
const offered = await prisma.catalogItem.findFirst({ where: { id: w.itemId, facilityId: fid }, select: { archived: true } });
|
||||
if (offered?.archived) {
|
||||
throw new StaffOpError("That garment has been discontinued since you joined the list, so it can't be requested here. Ask the linen room — they're holding it for you at the counter.");
|
||||
}
|
||||
// Claim the offer before raising the request, so two taps can't turn one held garment into
|
||||
// two requests — and hand it straight back if the request can't be raised. An entry stamped
|
||||
// accepted with no request behind it is a queue place that can never be used again: accept
|
||||
// refuses it forever, and the linen room's waiting list has already dropped her.
|
||||
const claimed = await prisma.waitlistEntry.updateMany({ where: { id: w.id, acceptedAt: null }, data: { acceptedAt: new Date() } });
|
||||
if (claimed.count !== 1) throw new StaffOpError("Nothing to accept", 404);
|
||||
try {
|
||||
const req = await runStaffOp(sess, "request.create", { lines: [{ itemId: w.itemId, si: w.sizeIndex, qty: 1 }], reason: "Extra for shifts", note: "Accepted from the waitlist" });
|
||||
return { ok: true, request: req };
|
||||
} catch (e) {
|
||||
await prisma.waitlistEntry.updateMany({ where: { id: w.id }, data: { acceptedAt: null } });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- kit check */
|
||||
case "kit.answer": {
|
||||
const cycle = await prisma.kitCheck.findFirst({ where: { facilityId: fid, closedAt: null }, orderBy: { openedAt: "desc" } });
|
||||
if (!cycle) throw new StaffOpError("No kit check is open", 404);
|
||||
const item = await prisma.catalogItem.findFirst({ where: { id: str(p.itemId), facilityId: fid } });
|
||||
const si = int(p.si, -1);
|
||||
if (!item || si < 0 || si >= item.sizes.length) throw new StaffOpError("Unknown garment", 404);
|
||||
// "On record" is the linen room's figure, so it is read from the record and not from the
|
||||
// phone that is being asked about it. The screen is sent the same number and posts it back,
|
||||
// which is convenient and worthless as evidence — these answers are what a coordinator
|
||||
// adjusts the register against, and a shortfall the client chose is not a shortfall.
|
||||
const held = await prisma.issue.aggregate({
|
||||
where: { facilityId: fid, staffId: me.id, itemId: item.id, sizeIndex: si, returnedDate: null, handedIn: null },
|
||||
_sum: { qty: true },
|
||||
});
|
||||
const onRecord = held._sum.qty || 0;
|
||||
if (onRecord <= 0) throw new StaffOpError("You're not holding that garment", 404);
|
||||
const confirmed = Math.max(0, Math.min(onRecord, int(p.confirmed, 0)));
|
||||
const a = await prisma.kitCheckAnswer.upsert({
|
||||
where: { kitCheckId_staffId_itemId_sizeIndex: { kitCheckId: cycle.id, staffId: me.id, itemId: item.id, sizeIndex: si } },
|
||||
create: { kitCheckId: cycle.id, staffId: me.id, itemId: item.id, sizeIndex: si, onRecord, confirmed },
|
||||
update: { onRecord, confirmed, answeredAt: new Date() },
|
||||
});
|
||||
// Nothing is written off here. The answers are the linen room's evidence; they adjust the
|
||||
// record. A screen that silently removed garments from someone's name would be a screen
|
||||
// people learn to lie to.
|
||||
return { id: a.id, short: onRecord - confirmed };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- their own sign-in */
|
||||
case "account.password": {
|
||||
/* The one thing a staff member can change about their account, and the only revocation they
|
||||
* have. A staff token carries a fingerprint of the password hash, so a new password ends
|
||||
* every session signed against the old one — a phone left on a ward, a cookie copied off it
|
||||
* — without a session table to keep. Signing out clears one cookie; this clears the lot.
|
||||
*
|
||||
* Deleting the account is deliberately not here. Access is the linen room's to grant and
|
||||
* theirs to remove: a wearer who could delete their own account would take the record of
|
||||
* what they were issued with it. */
|
||||
const current = str(p.current, 200);
|
||||
const next = str(p.next, 200);
|
||||
if (next.length < MIN_PASSWORD) throw new StaffOpError(`Use at least ${MIN_PASSWORD} characters for your new password.`);
|
||||
if (next === current) throw new StaffOpError("That's the password you already have.");
|
||||
const acc = await prisma.staffAccount.findUnique({ where: { id: sess.accountId }, select: { id: true, passwordHash: true } });
|
||||
if (!acc) throw new StaffOpError("No such account", 404);
|
||||
if (!(await bcrypt.compare(current, acc.passwordHash))) throw new StaffOpError("That isn't your current password.", 403);
|
||||
const passwordHash = await bcrypt.hash(next, 12);
|
||||
await prisma.staffAccount.update({ where: { id: acc.id }, data: { passwordHash } });
|
||||
// Re-issued in the same breath, so the person who just changed it is the one session that
|
||||
// survives rather than the one that gets thrown out.
|
||||
await setStaffCookie(acc.id, passwordHash);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new StaffOpError("Unknown action", 400);
|
||||
}
|
||||
}
|
||||
+238
@@ -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")}`;
|
||||
}
|
||||
@@ -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)}` : "";
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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");
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
/* The short-lived ticket that carries "this password was correct" from the first step of sign-in
|
||||
* to the second.
|
||||
*
|
||||
* It is emphatically not a session: it grants nothing on its own, is only accepted by the
|
||||
* second-factor endpoint, and dies in five minutes. Keeping it stateless means a half-finished
|
||||
* sign-in leaves nothing behind to clean up, and there is no table for an attacker to fill.
|
||||
*
|
||||
* It carries the password version, so a password changed between the two steps invalidates the
|
||||
* ticket for exactly the same reason it invalidates a session.
|
||||
*/
|
||||
|
||||
const TICKET_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function secret(): string {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s) throw new Error("SESSION_SECRET is required");
|
||||
return s;
|
||||
}
|
||||
|
||||
const b64 = (b: Buffer) => b.toString("base64url");
|
||||
const sign = (payload: string) => b64(createHmac("sha256", secret()).update(`2fa.${payload}`).digest());
|
||||
|
||||
export function mintTicket(userId: string, pv: string): string {
|
||||
const payload = b64(Buffer.from(JSON.stringify({ uid: userId, pv, exp: Date.now() + TICKET_TTL_MS })));
|
||||
return `${payload}.${sign(payload)}`;
|
||||
}
|
||||
|
||||
export function readTicket(ticket: string): { uid: string; pv: string } | null {
|
||||
const [payload, mac] = String(ticket || "").split(".");
|
||||
if (!payload || !mac) return null;
|
||||
const expect = sign(payload);
|
||||
const a = Buffer.from(mac, "utf8");
|
||||
const b = Buffer.from(expect, "utf8");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const t = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { uid?: string; pv?: string; exp?: number };
|
||||
if (!t.uid || !t.pv || !t.exp || t.exp < Date.now()) return null;
|
||||
return { uid: t.uid, pv: t.pv };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user