Files
threadcount-community/lib/ops.ts
T
ThreadCount 46a1911c1c 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 5470a36 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:35:40 +10:00

3417 lines
262 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import { setSessionCookie, type SessionUser } from "./session";
import { buildSnapshot } from "./snapshot";
import { ALT_FLOW, FTE_CASUAL, FTE_OPTIONS, LOCATION_KINDS, UNIFORM_STYLES, addDays, approvalDeparture, capCheck, facilityToday, flaggedNeeds, formatInZone, garmentForGroup, garmentForStyle, garmentGroups, genderLabel, groupKey, groupsLabel, isKitGroup, isNursingGroup, isPantItem, isTopItem, key, ledger, locSubtree, locWouldLoop, normalUniformStyle, onhand, openApprovals, plOf, setsForFte, setsOnStart, sizeIndexOf, variantList, type Item } from "./compute";
import { Prisma, type CatalogItem } from "@prisma/client";
import { deletePhoto, deletePhotoDir, parseDataUrl, photoAsDataUrl, writePhoto } from "@/lib/photostore";
import { newActivateCode } from "./staffsession";
import { AWAITING_HANDOVER, LINE_STATUSES, OPEN_REQUEST, TRANSITIONS, approvedLines, canMove, collectionCode, holdEndsAt, requestCode } from "./staffreq";
import { approvalEmail, approvalUrl, decisionEmail, garmentBlock, signApprovalToken } from "./approvallink";
import { sendTo, transactionalConfigured } from "./mail";
import { inHouseEan13, isInHouse } from "./barcode";
// Straight from ./sets rather than through ./compute like its neighbour setsOnStart, because
// compute doesn't re-export this one. Same rule either way: the ceiling a coordinator types here
// and the ceiling a wearer's screen quotes have to come out of the one function.
import { setsCap } from "./sets";
import { PLAN_COLS, READ_ONLY_ALLOWED, READ_ONLY_REFUSAL, entitlements, staffRefusal, type PlanRow } from "./plan";
import { alertInvoiceRequested } from "./ops/alerts";
import { allow } from "./ratelimit";
type Tx = Prisma.TransactionClient;
export class OpError extends Error { status: number; constructor(msg: string, status = 400) { super(msg); this.status = status; } }
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) ? Math.max(-1e9, Math.min(1e9, n)) : d; };
const num = (v: unknown, d = 0) => { const n = parseFloat(String(v)); return Number.isFinite(n) ? n : d; };
const admin = (u: SessionUser) => { if (u.role !== "ADMIN") throw new OpError("Admin only", 403); };
const LOGO_MAX = 400 * 1024;
/** A list of staff group names off the settings screen: trimmed, blanks dropped, and one entry per
* group as the app compares names (groupKey), keeping the first spelling sent. Refused rather than
* ignored when it isn't a list, because the screen saves a field at a time and answering "Saved."
* over a value that was thrown away is a lie the next reload exposes. */
const groupList = (v: unknown, what: string): string[] => {
if (!Array.isArray(v)) throw new OpError(`Send the ${what} as a list of staff group names`);
const seen = new Set<string>(), out: string[] = [];
for (const x of v) {
const g = str(x, 80).trim(), k = groupKey(g);
if (k && !seen.has(k)) { seen.add(k); out.push(g); }
}
return out;
};
/** The staff groups a garment is for, off whatever a screen sent — or undefined to leave them be.
* The desktop sends `groups`, a list, empty meaning every group. The phone counter app still sends
* its single `group` and cannot change, and it shows several groups as one label ("Registered
* Nurse, Enrolled Nurse"). So a `group` equal to the garment's own label is the phone saving its
* other fields and changes nothing — read as one name it would collapse a four-group garment onto a
* group nobody is in. A label copied off another garment takes that garment's groups; "All" or
* blank is every group; any other value is a list of names split at its commas, or one name if it
* has none. */
async function groupsFromPayload(db: Tx, fid: string, p: { groups?: unknown; group?: unknown }, current: string[] | null): Promise<string[] | undefined> {
if (p.groups !== undefined && p.groups !== null) {
if (!Array.isArray(p.groups) || p.groups.length > 200) throw new OpError("Send the garment's staff groups as a list of names");
return garmentGroups(p.groups.map((x: unknown) => str(x, 80)));
}
if (p.group === undefined || p.group === null) return undefined;
const v = str(p.group, 4000).trim();
if (current && groupKey(v) === groupKey(groupsLabel(current))) return undefined;
if (v.includes(",")) {
const tagged = await db.catalogItem.findMany({ where: { facilityId: fid, NOT: { groups: { isEmpty: true } } }, select: { groups: true } });
const same = tagged.find((t) => t.groups.length > 1 && groupKey(groupsLabel(t.groups)) === groupKey(v));
if (same) return garmentGroups(same.groups);
// Nobody's label, so it is a list typed on the phone. Kept whole it would be one group called
// "A, B" that nobody is in, and the garment would be refused to both A and B. A facility's own
// group name can carry a comma of its own, though, so the pieces are joined back up wherever
// they spell a name this facility already uses — on its settings, its register or a garment.
const [fac, onRegister] = await Promise.all([
db.facility.findUnique({ where: { id: fid }, select: { staffGroups: true } }),
db.staff.findMany({ where: { facilityId: fid }, select: { group: true }, distinct: ["group"] }),
]);
return garmentGroups(splitGroupNames(v, [...(fac?.staffGroups || []), ...onRegister.map((s) => s.group), ...tagged.flatMap((t) => t.groups)]));
}
return garmentGroups([str(v, 80)]);
}
/** A comma-separated list of staff groups as names, joining neighbouring pieces back together where
* they spell one of the `known` names that itself contains a comma. Longest match first, so a
* known "Food, Retail" wins over a lone "Food". The pieces are left for garmentGroups() to trim. */
function splitGroupNames(v: string, known: readonly string[]): string[] {
const commaKey = (s: string) => s.split(",").map((x) => groupKey(x)).join(",");
const names = new Map<string, string>();
for (const n of known) if (n.includes(",")) names.set(commaKey(n), n.trim());
const parts = v.split(",");
const out: string[] = [];
for (let i = 0; i < parts.length;) {
let j = parts.length;
while (j > i + 1 && !names.has(commaKey(parts.slice(i, j).join(",")))) j--;
out.push(j > i + 1 ? names.get(commaKey(parts.slice(i, j).join(",")))! : parts[i]);
i = j;
}
return out;
}
/** A date-only value, normalised to YYYY-MM-DD, or "" if it isn't a real calendar date.
*
* `str(v, 10)` used to stand in for this, which is a truncation and not a check: a spreadsheet
* saved on an Australian machine writes "11/03/2024", exactly ten characters, and it was stored
* verbatim and rendered as the literal text "Invalid Date" ever after. Day-first D/M/Y and D-M-Y
* are accepted and converted, because that is what a local spreadsheet produces and refusing a
* whole staff import over a date format helps nobody; anything else is refused. */
const isoDate = (v: unknown): string => {
const s = str(v, 40).trim();
if (!s) return "";
let y: number, m: number, d: number;
// A trailing time is allowed and dropped: a date-only column occasionally receives a full
// timestamp (an older row, a date picker that sends one), and the day is the part that matters.
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/.exec(s);
if (iso) { y = +iso[1]; m = +iso[2]; d = +iso[3]; }
else {
const au = /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/.exec(s);
if (!au) return "";
d = +au[1]; m = +au[2]; y = +au[3];
}
// Round-tripped through UTC so 31 February and friends are rejected rather than rolled forward.
const at = new Date(Date.UTC(y, m - 1, d));
if (at.getUTCFullYear() !== y || at.getUTCMonth() !== m - 1 || at.getUTCDate() !== d) return "";
return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
};
/** The statuses a request is allowed to hold, read off the state machine itself so a restored file
* can never introduce one the screens have no words for. */
const REQ_STATUSES = new Set(Object.keys(TRANSITIONS));
/** A timestamp out of a backup file, or null. `new Date("nonsense")` is an Invalid Date, and
* handing one to Prisma fails the whole restore over a single unreadable field. */
const stamp = (v: unknown): Date | null => {
if (!v) return null;
const d = new Date(String(v));
return Number.isNaN(d.getTime()) ? null : d;
};
/** An IANA zone name that this runtime actually knows, or the Brisbane default. Restoring a zone
* the box has never heard of would leave every date in the facility being computed against a
* fallback while the settings screen claimed otherwise. */
const zoneOf = (v: unknown): string => {
const z = str(v, 60).trim();
if (!z) return "Australia/Brisbane";
try { new Intl.DateTimeFormat("en-AU", { timeZone: z }); return z; } catch { return "Australia/Brisbane"; }
};
/** A combined FTE, spelt the way the order form spells it wherever it can be: "1.0" … "0.1",
* "Casual", or "" when nobody has recorded one. null means it is not a fraction at all, and that
* is the only thing refused.
*
* Any positive figure is kept, not just the eleven steps the picker offers, because the FTE table
* is what decides what a figure means and it decided long ago: setsForFte() reads a figure with no
* row of its own as the band it falls in, so 0.75 is a four-set person, deliberately, since the
* number is copied off a paper roster. Refusing 0.75 here while the table read it perfectly well
* was two answers to the same question — and the one people met first was the one that taught them
* to round the roster until the box stopped complaining, which leaves the register holding an FTE
* nobody is employed on and a manager signing for the sets that go with it. A coordinator typing a
* real roster figure is not making a mistake.
*
* A figure landing exactly on one of the form's own steps is re-spelt as the form spells it, so
* "1" and "0.50" are stored as "1.0" and "0.5" and the register reads the way the paper does;
* anything else is stored as the number it is, tidied of stray zeroes. Nought and below are the
* one arithmetic refusal: nobody is employed at nought FTE, the table proposes nothing for it, and
* storing it would only be a blank wearing a number. */
const normalFte = (v: unknown): string | null => {
const raw = str(v, 20).trim();
if (!raw) return "";
if (raw.toLowerCase() === FTE_CASUAL.toLowerCase()) return FTE_CASUAL;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return FTE_OPTIONS.find((o) => o !== FTE_CASUAL && Number(o) === n) ?? String(n);
};
/** What the FTE field will take, for a refusal that says so rather than just saying no. */
const FTE_ALLOWED = `a fraction like ${FTE_OPTIONS[0]} or 0.75, or ${FTE_CASUAL}`;
/** The refusal when a uniform style is a word the rule doesn't know. Blank is never refused: it is
* what every record starts as — nobody has said which cut this person wears — and it offers every
* style, so leaving it alone is always allowed. */
const STYLE_REFUSAL = `Uniform style has to be ${UNIFORM_STYLES.join(", ")} — leave it blank if nobody has said which cut they wear`;
/** A date-only field off a payload: blank falls back, anything unparseable is refused outright
* rather than stored as text that every screen downstream will fail to format. */
const dateField = (v: unknown, fallback: string, what: string): string => {
if (v === undefined || v === null || str(v, 40).trim() === "") return fallback;
const d = isoDate(v);
if (!d) throw new OpError(`${what} has to be a date, written as YYYY-MM-DD`);
return d;
};
async function nextOrderCode(tx: Tx, facilityId: string, today: string) {
const f = await tx.facility.update({ where: { id: facilityId }, data: { orderSeq: { increment: 1 } }, select: { orderSeq: true } });
return `ORD-${today.slice(0, 4)}-${String(f.orderSeq).padStart(4, "0")}`;
}
async function nextSort(tx: Tx, facilityId: string) {
const f = await tx.facility.update({ where: { id: facilityId }, data: { catalogSeq: { increment: 1 } }, select: { catalogSeq: true } });
return f.catalogSeq;
}
async function ownItem(tx: Tx, facilityId: string, id: string) {
const it = await tx.catalogItem.findFirst({ where: { id, facilityId } });
if (!it) throw new OpError("Unknown catalogue item", 404);
return it;
}
/** A catalogue row as the snapshot hands it out (lib/snapshot.ts): groups tidied through
* garmentGroups() and the display label derived from them. For a path that reads the table itself
* but asks a question of lib/compute, which speaks Item. */
function asItem(c: CatalogItem): Item {
return { 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 };
}
async function ownStaff(tx: Tx, facilityId: string, id: string) {
const s = await tx.staff.findFirst({ where: { id, facilityId } });
if (!s) throw new OpError("Unknown staff member", 404);
return s;
}
/* Self-approval, as the owner decided it on 12 September 2026: anybody may approve for themselves
* — their own requests, and a signed order form for their own kit. There used to be a test here,
* managesSomebody(), that allowed it only to somebody with at least one active report on the
* register. It is gone, not relaxed: on a register where the linen-room coordinator is the only
* person, nobody manages anybody and nothing could be approved at all.
*
* What stands in for it is the record. Every self-approval is marked where it happened and can be
* read back without any flag of its own — an approval whose byStaffId is its staffId, a request
* decided by the manager it is for (its timeline says "Self-approved" in words).
*
* One rule does stand, in lib/staffops.ts and in request.reassign below: nobody approves a raise
* they made on somebody else's behalf. Approving your own kit is one person deciding about their
* own uniform where everyone can see whose it is; asking for somebody else's garments and then
* granting the ask yourself is one person doing both halves of a decision the ward is told two
* people made. */
/** Mark the facility as changed, so screens elsewhere know to reload.
*
* Called from the three routes that can change anything — the coordinator's mutate, the staff
* app's, and the emailed approve/decline — rather than from inside each op, for the same reason
* the audit trail is written there: a new case branch cannot forget to do it.
*
* Never allowed to fail a request. The write has already committed and been recorded by the time
* this runs; a facility that briefly reports a stale number costs somebody a few seconds before
* their screen catches up on its next focus, which is not worth turning a completed mutation into
* an error. */
export async function bumpRev(facilityId: string): Promise<number | null> {
try {
const f = await prisma.facility.update({ where: { id: facilityId }, data: { rev: { increment: 1 } }, select: { rev: true } });
return f.rev;
} catch (e) {
console.error("[rev] could not bump", facilityId, e);
return null;
}
}
/** Refuse a binding that would make a scan ambiguous: a code already on another garment, or one that
* collides with the generated 93XXXXXXX code of a different item. `force` is the deliberate re-bind. */
async function assertBindable(tx: Tx | typeof prisma, facilityId: string, code: string, itemId: string, si: number, force: boolean) {
const cur = await tx.barcode.findUnique({ where: { facilityId_code: { facilityId, code } }, include: { item: { select: { id: true, item: true, sizes: true } } } });
if (cur && cur.itemId === itemId && cur.sizeIndex === si) return;
if (cur && !force) throw new OpError(`${code} is already on ${cur.item.item} · size ${cur.item.sizes[cur.sizeIndex] ?? cur.sizeIndex}. Unbind it there first, or re-bind to move it.`);
if (/^93\d{7}$/.test(code)) {
const v = +code - 930000000, sort = Math.floor(v / 100);
const owner = await tx.catalogItem.findFirst({ where: { facilityId, sort }, select: { id: true, item: true } });
if (owner && owner.id !== itemId) throw new OpError(`${code} is the generated code for ${owner.item} — binding it here would make that garment unscannable. Use the barcode printed on the label.`);
}
}
async function ownOrder(tx: Tx, facilityId: string, id: string) {
const o = await tx.order.findFirst({ where: { id, facilityId }, include: { lines: { orderBy: { sort: "asc" } }, receipts: { include: { lines: true } } } });
if (!o) throw new OpError("Unknown order", 404);
return o;
}
async function upsertLevel(tx: Tx, facilityId: string, itemId: string, si: number, data: { opening?: number; adj?: { increment: number } | number; reorder?: number | null; preloved?: { increment: number } | number }) {
await tx.stockLevel.upsert({
where: { itemId_sizeIndex: { itemId, sizeIndex: si } },
create: { facilityId, itemId, sizeIndex: si, opening: data.opening ?? 0, adj: typeof data.adj === "number" ? data.adj : data.adj?.increment ?? 0, reorder: data.reorder === undefined ? null : data.reorder, preloved: Math.max(0, typeof data.preloved === "number" ? data.preloved : data.preloved?.increment ?? 0) },
update: data,
});
}
const PHOTO_MAX = 700 * 1024;
/** What a backup file may carry in images, and what a restore will take back — one pair of numbers
* for both ends, because a file the product writes and then refuses to read is not a backup.
* The count is the restore's own limit; the byte budget keeps the finished JSON inside the 60 MB
* the import POST accepts, with room for the records themselves (base64 is ~4/3 of the file on
* disk). Both are far more than a facility accumulates in the year between a restore drill. */
const BACKUP_PHOTO_MAX = 2000;
const BACKUP_PHOTO_BYTES = 40 * 1024 * 1024;
/** How many images the shared public demo will hold. Enough to sign for every delivery on the
* seeded round; nowhere near enough for an anonymous visitor to use the box as image hosting
* between resets. */
const DEMO_PHOTO_MAX = 40;
/** Delete photos older than a day that nothing references (abandoned captures). */
async function gcPhotos(fid: string) {
const [ap, rc, is, pu] = await Promise.all([
prisma.approval.findMany({ where: { facilityId: fid, photoId: { not: null } }, select: { photoId: true } }),
prisma.receipt.findMany({ where: { order: { facilityId: fid }, photoId: { not: null } }, select: { photoId: true } }),
prisma.issue.findMany({ where: { facilityId: fid, returnPhotoId: { not: null } }, select: { returnPhotoId: true } }),
prisma.pickup.findMany({ where: { facilityId: fid, OR: [{ sigId: { not: null } }, { proofId: { not: null } }] }, select: { sigId: true, proofId: true } }),
]);
const keep = new Set<string>([...ap.map((x) => x.photoId!), ...rc.map((x) => x.photoId!), ...is.map((x) => x.returnPhotoId!), ...pu.flatMap((x) => [x.sigId, x.proofId].filter(Boolean) as string[])]);
const where = { facilityId: fid, createdAt: { lt: new Date(Date.now() - 24 * 3600 * 1000) }, id: { notIn: [...keep] } };
// Read the paths before deleting the rows: afterwards there is nothing left to say which files
// to remove, and orphaned images on disk are exactly the mess this move was meant to avoid.
const doomed = await prisma.photo.findMany({ where, select: { path: true } });
await prisma.photo.deleteMany({ where });
for (const d of doomed) if (d.path) await deletePhoto(d.path);
}
/** Delete a facility's Photo rows and hand back the files they pointed at.
*
* The paths have to be read before the rows go: afterwards nothing is left that could say which
* files to remove, and an image nobody can identify is an image nobody can ever delete. The
* unlinking is deliberately left to the caller so it happens *after* the transaction commits — a
* rolled-back wipe that had already erased the files would leave rows pointing at nothing. */
async function purgePhotoRows(tx: Tx, facilityId: string): Promise<string[]> {
const doomed = await tx.photo.findMany({ where: { facilityId }, select: { path: true } });
await tx.photo.deleteMany({ where: { facilityId } });
return doomed.map((d) => d.path).filter(Boolean);
}
const unlinkAll = async (paths: string[]) => { for (const rel of paths) await deletePhoto(rel); };
/** Serialise stock-mutating work per facility: lock the Facility row for the transaction, so two counter
* actions (double-click, two terminals) can't both pass the same shelf/pool/approval check. */
async function lockedTx<T>(facilityId: string, fn: (tx: Tx) => Promise<T>): Promise<T> {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT "id" FROM "Facility" WHERE "id" = ${facilityId} FOR UPDATE`;
return fn(tx);
}, { maxWait: 10_000, timeout: 30_000 });
}
/** Resolve a client-supplied photo id to one this facility owns (or null). */
async function ownPhoto(tx: Tx | typeof prisma, facilityId: string, id: unknown): Promise<string | null> {
const pid = str(id, 40); if (!pid) return null;
const ph = await tx.photo.findFirst({ where: { id: pid, facilityId }, select: { id: true } });
if (!ph) throw new OpError("Photo not found — take it again", 404);
return ph.id;
}
/** Move the pre-loved pool for a variant by `delta`, never below zero. */
async function poolAdd(tx: Tx, facilityId: string, itemId: string, si: number, delta: number) {
const cur = await tx.stockLevel.findUnique({ where: { itemId_sizeIndex: { itemId, sizeIndex: si } }, select: { preloved: true } });
await upsertLevel(tx, facilityId, itemId, si, { preloved: Math.max(0, (cur?.preloved || 0) + delta) });
}
/** Make sure the directory holds this supplier, and hand back the name the DIRECTORY spells it with.
*
* The match is deliberately case-insensitive, so a coordinator typing "northline workwear" on the
* counter phone finds the existing "Northline Workwear" and no second row is created. Storing what
* they typed against the garment is what did the damage: everything downstream matches the name
* exactly — the lead time behind an expected date, the contact block on a printed PO, the draft
* replenishment order a garment's lines are merged into — so the item pointed at a supplier the
* directory does not hold, and the room ended up with two draft orders for the one supplier.
* Callers store the name this returns, never the one that was typed. */
async function ensureSupplier(tx: Tx, facilityId: string, name: string): Promise<string> {
const n = str(name, 80).trim(); if (!n) return "";
const ex = await tx.supplier.findFirst({ where: { facilityId, name: { equals: n, mode: "insensitive" } } });
if (ex) return ex.name;
const count = await tx.supplier.count({ where: { facilityId } });
await tx.supplier.create({ data: { facilityId, name: n, sort: count } });
return n;
}
/** Expected delivery for an auto-created order: today + the supplier's lead time (14 days when none is set). */
async function expectedFor(tx: Tx, facilityId: string, supplier: string, today: string) {
const sp = await tx.supplier.findFirst({ where: { facilityId, name: supplier }, select: { lead: true } });
return addDays(today, sp?.lead && sp.lead > 0 ? sp.lead : 14);
}
/** Merge lines into the supplier's Draft replenishment order (creating one if needed). mode "add" increments, "max" tops up. */
async function mergeReplenish(tx: Tx, fid: string, today: string, sup: string, lines: { itemId: string; size: string; qty: number }[], mode: "add" | "max", notes: string) {
let o = await tx.order.findFirst({ where: { facilityId: fid, replenish: true, status: "Draft", supplier: sup }, include: { lines: true } });
if (!o) {
const code = await nextOrderCode(tx, fid, today);
o = await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: "Stock", supplier: sup, status: "Draft", expected: await expectedFor(tx, fid, sup, today), notes, replenish: true }, include: { lines: true } });
}
let n = 0;
for (const c of lines) {
const l = o.lines.find((x) => x.itemId === c.itemId && x.size === c.size);
if (l) await tx.orderLine.update({ where: { id: l.id }, data: mode === "add" ? { qty: { increment: c.qty } } : { qty: Math.max(l.qty, c.qty) } });
else { const nl = await tx.orderLine.create({ data: { orderId: o.id, itemId: c.itemId, size: c.size, qty: c.qty, sort: o.lines.length } }); o.lines.push(nl); }
n++;
}
return n;
}
/** buildSnapshot() is shaped around a coordinator session, but the only things it reads off one are
* the facility id and the role — which gates the user list nothing here looks at. This stand-in
* lets a path with no coordinator (a ward sign-off) read the same ledger the counter reads,
* without inventing a second way to work out what is on the shelf. */
const stockReader = (facilityId: string): SessionUser =>
({ id: "", facilityId, email: "", first: "", last: "", title: "", role: "ISSUER", isDemo: false, viaSso: false });
/* Approved garments in a bag nobody has collected yet, for one person. The snapshot carries them
* for everybody, but it is read before the facility lock and, on the hand-over path, before the bag
* itself has moved — so every server path that asks the ceiling reads this person's afresh at the
* moment it decides, and replaces the snapshot's list rather than adding to it. A bag being handed
* over is therefore never counted as both owed and issued. */
async function owedRequestLines(db: Tx | typeof prisma, facilityId: string, staffId: string): Promise<{ staffId: string; itemId: string; qty: number }[]> {
const rows = await db.requestLine.findMany({
where: { status: "approved", request: { facilityId, subjectId: staffId, status: { in: AWAITING_HANDOVER } } },
select: { itemId: true, qty: true },
});
return rows.map((l) => ({ staffId, itemId: l.itemId, qty: l.qty }));
}
/* Handing a request's garments over — the half of the flow that touches stock.
*
* Two doors reach this moment. The counter is one (request.collected, below); the ward round is
* the other, where the person who signs for the bag holds a *staff* session and comes in through
* lib/staffops.ts. Both have to record the same three things or the shelf count silently loses a
* garment on one of the two routes: the shelf is re-checked, an Issue goes on the wearer's record
* at the catalogue cost, and a replenishment line puts the garment back on order.
*
* Only the APPROVED lines are in the bag. A line the manager knocked back never reaches the linen
* room, is never picked and must never come off the shelf — so approvedLines() is the one place
* "what is being handed over" is decided, here as everywhere else.
*
* `move` is the caller's own status change, run inside the same lock and after the shelf check, so
* a request only moves if the garments were actually there to hand over. It is the only part the
* two doors differ on: the counter moves ready → collected, the ward moves round → delivered.
*/
export async function handOverRequestStock(
facilityId: string,
today: string,
r: { subjectId: string; lines: readonly { itemId: string; sizeIndex: number; qty: number; status: string }[] },
move: (tx: Tx) => Promise<void>,
/* Which door the garments are leaving by. At the counter a short shelf is a reason to stop: the
* garment is still on the shelf (or isn't), and the coordinator can count or order. At the ward
* door the bag physically left the linen room on the trolley (request.round moved nothing), and
* the clerk signing for it can neither count the shelf nor order anything — and round.sign is
* the only way out of `round`, so a refusal there stranded the bag and the wearer's order for
* good. The ward door records the hand-over and lets the shelf go short, which the linen room's
* next count corrects; that is a stock discrepancy to fix at the counter, not a reason to tell
* a nurse her uniform didn't arrive. */
door: "counter" | "ward" = "counter",
): Promise<void> {
const lines = approvedLines(r.lines);
if (!lines.length) throw new OpError("Nothing on that request was approved, so there is nothing to hand over.");
await lockedTx(facilityId, async (tx) => {
const snap = await buildSnapshot(stockReader(facilityId), tx);
const led = ledger(snap);
const picked = lines.map((l) => {
const it = snap.catalog.find((x) => x.id === l.itemId);
if (!it) throw new OpError("A garment on that request is no longer in the catalogue", 404);
return { l, it, size: String(it.sizes[l.sizeIndex] ?? l.sizeIndex) };
});
// Totalled per garment and size before anything moves. Two approved lines for the same
// variant have to clear the shelf together, or the second is checked against stock the first
// has already taken and the bag leaves the room with one garment more than the shelf held.
const need = new Map<string, { qty: number; what: string }>();
for (const p of picked) {
const k = key(p.l.itemId, p.l.sizeIndex);
need.set(k, { qty: (need.get(k)?.qty || 0) + p.l.qty, what: `${p.it.item} ${p.size}` });
}
for (const [k, n] of need) {
if (door === "counter" && n.qty > onhand(snap, led, k)) {
throw new OpError(`Not enough ${n.what} on the shelf to hand over — count the shelf or order it in first.`);
}
}
await move(tx);
// This bag stopped being owed the moment `move` marked it collected or delivered, and it is about
// to become issue rows below. Every other approved bag this person hasn't collected is still owed
// and still counts, so they are read now — after the move, inside the same lock — which is what
// keeps this bag counted once, as it is handed over, rather than once as owed and again as taken.
// Counted twice, an ordinary collection at five sets would be stamped as an override.
snap.owedRequestLines = await owedRequestLines(tx, facilityId, r.subjectId);
// Past six sets can't block a hand-over the manager has already approved — the ward door records
// rather than refuses — but it is still stamped as an override so the exceptions report keeps
// seeing it. The running total matters now a request can carry several garments: the third
// garment in one bag is measured against a ceiling the first two have already filled, which the
// snapshot — read before any of these issues existed — cannot see on its own. So the bag grows a
// line at a time and the whole of it so far goes to capCheck(), which measures what this person
// holds plus what is being handed over.
//
// "Override" on these rows means what it means at the counter and nothing else: this garment took
// somebody past the six sets one person holds. Asking the counter's own question keeps it that
// way. A ward route that stamped the flag on a bag the linen room would have handed over without
// one turns the exceptions report into a list of which door somebody walked through, and every
// line here is there because a manager approved it — a kitchen hand collecting an approved set on
// the ward should read no differently from the same collection at the counter.
//
// No staff-group check either, and no uniform-style one: a manager approved this bag, and a
// hand-over is not a second chance to refuse it. But a garment outside the wearer's group is still
// stamped offGroup, and one that is not their cut offStyle, so the exceptions report sees both.
// Both doors that raise a request refuse such a garment now, yet one can still arrive here: a
// request raised before the rule existed, a wearer moved to another group or set to a style since
// they asked, or a damage replacement of a garment they were already holding.
const st = snap.staff.find((x) => x.id === r.subjectId);
const taken: { itemId: string; qty: number }[] = [];
for (const p of picked) {
taken.push({ itemId: p.l.itemId, qty: p.l.qty });
const over = st ? capCheck(snap, st, taken).over : false;
await tx.issue.create({ data: { facilityId, date: today, staffId: r.subjectId, itemId: p.l.itemId, sizeIndex: p.l.sizeIndex, qty: p.l.qty, cond: "New", cost: p.it.cost, override: over, offGroup: st ? !garmentForGroup(p.it, st.group) : false, offStyle: st ? !garmentForStyle(p.it, st.uniformStyle) : false } });
}
// Replenishment is grouped by supplier, because a replenishment order is one supplier's order:
// a bag holding a tunic from one supplier and a fleece from another puts a line on each of
// their draft orders rather than inventing a mixed one nobody can send.
const bySupplier = new Map<string, { itemId: string; size: string; qty: number }[]>();
for (const p of picked) {
const sup = p.it.supplier || snap.settings.suppliers[0] || "Supplier";
bySupplier.set(sup, [...(bySupplier.get(sup) || []), { itemId: p.l.itemId, size: p.size, qty: p.l.qty }]);
}
for (const [sup, rows] of bySupplier) {
await mergeReplenish(tx, facilityId, today, sup, rows, "add", "Replenishment — replaces issued stock");
}
});
}
/* ---------- the garments on a request ----------
*
* A nurse who needs a tunic, trousers and a fleece makes ONE ask. Both doors that raise a request
* — the staff app's raise path in lib/staffops.ts and the counter below — read their garments
* through here, so the limits, the duplicate rule and the wording of a refusal are stated once.
*/
export type RequestLineInput = { itemId: string; sizeIndex: number; qty: number; item: string; size: string };
/** Ten garments is a long way past a full set of uniform, and a cap keeps one tap from writing a
* hundred lines the manager then has to read. Twenty of one garment is the same idea per line. */
export const REQUEST_MAX_LINES = 10;
export const REQUEST_MAX_QTY = 20;
/** Read and check the garments off a request payload: `[{ itemId, si, qty }]`. Throws OpError, so
* the staff-app door translates it into its own error type. */
export async function readRequestLines(facilityId: string, raw: unknown): Promise<RequestLineInput[]> {
const rows = Array.isArray(raw) ? raw : [];
if (!rows.length) throw new OpError("Add at least one garment to the request");
if (rows.length > REQUEST_MAX_LINES) throw new OpError(`One request covers up to ${REQUEST_MAX_LINES} garments — raise a second one for the rest.`);
const wanted = [...new Set(rows.map((r) => str((r as { itemId?: unknown } | null)?.itemId)))].filter(Boolean);
const items = await prisma.catalogItem.findMany({ where: { id: { in: wanted }, facilityId } });
const byId = new Map(items.map((i) => [i.id, i]));
const out: RequestLineInput[] = [];
for (const row of rows) {
const r = (row || {}) as { itemId?: unknown; si?: unknown; qty?: unknown };
const it = byId.get(str(r.itemId));
if (!it || it.archived) throw new OpError("That garment isn't available", 404);
const si = int(r.si, -1);
if (si < 0 || si >= it.sizes.length) throw new OpError(`Pick a size for the ${it.item}`);
const qty = int(r.qty, 1);
if (qty < 1 || qty > REQUEST_MAX_QTY) throw new OpError(`Ask for between 1 and ${REQUEST_MAX_QTY} of a garment`);
// The same garment in the same size twice is one line with the quantity added up, not two
// identical rows: it is one decision for the manager and one line on the pick list, and a
// screen that lets somebody tap a size twice should not double everything downstream.
const same = out.find((x) => x.itemId === it.id && x.sizeIndex === si);
if (same) {
same.qty += qty;
if (same.qty > REQUEST_MAX_QTY) throw new OpError(`Ask for between 1 and ${REQUEST_MAX_QTY} of ${it.item}`);
} else out.push({ itemId: it.id, sizeIndex: si, qty, item: it.item, size: String(it.sizes[si]) });
}
return out;
}
/** One reason a cart is outside what this person is normally handed: each garment with the groups it
* is for, then the group the person is in. A clause, not a whole refusal — issueRefusal() adds the
* sentence about the override, so a cart wrong on two counts asks for the tick once. */
function offGroupRefusal(name: string, group: string, off: readonly { item: string; groups: string[] }[]): string {
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)}`).join("; ");
const g = (group || "").trim();
const who = g ? `${name} is in ${g}` : `${name} has no staff group recorded`;
return `${each}${who}`;
}
/** The same clause for the wrong cut: each garment with the cut it is, then the style this person is
* set to. Only ever built when the list is non-empty, which garmentForStyle() only allows for
* somebody set to Men's or Women's — blank and Either are offered everything. */
function offStyleRefusal(name: string, style: string, off: readonly { item: string; gender: string }[]): string {
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
return `${each}${name} is set to ${style}`;
}
/** The counter's refusal for a cart the coordinator's override would let through.
*
* EVERY reason that applies is named in it — outside their staff group, the wrong cut, past the sets
* one person holds — because one tick answers all of them at once. A refusal that named the first
* and stopped would have the coordinator tick the box for that and wave the others through without
* anybody ever having been told about them. */
function issueRefusal(reasons: readonly string[], count: number): string {
return `${reasons.join(". ")}. Tick the coordinator override to issue ${count === 1 ? "it" : "them"} anyway.`;
}
/** The garments among these that are not for this staff group, in the order asked for — the check
* both request doors (request.create in lib/staffops.ts, request.raise here) and the staff-member
* order paths make. garmentForGroup() is the rule; this only loads the catalogue rows it needs.
*
* `replacing` is the one exemption: a damage replacement for a garment the person is holding right
* now (lib/staffops.ts damage.report). Only that garment, and only while the record still has it
* out with them. A fresh request for another group's garment is refused even from somebody who
* was issued one before the rule existed — holding one is not a reason to be handed another. */
export async function offGroupGarments(facilityId: string, group: string, itemIds: readonly string[], replacing?: { staffId: string; itemId: string }) {
const ids = [...new Set(itemIds)];
if (!ids.length) return [];
const items = await prisma.catalogItem.findMany({ where: { id: { in: ids }, facilityId }, select: { id: true, item: true, groups: true } });
const off = items.filter((i) => !garmentForGroup(i, group));
if (!off.length) return [];
const exempt = replacing && off.some((i) => i.id === replacing.itemId)
&& (await prisma.issue.count({ where: { facilityId, staffId: replacing.staffId, itemId: replacing.itemId, returnedDate: null, handedIn: null } })) > 0
? replacing.itemId : null;
return ids.map((id) => off.find((i) => i.id === id)).filter((i): i is (typeof off)[number] => !!i && i.id !== exempt);
}
/** The garments among these that are not the cut this person is offered, in the order asked for —
* the same check offGroupGarments() makes, asked of Uniform style instead of the staff group.
* garmentForStyle() is the rule; this only loads the catalogue rows it needs.
*
* `replacing` is the same one exemption, for the same reason: a damage replacement for a garment
* the person is holding right now. Somebody set to Men's who was issued the women's cut before
* anybody set the field can have that garment replaced like for like, and nothing else. */
export async function offStyleGarments(facilityId: string, style: string, itemIds: readonly string[], replacing?: { staffId: string; itemId: string }) {
const ids = [...new Set(itemIds)];
if (!ids.length) return [];
const items = await prisma.catalogItem.findMany({ where: { id: { in: ids }, facilityId }, select: { id: true, item: true, gender: true } });
const off = items.filter((i) => !garmentForStyle(i, style));
if (!off.length) return [];
const exempt = replacing && off.some((i) => i.id === replacing.itemId)
&& (await prisma.issue.count({ where: { facilityId, staffId: replacing.staffId, itemId: replacing.itemId, returnedDate: null, handedIn: null } })) > 0
? replacing.itemId : null;
return ids.map((id) => off.find((i) => i.id === id)).filter((i): i is (typeof off)[number] => !!i && i.id !== exempt);
}
/** The counter's refusal when it raises a request, or writes an order, for somebody with garments
* outside their staff group on it. Neither has an override of its own: the one way to give
* somebody another group's garment is the Issue screen with the coordinator override ticked, which
* stamps the issue — or, for Order in, the order it raises — so the Exceptions report sees it.
* `onOrder` is for garments already on the order being changed, which can also simply come off. */
function offGroupElsewhere(st: { first: string; last: string; group: string }, off: readonly { item: string; groups: string[] }[], doing: "request" | "order", onOrder = false): string {
const n = off.length === 1 ? "it" : "them";
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)} only`).join("; ");
const name = `${st.first} ${st.last}`.trim();
const g = (st.group || "").trim();
const who = g ? `${name} is in ${g}` : `${name} has no staff group recorded`;
return doing === "request"
? `${each}${who}, and a request can only carry their own group's garments. To give ${n} to ${st.first} anyway, issue ${n} on the Issue screen with the coordinator override ticked.`
: `${each}${who}, and an order for somebody can only carry their own group's garments. ${onOrder ? `Take ${n} off this order first, or to` : "To"} order ${n} in for ${st.first} anyway, use Order in on the Issue screen with the coordinator override ticked.`;
}
/** The same refusal for the wrong cut, said the same way and pointing at the same one override. */
function offStyleElsewhere(st: { first: string; last: string; uniformStyle: string }, off: readonly { item: string; gender: string }[], doing: "request" | "order", onOrder = false): string {
const n = off.length === 1 ? "it" : "them";
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
const who = `${`${st.first} ${st.last}`.trim()} is set to ${st.uniformStyle}`;
return doing === "request"
? `${each}${who}, and a request can only carry their own style. To give ${n} to ${st.first} anyway, issue ${n} on the Issue screen with the coordinator override ticked.`
: `${each}${who}, and an order for somebody can only carry their own style. ${onOrder ? `Take ${n} off this order first, or to` : "To"} order ${n} in for ${st.first} anyway, use Order in on the Issue screen with the coordinator override ticked.`;
}
/** Every path that puts a garment on an order for a staff member asks this: creating one, copying
* one, attaching the person to one, adding a line, raising a quantity. What reaches them off it is
* issued at pickup with nobody deciding anything, so the order is the moment to refuse. Orders the
* counter raises itself (issue.create's Order in, and the back orders split off those) are not
* written through here — the override was ticked for them, and the order's note says so. */
async function refuseWrongGarmentOnOrder(facilityId: string, staff: { first: string; last: string; group: string; uniformStyle: string } | null, itemIds: readonly string[], onOrder = false) {
if (!staff) return;
const off = await offGroupGarments(facilityId, staff.group, itemIds);
if (off.length) throw new OpError(offGroupElsewhere(staff, off, "order", onOrder));
// The cut, asked after the group and refused the same way. Neither has an override of its own, so
// there is nothing for one refusal to wave through by naming only the other: whichever it names,
// the answer is the same Issue screen with the same tick.
const offStyle = await offStyleGarments(facilityId, staff.uniformStyle, itemIds);
if (offStyle.length) throw new OpError(offStyleElsewhere(staff, offStyle, "order", onOrder));
}
/** The staff member an existing order is for, or null for a stock order. */
const orderStaff = (facilityId: string, o: { staffId: string | null }) =>
o.staffId ? prisma.staff.findFirst({ where: { id: o.staffId, facilityId }, select: { first: true, last: true, group: true, uniformStyle: true } }) : Promise.resolve(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** Ops the public demo facility refuses — everyone shares its accounts, so nobody may change them or nuke the data. */
// staff.selfCode is blocked in the demo for a reason worth stating: a claimed staff account holds a
// globally unique email, so a visitor activating one against demo data would take that address out
// of circulation for the real facility that later needs it.
const DEMO_BLOCKED = new Set(["users.add", "users.update", "users.remove", "me.password", "me.profile", "data.wipeActivity", "data.reset", "backup.restore", "me.deleteAccount", "staff.selfCode"]);
/** Settings a demo visitor may not change — see settings.update for why each one is here. Named in
* plain words because a refusal that says which field it means is the difference between a message
* and a mystery. */
const DEMO_FIXED: Record<string, string> = {
logoData: "the slip logo",
facility: "the facility name",
location: "the stock location",
coordinator: "the coordinator name",
// The linen room's own contacts, for the same reason as the coordinator's name and then one
// more: they print in the footer of every order form, and in a facility everyone is sharing a
// visitor could put a stranger's e-mail address and phone number in front of every other visitor
// and onto everything they print. The allowance settings next to them — the starting kit, the set
// ceiling, which route each group is on — stay open, because changing those moves numbers about in a
// sandbox that resets in twenty minutes and puts nobody's contact details anywhere.
coordinatorEmail: "the linen room's e-mail address",
coordinatorPhone: "the linen room's phone number",
slipOrg: "the organisation name on slips",
slipCollectionFooter: "the collection slip footer",
slipDeliveryFooter: "the delivery slip footer",
timezone: "the time zone",
};
export function demoGuard(user: SessionUser, op: string) {
if (user.isDemo && DEMO_BLOCKED.has(op)) throw new OpError("Not available in the demo — it resets every 20 minutes and its accounts are shared.", 403);
}
/** The plan's answer to a write, from the same row runOp already has. Read-only refuses everything
* but sorting the plan out and leaving (READ_ONLY_ALLOWED); the staff ceiling is checked where a
* staff row is created, with `staffRoom`. */
export function planGuard(f: PlanRow, op: string) {
if (entitlements(f).readOnly && !READ_ONLY_ALLOWED.has(op)) throw new OpError(READ_ONLY_REFUSAL, 403);
}
/** How many more staff records this facility may create, or null for no ceiling. */
async function staffRoom(fid: string, db: { facility: { findUniqueOrThrow: typeof prisma.facility.findUniqueOrThrow }; staff: { count: typeof prisma.staff.count } } = prisma): Promise<{ room: number | null; max: number | null }> {
const f = await db.facility.findUniqueOrThrow({ where: { id: fid }, select: PLAN_COLS });
const max = entitlements(f).maxStaff;
if (max === null) return { room: null, max: null };
const have = await db.staff.count({ where: { facilityId: fid } });
return { room: Math.max(0, max - have), max };
}
export async function runOp(user: SessionUser, op: string, p: any): Promise<unknown> {
demoGuard(user, op);
const fid = user.facilityId;
// Every date-only column an op writes is "today" where the linen room stands, not where the
// server does — a Perth room issuing at 8am must not file the issue against yesterday because
// the box is set to UTC. One lookup up front, shared with the name in the foot of an email — and
// with the plan columns, so a read-only room is refused before any case below runs.
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: fid }, select: { name: true, timezone: true, billingEmail: true, ...PLAN_COLS } });
planGuard(fac, op);
const tz = fac.timezone;
const today = facilityToday(tz);
const byName = `${user.first} ${user.last}`;
/** The facility's own name, for the foot of an email to a wearer. */
const signOff = () => `${fac.name || "Linen room"} · ThreadCount`;
p = p || {};
switch (op) {
// ---------- settings
case "settings.update": {
admin(user);
// Everyone shares the demo facility, so anything a visitor changes here lands on every other
// visitor's screen at once — including the director of nursing or the Play reviewer who
// clicked through from the store listing. Two kinds of field are fixed there: the chrome that
// appears on every screen and every printed slip, and the time zone, which decides the day
// every date-only column is filed against — move the demo to Honolulu and the dashboard, the
// exceptions report and the cost-centre journal re-date themselves under everyone else. The
// rest of the screen stays a sandbox on purpose. Refused here rather than quietly dropped:
// greying a control out is the browser's opinion and a POST to /api/mutate never sees it,
// and the screen — which sends one field per edit — used to answer "Saved." over a value
// the server had already thrown away, which the next reload exposed as a lie.
if (user.isDemo) {
const fixed = Object.keys(DEMO_FIXED).find((k) => p[k] !== undefined);
if (fixed) throw new OpError(`The demo is one facility shared with everyone looking at it right now, so ${DEMO_FIXED[fixed]} is fixed here. The rest of this screen is yours to change.`, 403);
}
const data: Prisma.FacilityUpdateInput = {};
if (p.facility !== undefined) data.name = str(p.facility, 120) || "Facility";
if (p.location !== undefined) data.location = str(p.location, 120);
if (p.coordinator !== undefined) data.coordinator = str(p.coordinator, 120);
if (p.coordinatorEmail !== undefined) {
// The footer of the printed order form, and the only place the linen room's own address is
// held: nothing about a customer is written into the product, so a blank here prints a
// blank footer rather than falling back to something invented. Checked because the form is
// what a manager replies to — an address with a typo in it is a form nobody can answer, and
// nobody finds out until a ward has been waiting a fortnight.
const em = str(p.coordinatorEmail, 160).trim().toLowerCase();
if (em && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)) throw new OpError("That doesn't look like an e-mail address. It prints on the order form, so a manager has to be able to reply to it.");
data.coordinatorEmail = em;
}
if (p.coordinatorPhone !== undefined) {
// Digits, in whatever shape the site writes them — an extension, a pager, a switchboard
// number with a prefix are all real answers, so only an entry with no number in it at all
// is refused. A footer reading "linen room" with nothing to ring is worse than a blank one.
const ph = str(p.coordinatorPhone, 40).trim();
if (ph && !/\d/.test(ph)) throw new OpError("A phone number needs some digits in it — leave it blank if the linen room doesn't take calls.");
data.coordinatorPhone = ph;
}
// Garments a year, and a reporting figure only: it is what the register, the monthly report and
// the exceptions list measure a year's drawing against. It stopped being what the counter
// enforces when the ceiling became six sets held at any time — capSets below — so a site can
// move this number, or leave it where it has always been, without a soul being turned away at
// the counter for it.
if (p.defaultEntitlement !== undefined) data.defaultEntitlement = Math.max(0, int(p.defaultEntitlement));
// The starting kit for the groups on the starting-kit route (kitGroups), in SETS — a set being
// a top and a bottom, so the number of garments is twice this. Kept apart from
// defaultEntitlement, which still counts garments per financial year for the reports that
// read it. What a blank or a nought falls back to is setsOnStart's answer rather than one of
// ours: a kit of nought is not a kit, it is an order form that proposes nothing to somebody
// starting on Monday, and the standing figure it falls back to has to be the same one the
// wearer's own app quotes them.
if (p.initialSets !== undefined) data.initialSets = setsOnStart(int(p.initialSets, 0));
// The ceiling, for everybody in the building, whichever route their group is on. In SETS, like
// the kit above, so twelve garments at the standing six, and it is a ceiling on what a person
// HOLDS at any one time rather than an allowance that starts again in July. The routes differ
// only in how somebody gets up to it — on the FTE table their hours propose a number, on the
// starting kit they start with one and collect more as needed, on manager approval they
// collect a set per signature — and this is where all three stop. Past it the ways on are a hand-in, which swaps rather than adds, and a
// coordinator's override, recorded as the exception it is.
//
// What a blank, a nought or a nonsense entry falls back to is setsCap's answer rather than one
// of ours — the standing six — for the same reason initialSets defers to setsOnStart above:
// sets.ts applies that fallback again every time an allowance is worked out, so a second rule
// here would mean the number stored from this screen and the number quoted to the wearer
// could differ. A ceiling of nought is not a strict facility either way; it is a facility where
// nobody at all can be issued so much as a shirt without a coordinator standing over it.
if (p.capSets !== undefined) data.capSets = setsCap(int(p.capSets, 0));
// Which route each of this facility's staff groups is on: nursingGroups for the FTE table,
// kitGroups for the starting kit, neither for manager approval. Each is sent as a whole list,
// and an empty one means exactly that — no group on that route. There is no list of ours to
// fall back on.
//
// Names are not checked against staffGroups. People imported from a roster can be filed under
// a group the settings list never had, and refusing that name here would leave them with no
// route anybody could put them on. A group nobody is in matches nobody.
const nursingIn = p.nursingGroups !== undefined ? groupList(p.nursingGroups, "FTE table groups") : undefined;
const kitIn = p.kitGroups !== undefined ? groupList(p.kitGroups, "starting-kit groups") : undefined;
if (p.defaultReorder !== undefined) data.defaultReorder = Math.max(0, int(p.defaultReorder));
if (p.exceptionHigh !== undefined) data.exceptionHigh = Math.max(0, int(p.exceptionHigh));
if (p.varianceReason !== undefined) data.varianceReason = Math.max(1, int(p.varianceReason, 5));
if (p.timezone !== undefined) {
// The zone every date-only column in the product is written against (see facilityToday).
// A facility that cannot change it runs on the Brisbane default forever: a Perth late
// shift issuing at 22:30 on 30 June files the issue against 1 July, so the garment counts
// against the next financial year's entitlement, drops out of June's exceptions report and
// cost-centre journal and turns up in July's. Checked against the runtime's own zone table
// rather than stored as typed — a name this box has never heard of would leave every date
// computed against a fallback while this screen claimed otherwise.
const z = str(p.timezone, 60).trim();
if (!z) throw new OpError("Pick the time zone the linen room is in");
try { new Intl.DateTimeFormat("en-AU", { timeZone: z }); } catch { throw new OpError(`${z} isn't a time zone this system knows — use a name like Australia/Perth`); }
data.timezone = z;
}
if (p.glAccount !== undefined) data.glAccount = str(p.glAccount, 40);
if (p.journalDesc !== undefined) data.journalDesc = str(p.journalDesc, 120);
const staffIn = Array.isArray(p.staffGroups) ? [...new Set(p.staffGroups.map((s: unknown) => str(s, 80).trim()).filter(Boolean))] as string[] : undefined;
if (p.slipCollectionFooter !== undefined) data.slipCollectionFooter = str(p.slipCollectionFooter, 400);
if (p.slipDeliveryFooter !== undefined) data.slipDeliveryFooter = str(p.slipDeliveryFooter, 400);
if (p.slipOrg !== undefined) data.slipOrg = str(p.slipOrg, 120);
if (p.barcodeLookup !== undefined) data.barcodeLookup = !!p.barcodeLookup;
if (p.logoData !== undefined) {
const l = String(p.logoData || "");
// SVG deliberately excluded: it can carry scripts and is served from the app origin.
if (l && !/^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(l)) throw new OpError("Logo must be a PNG, JPG, GIF or WebP image");
if (l.length > LOGO_MAX * 1.4) throw new OpError("Logo is too large — keep it under 400 KB");
data.logoData = l;
}
if (nursingIn === undefined && kitIn === undefined && staffIn === undefined) {
await prisma.facility.update({ where: { id: fid }, data });
return { ok: true };
}
// The group lists are settled against what is stored, under the facility's lock, because each
// answer depends on the other lists: a save that read them and wrote a moment after somebody
// else's would put back whatever that person had just changed.
await lockedTx(fid, async (tx) => {
const cur = await tx.facility.findUniqueOrThrow({ where: { id: fid }, select: { staffGroups: true, nursingGroups: true, kitGroups: true } });
let nursing = nursingIn ?? cur.nursingGroups, kit = kitIn ?? cur.kitGroups;
if (staffIn) {
// A group taken off the list comes off both routes with it. Left on one, it would go on
// deciding the allowance of anybody still filed under it from a list the settings screen no
// longer shows, and nobody could see why. So a group that still has active staff filed under
// it can't be taken off at all — the same rule departments keep. Without it, a save from a
// screen opened before somebody else added or renamed a group would move a whole team to
// manager approval, with nothing on either screen to say so.
const kept = new Set(staffIn.map(groupKey));
const gone = new Set(cur.staffGroups.map(groupKey).filter((k) => !kept.has(k)));
if (gone.size) {
const filed = await tx.staff.findMany({ where: { facilityId: fid, inactive: false }, select: { group: true } });
const still = new Map<string, { name: string; n: number }>();
for (const x of filed) {
const k = groupKey(x.group);
if (gone.has(k)) still.set(k, { name: x.group.trim(), n: (still.get(k)?.n || 0) + 1 });
}
const first = [...still.values()][0];
if (first) throw new OpError(`${first.name} still has ${first.n} ${first.n === 1 ? "person" : "people"} filed under it. Move them to another group, or rename it instead, before taking it off the list.`);
}
nursing = nursing.filter((g) => !gone.has(groupKey(g)));
kit = kit.filter((g) => !gone.has(groupKey(g)));
data.staffGroups = staffIn;
}
// One route per group. Refused by name rather than settled by a rule, because a group quietly
// dropped from one list is a team's first kit changing with nothing on the screen to say so.
// Moving a group from one route to the other is one save carrying both lists. Asked only when
// a route list is being set, so adding or removing a staff group is never refused over it.
if (nursingIn || kitIn) {
const both = nursing.find((g) => isKitGroup(kit, g));
if (both) throw new OpError(`${both} can't be on the FTE table and the starting kit at once. Each group takes one route — take it off one before putting it on the other.`);
}
data.nursingGroups = nursing;
data.kitGroups = kit;
await tx.facility.update({ where: { id: fid }, data });
});
return { ok: true };
}
case "settings.renameGroup": {
admin(user);
// A staff group's name corrected everywhere the name is held — the settings list, both route
// lists, and every staff record filed under it — in one go. The name is what decides somebody's
// route, so a rename that missed any of those would move people onto a different allowance for
// the sake of a spelling, and nobody's allowance may change because a label was tidied up. Its
// own op rather than a removal and an addition, because the lists alone cannot tell the two
// apart, and a removal takes the group off its route.
const from = str(p.from, 80).trim(), to = str(p.to, 80).trim();
if (!from) throw new OpError("Say which staff group to rename");
if (!to) throw new OpError("A staff group needs a name");
const fromK = groupKey(from), toK = groupKey(to);
const moved = await lockedTx(fid, async (tx) => {
const cur = await tx.facility.findUniqueOrThrow({ where: { id: fid }, select: { staffGroups: true, nursingGroups: true, kitGroups: true } });
const filedAs = (await tx.staff.findMany({ where: { facilityId: fid }, select: { group: true }, distinct: ["group"] })).map((x) => x.group);
const names = [...cur.staffGroups, ...cur.nursingGroups, ...cur.kitGroups, ...filedAs];
if (!names.some((g) => groupKey(g) === fromK)) throw new OpError(`${from} isn't one of this facility's staff groups`, 404);
// Renaming onto a name already in use is a merge, not a rename: two teams under one name, and
// whichever route the other was on quietly becoming this one's too. The same refusal a
// department gets, for the same reason. Changing only the case or the spacing is allowed —
// that is the same group, spelt better.
if (toK !== fromK && names.some((g) => groupKey(g) === toK)) throw new OpError(`${to} is already a staff group here. Renaming ${from} to it would put two groups under one name — pick a name nobody is using.`);
const swap = (list: string[]) => [...new Set(list.map((g) => (groupKey(g) === fromK ? to : g)))];
await tx.facility.update({ where: { id: fid }, data: { staffGroups: swap(cur.staffGroups), nursingGroups: swap(cur.nursingGroups), kitGroups: swap(cur.kitGroups) } });
const spellings = filedAs.filter((g) => groupKey(g) === fromK && g !== to);
// Garments tagged for the group follow it too. Left under the old name they would be for a
// group nobody is in any more, and the staff app refuses a garment outside the person's group.
const tagged = await tx.catalogItem.findMany({ where: { facilityId: fid, NOT: { groups: { isEmpty: true } } }, select: { id: true, groups: true } });
let garments = 0;
for (const it of tagged) {
if (!it.groups.some((g) => groupKey(g) === fromK && g !== to)) continue;
await tx.catalogItem.update({ where: { id: it.id }, data: { groups: garmentGroups(it.groups.map((g) => (groupKey(g) === fromK ? to : g))) } });
garments++;
}
const staff = spellings.length ? (await tx.staff.updateMany({ where: { facilityId: fid, group: { in: spellings } }, data: { group: to } })).count : 0;
return { staff, garments };
});
return { ok: true, staff: moved.staff, garments: moved.garments };
}
// ---------- suppliers
case "supplier.add": {
admin(user);
const name = str(p.name, 80).trim(); if (!name) throw new OpError("Supplier name required");
const dup = await prisma.supplier.findFirst({ where: { facilityId: fid, name: { equals: name, mode: "insensitive" } } });
if (dup) throw new OpError("That supplier is already on the list");
const count = await prisma.supplier.count({ where: { facilityId: fid } });
const s = await prisma.supplier.create({ data: { facilityId: fid, name, sort: count } });
return { id: s.id };
}
case "supplier.update": {
admin(user);
const s = await prisma.supplier.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!s) throw new OpError("Unknown supplier", 404);
const data: Prisma.SupplierUpdateInput = {};
for (const k of ["contact", "phone", "account"] as const) if (p[k] !== undefined) data[k] = str(p[k], 120);
if (p.lead !== undefined) data.lead = p.lead === "" || p.lead === null ? null : Math.max(0, int(p.lead));
await prisma.supplier.update({ where: { id: s.id }, data });
return { ok: true };
}
case "supplier.remove": {
admin(user);
const s = await prisma.supplier.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!s) throw new OpError("Unknown supplier", 404);
const used = await prisma.catalogItem.count({ where: { facilityId: fid, supplier: s.name } }) + await prisma.order.count({ where: { facilityId: fid, supplier: s.name } });
if (used) throw new OpError("This supplier has products or orders and can't be removed.");
await prisma.supplier.delete({ where: { id: s.id } });
return { ok: true };
}
// ---------- catalogue
case "catalog.add": {
admin(user);
const sizes = (Array.isArray(p.sizes) ? p.sizes : []).map((s: unknown) => str(s, 20).trim()).filter(Boolean);
if (!str(p.item).trim()) throw new OpError("Item name required");
if (!sizes.length) throw new OpError("At least one size required");
// Quick-add from a scan passes the barcode(s) along so the code is bound in the same write —
// no window where the item exists but the code that created it isn't attached to anything.
const codes = (Array.isArray(p.barcodes) ? p.barcodes : []).map((b: { si?: unknown; code?: unknown }) => ({ si: int(b?.si, -1), code: str(b?.code, 64).trim() })).filter((b: { si: number; code: string }) => b.code);
for (const b of codes) if (b.si < 0 || b.si >= sizes.length) throw new OpError("The scanned barcode has to point at one of the sizes you picked");
if (new Set(codes.map((b: { code: string }) => b.code)).size !== codes.length) throw new OpError("The same barcode is on two sizes — each size needs its own code");
for (const b of codes) await assertBindable(prisma, fid, b.code, "", b.si, false);
// Opening stock entered on the form: the counted quantity already on the shelf, per size.
const opens = (Array.isArray(p.opening) ? p.opening : []).map((o: { si?: unknown; qty?: unknown }) => ({ si: int(o?.si, -1), qty: int(o?.qty, 0) })).filter((o: { si: number; qty: number }) => o.qty !== 0);
for (const o of opens) {
if (o.si < 0 || o.si >= sizes.length) throw new OpError("Opening stock has to point at one of the sizes you picked");
if (o.qty < 0) throw new OpError("Opening stock can't be negative");
}
const groups = (await groupsFromPayload(prisma, fid, p, null)) ?? [];
return prisma.$transaction(async (tx) => {
const sort = await nextSort(tx, fid);
const supplier = await ensureSupplier(tx, fid, str(p.supplier, 80));
const it = await tx.catalogItem.create({ data: { facilityId: fid, sort, item: str(p.item, 160).trim(), gender: str(p.gender, 20) || "Unisex", type: str(p.type, 40).trim(), groups, sku: str(p.sku, 60).trim(), supplier, cost: Math.max(0, num(p.cost)), notes: str(p.notes, 400), sizes } });
for (const b of codes) await tx.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code: b.code } }, create: { facilityId: fid, code: b.code, itemId: it.id, sizeIndex: b.si, source: "bound" }, update: { itemId: it.id, sizeIndex: b.si } });
for (const o of opens) await upsertLevel(tx, fid, it.id, o.si, { opening: o.qty });
// The opening figure, so the history starts at the beginning rather than at the first edit.
if (it.cost > 0) await tx.costChange.create({ data: { facilityId: fid, itemId: it.id, cost: it.cost, previous: null, byName } });
return { id: it.id };
});
}
case "catalog.update": {
admin(user);
const it = await ownItem(prisma, fid, str(p.id));
const data: Prisma.CatalogItemUpdateInput = {};
if (p.item !== undefined) data.item = str(p.item, 160).trim() || it.item;
if (p.gender !== undefined) data.gender = str(p.gender, 20);
if (p.type !== undefined) data.type = str(p.type, 40).trim();
const groups = await groupsFromPayload(prisma, fid, p, it.groups);
if (groups !== undefined) data.groups = groups;
if (p.sku !== undefined) data.sku = str(p.sku, 60).trim();
if (p.supplier !== undefined) data.supplier = await ensureSupplier(prisma, fid, str(p.supplier, 80));
if (p.notes !== undefined) data.notes = str(p.notes, 400);
// Captured before the write so the row can say what it moved from — "we used to pay $30" is
// the half of the answer a single current-value field could never give.
let costFrom: number | null = null;
let costTo: number | null = null;
if (p.cost !== undefined) {
const c = num(p.cost, NaN);
if (!(c >= 0)) throw new OpError("Invalid cost");
data.cost = c;
if (c !== it.cost) { costFrom = it.cost; costTo = c; }
}
if (p.archived !== undefined) data.archived = !!p.archived;
if (Array.isArray(p.sizes) || p.addSize !== undefined) {
const sizes: string[] = Array.isArray(p.sizes) ? p.sizes.map((s: unknown) => str(s, 20).trim()).filter(Boolean) : [...it.sizes, str(p.addSize, 20).trim()].filter(Boolean);
if (!sizes.length) throw new OpError("At least one size required");
if (new Set(sizes).size !== sizes.length) throw new OpError("That size is already on the item");
// Only allow appending sizes once the item has history, to keep size indexes stable.
// Request lines and waitlist places count as history like anything else: both store a
// POSITION in this array, so reordering it under a pending ward request either points the
// line past the end of the run — the pick list reads "size 3" and the bag can never be
// handed over — or, worse, quietly one place further up it, and the nurse is handed the
// wrong size with every screen agreeing it is right.
const prefixOk = it.sizes.every((s, i) => sizes[i] === s);
const hasHistory = (await Promise.all([prisma.issue.count({ where: { itemId: it.id } }), prisma.stockLevel.count({ where: { itemId: it.id } }), prisma.orderLine.count({ where: { itemId: it.id } }), prisma.stockMove.count({ where: { itemId: it.id } }), prisma.barcode.count({ where: { itemId: it.id } }), prisma.stocktakeLine.count({ where: { itemId: it.id } }), prisma.receiptLine.count({ where: { itemId: it.id } }), prisma.pickupLine.count({ where: { itemId: it.id } }), prisma.requestLine.count({ where: { itemId: it.id } }), prisma.waitlistEntry.count({ where: { itemId: it.id } })])).reduce((a, b) => a + b, 0);
if (hasHistory && !prefixOk) throw new OpError("This item has history — sizes can be added but existing sizes can't be removed or reordered.");
data.sizes = sizes;
}
await prisma.catalogItem.update({ where: { id: it.id }, data });
if (costTo !== null) {
await prisma.costChange.create({ data: { facilityId: fid, itemId: it.id, cost: costTo, previous: costFrom, byName } });
}
return { ok: true };
}
case "catalog.duplicate": {
// Same garment, different colour/role: one catalogue item per colour, because group routing on
// the Issue screen and stock/barcodes are all per item. Copies the description and the size run
// (and the reorder levels, which are a property of the garment) — never barcodes, stock or
// history, which belong to the colour you're about to scan in.
admin(user);
const src = await ownItem(prisma, fid, str(p.id));
const item = str(p.item, 160).trim() || src.item;
const groups = (await groupsFromPayload(prisma, fid, p, src.groups)) ?? garmentGroups(src.groups);
const sku = p.sku !== undefined ? str(p.sku, 60).trim() : src.sku;
// The same name for the same groups, in any order, is the same garment.
const setOf = (l: string[]) => garmentGroups(l).map(groupKey).sort().join("\n");
const sameName = await prisma.catalogItem.findMany({ where: { facilityId: fid, item, archived: false }, select: { groups: true } });
if (sameName.some((d) => setOf(d.groups) === setOf(groups))) throw new OpError(`“${item}” already exists for ${groups.length ? groupsLabel(groups) : "all groups"}. Give this one a different name.`);
return prisma.$transaction(async (tx) => {
const sort = await nextSort(tx, fid);
const it = await tx.catalogItem.create({ data: { facilityId: fid, sort, item, gender: src.gender, type: src.type, sku, supplier: src.supplier, cost: src.cost, groups, notes: src.notes, sizes: src.sizes } });
const levels = await tx.stockLevel.findMany({ where: { itemId: src.id }, select: { sizeIndex: true, reorder: true } });
for (const l of levels) if (l.reorder !== null) await upsertLevel(tx, fid, it.id, l.sizeIndex, { reorder: l.reorder });
return { id: it.id, sizes: it.sizes.length };
});
}
case "catalog.bulk": {
// Inventory multi-select actions. Delete is history-safe: anything with issues/orders/stocktake/pickup lines
// or stock movement is discontinued instead, so records keep resolving.
admin(user);
const ids = (Array.isArray(p.ids) ? p.ids : []).map((x: unknown) => str(x)).filter(Boolean);
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid, id: { in: ids } } });
if (!items.length) throw new OpError("Select at least one product");
const action = str(p.action);
const n = items.length, plural = `${n} product${n === 1 ? "" : "s"}`;
const idsIn = items.map((i) => i.id);
if (action === "discontinue") { await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { archived: true } }); return { ok: true, message: `${plural} discontinued.` }; }
if (action === "reinstate") { await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { archived: false } }); return { ok: true, message: `${plural} reinstated.` }; }
if (action === "supplier") { const v = str(p.value, 80).trim(); if (!v) throw new OpError("Pick a supplier"); const canon = await ensureSupplier(prisma, fid, v); await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { supplier: canon } }); return { ok: true, message: `${plural} moved to ${canon}.` }; }
if (action === "group") {
// `groups` from a tick-list, or the one `value` the select sends — a name, "All", or a
// garment's label ("A, B"), which takes that garment's groups.
if ((p.groups === undefined || p.groups === null) && !str(p.value, 4000).trim()) throw new OpError("Pick a group");
const groups = (await groupsFromPayload(prisma, fid, { groups: p.groups, group: p.value }, null)) ?? [];
await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { groups } });
return { ok: true, message: `${plural} moved to ${groups.length ? `group${groups.length > 1 ? "s" : ""} ${groupsLabel(groups)}` : "all groups"}.` };
}
if (action === "reorder") {
const lvl = int(p.value, -1); if (lvl < 0) throw new OpError("Enter a reorder level");
await prisma.$transaction(async (tx) => { for (const it of items) for (let si = 0; si < it.sizes.length; si++) await upsertLevel(tx, fid, it.id, si, { reorder: lvl }); });
return { ok: true, message: `Reorder level set to ${lvl} on every size of ${plural}.` };
}
if (action === "price") {
const raw = str(p.value, 20).trim();
const pct = /^[+-]\d+(\.\d+)?%$/.test(raw), abs = /^\$?\d+(\.\d+)?$/.test(raw);
if (!pct && !abs) throw new OpError("Enter a price like 25.50 or a change like +5%");
await prisma.$transaction(async (tx) => {
for (const it of items) {
const cost = pct ? Math.max(0.01, Math.round(it.cost * (1 + parseFloat(raw.slice(0, -1)) / 100) * 100) / 100) : Math.max(0.01, num(raw.replace("$", "")));
if (cost === it.cost) continue;
await tx.catalogItem.update({ where: { id: it.id }, data: { cost } });
// In the same transaction as the price itself: a bulk uplift that left no CostChange
// behind put a hole in the one record of what a garment used to cost, and "what did we
// used to pay for these" is a question finance asks every year.
await tx.costChange.create({ data: { facilityId: fid, itemId: it.id, cost, previous: it.cost, byName } });
}
});
return { ok: true, message: `Prices updated on ${plural}${pct ? ` (${raw})` : ""}. Past issues keep their recorded price.` };
}
if (action === "delete") {
const gone: string[] = [], kept: string[] = [];
for (const it of items) {
// The same list catalog.delete checks, request lines included. They cascade from the
// garment, so a brand-new item with no stock behind it — a fleece added last week and
// asked for on a live request — was being hard-deleted from a tidy-up multi-select, and
// Postgres quietly took the approved line with it: the ward's request lost a garment
// with no event and no message saying why, and a request whose only line went that way
// sticks at picking forever because there is nothing left to hand over.
const used = await prisma.issue.count({ where: { itemId: it.id } }) + await prisma.orderLine.count({ where: { itemId: it.id } }) + await prisma.stocktakeLine.count({ where: { itemId: it.id } })
+ await prisma.pickupLine.count({ where: { itemId: it.id } }) + await prisma.stockMove.count({ where: { itemId: it.id } }) + await prisma.handInLine.count({ where: { itemId: it.id } })
+ await prisma.requestLine.count({ where: { itemId: it.id } })
+ await prisma.stockLevel.count({ where: { itemId: it.id, OR: [{ opening: { gt: 0 } }, { adj: { not: 0 } }, { preloved: { gt: 0 } }] } });
(used ? kept : gone).push(it.id);
}
await prisma.$transaction([
prisma.catalogItem.deleteMany({ where: { id: { in: gone } } }),
prisma.catalogItem.updateMany({ where: { id: { in: kept } }, data: { archived: true } }),
]);
const pl = (k: number) => `${k} product${k === 1 ? "" : "s"}`;
return { ok: true, deleted: gone.length, discontinued: kept.length, message: (gone.length ? `${pl(gone.length)} deleted` : "") + (gone.length && kept.length ? " · " : "") + (kept.length ? `${pl(kept.length)} had history or stock on hand, so they were discontinued instead (records stay intact)` : "") + "." };
}
throw new OpError("Unknown bulk action");
}
case "catalog.delete": {
admin(user);
const it = await ownItem(prisma, fid, str(p.id));
// Request lines are counted with the rest: they cascade from the garment, so deleting one a
// ward has asked for would strip the line out from under a live request and leave the
// manager an ask with nothing on it.
const used = await prisma.issue.count({ where: { itemId: it.id } }) + await prisma.orderLine.count({ where: { itemId: it.id } }) + await prisma.stocktakeLine.count({ where: { itemId: it.id } }) + await prisma.handInLine.count({ where: { itemId: it.id } }) + await prisma.pickupLine.count({ where: { itemId: it.id } }) + await prisma.stockMove.count({ where: { itemId: it.id } }) + await prisma.requestLine.count({ where: { itemId: it.id } }) + await prisma.stockLevel.count({ where: { itemId: it.id, preloved: { gt: 0 } } });
if (used) throw new OpError("This item has history. Discontinue it instead of deleting.");
await prisma.catalogItem.delete({ where: { id: it.id } });
return { ok: true };
}
case "barcode.bind": {
admin(user);
const code = str(p.code, 64).trim();
if (!code) throw new OpError("Barcode required");
const it = await ownItem(prisma, fid, str(p.itemId));
const si = int(p.si, -1);
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
await assertBindable(prisma, fid, code, it.id, si, !!p.force);
await prisma.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code } }, create: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "bound" }, update: { itemId: it.id, sizeIndex: si } });
return { ok: true };
}
case "catalog.variantAdd": {
// Scan-a-size: append the size if it's new and bind the scanned code to it, in one write, so a
// half-finished scan can't leave a size with no barcode (or a barcode on no size).
admin(user);
const it = await ownItem(prisma, fid, str(p.itemId));
const size = str(p.size, 20).trim();
if (!size) throw new OpError("Size required");
const code = str(p.code, 64).trim();
const existing = it.sizes.findIndex((s) => s.toLowerCase() === size.toLowerCase());
const si = existing >= 0 ? existing : it.sizes.length;
if (code) await assertBindable(prisma, fid, code, it.id, si, !!p.force);
await prisma.$transaction(async (tx) => {
if (existing < 0) await tx.catalogItem.update({ where: { id: it.id }, data: { sizes: [...it.sizes, size] } });
if (code) await tx.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code } }, create: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "bound" }, update: { itemId: it.id, sizeIndex: si } });
});
return { si, size, created: existing < 0 };
}
case "catalog.removeSize": {
/* Take a size off a garment.
*
* `sizeIndex` is a POSITION in the sizes array, not a name, so pulling one out shifts every
* size after it down by one — and ten tables store that position against the item. Left
* unremapped, a stock level for "14" silently starts describing "16" and an issue against
* somebody's name changes size under them. So this walks all ten in one transaction.
*
* What stops it is anything actually RECORDED against this particular size: an issue, a
* movement, a count, a hand-in, a request line, a place in a queue. Those are the history the
* size exists to explain, and deleting them to tidy the list is not a trade worth offering.
* Scaffolding goes quietly with it — an untouched stock level (a par nobody has counted
* against) and the barcode bound to that size, which describes a size that is about to stop
* existing. `catalog.update` still refuses to reorder or remove sizes wholesale for the same
* reason; this is the narrow, checked way to do one. */
admin(user);
const it = await ownItem(prisma, fid, str(p.id));
const si = int(p.si, -1);
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
if (it.sizes.length <= 1) throw new OpError("A garment needs at least one size — archive it instead.");
const where = { itemId: it.id, sizeIndex: si };
// A stock level that has never been counted or adjusted is a par setting, not a record.
//
// On hand is DERIVED, not stored (see ledger()/onhand() in lib/compute.ts): opening + adj on
// the StockLevel row, plus what came in on deliveries and stock moves. So a size whose stock
// arrived entirely on a supplier delivery — ordered against an empty new garment, received
// onto the shelf, never issued — has opening/adj/preloved all zero and used to read as empty.
// Removing it deleted six real garments off the books with no StockMove to explain the loss.
// Order and receipt lines key on the size STRING rather than the index, which is why they sit
// outside the `where` above and why they matter twice over: an open order for a removed size
// stops counting as on order (onOrderMap drops si < 0), and when the box lands the receipt
// writes a line the ledger then ignores, so twenty garments enter the room and are never added.
// Only an order still outstanding blocks, on the same test onOrderMap uses: a size whose one
// order was cancelled, or received years ago, is history the receipt lines already speak for,
// and counting it would leave a discontinued size on the list with no way to ever take it off.
const sizeName = String(it.sizes[si]);
const [issues, moves, takes, handins, reqs, waits, kits, disputes, stocked, received, ordered] = await Promise.all([
prisma.issue.count({ where }),
prisma.stockMove.count({ where }),
prisma.stocktakeLine.count({ where }),
prisma.handInLine.count({ where }),
prisma.requestLine.count({ where }),
prisma.waitlistEntry.count({ where }),
prisma.kitCheckAnswer.count({ where }),
prisma.recordDispute.count({ where }),
prisma.stockLevel.count({ where: { ...where, NOT: { opening: 0, adj: 0, preloved: 0 } } }),
prisma.receiptLine.count({ where: { itemId: it.id, size: sizeName } }),
prisma.orderLine.count({ where: { itemId: it.id, size: sizeName, order: { status: { notIn: ["Received", "Cancelled"] } } } }),
]);
const blocked: string[] = [];
if (issues) blocked.push(`${issues} issue${issues === 1 ? "" : "s"}`);
if (stocked || received) blocked.push("stock on hand");
if (ordered) blocked.push("a supplier order");
if (moves) blocked.push(`${moves} stock movement${moves === 1 ? "" : "s"}`);
if (takes) blocked.push("a stocktake");
if (handins) blocked.push("a hand-in");
if (reqs) blocked.push("a ward request");
if (waits) blocked.push("somebody waiting for it");
if (kits) blocked.push("a kit check answer");
if (disputes) blocked.push("a record query");
if (blocked.length) {
throw new OpError(`Size ${it.sizes[si]} has ${blocked.join(", ")} against it, so it can't be removed. Its history would go with it.`);
}
const sizes = it.sizes.filter((_, i) => i !== si);
await prisma.$transaction(async (tx) => {
// The size itself: its par row and the code on its label, neither of which outlives it.
await tx.stockLevel.deleteMany({ where });
await tx.barcode.deleteMany({ where });
// Everything above it moves down one. Every table that stores the position has to move.
const shift = { where: { itemId: it.id, sizeIndex: { gt: si } }, data: { sizeIndex: { decrement: 1 } } };
await tx.stockMove.updateMany(shift);
await tx.issue.updateMany(shift);
await tx.barcode.updateMany(shift);
await tx.stocktakeLine.updateMany(shift);
await tx.handInLine.updateMany(shift);
await tx.requestLine.updateMany(shift);
await tx.recordDispute.updateMany(shift);
/* StockLevel, WaitlistEntry and KitCheckAnswer carry sizeIndex inside a UNIQUE index, and
* Postgres checks a unique index as each row is written, not at the end of the statement.
* One decrementing UPDATE therefore fails the moment it happens to reach XL (3 → 2) before
* L (still 2) — and the physical order of those rows is whatever the table's last write
* left behind, so removing a size worked on one garment and rolled the whole transaction
* back on the next with nothing on screen but "Something went wrong". Parked below zero
* first, where no real size index can be, then brought back: both passes only ever write
* values nothing else holds. */
const PARK = 1_000_000;
const park = { where: { itemId: it.id, sizeIndex: { gt: si } }, data: { sizeIndex: { decrement: PARK + 1 } } };
const unpark = { where: { itemId: it.id, sizeIndex: { lt: 0 } }, data: { sizeIndex: { increment: PARK } } };
await tx.stockLevel.updateMany(park); await tx.stockLevel.updateMany(unpark);
await tx.waitlistEntry.updateMany(park); await tx.waitlistEntry.updateMany(unpark);
await tx.kitCheckAnswer.updateMany(park); await tx.kitCheckAnswer.updateMany(unpark);
await tx.catalogItem.update({ where: { id: it.id }, data: { sizes } });
});
return { ok: true, sizes };
}
case "barcode.generate": {
/* Print our own barcode for a garment that arrived without one.
*
* Whole ranges turn up unlabelled — the cafe shirts came with nothing on any size — and a
* garment nobody can scan is invisible to a count and cannot be issued by scanning. The room
* prints its own label instead, carrying a number GS1 reserves for exactly this (see
* inHouseEan13): a real EAN-13 in the restricted-circulation range, which every scanner in
* the building already reads.
*
* Only ever fills the gaps. A size that already carries a supplier's code keeps it — the code
* on the garment is the one the supplier will use on the next delivery note, and replacing it
* with ours would quietly cut that tie. Pass `si` for one size, or leave it out for every
* unlabelled size on the garment, which is the usual case: somebody is standing at a rack
* about to label the lot.
*
* The counter lives on the facility and moves inside the same transaction as the binding, so
* two people labelling different racks cannot mint the same number. The uniqueness check is
* still there behind it: a supplier code that happens to start 29 would be rare but is not
* impossible, and quietly re-binding somebody's real barcode is not a failure worth risking. */
admin(user);
const it = await ownItem(prisma, fid, str(p.itemId));
const only = p.si === undefined ? -1 : int(p.si, -1);
if (only >= 0 && only >= it.sizes.length) throw new OpError("Invalid size");
const bound = await prisma.barcode.findMany({ where: { itemId: it.id }, select: { sizeIndex: true } });
const has = new Set(bound.map((b) => b.sizeIndex));
const wanted = it.sizes.map((_, i) => i).filter((i) => !has.has(i) && (only < 0 || i === only));
if (!wanted.length) {
throw new OpError(only >= 0 ? "That size already has a barcode." : "Every size on this garment already has a barcode.");
}
const made: { si: number; size: string; code: string }[] = [];
await prisma.$transaction(async (tx) => {
for (const si of wanted) {
// Walk forward past anything already taken rather than failing the whole run: one
// collision should cost a number, not the rack somebody is standing in front of.
let code = "";
for (let tries = 0; tries < 50 && !code; tries++) {
const f = await tx.facility.update({ where: { id: fid }, data: { barcodeSeq: { increment: 1 } }, select: { barcodeSeq: true } });
const candidate = inHouseEan13(f.barcodeSeq);
const clash = await tx.barcode.findUnique({ where: { facilityId_code: { facilityId: fid, code: candidate } }, select: { id: true } });
if (!clash) code = candidate;
}
if (!code) throw new OpError("Couldn't find a free number — tell whoever looks after this.");
await tx.barcode.create({ data: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "generated" } });
made.push({ si, size: String(it.sizes[si] ?? si), code });
}
});
return { ok: true, made, count: made.length };
}
case "barcode.unbind": {
admin(user);
await prisma.barcode.deleteMany({ where: { facilityId: fid, code: str(p.code, 64).trim() } });
return { ok: true };
}
// ---------- stock levels
case "stock.reorder": {
admin(user);
const it = await ownItem(prisma, fid, str(p.itemId));
const si = int(p.si, -1);
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
await upsertLevel(prisma, fid, it.id, si, { reorder: Math.max(0, int(p.reorder)) });
return { ok: true };
}
case "stock.moves": {
const mode = str(p.mode);
if (!["Set", "Receive", "Adjust", "Opening", "Pre-loved"].includes(mode)) throw new OpError("Bad mode");
if (mode === "Opening" || mode === "Adjust" || mode === "Set") admin(user); // write-offs and corrections are an Admin call; Issuers receive and count
if (mode === "Pre-loved" && (Array.isArray(p.lines) ? p.lines : []).some((l: { qty?: unknown }) => int(l?.qty) < 0)) admin(user);
const lines = Array.isArray(p.lines) ? p.lines : [];
if (!lines.length) throw new OpError("No lines");
await lockedTx(fid, async (tx) => {
// "Set" is the counted figure, not a movement: work out the difference against on-hand read
// through this transaction, and record that difference so the ledger still explains itself.
const snap = mode === "Set" ? await buildSnapshot(user, tx) : null;
const L = snap ? ledger(snap) : null;
for (const l of lines) {
const it = await ownItem(tx, fid, str(l.itemId));
const si = int(l.si, -1);
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
const qty = int(l.qty);
if (mode === "Set") {
if (qty < 0) throw new OpError("A counted quantity can't be negative");
const have = onhand(snap!, L!, key(it.id, si));
const delta = qty - have;
if (delta === 0) continue;
await tx.stockMove.create({ data: { facilityId: fid, date: today, type: "adjust", itemId: it.id, sizeIndex: si, qty: delta, reason: str(p.reason, 120) || "Counted correction", byName } });
}
else if (mode === "Opening") await upsertLevel(tx, fid, it.id, si, { opening: Math.max(0, qty) });
else if (mode === "Pre-loved") { if (qty !== 0) await poolAdd(tx, fid, it.id, si, qty); }
else {
if (qty === 0) continue;
await tx.stockMove.create({ data: { facilityId: fid, date: today, type: mode.toLowerCase(), itemId: it.id, sizeIndex: si, qty: mode === "Receive" ? Math.abs(qty) : qty, reason: mode === "Receive" ? "Received without order" : str(p.reason, 120) || "Correction", byName } });
}
}
});
return { ok: true };
}
case "stock.orderFlagged": {
const snap = await buildSnapshot(user);
const L = ledger(snap);
const byId = Object.fromEntries(snap.catalog.map((i) => [i.id, i]));
const needs = flaggedNeeds(snap, L, byId);
const bySup: Record<string, typeof needs> = {};
for (const c of needs) (bySup[c.supplier] = bySup[c.supplier] || []).push(c);
let added = 0;
await lockedTx(fid, async (tx) => {
for (const sup in bySup) added += await mergeReplenish(tx, fid, today, sup, bySup[sup].map((c) => ({ itemId: c.itemId, size: c.size, qty: c.qty })), "max", "Reorder — at or below reorder level");
});
return { added };
}
// ---------- departments & staff
case "dept.save": {
admin(user);
const name = str(p.name, 120).trim(); if (!name) throw new OpError("Department name required");
if (p.id) {
const d = await prisma.department.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!d) throw new OpError("Unknown department", 404);
// A rename has to clear the same name check a new department does, and for a worse reason.
// Departments are matched by name further down — staff carry the name, so do orders' cost
// centres — so renaming Theatres to Emergency doesn't collide, it merges: two wards' staff end
// up under one entry and nothing on the screen says which of them started where. The list on
// the settings screen was the only thing stopping it, and a list in a browser stops nothing.
const clash = await prisma.department.findFirst({ where: { facilityId: fid, name: { equals: name, mode: "insensitive" }, NOT: { id: d.id } } });
if (clash) throw new OpError("That department is already on the list");
await prisma.$transaction(async (tx) => {
await tx.department.update({ where: { id: d.id }, data: { name, cc: str(p.cc, 40).trim() } });
if (d.name !== name) { await tx.staff.updateMany({ where: { facilityId: fid, dept: d.name }, data: { dept: name } }); await tx.order.updateMany({ where: { facilityId: fid, cc: d.name }, data: { cc: name } }); }
});
}
else {
const dup = await prisma.department.findFirst({ where: { facilityId: fid, name: { equals: name, mode: "insensitive" } } });
if (dup) throw new OpError("That department is already on the list");
const count = await prisma.department.count({ where: { facilityId: fid } });
await prisma.department.create({ data: { facilityId: fid, name, cc: str(p.cc, 40).trim(), sort: count } });
}
return { ok: true };
}
case "dept.delete": {
admin(user);
const d = await prisma.department.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!d) throw new OpError("Unknown department", 404);
const n = await prisma.staff.count({ where: { facilityId: fid, dept: d.name } });
if (n) throw new OpError("Departments with staff assigned can't be removed.");
await prisma.department.delete({ where: { id: d.id } });
return { ok: true };
}
case "staff.save": {
admin(user);
const numv = str(p.num, 40).trim(), first = str(p.first, 80).trim(), last = str(p.last, 80).trim();
if (!numv || !first || !last) throw new OpError("Staff number, first and last name are required");
// FTE is left alone unless this call actually carries one. It has its own control on the
// profile, which saves through staff.patch the moment it changes, so the details form doesn't
// send it — and blanking it here would wipe the FTE every time somebody corrected a phone
// number, taking the nurse's initial kit with it.
const fte = normalFte(p.fte);
if (fte === null) throw new OpError(`FTE has to be ${FTE_ALLOWED} — leave it blank if nobody has recorded one`);
// Uniform style is left alone unless the call carries one, for the same reason FTE is. It is a
// coordinator's decision about which cut somebody wears, and quietly clearing it every time a
// phone number was corrected would put that person back to "nobody has said" — offered the
// whole catalogue again — with nothing on the screen to say it had happened.
const style = normalUniformStyle(p.uniformStyle);
if (style === null) throw new OpError(STYLE_REFUSAL);
const data = { num: numv, first, last, phone: str(p.phone, 40).trim(), group: str(p.group, 80), dept: str(p.dept, 120), top: str(p.top, 20), pants: str(p.pants, 20), ccOverride: str(p.ccOverride, 40).trim(), ent: p.ent === "" || p.ent === null || p.ent === undefined ? null : Math.max(0, int(p.ent)), start: dateField(p.start, "", "Start date"), notes: str(p.notes, 2000), ...(p.fte === undefined ? {} : { fte }), ...(p.uniformStyle === undefined ? {} : { uniformStyle: style }) };
if (p.id) {
const ex = await ownStaff(prisma, fid, str(p.id));
if (ex.num !== numv) throw new OpError("Staff number can't be changed — issue history and reports are keyed to it.");
await prisma.staff.update({ where: { id: ex.id }, data }); return { id: ex.id };
}
const dup = await prisma.staff.findFirst({ where: { facilityId: fid, num: numv } });
if (dup) throw new OpError(`Staff number ${numv} is already on the register (${dup.first} ${dup.last})`);
const { room, max } = await staffRoom(fid);
if (room !== null && room < 1) throw new OpError(staffRefusal(max!), 403);
const s = await prisma.staff.create({ data: { facilityId: fid, ...data } });
return { id: s.id };
}
case "staff.patch": {
// Small field updates that don't require the whole record (notes, inactive, ccOverride,
// and the two staff-app fields).
admin(user);
const s = await ownStaff(prisma, fid, str(p.id));
const data: Prisma.StaffUpdateInput = {};
if (p.notes !== undefined) data.notes = str(p.notes, 2000);
if (p.fte !== undefined) {
// Stored as the fraction it is, whether or not the picker offers that step: 0.75 is a real
// roster figure and the table reads it as the band it falls in. What is refused is an entry
// that is no fraction at all — "full time", "N/A" — because the table proposes no kit for
// that, and a proposal silently missing is indistinguishable, on the screen, from a person
// the table genuinely has no number for. Blank is allowed and means what it has always
// meant: nobody has recorded one yet.
const f = normalFte(p.fte);
if (f === null) throw new OpError(`FTE has to be ${FTE_ALLOWED} — leave it blank if nobody has recorded one`);
data.fte = f;
}
if (p.uniformStyle !== undefined) {
// Blank is an answer here, and the one every record starts with: nobody has said which cut
// this person wears, so the counter and the staff app offer them every style exactly as
// "Either" does. Setting it back to blank is therefore allowed — a coordinator who ticked
// the wrong one has to be able to undo it — while a word the rule doesn't know is refused
// rather than stored, because a style nothing recognises would silently offer everything.
const styleIn = normalUniformStyle(p.uniformStyle);
if (styleIn === null) throw new OpError(STYLE_REFUSAL);
data.uniformStyle = styleIn;
}
if (p.inactive !== undefined) {
// Taking somebody off the register when other people still name them as their approver
// strands every one of those people: request.create refuses to address a deactivated
// manager ("The recorded manager is no longer on the register"), and the wearer has no way
// to see why or to change it. Line 504 already refuses to *set* an inactive manager; this
// is the same rule read from the other end, and it is refused here where the coordinator
// is holding the staff list and can move the reports first.
if (p.inactive && !s.inactive) {
// Not counting themselves: somebody who is their own manager names nobody else, and
// nobody is stranded by taking them off the register.
const reports = await prisma.staff.count({ where: { facilityId: fid, managerId: s.id, inactive: false, id: { not: s.id } } });
if (reports) {
throw new OpError(`${reports} ${reports === 1 ? "person still names" : "people still name"} ${s.first} as their manager, and a request can't be sent to somebody who is off the register. Give them a new manager first.`);
}
}
data.inactive = !!p.inactive;
}
// Whether this call is the moment they come off the register, read before the write so their
// waiting requests can be closed in the same transaction (below).
const closing = p.inactive !== undefined && !!p.inactive && !s.inactive;
if (p.ccOverride !== undefined) data.ccOverride = str(p.ccOverride, 40).trim();
if (p.wardDesk !== undefined) data.wardDesk = !!p.wardDesk;
if (p.managerId !== undefined) {
const mid = str(p.managerId);
if (!mid) data.manager = { disconnect: true };
else {
// Anybody may be their own manager — the owner's decision. On a facility where one person
// is the whole register there is nobody else to put here, and the staff app refuses every
// request from somebody with no manager set. What that asks of the record is answered
// where the approvals are made: every request or signed form a person approves for
// themselves is marked on it as a self-approval, so none of it passes unseen. A raise
// they make for somebody else still never lands back on them (lib/staffops.ts).
const m = mid === s.id ? s : await ownStaff(prisma, fid, mid);
if (m.inactive) throw new OpError("That manager is no longer active on the register.");
data.manager = { connect: { id: m.id } };
}
}
let closed = 0;
await prisma.$transaction(async (tx) => {
await tx.staff.update({ where: { id: s.id }, data });
if (!closing) return;
// A request still waiting on a manager outlives the person who raised it. The manager's
// home screen counts it every day from here on and the approvals queue keeps showing a
// live Approve bar, but both buttons are refused with "That person is no longer on the
// register" — and there is no op on the ward side that can clear it, so it sits there
// forever unless a coordinator happens to spot it and withdraw it. Deactivation is the
// moment somebody is actually looking at the right screen, so it closes them here.
const open = await tx.request.findMany({ where: { facilityId: fid, subjectId: s.id, status: "awaiting" }, select: { id: true } });
if (!open.length) return;
const why = `${s.first} ${s.last}`.trim() + " is no longer on the register";
for (const { id } of open) {
// Each one is re-checked as it is written, the way request.withdraw does. A manager
// sitting on the approvals screen can answer a request in the moment between the read
// above and this write, and blindly declining it would bury an approval she has already
// been told landed — with the garments then never picked for a ward that is expecting them.
const moved = await tx.request.updateMany({ where: { id, status: "awaiting" }, data: { status: "declined", decidedAt: new Date(), declineReason: why } });
if (!moved.count) continue;
// The lines are settled in the same breath as the request, as a withdrawal does: garments
// left at "awaiting approval" under a closed request tell the ward two different things.
await tx.requestLine.updateMany({ where: { requestId: id }, data: { status: "declined", declineReason: why } });
await tx.requestEvent.create({ data: { requestId: id, label: "Closed — the wearer came off the register", meta: why, actorName: byName } });
closed++;
}
});
return { ok: true, closedRequests: closed };
}
case "staff.delete": {
admin(user);
const s = await ownStaff(prisma, fid, str(p.id));
// Everything below cascades from the Staff row, so the guard has to name everything, not
// just the two tables it started with. A staff member with signed manager approvals, a hand-in,
// a live request or a claimed staff-app account was being deleted silently and taking all
// of it with her — including requests the linen room was part-way through fulfilling.
const [issues, orders, approvals, alterations, handins, requests, raised, waitlist, kitAnswers, damage, disputes, account] = await Promise.all([
prisma.issue.count({ where: { staffId: s.id } }),
prisma.order.count({ where: { staffId: s.id } }),
prisma.approval.count({ where: { staffId: s.id } }),
prisma.alteration.count({ where: { staffId: s.id } }),
prisma.handIn.count({ where: { staffId: s.id } }),
prisma.request.count({ where: { subjectId: s.id } }),
prisma.request.count({ where: { raisedByStaffId: s.id } }),
prisma.waitlistEntry.count({ where: { staffId: s.id } }),
prisma.kitCheckAnswer.count({ where: { staffId: s.id } }),
prisma.damageReport.count({ where: { staffId: s.id } }),
prisma.recordDispute.count({ where: { staffId: s.id } }),
prisma.staffAccount.count({ where: { staffId: s.id } }),
]);
const held: string[] = [];
const note = (n: number, what: string) => { if (n) held.push(`${n} ${what}${n === 1 ? "" : "s"}`); };
note(issues, "issue"); note(orders, "order"); note(approvals, "manager approval"); note(alterations, "alteration");
note(handins, "hand-in"); note(requests + raised, "request"); note(waitlist, "waitlist place");
note(kitAnswers, "kit-check answer"); note(damage, "damage report"); note(disputes, "query");
if (account) held.push("a staff-app account");
if (held.length) throw new OpError(`This staff member has history on file (${held.join(", ")}) — deactivate them instead, so the records keep resolving.`);
await prisma.staff.delete({ where: { id: s.id } });
return { ok: true };
}
// ---------- staff self-service: the code that lets a person claim their own read-only view
case "staff.selfCode": {
admin(user);
const s = await ownStaff(prisma, fid, str(p.id));
const linked = await prisma.staffAccount.findUnique({ where: { staffId: s.id }, select: { email: true } });
if (linked) throw new OpError(`${s.first} already has an account (${linked.email}). Remove it first if they need to start again.`);
// Retry on the vanishingly unlikely collision rather than handing back a confusing error.
for (let attempt = 0; attempt < 5; attempt++) {
const code = newActivateCode();
try {
// The date is stamped with the code, not merely alongside it: a slip is a bearer token
// on paper, and the activation route refuses one printed more than a fortnight ago. A
// code written without its date is refused as stale on the day it is handed over.
await prisma.staff.update({ where: { id: s.id }, data: { activateCode: code, activateCodeAt: new Date() } });
return { code, name: `${s.first} ${s.last}` };
} catch (e) {
if (!(e instanceof Prisma.PrismaClientKnownRequestError) || e.code !== "P2002") throw e;
}
}
throw new OpError("Couldn't generate a code — try again.", 500);
}
case "staff.selfClear": {
admin(user);
const s = await ownStaff(prisma, fid, str(p.id));
// The date goes with the code. Left behind, it would outlive the slip it describes and the
// register would go on reporting an outstanding code that nobody can use.
await prisma.staff.update({ where: { id: s.id }, data: { activateCode: null, activateCodeAt: null } });
return { ok: true };
}
case "staff.selfUnlink": {
admin(user);
const s = await ownStaff(prisma, fid, str(p.id));
// Deleting the account ends every session it had: currentStaff() looks the row up on each
// request, so there is no token left to expire.
await prisma.staffAccount.deleteMany({ where: { staffId: s.id, facilityId: fid } });
await prisma.staff.update({ where: { id: s.id }, data: { activateCode: null, activateCodeAt: null } });
return { ok: true };
}
/* ---------- staff-app requests: the linen room's half of the state machine
*
* The manager's half lives in lib/staffops.ts and is not reachable from here. A coordinator
* cannot approve a request on a manager's behalf — that would make the approval a formality,
* and the approval is the entire control for clinical staff.
*/
case "request.pick":
case "request.hold":
case "request.round":
case "request.collected": {
const r = await prisma.request.findFirst({
where: { id: str(p.id), facilityId: fid },
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, dept: true } },
},
});
if (!r) throw new OpError("No such request", 404);
const to = op === "request.pick" ? "picking" : op === "request.hold" ? "ready" : op === "request.round" ? "round" : "collected";
if (!canMove(r.status, to)) {
throw new OpError(`A request that is "${r.status}" can't move to "${to}".`);
}
const data: Prisma.RequestUpdateInput = { status: to };
let label = "", meta = "";
if (to === "picking") { label = "Being picked"; meta = str(p.where, 60) || "In the linen room"; }
if (to === "ready") {
// The requester never chooses counter versus ward round — the linen room does, and the
// requester finds out after the fact. Both branches therefore set `route` here.
data.route = "counter";
data.holdUntil = str(p.holdUntil, 40);
// The code itself is picked below, inside the lock, against the bags already waiting.
label = "Ready at the counter";
meta = data.holdUntil ? `Held until ${data.holdUntil}` : "Held at the counter";
}
if (to === "round") {
// The round delivers to a ward, so a wearer with no ward recorded has nowhere for the bag
// to go. Left to the count below, a blank ward would have matched every ward-less desk
// clerk in the facility and the bag would have gone out to whichever of them happened to
// open /my/round. The counter is the only honest answer until somebody fills the ward in.
if (!r.subject.dept) throw new OpError(`${r.subject.first} has no ward recorded, so there is no round to send this on. Hold it at the counter, or record their ward on the staff register first.`);
// Only a ward-desk account can sign a round bag off (round → delivered lives in
// lib/staffops.ts and /my/round, which 404s for anyone without the flag). A ward with
// nobody like that has no way to finish the round, so the bag would be handed over on the
// floor and the request would sit in the linen room's open queue for ever. Refuse here,
// where the coordinator still has the counter to fall back on.
const signers = await prisma.staff.count({ where: { facilityId: fid, dept: r.subject.dept, wardDesk: true, inactive: false, account: { isNot: null } } });
if (!signers) throw new OpError(`Nobody on ${r.subject.dept} can sign for a round bag — that needs somebody with the ward-desk flag and their own staff-app account. Hold it at the counter instead, or set the flag on their staff record first.`);
data.route = "ward_round"; label = "Out on the ward round"; meta = r.subject.dept ? `Due on ${r.subject.dept}` : "On today's round";
}
if (to === "collected") { label = "Collected"; meta = r.collectCode ? `Code ${r.collectCode}` : "At the counter"; }
// Conditional on the status we read, so two coordinators on two phones can't both move it.
const move = async (db: Tx | typeof prisma) => {
const moved = await db.request.updateMany({ where: { id: r.id, status: r.status }, data: data as Prisma.RequestUpdateManyMutationInput });
if (moved.count !== 1) throw new OpError("Somebody else moved that request just now — reopen it.");
await db.requestEvent.create({ data: { requestId: r.id, label, meta, actorName: byName } });
};
// Handing the garment over is the moment it leaves the shelf, so that branch goes through
// handOverRequestStock — the shelf re-check, the Issue and the replenishment line, shared
// with the ward round so the two routes cannot drift apart. Every other transition is a
// status change and nothing more.
if (to === "collected") await handOverRequestStock(fid, today, r, move);
else if (to === "ready") {
/* The collection code has to be unique among the bags it will sit next to, which means
* reading the codes already out there and writing this one without another counter action
* getting in between — two coordinators holding two bags a second apart would otherwise
* both see the same free number and both take it. lockedTx is the facility-wide serialiser
* that already stops two counter actions passing the same shelf check; this is the same
* shape of check, so it uses the same lock rather than a second mechanism.
*
* "Could be confused with each other" is the bags still waiting at the counter: a request
* that has been collected has gone home with somebody, and its code is free again. */
await lockedTx(fid, async (tx) => {
const waiting = await tx.request.findMany({
where: { facilityId: fid, status: "ready", collectCode: { not: null }, id: { not: r.id } },
select: { collectCode: true },
});
const code = collectionCode(new Set(waiting.map((w) => w.collectCode).filter((c): c is string => c !== null)));
if (!code) throw new OpError("Couldn't find a collection code that isn't already on a bag at the counter — hand some of the waiting bags over first.", 500);
data.collectCode = code;
await move(tx);
});
} else await move(prisma);
const acct = await prisma.staffAccount.findUnique({ where: { staffId: r.subjectId }, select: { email: true } });
if (acct?.email && transactionalConfigured() && (to === "ready" || to === "round")) {
const fresh = await prisma.request.findUniqueOrThrow({ where: { id: r.id }, select: { collectCode: true, holdUntil: true } });
// The bag holds what the manager approved and nothing else, so a declined line stays off
// this email as it stays off the pick list — being told a fleece is ready to collect and
// then not finding one in the bag is worse than never hearing about it.
const detail = garmentBlock(approvedLines(r.lines).map((l) => ({ qty: l.qty, item: l.item.item, size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex) })));
const foot = signOff();
const subject = to === "ready" ? "Ready to collect" : "On the ward round";
/* Paragraphs joined by blank lines, with the optional slots as `null` — the same shape as
* the emails in lib/approvallink.ts, and for the same reason. This used to be a list of
* lines with blank strings written in among them and a `.filter(Boolean)` over the lot,
* which dropped every one of those blanks along with the optional lines it was aimed at:
* the collection code arrived jammed against the sign-off. */
const held = [
fresh.collectCode ? `Collection code: ${fresh.collectCode}` : null,
fresh.holdUntil ? `Held until ${fresh.holdUntil}.` : null,
].filter((l): l is string => l !== null).join("\n");
const text = (to === "ready"
? [`Hi ${r.subject.first},`, `Request ${r.code} is ready at the linen room.`, detail, held || null, foot]
: [`Hi ${r.subject.first},`, `Request ${r.code} is on today's round to ${r.subject.dept || "your ward"}.`, detail,
"Whoever signs for it at the desk will be named on your order.", foot]
).filter((l): l is string => l !== null).join("\n\n");
void sendTo(acct.email, subject, text).catch((e) => console.error("[request mail]", (e as Error).message));
}
return { ok: true, status: to };
}
case "request.raise": {
// The counter raising on somebody's behalf — the same job a manager does in 2C, for the
// person who walks in without a phone. It still goes to *their* manager: a coordinator who
// could approve as well as raise would make the approval a formality.
const staff = await ownStaff(prisma, fid, str(p.staffId));
if (staff.inactive) throw new OpError("This staff member is inactive — reactivate them first");
if (!staff.managerId) throw new OpError(`${staff.first} has no manager recorded, so there is nobody to approve this. Set one on their staff record first.`);
// 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.
const manager = await prisma.staff.findFirst({ where: { id: staff.managerId, facilityId: fid, inactive: false } });
if (!manager) throw new OpError("The recorded manager is no longer on the register.");
const lines = await readRequestLines(fid, p.lines);
// The same rule request.create applies in the staff app: only their own staff group's
// garments, plus those for every group. The hand-over trusts it (handOverRequestStock), so the
// counter's door has to ask it too, or a raise here is the way round it.
const off = await offGroupGarments(fid, staff.group, lines.map((l) => l.itemId));
if (off.length) throw new OpError(offGroupElsewhere(staff, off, "request"));
// And the same for the cut they are offered. Blank — nobody has said which cut this person
// wears — and Either take every garment, so this refuses nothing that was allowed before a
// coordinator set the field.
const offStyle = await offStyleGarments(fid, staff.uniformStyle, lines.map((l) => l.itemId));
if (offStyle.length) throw new OpError(offStyleElsewhere(staff, offStyle, "request"));
const created = await prisma.$transaction(async (tx) => {
const f = await tx.facility.update({ where: { id: fid }, data: { requestSeq: { increment: 1 } }, select: { requestSeq: true } });
const req = await tx.request.create({
data: {
facilityId: fid, code: requestCode(f.requestSeq), subjectId: staff.id,
raisedByUserId: user.id, raisedByName: byName,
reason: str(p.reason, 40), note: str(p.note, 400),
status: "awaiting", managerId: manager.id, managerName: `${manager.first} ${manager.last}`.trim(),
lines: { create: lines.map((l, i) => ({ itemId: l.itemId, sizeIndex: l.sizeIndex, qty: l.qty, sort: i })) },
},
});
await tx.requestEvent.create({ data: { requestId: req.id, label: "Requested", meta: `Raised at the counter by ${byName}`, actorName: byName } });
return req;
});
const mgrAccount = await prisma.staffAccount.findUnique({ where: { staffId: manager.id }, select: { email: true } });
// `notified` says an email actually went out, not that the manager happens to have an
// account: the send below is gated on transactional mail being configured as well, and a
// counter that says "we've told them" when nothing was sent is the reason a request sits for
// three weeks with nobody chasing it.
const mgrEmail = mgrAccount?.email || "";
const notified = !!mgrEmail && transactionalConfigured();
if (notified) {
const em = approvalEmail({
managerFirst: manager.first, subjectName: `${staff.first} ${staff.last}`.trim(), raisedByName: byName,
lines, reason: str(p.reason, 40), note: str(p.note, 400),
url: approvalUrl(signApprovalToken(created.id, manager.id)),
facility: fac.name,
});
void sendTo(mgrEmail, em.subject, em.text).catch((e) => console.error("[raise mail]", (e as Error).message));
}
return { id: created.id, code: created.code, manager: `${manager.first} ${manager.last}`.trim(), notified };
}
case "request.reply": {
const r = await prisma.request.findFirst({ where: { id: str(p.id), facilityId: fid } });
if (!r) throw new OpError("No such request", 404);
const body = str(p.body, 2000).trim();
if (!body) throw new OpError("Write something first");
const m = await prisma.requestMessage.create({
data: { requestId: r.id, fromStaff: false, authorName: byName, body },
});
return { id: m.id };
}
case "damage.handedIn": {
// The counter-side half of the staff app's damage.report. The Damage screen tells the wearer
// "the damaged item comes off your record when you hand it in at the counter", and this is
// the hand that does it — a coordinator op, because it is the linen room that has the
// garment in front of them, not the person who reported it.
const d = await prisma.damageReport.findFirst({ where: { id: str(p.id), facilityId: fid } });
if (!d) throw new OpError("No such damage report", 404);
if (d.handedInAt) return { ok: true };
await prisma.damageReport.update({ where: { id: d.id }, data: { handedInAt: new Date() } });
return { ok: true };
}
case "request.withdraw":
case "request.reassign": {
// A request addressed to a manager who never claimed a staff-app account, or who has since
// left, waits for a decision that cannot arrive. Neither the wearer nor the manager can clear
// it — the wearer has no such op and the manager cannot sign in — so the linen room needs a
// way out of the dead end: send it to somebody who can decide it, or close it off honestly.
const r = await prisma.request.findFirst({
where: { id: str(p.id), facilityId: fid },
include: {
subject: { select: { id: true, first: true, last: true } },
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
},
});
if (!r) throw new OpError("No such request", 404);
if (r.status !== "awaiting") throw new OpError("That request has already been decided — only one still waiting for a manager can be withdrawn or re-addressed.");
// Nothing has been decided yet at this point in the flow, so every line is still on the ask:
// both the withdrawal note and the re-addressed approval email list the whole request.
const reqLines = r.lines.map((l) => ({ qty: l.qty, item: l.item.item, size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex) }));
const subjAccount = await prisma.staffAccount.findUnique({ where: { staffId: r.subject.id }, select: { email: true } });
if (op === "request.withdraw") {
const why = str(p.reason, 120).trim() || "Withdrawn by the linen room";
// The lines are settled in the same breath as the request. A withdrawal ends the whole ask,
// so leaving the garments sitting at "awaiting approval" under a request that reads
// "Declined" would have every screen telling the wearer two different things at once.
await prisma.$transaction(async (tx) => {
const moved = await tx.request.updateMany({
where: { id: r.id, status: "awaiting" },
data: { status: "declined", decidedAt: new Date(), declineReason: why },
});
if (moved.count !== 1) throw new OpError("Somebody else moved that request just now — reopen it.");
await tx.requestLine.updateMany({ where: { requestId: r.id }, data: { status: "declined", declineReason: why } });
await tx.requestEvent.create({ data: { requestId: r.id, label: "Withdrawn by the linen room", meta: why, actorName: byName } });
});
// Told, and told why. The whole flow's rule is that a request never just goes quiet on the
// person who raised it, and a withdrawal is the one ending nobody on the ward asked for.
if (subjAccount?.email && transactionalConfigured()) {
const em = decisionEmail({
staffFirst: r.subject.first, managerName: byName, approved: false, reason: why,
lines: reqLines, facility: fac.name,
});
void sendTo(subjAccount.email, em.subject, em.text).catch((e) => console.error("[withdraw mail]", (e as Error).message));
}
return { ok: true, status: "declined" };
}
// Re-addressing keeps the request exactly as it was raised and only changes who is being
// asked. The new approver has to be somebody who can actually answer: on the register, and
// not the person who raised it for somebody else (below).
const m = await ownStaff(prisma, fid, str(p.managerId));
if (m.inactive) throw new OpError("That manager is no longer active on the register.");
// Handing a request back to the person it is for is allowed — anybody may approve their own
// (the owner's decision). It is never mistaken later for an ordinary approval: the timeline
// row below says this one is going back to the wearer, and the decision itself is stamped
// "Self-approved" in lib/staffops.ts.
const selfApproval = m.id === r.subject.id;
// Nor the person who typed it. A manager raising for one of her own reports is exactly why
// the request escalated and landed on this queue with nobody to approve it — and she is the
// natural pick in the dropdown, because she IS the wearer's manager on the register, with
// nothing on the screen to say the ask came from her. Handing it back would let her approve
// her own raise, and that stands whatever else has been relaxed: a manager may sign for the
// kit she is going to wear, where everyone can see whose it is, but asking for somebody
// else's garments and then granting the ask herself is one person doing both halves of a
// decision the ward is told two people made.
if (r.raisedByStaffId && m.id === r.raisedByStaffId) throw new OpError(`${m.first} raised this request, so it can't be sent back for ${m.first} to approve. Pick somebody else.`);
// Sending a request back to the person it is for makes them its approver. That is for somebody set
// as their own manager — the staff record's Manager box is where a person is made self-approving —
// and nobody else, or their own app would show them a request it can't let them decide.
if (m.id === r.subject.id && m.managerId !== m.id) throw new OpError(`${m.first} isn't set as their own manager, so this can't be sent to them to approve. Set them as their own manager on their staff record, or pick somebody else.`);
const mgrName = `${m.first} ${m.last}`.trim();
const moved = await prisma.request.updateMany({
where: { id: r.id, status: "awaiting" },
data: { managerId: m.id, managerName: mgrName },
});
if (moved.count !== 1) throw new OpError("Somebody else moved that request just now — reopen it.");
// Who changed it, and — when it has gone back to the wearer — that it is now theirs to
// approve. Somebody reading this request in six months sees where the approval came from
// before they see the approval itself.
const how = selfApproval
? `Changed at the counter by ${byName}${m.first} is the person this request is for, so it will be self-approved`
: `Changed at the counter by ${byName}`;
await prisma.requestEvent.create({ data: { requestId: r.id, label: `Re-addressed to ${mgrName}`, meta: how, actorName: byName } });
const mgrAccount = await prisma.staffAccount.findUnique({ where: { staffId: m.id }, select: { email: true } });
const mgrEmail = mgrAccount?.email || "";
const notified = !!mgrEmail && transactionalConfigured();
if (notified) {
const em = approvalEmail({
managerFirst: m.first, subjectName: `${r.subject.first} ${r.subject.last}`.trim(), raisedByName: r.raisedByName,
lines: reqLines, reason: r.reason, note: r.note,
url: approvalUrl(signApprovalToken(r.id, m.id)),
facility: fac.name,
});
void sendTo(mgrEmail, em.subject, em.text).catch((e) => console.error("[reassign mail]", (e as Error).message));
}
return { ok: true, manager: mgrName, notified };
}
case "dispute.resolve": {
const d = await prisma.recordDispute.findFirst({ where: { id: str(p.id), facilityId: fid } });
if (!d) throw new OpError("No such query", 404);
await prisma.recordDispute.update({ where: { id: d.id }, data: { resolvedAt: new Date(), resolvedBy: byName } });
return { ok: true };
}
case "notice.set": {
admin(user);
const body = str(p.body, 400).trim();
// An empty body clears the board rather than posting nothing — the linen room's way of
// saying "that's over now".
await prisma.linenNotice.deleteMany({ where: { facilityId: fid } });
if (body) await prisma.linenNotice.create({ data: { facilityId: fid, body, endsAt: dateField(p.endsAt, "", "The end date") } });
return { ok: true, cleared: !body };
}
case "kitcheck.open": {
admin(user);
const open = await prisma.kitCheck.findFirst({ where: { facilityId: fid, closedAt: null } });
if (open) throw new OpError("A kit check is already running. Close that one first.");
const k = await prisma.kitCheck.create({ data: { facilityId: fid, dueBy: dateField(p.dueBy, addDays(today, 21), "The due date"), openedBy: byName } });
return { id: k.id };
}
case "kitcheck.close": {
admin(user);
const k = await prisma.kitCheck.findFirst({ where: { id: str(p.id), facilityId: fid, closedAt: null } });
if (!k) throw new OpError("No kit check to close", 404);
await prisma.kitCheck.update({ where: { id: k.id }, data: { closedAt: new Date() } });
return { ok: true };
}
case "waitlist.offer": {
// Stock landed. Offering starts the 48-hour hold; accepting is the person's own act and
// raises a request that still needs their manager.
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), facilityId: fid, leftAt: null, acceptedAt: null } });
if (!w) throw new OpError("No such waitlist entry", 404);
// A place joined before the staff-group rule can be for a garment outside the person's group.
// Offering it would hold the stock for forty-eight hours for somebody whose acceptance is then
// refused, so it is refused here, where the linen room can take them off the list instead.
const wStaff = await prisma.staff.findFirst({ where: { id: w.staffId, facilityId: fid }, select: { first: true, last: true, group: true, uniformStyle: true } });
const wItem = await prisma.catalogItem.findFirst({ where: { id: w.itemId, facilityId: fid }, select: { item: true, gender: true, groups: true } });
if (wStaff && wItem && !garmentForGroup(wItem, wStaff.group)) {
throw new OpError(`${wItem.item} is for ${groupsLabel(wItem.groups)} only — ${wStaff.first} ${wStaff.last} is in ${wStaff.group || "no staff group"}, so they can't take it. Take them off this waitlist instead.`);
}
// The same for the cut, and for the same reason: a place joined before the uniform-style rule,
// or before this person was set to one, ends in a request that is now refused.
if (wStaff && wItem && !garmentForStyle(wItem, wStaff.uniformStyle)) {
throw new OpError(`${wItem.item} is the ${genderLabel(wItem.gender)} cut — ${wStaff.first} ${wStaff.last} is set to ${wStaff.uniformStyle}, so they can't take it. Take them off this waitlist instead.`);
}
const offeredAt = new Date();
const heldUntil = holdEndsAt(offeredAt);
await prisma.waitlistEntry.update({ where: { id: w.id }, data: { offeredAt } });
const acct = await prisma.staffAccount.findUnique({ where: { staffId: w.staffId }, select: { email: true } });
const it = await prisma.catalogItem.findUnique({ where: { id: w.itemId }, select: { item: true, sizes: true } });
if (acct?.email && it && transactionalConfigured()) {
const foot = signOff();
// The deadline is stated as a date and a time in the linen room's own zone rather than as
// "48 hours", because a duration in an email is unanswerable by the time it matters: the
// person behind them in the queue is waiting on this exact moment passing. The hours
// themselves come from staffreq, where the rule that enforces them lives.
const until = formatInZone(heldUntil ?? offeredAt, tz, { weekday: "short", day: "numeric", month: "short", hour: "numeric", minute: "2-digit" });
void sendTo(acct.email, "The size you were waiting for is in",
`${it.item} — size ${it.sizes[w.sizeIndex]} has come in and is held for you until ${until}.\n\nOpen ThreadCount to accept it. After that it goes to the next person waiting.\n\n${foot}`)
.catch((e) => console.error("[waitlist mail]", (e as Error).message));
}
return { ok: true, heldUntil: heldUntil?.toISOString() ?? null };
}
// ---------- Manager approvals & alterations
case "approval.add": {
const s = await ownStaff(prisma, fid, str(p.staffId));
// The FTE this approval was signed against. Taken off the staff record when the form doesn't
// carry one, because that is where it was typed in from the same paper form. Kept as written
// even when it is no fraction at all: this is a transcription of something already signed,
// and refusing it would mean a signed approval that cannot be recorded at all.
const fteIn = str(p.fte, 10).trim();
const fte = fteIn ? normalFte(fteIn) ?? fteIn : s.fte;
// What the table proposes for that FTE — null for a casual, and null when nobody has recorded
// an FTE at all, which is the same answer for a different reason: the app has no number of
// its own to offer and must not invent one.
const proposed = setsForFte(fte);
const given = p.sets === undefined || p.sets === null || String(p.sets).trim() === "" ? null : int(p.sets);
const sets = given ?? proposed ?? 0;
if (sets <= 0) {
if (given === null) throw new OpError(fte.toLowerCase() === FTE_CASUAL.toLowerCase()
? "How many sets? The form leaves a casual's number to the manager, so it has to be the one they signed for."
: "How many sets? There's no FTE on this staff record for the table to work one out from.");
throw new OpError("Sets must be at least 1");
}
/* Who signed the form. Two ways in, and both have to keep working: the staff screen picks
* the manager out of the register and sends `byStaffId`, while a restored backup, the demo
* seeder and any older screen send nothing but the typed name. A name on its own is only a
* string — finding what a manager had approved meant matching a spelling, and the match
* broke silently the day she was married, promoted, or entered twice.
*
* When both arrive the link wins, and the name is taken off the register row rather than
* from whatever was typed beside it: a record that links to one person and names another
* cannot be read either way round, and the person picked is the one the coordinator actually
* chose. It is still a copy, not a lookup, so the form goes on reading as it was signed even
* after that manager is renamed or leaves the register.
*
* An approver who is not on the register at all — an agency manager, somebody covering for
* the afternoon — still signs forms, so a typed name with no link stays a complete record. */
const byStaffId = str(p.byStaffId).trim();
const approver = byStaffId ? await ownStaff(prisma, fid, byStaffId) : null;
const by = approver ? `${approver.first} ${approver.last}`.trim() : str(p.by, 120).trim();
if (!by) throw new OpError("Approved-by (manager) is required");
// The coordinator may pick the wearer as their own approver while typing the form up —
// anybody may approve their own kit (the owner's decision). It is recorded as exactly that:
// staffId === byStaffId, which the staff record reads as "Self-approved" (below).
// A manager may sign for more than the table proposes — the form itself grants that discretion,
// and this has never been the place to refuse a decision somebody has already made in ink.
// But it is recorded, in the one column that survives on the record itself, so that months
// later the extra set reads as a decision with a name on it rather than as a typo nobody can
// account for. Written after the manager's own note and never instead of it: their words are the
// reason, this is only the arithmetic.
//
// A casual has no table row — the form hands that number to the manager outright — but it does
// name 1, 2 or 3, and signing six is as much a departure as six against a table that proposed
// five. Left unrecorded it was the one case that read, months later, as though nothing out of
// the ordinary had happened, in exactly the case where somebody exercised the most discretion.
//
// The sentence itself is approvalDeparture()'s, in lib/compute, because the staff record
// previews it while the form is being typed in, and the preview has to be the row it becomes.
const note = str(p.notes, 400);
const above = approvalDeparture({ sets, fte, by }) ?? "";
// A manager signing for her own kit lands here as staffId === byStaffId, and that pair is the
// whole marking — no flag of its own, because a flag is a second answer that can disagree
// with the two ids it was summarising. Anything showing an approval reads the pair and says
// so; the sets themselves are no different for having been self-signed.
const a = await prisma.approval.create({ data: { facilityId: fid, staffId: s.id, date: dateField(p.date, today, "The approval date"), byName: by, byStaffId: approver?.id ?? null, sets, fte, notes: note && above ? `${note} · ${above}` : note || above, photoId: await ownPhoto(prisma, fid, p.photoId) } });
return { id: a.id, sets, proposedSets: proposed, by, selfApproved: !!approver && approver.id === s.id };
}
case "approval.remove": {
admin(user);
const a = await prisma.approval.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!a) throw new OpError("Unknown approval", 404);
await prisma.approval.delete({ where: { id: a.id } });
return { ok: true };
}
case "alteration.add": {
const s = await ownStaff(prisma, fid, str(p.staffId));
const garment = str(p.garment, 120).trim(); if (!garment) throw new OpError("Garment is required");
const a = await prisma.alteration.create({ data: { facilityId: fid, staffId: s.id, date: today, garment, desc: str(p.desc, 400).trim() } });
return { id: a.id };
}
case "alteration.advance": {
const a = await prisma.alteration.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!a) throw new OpError("Unknown alteration", 404);
const ni = ALT_FLOW.indexOf(a.status) + 1;
if (ni < ALT_FLOW.length) await prisma.alteration.update({ where: { id: a.id }, data: { status: ALT_FLOW[ni] } });
return { ok: true };
}
case "alteration.remove": {
const a = await prisma.alteration.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!a) throw new OpError("Unknown alteration", 404);
await prisma.alteration.delete({ where: { id: a.id } });
return { ok: true };
}
// ---------- issuing
case "issue.create": {
const staff = await ownStaff(prisma, fid, str(p.staffId));
if (staff.inactive) throw new OpError("This staff member is inactive — reactivate them on their profile first");
const cart = Array.isArray(p.lines) ? p.lines : [];
if (!cart.length) throw new OpError("Nothing to issue");
const snap = await buildSnapshot(user);
const L = ledger(snap);
const byId = Object.fromEntries(snap.catalog.map((i) => [i.id, i]));
const stockLines: { itemId: string; si: number; qty: number }[] = [], orderLines: { itemId: string; si: number; qty: number; supplier: string }[] = [], plLines: { itemId: string; si: number; qty: number }[] = [];
for (const c of cart) {
const it = byId[str(c.itemId)]; const si = int(c.si, -1); const qty = int(c.qty);
if (!it || si < 0 || si >= it.sizes.length || qty <= 0) throw new OpError("Invalid cart line");
if (it.archived) throw new OpError(`${it.item} is discontinued`);
// Order-in supplier always comes from the product's catalogue record.
if (c.src === "order") orderLines.push({ itemId: it.id, si, qty, supplier: it.supplier || (snap.settings.suppliers[0] || "Supplier") });
else if (c.src === "preloved") plLines.push({ itemId: it.id, si, qty });
else stockLines.push({ itemId: it.id, si, qty });
}
// Pre-loved lines draw from the pool (cumulative per variant), free and uncounted.
const plWanted: Record<string, number> = {};
for (const c of plLines) plWanted[key(c.itemId, c.si)] = (plWanted[key(c.itemId, c.si)] || 0) + c.qty;
for (const k in plWanted) if (plWanted[k] > plOf(snap, k)) { const it = byId[k.slice(0, k.lastIndexOf(":"))]; throw new OpError(`Not enough pre-loved ${it?.item} ${it?.sizes[+k.slice(k.lastIndexOf(":") + 1)]} in the pool`); }
// Shelf check is cumulative per variant (two cart lines for the same size can't each pass alone).
const wanted: Record<string, number> = {};
for (const c of stockLines) wanted[key(c.itemId, c.si)] = (wanted[key(c.itemId, c.si)] || 0) + c.qty;
for (const k in wanted) if (wanted[k] > onhand(snap, L, k)) { const it = byId[k.slice(0, k.lastIndexOf(":"))]; throw new OpError(`Not enough on the shelf for ${it?.item} ${it?.sizes[+k.slice(k.lastIndexOf(":") + 1)]}`); }
// The ceiling is enforced here and not only in the UI: six sets is the most anyone holds, and
// past it an issue needs an explicit coordinator override.
//
// Six sets at any time, for every group, nursing included — what somebody has on their back and
// in their locker, not an allowance that starts again in July. So the whole sum is what they
// have out now plus what is in the bag, there is no date in it, and capCheck() in lib/compute is
// the one place it is worked out, so the counter screen's warning and this refusal cannot drift
// apart. The starting kit has stopped being a term in it: a new starter holds nothing and takes
// three sets, three is inside six, and the head-room that used to be added for them existed only
// to stop this line turning their own record into an override.
//
// The whole cart goes in. Pre-loved lines are free and draw no approval, but six pre-loved tops
// fill a locker exactly as six new ones do, and leaving them out would make a pre-loved sixth
// set the way round the ceiling.
//
// Garments ordered in count on both sides of the sum. The ones in this cart are added to it; the
// ones already committed to this person — on an order, waiting at the counter, or approved in a
// bag nobody has collected — are part of what heldGarments() says they hold. Nothing asks the
// ceiling again when any of those is handed over, so this is the one moment they are measured:
// counted only on the day they land, somebody holding nothing could order six sets in today and
// six more tomorrow, and both carts would pass. The approved bags live outside the snapshot, so
// they are read in here.
snap.owedRequestLines = await owedRequestLines(prisma, fid, staff.id);
const cartLines = [...stockLines, ...plLines, ...orderLines].map((c) => ({ itemId: c.itemId, qty: c.qty }));
const staffRec = snap.staff.find((x) => x.id === staff.id)!;
// Somebody is handed their own staff group's garments, plus anything for every group — and, of
// those, the cut they are offered: the men's range for somebody set to Men's, the women's for
// Women's, the unisex range for everybody, and the whole catalogue for somebody set to Either or
// whom nobody has decided yet. Anything else — stock, pre-loved or ordered in — needs the
// coordinator's override, and is recorded as offGroup or offStyle rather than as `override`,
// which stays the six-set ceiling's and nothing else's.
const cartItemIds = [...new Set(cartLines.map((c) => c.itemId))];
const offGroupOf = (catalog: readonly { id: string; item: string; groups: string[] }[], group: string) =>
cartItemIds.map((id) => catalog.find((i) => i.id === id)).filter((it): it is NonNullable<typeof it> => !!it && !garmentForGroup(it, group));
const offStyleOf = (catalog: readonly { id: string; item: string; gender: string }[], style: string) =>
cartItemIds.map((id) => catalog.find((i) => i.id === id)).filter((it): it is NonNullable<typeof it> => !!it && !garmentForStyle(it, style));
const offGroup = offGroupOf(snap.catalog, staffRec.group);
const offStyle = offStyleOf(snap.catalog, staffRec.uniformStyle);
const fullName = `${staff.first} ${staff.last}`;
/** Every reason this cart needs the tick, in one refusal — see issueRefusal(). `capNote` is
* blank where the ceiling isn't part of it. */
const refusalFor = (og: readonly { id: string; item: string; groups: string[] }[], os: readonly { id: string; item: string; gender: string }[], group: string, style: string, capNote: string) =>
issueRefusal([
og.length ? offGroupRefusal(fullName, group, og) : null,
os.length ? offStyleRefusal(fullName, style, os) : null,
capNote ? `It would also take them past what one person holds: ${capNote}` : null,
].filter((x): x is string => x !== null), new Set([...og, ...os].map((i) => i.id)).size);
// Measured before any refusal, because one override tick answers all of them: a cart that is
// outside the group, or the wrong cut, AND past six sets must say so in one refusal, or ticking
// the box for the first would wave the rest through without anybody having been told about them.
const cap = capCheck(snap, staffRec, cartLines);
if ((offGroup.length || offStyle.length) && !p.override) throw new OpError(refusalFor(offGroup, offStyle, staffRec.group, staffRec.uniformStyle, cap.over ? cap.note : ""));
if (cap.over && !p.override) {
// Read by a coordinator with the person standing in front of them, so it says what they have
// out, what the ceiling is and what makes room — all three checkable against the pile on the
// counter. A bare refusal sends somebody back to the ward with nothing to tell their manager.
const plural = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
const holds = cap.breach === "other" ? `${plural(cap.other, "garment", "garments")} outside a set` : `${plural(cap.tops, "top", "tops")} and ${plural(cap.pants, "pair", "pairs")}`;
const hasSome = cap.breach === "other" ? cap.other > 0 : cap.tops + cap.pants > 0;
// Part of what they hold may not have reached them yet, and nobody can see a garment on order
// in their locker, so the sentence says how much is still to come — and only when some is, so
// a refusal for somebody with nothing on the way reads exactly as it always has.
const coming = cap.breach === "other" ? cap.owed.other : cap.owed.tops + cap.owed.pants;
throw new OpError(`${staff.first} ${staff.last} ${hasSome ? `is holding ${holds}${coming ? `, ${coming} of them still to come` : ""}` : "has nothing out"}. ${cap.note}`);
}
// Manager approvals: draw the requested sets down oldest-first, rolling into the next approval.
//
// Never for a pre-loved garment. An approval is a ward manager agreeing to pay for new
// uniform; a pre-loved garment was handed back by somebody else, costs the ward nothing and
// is reissued free — every other money surface in the product already filters preloved out.
// Spending a set on one takes something from the wearer that the ward never spent, and
// nothing on their screen would ever show it happened. cartQty is already "the garments here
// that are not pre-loved", so an issue with none of those draws nothing down. Clamped on the
// server because both the counter phone and the desktop send apDeduct, and a rule enforced
// in a screen is not enforced at all.
const cartQty = cart.reduce((t: number, c: { qty: unknown; src?: unknown }) => t + (c.src === "preloved" ? 0 : int(c.qty)), 0);
const aps = openApprovals(snap, staff.id);
const apAsked = cartQty > 0 ? int(p.apDeduct, 0) : 0;
let apDeduct = Math.max(0, Math.min(apAsked, aps.reduce((t, a) => t + a.sets - a.used, 0)));
const apPlan: { id: string; n: number }[] = [];
for (const a of aps) { if (apDeduct <= 0) break; const n = Math.min(a.sets - a.used, apDeduct); apPlan.push({ id: a.id, n }); apDeduct -= n; }
const apDeducted = apPlan.reduce((t, x) => t + x.n, 0);
await lockedTx(fid, async (tx) => {
// Re-check under the facility lock: the pre-lock snapshot may be stale if another issue just landed.
const fresh = await buildSnapshot(user, tx); const Lf = ledger(fresh);
for (const k in wanted) if (wanted[k] > onhand(fresh, Lf, k)) throw new OpError("Someone just issued from that shelf — not enough left, refresh and try again");
for (const k in plWanted) if (plWanted[k] > plOf(fresh, k)) throw new OpError("The pre-loved pool just changed — refresh and try again");
const freshAps = openApprovals(fresh, staff.id);
for (const x of apPlan) { const a = freshAps.find((q) => q.id === x.id); if (!a || a.sets - a.used < x.n) throw new OpError("The manager's approval balance just changed — refresh and try again"); }
// The ceiling asked again, of what is on the record now. Two counters can serve the same
// person at once, and without this both hand over the sixth set, both pass, and neither is
// recorded — which is the one thing the override is for.
//
// Stamped only where the ceiling was really passed, and only where somebody chose to pass it:
// a screen that sends the flag out of habit must not put the word "override" on an ordinary
// collection, because that column is all anybody has, months later, to tell a decision
// somebody made from a hand-over that went exactly as it should.
fresh.owedRequestLines = await owedRequestLines(tx, fid, staff.id);
const freshRec = fresh.staff.find((x) => x.id === staff.id);
const freshCap = freshRec ? capCheck(fresh, freshRec, cartLines) : cap;
if (freshCap.over && !p.override) throw new OpError("Someone just issued to them — that would now take them past what one person holds. Refresh and try again");
const override = freshCap.over && !!p.override;
// The staff group and the uniform style asked again, of the catalogue and register as they are
// now: a garment re-tagged, a person moved group or set to a cut since the screen loaded is
// measured as it stands. Only the lines that are really outside the group, or really the wrong
// cut, are stamped, whatever the tick said.
const freshGroup = freshRec ? freshRec.group : staffRec.group;
const freshStyle = freshRec ? freshRec.uniformStyle : staffRec.uniformStyle;
const freshOff = offGroupOf(fresh.catalog, freshGroup);
const freshOffStyle = offStyleOf(fresh.catalog, freshStyle);
if ((freshOff.length || freshOffStyle.length) && !p.override) throw new OpError(refusalFor(freshOff, freshOffStyle, freshGroup, freshStyle, ""));
const offIds = new Set(freshOff.map((i) => i.id));
const offStyleIds = new Set(freshOffStyle.map((i) => i.id));
for (const c of stockLines) await tx.issue.create({ data: { facilityId: fid, date: today, staffId: staff.id, itemId: c.itemId, sizeIndex: c.si, qty: c.qty, cond: "New", cost: byId[c.itemId].cost, override, offGroup: offIds.has(c.itemId), offStyle: offStyleIds.has(c.itemId) } });
// Pre-loved rows carry the override too. They cost the ward nothing and draw no approval, but
// they fill a locker like anything else and the ceiling counts them, so a bag of pre-loved
// garments handed over past six sets was a rule bent — and with the flag left off these rows
// a wholly pre-loved override would show on nobody's exceptions report at all.
for (const c of plLines) { await tx.issue.create({ data: { facilityId: fid, date: today, staffId: staff.id, itemId: c.itemId, sizeIndex: c.si, qty: c.qty, cond: "Pre-loved", cost: 0, preloved: true, override, offGroup: offIds.has(c.itemId), offStyle: offStyleIds.has(c.itemId) } }); await poolAdd(tx, fid, c.itemId, c.si, -c.qty); }
const bySup: Record<string, typeof stockLines> = {};
for (const c of stockLines) { const sup = byId[c.itemId].supplier || snap.settings.suppliers[0] || "Supplier"; (bySup[sup] = bySup[sup] || []).push(c); }
for (const sup in bySup) await mergeReplenish(tx, fid, today, sup, bySup[sup].map((c) => ({ itemId: c.itemId, size: String(byId[c.itemId].sizes[c.si]), qty: c.qty })), "add", "Replenishment — replaces issued stock");
const ordBySup: Record<string, typeof orderLines> = {};
for (const c of orderLines) (ordBySup[c.supplier] = ordBySup[c.supplier] || []).push(c);
// An override that went on ordered-in lines is written on the order. There is no issue row to
// stamp yet, and the rows made when the garments are collected come from the order rather than
// from this decision, so without the note a cart of nothing but ordered-in lines could go past
// six and leave no trace of it anywhere.
// The same goes for ordered-in garments outside the person's staff group, and for ones that
// are not the cut they are offered.
for (const sup in ordBySup) {
const code = await nextOrderCode(tx, fid, today);
const offNames = [...new Set(ordBySup[sup].filter((c) => offIds.has(c.itemId)).map((c) => byId[c.itemId].item))];
const offNote = offNames.length ? ` · Outside ${staff.first}'s staff group: ${offNames.join(", ")} — override recorded by ${byName}` : "";
const styleNames = [...new Set(ordBySup[sup].filter((c) => offStyleIds.has(c.itemId)).map((c) => byId[c.itemId].item))];
const styleNote = styleNames.length ? ` · Not ${staff.first}'s uniform style: ${styleNames.join(", ")} — override recorded by ${byName}` : "";
await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: "Staff Member", staffId: staff.id, supplier: sup, status: "Ordered", expected: await expectedFor(tx, fid, sup, today), cc: staff.ccOverride || staff.dept, notes: `Ordered at issue for ${staff.first} ${staff.last}` + (override ? ` · Past the ${freshCap.cap} sets one person holds — override recorded by ${byName}` : "") + offNote + styleNote, lines: { create: ordBySup[sup].map((c, i) => ({ itemId: c.itemId, size: String(byId[c.itemId].sizes[c.si]), qty: c.qty, sort: i })) } } });
}
for (const x of apPlan) await tx.approval.update({ where: { id: x.id }, data: { used: { increment: x.n } } });
});
return { stock: stockLines.reduce((t, c) => t + c.qty, 0), ordered: orderLines.reduce((t, c) => t + c.qty, 0), preloved: plLines.reduce((t, c) => t + c.qty, 0), apDeducted, apRemaining: aps.reduce((t, a) => t + a.sets - a.used, 0) - apDeducted, offGroup: offGroup.length, offStyle: offStyle.length };
}
case "handin.add": {
// Uniform hand-in: Good lines join the pre-loved pool, Rag lines are tallied for disposal, matched past
// issues are stamped handedIn — pre-loved ones too — and (coordinator's call) the year's figure / manager's
// approval balance is credited for the new garments among them.
//
// The stamp is what makes room, and it happens whether or not the coordinator ticks credit: a
// garment marked handed in stops counting towards the six sets this person holds, so somebody
// who brings a set back can be given a replacement without anybody overriding anything. The
// tick is a separate kindness — it hands a drawn set back to the manager's approval balance and
// credits the year's reporting figure — and forgetting it can no longer strand a wearer at the
// counter with an empty locker.
const staff = await ownStaff(prisma, fid, str(p.staffId));
const raw = Array.isArray(p.lines) ? p.lines : [];
if (!raw.length) throw new OpError("Add at least one garment");
const snap = await buildSnapshot(user);
const byId = Object.fromEntries(snap.catalog.map((i) => [i.id, i]));
const lines: { itemId: string; si: number; qty: number; cond: string; laundered: boolean }[] = [];
for (const l of raw) {
const it = byId[str(l.itemId)]; const si = int(l.si, -1); const qty = int(l.qty);
if (!it || si < 0 || si >= it.sizes.length || qty <= 0) throw new OpError("Invalid hand-in line");
lines.push({ itemId: it.id, si, qty, cond: l.cond === "Rag" ? "Rag" : "Good", laundered: l.laundered !== false });
}
const credit = !!p.credit;
const good = lines.filter((l) => l.cond === "Good"), rag = lines.filter((l) => l.cond === "Rag");
const goodN = good.reduce((t, l) => t + l.qty, 0), ragN = rag.reduce((t, l) => t + l.qty, 0);
let setsBack = 0;
const rec = await lockedTx(fid, async (tx) => {
// Stamp the garments' original issues as handed in — pre-loved ones included. A pre-loved garment
// counts towards the six sets somebody holds, so giving one back has to make the same room as
// giving back a new one: left unstamped, a wearer at six who hands a pre-loved top back is
// still at six and is refused its replacement. Credit is the part that still ignores
// pre-loved. It hands a drawn set back to the manager's approval balance and credits the
// year's reporting figure, and a pre-loved garment never drew on either — so only Good lines
// matched to a new issue earn it.
//
// When somebody holds a new and a pre-loved copy of the same size, the new one is matched
// first. Two tunics of one size can't be told apart across the counter, so this is a choice,
// and it is the one that changes nothing that was already true: every hand-in that matched
// before matches the same rows and earns the same credit, and pre-loved rows only take up what
// used to fall through unmatched. Matching pre-loved first would quietly withhold credit a
// coordinator had ticked for somebody handing back a garment the ward paid for. Newest first
// within each, as before.
const credited: number[] = lines.map(() => 0);
for (let ix = 0; ix < lines.length; ix++) {
const l = lines[ix]; let left = l.qty;
const past = await tx.issue.findMany({ where: { facilityId: fid, staffId: staff.id, itemId: l.itemId, sizeIndex: l.si, returnedDate: null, handedIn: null }, orderBy: [{ preloved: "asc" }, { date: "desc" }] });
for (const i of past) {
if (left <= 0) break;
const m = Math.min(left, i.qty);
if (m < i.qty) {
// Only part of that line came back. Stamping the whole row would take the garments
// still in the wearer's possession off her record — five issued, one handed in, four
// gone from the books — so the row is split the same way a partial exchange splits
// it: the handed-in quantity becomes its own row and the rest stays out with her.
await tx.issue.update({ where: { id: i.id }, data: { qty: i.qty - m } });
await tx.issue.create({ data: { facilityId: fid, date: i.date, staffId: i.staffId, itemId: i.itemId, sizeIndex: i.sizeIndex, qty: m, cond: i.cond, cost: i.cost, orderCode: i.orderCode, receipt: i.receipt, preloved: i.preloved, override: i.override, offGroup: i.offGroup, offStyle: i.offStyle, direct: i.direct, handedIn: today } });
} else {
await tx.issue.update({ where: { id: i.id }, data: { handedIn: today } });
}
left -= m;
if (credit && l.cond === "Good" && !i.preloved) credited[ix] += m;
}
}
const h = await tx.handIn.create({ data: { facilityId: fid, date: today, staffId: staff.id, byName, credit, lines: { create: lines.map((l, ix) => ({ itemId: l.itemId, sizeIndex: l.si, qty: l.qty, cond: l.cond, laundered: l.laundered, credited: credited[ix] })) } } });
for (const l of good) await poolAdd(tx, fid, l.itemId, l.si, l.qty);
// Nursing credit = credited sets (1 set = 1 top + 1 pants), returned to drawn approvals newest-first.
if (credit) {
const gt = lines.reduce((t, l, ix) => t + (isTopItem(byId[l.itemId]) ? credited[ix] : 0), 0), gp = lines.reduce((t, l, ix) => t + (isPantItem(byId[l.itemId]) ? credited[ix] : 0), 0);
setsBack = Math.max(gt, gp);
let left = setsBack;
const aps = await tx.approval.findMany({ where: { facilityId: fid, staffId: staff.id, used: { gt: 0 } }, orderBy: { date: "desc" } });
for (const ap of aps) { if (left <= 0) break; const give = Math.min(ap.used, left); await tx.approval.update({ where: { id: ap.id }, data: { used: ap.used - give } }); left -= give; }
}
return h;
});
return { id: rec.id, good: goodN, rag: ragN, credit, setsBack, message: `Hand-in recorded for ${staff.first} ${staff.last}: ${goodN ? `${goodN} to the pre-loved pool` : ""}${goodN && ragN ? " · " : ""}${ragN ? `${ragN} to rag disposal` : ""}${credit ? " · allowance credited" : ""}.` };
}
case "photo.put": {
// Camera capture / signature from the device: JPEG or PNG data URL, already downscaled client-side.
const data = str(p.data, PHOTO_MAX + 200);
if (!/^data:image\/(jpeg|png);base64,[A-Za-z0-9+/=]+$/.test(data)) throw new OpError("Photo must be a JPEG or PNG");
if (data.length > PHOTO_MAX) throw new OpError("Photo is too large — try again");
const kind = ["approval", "receipt", "return", "sig", "proof"].includes(str(p.kind)) ? str(p.kind) : "photo";
// A demo visitor signs for a delivery like anyone else, but the demo is open to the internet
// on a shared admin session: without a ceiling that is an anonymous upload endpoint, and the
// per-facility rate limit resets with the facility every twenty minutes.
if (user.isDemo && (await prisma.photo.count({ where: { facilityId: fid } })) >= DEMO_PHOTO_MAX) throw new OpError("The demo only keeps a handful of photos — it resets every 20 minutes.");
const parsed = parseDataUrl(data);
if (!parsed) throw new OpError("Photo must be a JPEG or PNG");
// The row is created first so the file can be named after its id, which is what makes the
// path derivable from data we generated rather than from anything the request supplied.
const ph = await prisma.photo.create({ data: { facilityId: fid, kind, mime: parsed.mime, bytes: parsed.bytes.length } });
try {
const rel = await writePhoto(fid, ph.id, parsed);
await prisma.photo.update({ where: { id: ph.id }, data: { path: rel } });
} catch (e) {
// A row pointing at a file that was never written is worse than no row at all.
await prisma.photo.delete({ where: { id: ph.id } }).catch(() => {});
console.error("[photo] could not write to disk:", (e as Error).message);
throw new OpError("Couldn't save the photo — try again");
}
if (Math.random() < 0.05) await gcPhotos(fid).catch(() => {});
return { id: ph.id };
}
case "pickup.deliver": {
// Delivery round: the receiver signs on screen; same stock effect as a counter pickup (direct issues).
const pu = await prisma.pickup.findFirst({ where: { id: str(p.id), facilityId: fid }, include: { lines: true, order: true } }); if (!pu) throw new OpError("Unknown pickup", 404);
if (pu.pickedUp) throw new OpError("Already handed over");
const sigId = await ownPhoto(prisma, fid, p.sigId), proofId = await ownPhoto(prisma, fid, p.proofId);
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
const byId: Record<string, Item> = Object.fromEntries(items.map((i) => [i.id, asItem(i)]));
await lockedTx(fid, async (tx) => {
const cur = await tx.pickup.findUnique({ where: { id: pu.id }, select: { pickedUp: true } });
if (cur?.pickedUp) throw new OpError("Already handed over");
await tx.pickup.update({ where: { id: pu.id }, data: { pickedUp: today, deliveredTo: str(p.deliveredTo, 120).trim(), sigId, proofId, deliveredRound: true } });
// No ceiling check and no override stamp here, on purpose. These garments have counted towards
// the six this person holds since the day they were ordered — as an order line, then as this
// pickup — so handing them over moves them from owed to issued and the total doesn't change.
// Asking capCheck() with them as a cart would count them twice and brand an ordinary
// collection an override; any decision to go past six was made when they were ordered.
//
// The staff group and the uniform style are different: neither is refused here — the only way
// a garment outside either reaches a staff order is the counter's Order in with the override
// ticked — but both are stamped, so the Exceptions report sees the garment in the month it
// reaches them.
const wearer = await tx.staff.findUnique({ where: { id: pu.staffId }, select: { group: true, uniformStyle: true } });
for (const l of pu.lines) {
const it = byId[l.itemId] as Item | undefined;
const si = sizeIndexOf(it, l.size);
if (si < 0) throw new OpError(`Size ${l.size} is no longer on ${it?.item || "the item"} — fix the catalogue before marking this delivered`);
await tx.issue.create({ data: { facilityId: fid, date: today, staffId: pu.staffId, itemId: l.itemId, sizeIndex: si, qty: l.qty, cond: "New", cost: it?.cost || 0, orderCode: pu.order.code, direct: true, receipt: !!sigId, offGroup: !!it && !garmentForGroup(it, wearer?.group), offStyle: !!it && !garmentForStyle(it, wearer?.uniformStyle) } });
}
});
return { ok: true };
}
case "issue.receipt": {
const i = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!i) throw new OpError("Unknown issue", 404);
await prisma.issue.update({ where: { id: i.id }, data: { receipt: !!p.receipt } });
return { ok: true };
}
case "issue.return": {
const i = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!i) throw new OpError("Unknown issue", 404);
if (i.returnedDate) throw new OpError("This issue has already been returned / written off");
if (i.handedIn) throw new OpError("This garment was handed in on " + i.handedIn + " — it's already back in the pool");
const cond = str(p.cond, 40);
if (!["Returned - Good", "Returned - Damaged", "Lost", "Written Off"].includes(cond)) throw new OpError("Bad condition");
const photoId = await ownPhoto(prisma, fid, p.photoId);
/* Part of a line can come back on its own.
*
* An issue row is a quantity — three tunics handed over in one act — and returning the row
* used to be all-or-nothing, so bringing one of the three back credited the shelf with all
* three and cleared the other two off the wearer's record. ledger() reads the row's qty, not
* how many garments were physically handed over the counter, so the stock figure was simply
* wrong from that moment on. Splitting the row the way issue.exchange already does keeps the
* rest out in the person's name. A missing or oversized qty still means the whole line, so
* every caller that never knew about this carries on behaving exactly as it did. */
const qty = Math.min(i.qty, Math.max(1, int(p.qty, i.qty)));
await lockedTx(fid, async (tx) => {
if (qty < i.qty) {
await tx.issue.update({ where: { id: i.id }, data: { qty: i.qty - qty } });
await tx.issue.create({ data: { facilityId: fid, date: i.date, staffId: i.staffId, itemId: i.itemId, sizeIndex: i.sizeIndex, qty, cond: i.cond, cost: i.cost, orderCode: i.orderCode, preloved: i.preloved, override: i.override, offGroup: i.offGroup, offStyle: i.offStyle, direct: i.direct, returnedDate: today, returnedCond: cond, returnPhotoId: photoId } });
} else {
await tx.issue.update({ where: { id: i.id }, data: { returnedDate: today, returnedCond: cond, returnPhotoId: photoId } });
}
if (i.preloved && cond === "Returned - Good") await poolAdd(tx, fid, i.itemId, i.sizeIndex, qty); // a pre-loved garment returns to the pool, not the shelf
});
return { ok: true };
}
case "issue.delete": {
admin(user);
const i = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!i) throw new OpError("Unknown issue", 404);
await prisma.issue.delete({ where: { id: i.id } });
return { ok: true };
}
// ---------- orders
case "order.create": {
const orderFor = p.orderFor === "Staff Member" ? "Staff Member" : "Stock";
const staff = orderFor === "Staff Member" ? await ownStaff(prisma, fid, str(p.staffId)) : null;
const lines = Array.isArray(p.lines) ? p.lines : [];
if (!lines.length) throw new OpError("Add at least one line");
await refuseWrongGarmentOnOrder(fid, staff, lines.map((l: { itemId?: unknown }) => str(l?.itemId)));
return prisma.$transaction(async (tx) => {
const code = await nextOrderCode(tx, fid, today);
const create: { itemId: string; size: string; qty: number; sort: number }[] = [];
for (const [i, l] of lines.entries()) { const it = await ownItem(tx, fid, str(l.itemId)); const size = str(l.size, 20); if (!it.sizes.map(String).includes(size)) throw new OpError("Invalid size"); const qty = int(l.qty); if (qty <= 0) throw new OpError("Invalid quantity"); create.push({ itemId: it.id, size, qty, sort: i }); }
const o = await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor, staffId: staff?.id ?? null, supplier: str(p.supplier, 80), status: "Draft", expected: dateField(p.expected, addDays(today, 14), "The expected date"), cc: str(p.cc, 120) || (staff ? staff.ccOverride || staff.dept : ""), notes: str(p.notes, 400), replenish: !!p.replenish, lines: { create } } });
return { id: o.id, code };
});
}
case "order.duplicate": {
const o = await ownOrder(prisma, fid, str(p.id));
// A copy is a new decision. An order the counter raised on the override (Order in) carries
// garments outside the person's group, and copying it must not carry the override with it.
await refuseWrongGarmentOnOrder(fid, await orderStaff(fid, o), o.lines.map((l) => l.itemId));
return prisma.$transaction(async (tx) => {
const code = await nextOrderCode(tx, fid, today);
const n = await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: o.orderFor, staffId: o.staffId, supplier: o.supplier, status: "Draft", expected: "", cc: o.cc, notes: `Duplicated from ${o.code}`, lines: { create: o.lines.map((l, i) => ({ itemId: l.itemId, size: l.size, qty: l.qty, sort: i })) } } });
return { id: n.id, code };
});
}
case "order.update": {
const o = await ownOrder(prisma, fid, str(p.id));
if (o.status === "Received" || o.status === "Cancelled") throw new OpError(`This order is ${o.status.toLowerCase()} — its details are locked`);
const data: Prisma.OrderUpdateInput = {};
for (const k of ["ref", "invoice", "tracking", "cc", "notes", "supplier"] as const) if (p[k] !== undefined) data[k] = str(p[k], k === "notes" ? 400 : 120);
// The expected date is a date, not free text: stored unvalidated it reaches the orders
// screen as something no formatter can read, on the one column the overdue list sorts on.
if (p.expected !== undefined) data.expected = dateField(p.expected, "", "The expected date");
if (p.staffId !== undefined) {
const sid = str(p.staffId);
if (sid) {
const s = await ownStaff(prisma, fid, sid);
// Only a change of person is measured. Saving the same person back — the order screen's
// picker does — must not refuse an order the counter raised on the override for them.
if (s.id !== o.staffId) await refuseWrongGarmentOnOrder(fid, s, o.lines.map((l) => l.itemId), true);
data.staff = { connect: { id: s.id } }; data.orderFor = "Staff Member"; data.cc = s.ccOverride || s.dept;
}
else { data.staff = { disconnect: true }; data.orderFor = "Stock"; data.cc = ""; }
}
await prisma.order.update({ where: { id: o.id }, data });
return { ok: true };
}
case "order.status": {
const o = await ownOrder(prisma, fid, str(p.id));
const st = str(p.status, 20);
if (st === "Cancelled") { admin(user); if (!["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)) throw new OpError("Order can't be cancelled"); }
else if (st === "Ordered") { if (o.status !== "Draft") throw new OpError("Only drafts can be marked ordered"); if (!o.lines.length) throw new OpError("Order has no lines"); }
else if (st === "Shipped") { if (!["Ordered", "Back Order"].includes(o.status)) throw new OpError("Not an open order"); }
else throw new OpError("Bad status");
await prisma.order.update({ where: { id: o.id }, data: { status: st } });
return { ok: true };
}
case "order.lineRemove": {
const o = await ownOrder(prisma, fid, str(p.id));
if (o.status !== "Draft") throw new OpError("Only draft lines can be removed");
if (o.lines.length <= 1) throw new OpError("An order needs at least one line");
await prisma.orderLine.deleteMany({ where: { id: str(p.lineId), orderId: o.id } });
return { ok: true };
}
case "order.lineAdd": {
const o = await ownOrder(prisma, fid, str(p.id));
if (o.status !== "Draft") throw new OpError("Only drafts can be edited");
const it = await ownItem(prisma, fid, str(p.itemId)); const size = str(p.size, 20); const qty = Math.max(1, int(p.qty, 1));
if (!it.sizes.map(String).includes(size)) throw new OpError("Invalid size");
await refuseWrongGarmentOnOrder(fid, await orderStaff(fid, o), [it.id]);
const ex = o.lines.find((l) => l.itemId === it.id && l.size === size);
if (ex) await prisma.orderLine.update({ where: { id: ex.id }, data: { qty: { increment: qty } } });
else await prisma.orderLine.create({ data: { orderId: o.id, itemId: it.id, size, qty, sort: o.lines.length } });
return { ok: true };
}
case "order.lineQty": {
const o = await ownOrder(prisma, fid, str(p.id));
if (o.status !== "Draft") throw new OpError("Only drafts can be edited");
const qty = int(p.qty);
if (qty <= 0) throw new OpError("Quantity must be at least 1");
// More of a garment is more of it on their record; fewer is always allowed, so a line that
// would now be refused can still be brought down or taken off.
const line = o.lines.find((l) => l.id === str(p.lineId));
if (line && qty > line.qty) await refuseWrongGarmentOnOrder(fid, await orderStaff(fid, o), [line.itemId], true);
await prisma.orderLine.updateMany({ where: { id: str(p.lineId), orderId: o.id }, data: { qty } });
return { ok: true };
}
case "order.receive": {
const o0 = await ownOrder(prisma, fid, str(p.id));
if (!["Ordered", "Shipped", "Back Order"].includes(o0.status)) throw new OpError(o0.status === "Draft" ? "Mark the order as ordered before receiving it" : "Order isn't receivable");
const lines = Array.isArray(p.lines) ? p.lines : [];
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
const byId = Object.fromEntries(items.map((i) => [i.id, i]));
const date = dateField(p.date, today, "The delivery date"), invoice = str(p.invoice, 80), note = str(p.note, 400);
await lockedTx(fid, async (tx) => {
// Re-read inside the transaction so two simultaneous receives can't both see the full quantity outstanding.
const o = await ownOrder(tx, fid, o0.id);
if (o.status === "Received") throw new OpError("This order has already been received");
const recLines: { itemId: string; size: string; qty: number; dest: string; cost: number }[] = [], shorts: { itemId: string; size: string; qty: number }[] = [], puLines: { itemId: string; size: string; qty: number }[] = [];
for (const ol of o.lines) {
const l = lines.find((x: { lineId?: string; itemId?: string; size?: string }) => (x.lineId && x.lineId === ol.id) || (x.itemId === ol.itemId && String(x.size) === ol.size));
const it = byId[ol.itemId]; if (!it) continue;
const already = o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === ol.itemId && x.size === ol.size).reduce((s, x) => s + x.qty, 0), 0);
const outstanding = Math.max(0, ol.qty - already);
if (int(l?.arrived, 0) > outstanding) throw new OpError(`${it.item} ${ol.size}: ${int(l?.arrived, 0)} arrived but only ${outstanding} outstanding — receive the surplus as stock without an order`);
const a = Math.max(0, Math.min(int(l?.arrived, 0), outstanding));
const dest = l?.dest === "pickup" && o.staffId ? "pickup" : "shelf";
const cost = l && l.cost !== "" && l.cost !== undefined ? num(l.cost, it.cost) : it.cost;
if (!(cost >= 0 && cost <= 100000)) throw new OpError("Invoiced cost must be between $0 and $100,000");
if (a > 0) { const line = { itemId: it.id, size: ol.size, qty: a, dest, cost }; recLines.push(line); if (dest === "pickup") puLines.push(line); }
if (a < outstanding) shorts.push({ itemId: it.id, size: ol.size, qty: outstanding - a });
// Correcting the catalogue from the invoice is a price change like any other, so it
// leaves the same trail: without it the cost history jumps from the old figure straight
// to the next manual edit, and the "up from …" line on that entry is wrong.
if (l?.priceAction === "update" && user.role === "ADMIN" && Number.isFinite(cost) && cost > 0 && cost !== it.cost) {
await tx.catalogItem.update({ where: { id: it.id }, data: { cost } });
await tx.costChange.create({ data: { facilityId: fid, itemId: it.id, cost, previous: it.cost, byName } });
}
}
if (!recLines.length) throw new OpError("Nothing arrived — enter at least one quantity");
await tx.receipt.create({ data: { orderId: o.id, date, invoice, note, photoId: await ownPhoto(tx, fid, p.photoId), lines: { create: recLines } } });
await tx.order.update({ where: { id: o.id }, data: { invoice: invoice || o.invoice, received: date, status: "Received", notes: note ? (o.notes ? o.notes + " · " : "") + note : o.notes } });
if (shorts.length) {
const code = await nextOrderCode(tx, fid, today);
await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: o.orderFor, staffId: o.staffId, supplier: o.supplier, status: "Back Order", ref: o.ref, expected: await expectedFor(tx, fid, o.supplier, today), cc: o.cc, notes: `Back order — short on ${o.code}`, replenish: o.replenish, parentId: o.id, lines: { create: shorts.map((s, i) => ({ ...s, sort: i })) } } });
}
if (puLines.length && o.staffId) await tx.pickup.create({ data: { facilityId: fid, orderId: o.id, staffId: o.staffId, received: date, lines: { create: puLines.map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty })) } } });
});
return { ok: true };
}
// ---------- pickups
case "pickup.contacted": {
const pu = await prisma.pickup.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!pu) throw new OpError("Unknown pickup", 404);
await prisma.pickup.update({ where: { id: pu.id }, data: { contacted: p.contacted === undefined ? true : !!p.contacted } });
return { ok: true };
}
case "pickup.pickedUp": {
const pu = await prisma.pickup.findFirst({ where: { id: str(p.id), facilityId: fid }, include: { lines: true, order: true } }); if (!pu) throw new OpError("Unknown pickup", 404);
if (pu.pickedUp) return { ok: true };
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
const byId: Record<string, Item> = Object.fromEntries(items.map((i) => [i.id, asItem(i)]));
await lockedTx(fid, async (tx) => {
const cur = await tx.pickup.findUnique({ where: { id: pu.id }, select: { pickedUp: true } });
if (cur?.pickedUp) return; // already collected by a concurrent click
await tx.pickup.update({ where: { id: pu.id }, data: { pickedUp: today } });
// The same as a delivery on the round: no ceiling check and no override stamp. These garments
// have counted towards the six this person holds since the day they were ordered — as an order
// line, then as this pickup — so handing them over moves them from owed to issued and the
// total doesn't change.
// Asking capCheck() with them as a cart would count them twice and brand an ordinary
// collection an override; any decision to go past six was made when they were ordered.
//
// And, as on the round, a garment outside their staff group is stamped offGroup — and one
// that is not their cut offStyle — rather than refused: either can only be on their order
// because the counter ordered it in on the override.
const wearer = await tx.staff.findUnique({ where: { id: pu.staffId }, select: { group: true, uniformStyle: true } });
for (const l of pu.lines) {
const it = byId[l.itemId] as Item | undefined;
const si = sizeIndexOf(it, l.size);
if (si < 0) throw new OpError(`Size ${l.size} is no longer on ${it?.item || "the item"} — fix the catalogue before marking this picked up`);
await tx.issue.create({ data: { facilityId: fid, date: today, staffId: pu.staffId, itemId: l.itemId, sizeIndex: si, qty: l.qty, cond: "New", cost: it?.cost || 0, orderCode: pu.order.code, direct: true, offGroup: !!it && !garmentForGroup(it, wearer?.group), offStyle: !!it && !garmentForStyle(it, wearer?.uniformStyle) } });
}
});
return { ok: true };
}
// ---------- stocktake
case "stocktake.apply": {
const counts = Array.isArray(p.lines) ? p.lines : [];
if (!counts.length) throw new OpError("Nothing counted");
const snap = await buildSnapshot(user);
const L = ledger(snap);
const variants = variantList(snap);
const vk = new Set(variants.map((v) => v.key));
const plMode = p.mode === "preloved"; // pre-loved counts write the pool directly (at $0)
// A count can be scoped to a location (and everything under it), so a shelf take files as a shelf take.
const locationId = str(p.locationId) || null;
if (locationId && !snap.locations.some((l) => l.id === locationId)) throw new OpError("Unknown location", 404);
const scope = locationId ? locSubtree(snap, locationId) : null;
// Every counted line is filed (so "last counted" is right even when it matched); only variances adjust stock.
const lines: { itemId: string; si: number; sys: number; counted: number; reason: string }[] = [];
const seen = new Set<string>();
for (const c of counts) {
const it = str(c.itemId), si = int(c.si, -1); const k = key(it, si);
if (!vk.has(k) || seen.has(k)) continue;
if (scope && !scope.has(snap.placed[k] || "")) throw new OpError("A counted line isn't on that shelf — refresh and start the count again");
seen.add(k);
lines.push({ itemId: it, si, sys: plMode ? plOf(snap, k) : onhand(snap, L, k), counted: Math.max(0, int(c.counted)), reason: str(c.reason, 40) });
}
if (!lines.length) throw new OpError("Nothing counted");
const variances = lines.filter((l) => l.counted !== l.sys);
// A large gap has to say why, or a shelf quietly loses garments with nothing on the record.
const gate = Math.max(1, snap.settings.varianceReason);
const unexplained = variances.find((l) => Math.abs(l.counted - l.sys) >= gate && !l.reason);
if (unexplained) throw new OpError("A gap that size needs a reason before the count can be committed");
await lockedTx(fid, async (tx) => {
for (const l of variances) { if (plMode) await upsertLevel(tx, fid, l.itemId, l.si, { preloved: l.counted }); else await upsertLevel(tx, fid, l.itemId, l.si, { adj: { increment: l.counted - l.sys } }); }
await tx.stocktake.create({ data: { facilityId: fid, date: today, byName, counted: lines.length, variances: variances.length, mode: plMode ? "preloved" : "shelf", locationId, lines: { create: lines.map((l) => ({ itemId: l.itemId, sizeIndex: l.si, sys: l.sys, counted: l.counted, reason: l.reason })) } } });
});
return { counted: lines.length, variances: variances.length };
}
// ---------- locations
case "location.save": {
admin(user);
const id = str(p.id);
const name = str(p.name, 60).trim();
if (!name) throw new OpError("Give the location a name");
const kind = LOCATION_KINDS.includes(str(p.kind)) ? str(p.kind) : "Shelf";
const parentId = str(p.parentId) || null;
if (parentId) {
const par = await prisma.location.findFirst({ where: { id: parentId, facilityId: fid } });
if (!par) throw new OpError("Unknown parent location", 404);
}
const clash = await prisma.location.findFirst({ where: { facilityId: fid, name, NOT: id ? { id } : undefined } });
if (clash) throw new OpError(`There is already a location called ${name}`);
if (id) {
const cur = await prisma.location.findFirst({ where: { id, facilityId: fid } });
if (!cur) throw new OpError("Unknown location", 404);
// A location can't be moved inside itself — that would orphan the whole branch from the tree.
if (parentId) {
const snap = await buildSnapshot(user);
if (locWouldLoop(snap, id, parentId)) throw new OpError("A location can't sit inside itself");
}
await prisma.location.update({ where: { id }, data: { name, kind, parentId, sort: int(p.sort, cur.sort), archived: p.archived === undefined ? cur.archived : !!p.archived } });
return { id };
}
const made = await prisma.location.create({ data: { facilityId: fid, name, kind, parentId, sort: int(p.sort, 0) } });
return { id: made.id };
}
case "location.delete": {
admin(user);
const l = await prisma.location.findFirst({ where: { id: str(p.id), facilityId: fid } });
if (!l) throw new OpError("Unknown location", 404);
// Children and placements survive: the children move up to this location's parent and the
// garments become unplaced, so deleting a shelf never quietly loses a bay or a garment.
await prisma.$transaction(async (tx) => {
await tx.location.updateMany({ where: { parentId: l.id }, data: { parentId: l.parentId } });
await tx.location.delete({ where: { id: l.id } });
});
return { ok: true };
}
case "location.place": {
// Give variants a home shelf. Coordinators do this while they tidy, so it isn't admin-only.
const raw = Array.isArray(p.lines) ? p.lines : [{ itemId: p.itemId, si: p.si }];
const locationId = str(p.locationId) || null;
if (locationId) {
const l = await prisma.location.findFirst({ where: { id: locationId, facilityId: fid } });
if (!l) throw new OpError("Unknown location", 404);
}
// Checked before anything is written, because the sizes may have moved under the screen that
// sent this: another coordinator removing a size shifts every later size down one, so a Stock
// page opened before that still shows the old run and its last row now points past the end.
// Writing that placement creates a stock level for a size that doesn't exist — variantList()
// only walks it.sizes, so it is invisible on every screen, the shelf label she meant to set is
// never set, and the garment reads as unplaced on the next stocktake of that shelf.
const places: { itemId: string; si: number }[] = [];
for (const r of raw) {
const itemId = str(r.itemId), si = int(r.si, -1);
if (!itemId || si < 0) continue;
const it = await ownItem(prisma, fid, itemId);
if (si >= it.sizes.length) throw new OpError(`That size isn't on the ${it.item} any more — reload the page and place it again.`);
places.push({ itemId, si });
}
let n = 0;
for (const { itemId, si } of places) {
await upsertLevel(prisma, fid, itemId, si, {});
await prisma.stockLevel.update({ where: { itemId_sizeIndex: { itemId, sizeIndex: si } }, data: { locationId } });
n++;
}
if (!n) throw new OpError("Nothing to place");
return { placed: n };
}
// ---------- size exchange: one movement, not a return followed by an issue
case "issue.exchange": {
const old = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } });
if (!old) throw new OpError("Unknown issue", 404);
if (old.returnedDate) throw new OpError("That garment has already been returned");
if (old.handedIn) throw new OpError("That garment was handed in on " + old.handedIn);
const si = int(p.si, -1);
if (si === old.sizeIndex) throw new OpError("Pick a different size");
const snap = await buildSnapshot(user);
const it = snap.catalog.find((x) => x.id === old.itemId);
if (!it) throw new OpError("Unknown garment", 404);
if (si < 0 || si >= it.sizes.length) throw new OpError("Unknown size");
const qty = Math.min(old.qty, Math.max(1, int(p.qty, old.qty)));
const k = key(it.id, si);
if (!old.preloved && qty > onhand(snap, ledger(snap), k)) throw new OpError(`Not enough size ${it.sizes[si]} on the shelf`);
if (old.preloved && qty > plOf(snap, k)) throw new OpError(`Not enough pre-loved size ${it.sizes[si]} in the pool`);
const staff = await ownStaff(prisma, fid, old.staffId);
await lockedTx(fid, async (tx) => {
const fresh = await buildSnapshot(user, tx);
if (!old.preloved && qty > onhand(fresh, ledger(fresh), k)) throw new OpError("Someone just took that size — refresh and try again");
if (old.preloved && qty > plOf(fresh, k)) throw new OpError("The pre-loved pool just changed — refresh and try again");
if (qty < old.qty) {
// Only part of the line is swapped: split it so the rest stays out with the staff member.
await tx.issue.update({ where: { id: old.id }, data: { qty: old.qty - qty } });
await tx.issue.create({ data: { facilityId: fid, date: old.date, staffId: old.staffId, itemId: old.itemId, sizeIndex: old.sizeIndex, qty, cond: old.cond, cost: old.cost, orderCode: old.orderCode, preloved: old.preloved, override: old.override, offGroup: old.offGroup, offStyle: old.offStyle, direct: old.direct, returnedDate: today, returnedCond: "Returned - Good" } });
} else {
await tx.issue.update({ where: { id: old.id }, data: { returnedDate: today, returnedCond: "Returned - Good" } });
}
if (old.preloved) await poolAdd(tx, fid, old.itemId, old.sizeIndex, qty);
// The other size of the same garment, so whatever took it outside their staff group or their
// cut still stands: the new row carries the old one's offGroup and offStyle, as the split
// above does.
await tx.issue.create({ data: { facilityId: fid, date: today, staffId: old.staffId, itemId: old.itemId, sizeIndex: si, qty, cond: old.preloved ? "Pre-loved" : "New", cost: old.preloved ? 0 : it.cost, preloved: old.preloved, offGroup: old.offGroup, offStyle: old.offStyle } });
if (old.preloved) await poolAdd(tx, fid, old.itemId, si, -qty);
// The wrong size is on the staff record too, or they'll be handed it again next time.
if (p.updateSizes !== false) {
const size = String(it.sizes[si]);
if (isTopItem(it) && staff.top !== size) await tx.staff.update({ where: { id: staff.id }, data: { top: size } });
if (isPantItem(it) && staff.pants !== size) await tx.staff.update({ where: { id: staff.id }, data: { pants: size } });
}
});
return { size: String(it.sizes[si]), qty };
}
// ---------- users
case "users.add": {
admin(user);
const email = str(p.email, 160).trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new OpError("Valid email required");
if (str(p.password).length < 8) throw new OpError("Password must be at least 8 characters");
if (await prisma.user.findUnique({ where: { email } })) throw new OpError("That email already has an account");
const u = await prisma.user.create({ data: { facilityId: fid, email, passwordHash: await bcrypt.hash(str(p.password, 200), 12), first: str(p.first, 80).trim() || "New", last: str(p.last, 80).trim() || "User", title: str(p.title, 80), role: p.role === "ADMIN" ? "ADMIN" : "ISSUER" } });
return { id: u.id };
}
case "users.update": {
admin(user);
const u = await prisma.user.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!u) throw new OpError("Unknown user", 404);
const data: Prisma.UserUpdateInput = {};
if (p.first !== undefined) data.first = str(p.first, 80).trim() || u.first;
if (p.last !== undefined) data.last = str(p.last, 80).trim() || u.last;
if (p.title !== undefined) data.title = str(p.title, 80);
if (p.role !== undefined) {
const role = p.role === "ADMIN" ? "ADMIN" : "ISSUER";
if (role === "ISSUER" && u.role === "ADMIN" && !u.inactive) { const admins = await prisma.user.count({ where: { facilityId: fid, role: "ADMIN", inactive: false } }); if (admins <= 1) throw new OpError("Keep at least one active admin"); }
data.role = role;
}
if (p.inactive === false && u.inactive) data.inactive = false; // reactivate
// The fire escape when the facility requires single sign-on: an admin who keeps a password.
if (p.ssoBreakGlass !== undefined) { if (u.role !== "ADMIN" && p.role !== "ADMIN") throw new OpError("Only an admin can be the break-glass account"); data.ssoBreakGlass = !!p.ssoBreakGlass; }
if (p.password !== undefined && p.password !== "") { if (str(p.password).length < 8) throw new OpError("Password must be at least 8 characters"); data.passwordHash = await bcrypt.hash(str(p.password, 200), 12); }
await prisma.user.update({ where: { id: u.id }, data });
return { ok: true };
}
case "users.remove": {
admin(user);
const u = await prisma.user.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!u) throw new OpError("Unknown user", 404);
if (u.id === user.id) throw new OpError("You can't remove yourself");
if (u.role === "ADMIN" && !u.inactive) { const admins = await prisma.user.count({ where: { facilityId: fid, role: "ADMIN", inactive: false } }); if (admins <= 1) throw new OpError("Keep at least one active admin"); }
// Soft removal: history is stamped with the user's name, and the account can be reactivated.
await prisma.user.update({ where: { id: u.id }, data: { inactive: true } });
return { ok: true };
}
// ---------- plan (Settings Plan). Both stay open in a read-only room: they are how it stops being one.
case "plan.billing": {
admin(user);
const email = str(p.email, 200).trim().toLowerCase();
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new OpError("That doesn't look like an email address");
await prisma.facility.update({ where: { id: fid }, data: { billingEmail: email } });
return { ok: true };
}
case "plan.invoice": {
/* "Request an invoice": a notice to the owner with the facility's name and plan, never the
* coordinator's. The invoice itself is raised and its payment recorded by ThreadCount.
* Three a day: the button is easy to press twice. */
admin(user);
if (!allow("invoice:" + fid, 3, 24 * 60 * 60 * 1000)) throw new OpError("An invoice has already been requested today — it is on its way.", 429);
const email = str(p.email, 200).trim().toLowerCase();
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new OpError("That doesn't look like an email address");
const to = email || fac.billingEmail;
if (!to) throw new OpError("Add a billing contact first — that is where the invoice goes.");
if (email && email !== fac.billingEmail) await prisma.facility.update({ where: { id: fid }, data: { billingEmail: email } });
const ent = entitlements(fac);
const want = str(p.plan, 40) === "health_service" ? "Health Service" : "Hosted Facility";
alertInvoiceRequested({ id: fid, name: fac.name, plan: `${ent.label} · ${ent.state}`, wants: `${want}, annual` });
return { ok: true };
}
case "me.profile": {
const data: Prisma.UserUpdateInput = {};
if (p.first !== undefined) { const v = str(p.first, 80).trim(); if (!v) throw new OpError("First name is required"); data.first = v; }
if (p.last !== undefined) { const v = str(p.last, 80).trim(); if (!v) throw new OpError("Last name is required"); data.last = v; }
if (p.title !== undefined) data.title = str(p.title, 80).trim();
await prisma.user.update({ where: { id: user.id }, data });
return { ok: true };
}
case "me.password": {
const u = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
if (!(await bcrypt.compare(str(p.current, 200), u.passwordHash))) throw new OpError("Current password doesn't match");
if (str(p.next).length < 8) throw new OpError("New password must be at least 8 characters");
const passwordHash = await bcrypt.hash(str(p.next, 200), 12);
await prisma.user.update({ where: { id: u.id }, data: { passwordHash } });
// Re-issued in the same breath, as the staff app does. A session token carries a fingerprint
// of the password hash, so a new password ends every session signed against the old one —
// which is the point, except that it also ends the one belonging to the person who just
// changed it. They were thrown out to /auth before the confirmation was readable, read it as
// a failure, and retyped the OLD password at the sign-in screen — each attempt counting
// against the login lockout. This is the one session that should survive.
await setSessionCookie(u.id, passwordHash);
return { ok: true };
}
case "me.deleteAccount": {
// Google Play requires a deletion path that actually completes. Removing the last active
// admin would otherwise orphan a facility — data nobody can reach and nobody can erase — so
// that case takes the whole facility with it, behind the password and the typed name.
const u = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
if (!(await bcrypt.compare(str(p.password, 200), u.passwordHash))) throw new OpError("That password doesn't match");
const isDemo = (await prisma.facility.findUniqueOrThrow({ where: { id: fid }, select: { isDemo: true } })).isDemo;
if (isDemo) throw new OpError("The demo facility is shared — it resets on its own every 20 minutes.");
const others = await prisma.user.count({ where: { facilityId: fid, inactive: false, id: { not: u.id } } });
const last = others === 0;
if (last && user.role !== "ADMIN") throw new OpError("You're the last person who can sign in, so deleting you would delete the facility — that needs an admin account.");
if (last && str(p.confirm).trim() !== fac.name) throw new OpError(`Type the facility name exactly — ${fac.name} — to confirm`);
if (last) {
// Everything hangs off Facility with onDelete: Cascade, so one delete takes the lot — and
// then the images, which the cascade cannot reach. Photo rows carry the only record of
// which files on disk belonged to this facility, so they go in the same breath: a
// signature or a photograph of somebody's damaged uniform must not outlive the deletion
// the user was told had happened.
await prisma.facility.delete({ where: { id: fid } });
await deletePhotoDir(fid);
return { deleted: "facility", facility: fac.name };
}
// Somebody else still runs this facility: only the person goes. History keeps the name it was
// stamped with at the time, which is what an audit trail is for.
if (u.role === "ADMIN") {
const admins = await prisma.user.count({ where: { facilityId: fid, role: "ADMIN", inactive: false, id: { not: u.id } } });
if (admins === 0) throw new OpError("You're the only admin left. Make someone else an admin first, or delete the whole facility.");
}
await prisma.user.delete({ where: { id: u.id } });
return { deleted: "user" };
}
// ---------- import / data
case "import.rows": return importRows(user, str(p.kind), Array.isArray(p.rows) ? p.rows : []);
case "data.wipeActivity": {
admin(user);
if (p.confirm !== "WIPE") throw new OpError("Type WIPE to confirm");
const wiped = await prisma.$transaction(async (tx) => {
await tx.issue.deleteMany({ where: { facilityId: fid } });
await tx.pickup.deleteMany({ where: { facilityId: fid } });
await tx.order.deleteMany({ where: { facilityId: fid } });
await tx.stocktake.deleteMany({ where: { facilityId: fid } });
await tx.stockMove.deleteMany({ where: { facilityId: fid } });
await tx.approval.deleteMany({ where: { facilityId: fid } });
await tx.alteration.deleteMany({ where: { facilityId: fid } });
await tx.handIn.deleteMany({ where: { facilityId: fid } });
// The staff app's side of the same activity. Leaving it behind is what made the screens
// disagree: a wearer's collected orders still listed while the issues behind them were
// gone, an open kit check still asking about holdings that no longer exist, and a request
// queue against a facility that has just been cleared.
await tx.request.deleteMany({ where: { facilityId: fid } }); // events and messages cascade
await tx.waitlistEntry.deleteMany({ where: { facilityId: fid } });
await tx.kitCheck.deleteMany({ where: { facilityId: fid } }); // answers cascade
await tx.damageReport.deleteMany({ where: { facilityId: fid } });
await tx.recordDispute.deleteMany({ where: { facilityId: fid } });
await tx.linenNotice.deleteMany({ where: { facilityId: fid } });
const paths = await purgePhotoRows(tx, fid);
await tx.stockLevel.updateMany({ where: { facilityId: fid }, data: { adj: 0, preloved: 0 } });
// Request numbering restarts with the order numbering, so R-0001 and ORD-YYYY-0001 mean
// the same thing again after a wipe.
await tx.facility.update({ where: { id: fid }, data: { orderSeq: 0, requestSeq: 0 } });
return paths;
}, { timeout: 120000 });
await unlinkAll(wiped);
return { ok: true };
}
case "data.reset": {
// Start fresh: everything the facility has entered goes, logins and the facility's own settings
// stay. Deliberately harder to reach than wipeActivity — this also takes the catalogue and staff.
admin(user);
if (p.confirm !== "RESET") throw new OpError("Type RESET to confirm");
const reset = await prisma.$transaction(async (tx) => {
await tx.issue.deleteMany({ where: { facilityId: fid } });
await tx.pickup.deleteMany({ where: { facilityId: fid } });
await tx.order.deleteMany({ where: { facilityId: fid } });
await tx.stocktake.deleteMany({ where: { facilityId: fid } });
await tx.handIn.deleteMany({ where: { facilityId: fid } });
const paths = await purgePhotoRows(tx, fid);
await tx.stockMove.deleteMany({ where: { facilityId: fid } });
await tx.stockLevel.deleteMany({ where: { facilityId: fid } });
await tx.barcode.deleteMany({ where: { facilityId: fid } });
await tx.approval.deleteMany({ where: { facilityId: fid } });
await tx.alteration.deleteMany({ where: { facilityId: fid } });
await tx.catalogItem.deleteMany({ where: { facilityId: fid } });
await tx.department.deleteMany({ where: { facilityId: fid } });
await tx.staff.deleteMany({ where: { facilityId: fid } });
await tx.location.deleteMany({ where: { facilityId: fid } });
// Requests, the waitlist, kit-check answers, damage reports, disputes and staff accounts
// all cascade from the staff and catalogue rows above. These two hang off the facility
// alone, so a reset would otherwise leave an open kit check and a notice board addressing
// a facility that no longer has anybody to answer them.
await tx.kitCheck.deleteMany({ where: { facilityId: fid } });
await tx.linenNotice.deleteMany({ where: { facilityId: fid } });
if (p.keepSuppliers !== true) await tx.supplier.deleteMany({ where: { facilityId: fid } });
// Numbering restarts too, so the first order of the fresh facility is ORD-YYYY-0001.
await tx.facility.update({ where: { id: fid }, data: { orderSeq: 0, catalogSeq: 0, requestSeq: 0 } });
return paths;
}, { timeout: 120000 });
await unlinkAll(reset);
return { ok: true };
}
default:
throw new OpError(`Unknown op ${op}`, 404);
}
}
// CSV / row importers. Row keys are lower-case headers.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function importRows(user: SessionUser, kind: string, rows: any[]) {
admin(user);
const fid = user.facilityId;
// `styles` counts the uniform styles the staff import set, and stays 0 for every other kind. It is
// declared here beside the rest because they are all reported back from the one return below.
let created = 0, updated = 0, skipped = 0, styles = 0; const errors: string[] = [];
if (rows.length > 20000) throw new OpError("Import at most 20,000 rows at a time");
const g = (r: Record<string, unknown>, ...ks: string[]) => { for (const k of ks) { const v = r[k]; if (v !== undefined && v !== null && String(v).trim() !== "") return String(v).trim().slice(0, 400); } return ""; };
const norm = (r: Record<string, unknown>) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k.toLowerCase().replace(/[^a-z0-9]/g, ""), v]));
if (kind === "catalog") {
const existing = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
for (const [i, raw] of rows.entries()) {
const r = norm(raw);
const item = g(r, "item", "name", "itemname", "garment"); if (!item) { skipped++; continue; }
const genderRaw = g(r, "gender").toLowerCase();
const gender = genderRaw.startsWith("m") ? "Male" : genderRaw.startsWith("f") || genderRaw.startsWith("w") ? "Female" : "Unisex";
const sku = g(r, "sku", "code", "productcode");
const sizes = g(r, "sizes", "size").split(/[|,;/]/).map((s) => s.trim()).filter(Boolean);
const cost = parseFloat(g(r, "cost", "unitcost", "price").replace(/[^0-9.]/g, "")) || 0;
const supplierIn = g(r, "supplier", "vendor");
// Blank means "don't change it", not "All". A supplier price list carries item, sku and cost
// and no group column at all, and re-importing one used to move every matched garment back
// to All — which silently re-scopes what the Issue screen offers each staff group.
// Several groups are separated by |; "All" is every group, stored as no list at all.
const groupIn = g(r, "group", "staffgroup");
const groups = garmentGroups(groupIn.split("|"));
const notes = g(r, "notes", "note");
const type = g(r, "type", "producttype", "garmenttype").slice(0, 40);
try {
// The directory's spelling, not the spreadsheet's: a price list that writes the supplier in
// lower case must not leave the garment pointing at a name the supplier directory doesn't hold.
const supplier = supplierIn ? await ensureSupplier(prisma, fid, supplierIn) : "";
const ex = existing.find((e) => e.item === item && e.gender === gender && (sku ? e.sku === sku : true));
if (ex) {
const merged = [...ex.sizes]; for (const s of sizes) if (!merged.includes(s)) merged.push(s);
const patch = { sku: sku || ex.sku, cost: cost || ex.cost, supplier: supplier || ex.supplier, groups: groupIn ? groups : ex.groups, notes: notes || ex.notes, type: type || ex.type, sizes: merged };
await prisma.catalogItem.update({ where: { id: ex.id }, data: patch });
// Folded back into the list this loop matches against, because one garment routinely spans
// several rows — a supplier price list has a row per SKU-size, and the templates are all
// one row per size. `existing` is read once before the loop, so without this the next row
// for the same garment merges its size into the sizes the item had BEFORE the import and
// writes that back: every size but the last is silently dropped, and a nurse who wears M
// can never be issued one although the CSV plainly listed it.
Object.assign(ex, patch);
updated++;
} else {
if (!sizes.length) { errors.push(`Row ${i + 1}: ${item} has no sizes`); skipped++; continue; }
await prisma.$transaction(async (tx) => { const sort = await nextSort(tx, fid); const c = await tx.catalogItem.create({ data: { facilityId: fid, sort, item, gender, type, sku, supplier, cost, groups, notes, sizes } }); existing.push(c); });
created++;
}
} catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
}
} else if (kind === "staff") {
// Counted and reported back, because `gender` is among the column names this reads: a coordinator
// re-importing a roster export sets the cut for the whole register in one go, and a change that
// size should say how many records it touched rather than happen quietly. The counter itself is
// declared with the others at the top, because the return that reports it is shared by every kind.
const depts = await prisma.department.findMany({ where: { facilityId: fid } });
// The plan's ceiling, read once: rows past it are skipped and the refusal reported once, so a
// roster of eighty into a free room still updates the sixty already there.
const { room: staffRoomLeft, max: staffMax } = await staffRoom(fid);
let ceilingNoted = false;
for (const [i, raw] of rows.entries()) {
const r = norm(raw);
const numv = g(r, "num", "staffnumber", "staffno", "number", "payroll", "payrollnumber", "id");
let first = g(r, "first", "firstname", "given"); let last = g(r, "last", "lastname", "surname", "family");
const name = g(r, "name", "fullname"); if (!first && !last && name) { const parts = name.split(/\s+/); first = parts.shift() || ""; last = parts.join(" "); }
if (!numv || !first) { skipped++; continue; }
const dept = g(r, "dept", "department", "ward", "wardunit"); const cc = g(r, "cc", "costcentre", "costcenter", "departmentcostcentre");
if (dept && cc && !depts.find((d) => d.name === dept)) { const d = await prisma.department.create({ data: { facilityId: fid, name: dept, cc, sort: depts.length } }); depts.push(d); }
const entRaw = g(r, "ent", "entitlement", "annualentitlement");
const entN = parseInt(entRaw, 10);
// A start date typed in a spreadsheet arrives as whatever the machine's locale writes.
// Day-first is converted; anything else is reported and left blank rather than stored as
// text the register would then render as "Invalid Date" for the rest of the person's career.
const startRaw = g(r, "start", "startdate", "commenced");
const start = isoDate(startRaw);
if (startRaw && !start) errors.push(`Row ${i + 1}: start date “${startRaw}” isn't a date — write it as YYYY-MM-DD. Left blank.`);
// Combined FTE, off whatever the roster export calls the column. Whatever fraction the export
// carries is kept — a roster is where these figures come from, and the table reads one it has
// no row for as the band it falls in. A cell that is no fraction at all is reported and left
// blank, the way a bad start date is: it proposes no kit, and a whole register import is not
// worth failing over one odd cell — but nor should it pass silently, because the kit an FTE
// works out to is the number a manager will be asked to sign for.
const fteRaw = g(r, "fte", "combinedfte", "totalcombinedfte", "totalfte", "employmentfraction", "fraction");
const fteIn = normalFte(fteRaw);
if (fteRaw && fteIn === null) errors.push(`Row ${i + 1}: FTE “${fteRaw}” isn't a fraction — write ${FTE_ALLOWED}. Left blank.`);
// Which cut of uniform this person is offered, off whatever the file calls the column —
// `gender` among the names, because a register exported from a payroll or a roster system is
// the file this gets loaded from and that is the header it will have. The words are read
// through normalUniformStyle(), so "M", "Male" and "Men's" all land on the same value.
//
// A cell the rule can't read is reported and left blank, the way a bad FTE is: blank is what
// every record is today and it offers every style, so nothing is refused at the counter over
// it — but nor does it pass in silence, because a coordinator who meant to set the whole
// register would otherwise never learn that a column of "M/F" spelt some other way did nothing.
const styleRaw = g(r, "style", "uniformstyle", "uniform", "cut", "gender");
const styleIn = normalUniformStyle(styleRaw);
if (styleRaw && styleIn === null) errors.push(`Row ${i + 1}: uniform style “${styleRaw}” isn't ${UNIFORM_STYLES.join(", ")} or blank. Left blank.`);
const data = { first, last: last || "—", phone: g(r, "phone", "mobile", "contact"), group: g(r, "group", "staffgroup", "classification"), dept, top: g(r, "top", "topsize", "shirt", "shirtsize"), pants: g(r, "pants", "pantsize", "pant", "trouser"), ccOverride: g(r, "ccoverride", "costcentreoverride"), ent: Number.isFinite(entN) ? entN : null, fte: fteIn ?? "", uniformStyle: styleIn ?? "", start, notes: g(r, "notes", "note") };
try {
const ex = await prisma.staff.findFirst({ where: { facilityId: fid, num: numv } });
if (ex) {
// Re-import only overwrites columns the row actually provides — blank cells keep the existing value.
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) if (v !== "" && v !== null && v !== "—") patch[k] = v;
await prisma.staff.update({ where: { id: ex.id }, data: patch }); updated++;
if (patch.uniformStyle) styles++;
} else {
if (staffRoomLeft !== null && created >= staffRoomLeft) {
if (!ceilingNoted) { errors.push(staffRefusal(staffMax!)); ceilingNoted = true; }
skipped++; continue;
}
await prisma.staff.create({ data: { facilityId: fid, num: numv, ...data } }); created++; if (styleIn) styles++;
}
} catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
}
// Managers are linked in a second pass, on purpose: a manager can appear further down the same
// file than the people reporting to them, so nothing can be linked until every row exists.
// The column holds a staff number, not a name — names repeat on a big register, and a wrong
// link here would send somebody's approvals to a stranger.
const byNum = new Map((await prisma.staff.findMany({ where: { facilityId: fid }, select: { id: true, num: true } })).map((x) => [x.num, x.id] as const));
for (const [i, raw] of rows.entries()) {
const r = norm(raw);
const numv = g(r, "num", "staffnumber", "staffno", "number", "payroll", "payrollnumber", "id");
const mgr = g(r, "manager", "managernum", "managernumber", "approver", "reportsto");
if (!numv || !mgr) continue;
const meId = byNum.get(numv), mgrId = byNum.get(mgr);
if (!meId) continue;
if (!mgrId) { errors.push(`Row ${i + 1}: no staff member with number ${mgr} to be the manager`); continue; }
// A row naming its own staff number as the manager is kept as written: anybody may be their
// own manager, and staff.patch sets it the same way.
try { await prisma.staff.update({ where: { id: meId }, data: { managerId: mgrId } }); }
catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
}
} else if (kind === "depts") {
for (const [i, raw] of rows.entries()) {
const r = norm(raw);
const name = g(r, "dept", "department", "name", "ward"); const cc = g(r, "cc", "costcentre", "costcenter", "code");
if (!name) { skipped++; continue; }
// A blank cost-centre cell means "not in this file", not "clear it" — the same rule the staff
// importer follows. A ward that had no cost centre when the list was first imported and had
// one typed in on Settings afterwards was losing it again the next time the unedited file was
// re-imported to add a couple of wards, and every issue on that ward then reported with an
// empty cost centre and landed in the monthly journal under UNALLOCATED.
try { const ex = await prisma.department.findFirst({ where: { facilityId: fid, name } }); if (ex) { await prisma.department.update({ where: { id: ex.id }, data: { cc: cc || ex.cc, sort: i } }); updated++; } else { await prisma.department.create({ data: { facilityId: fid, name, cc, sort: i } }); created++; } }
catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
}
} else if (kind === "barcodes" || kind === "opening" || kind === "reorder") {
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
for (const [i, raw] of rows.entries()) {
const r = norm(raw);
const sku = g(r, "sku", "code", "productcode"), item = g(r, "item", "name", "itemname", "garment"), size = g(r, "size"), genderRaw = g(r, "gender").toLowerCase();
const gender = genderRaw ? (genderRaw.startsWith("m") ? "Male" : genderRaw.startsWith("f") || genderRaw.startsWith("w") ? "Female" : "Unisex") : "";
const cands = items.filter((it) => (sku ? it.sku === sku : true) && (item ? it.item === item : true) && (gender ? it.gender === gender : true));
if (cands.length !== 1) { errors.push(`Row ${i + 1}: ${cands.length === 0 ? "no" : "several"} catalogue items match sku=${sku || "?"} item=${item || "?"} gender=${gender || "?"}`); skipped++; continue; }
const it = cands[0]; const si = it.sizes.map(String).indexOf(size);
if (si < 0) { errors.push(`Row ${i + 1}: size ${size} not on ${it.item}`); skipped++; continue; }
try {
if (kind === "barcodes") {
const code = g(r, "barcode", "ean", "code128", "scan"); if (!code) { skipped++; continue; }
// A supplier price list often carries one code per STYLE rather than per size, so the
// same number arrives on the S, M and L rows. The upsert is keyed on the code alone, so
// it just walked the single binding down the rows: S and M ended up unlabelled while L
// looked fine, and every garment on the rack then scanned as an L — tallying against L in
// a stocktake and decrementing L when a size S was handed over. Refused here, naming
// where the code already sits, so the coordinator can see the list is per style.
await assertBindable(prisma, fid, code, it.id, si, false);
await prisma.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code } }, create: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "supplier" }, update: { itemId: it.id, sizeIndex: si } });
}
else if (kind === "opening") {
// A blank quantity is "I haven't counted this shelf", not "there are none". These sheets
// are usually every size of every garment with only this week's recount filled in, and
// writing 0 for the rest wiped opening balances set months earlier through the UI or a
// previous import — the shelf reads empty with garments on it, the row is counted as a
// success, and the reorder engine starts ordering stock the room already has.
const qRaw = g(r, "opening", "qty", "quantity", "onhand", "count");
const ro = g(r, "reorder", "reorderat", "reorderlevel");
const q = parseInt(qRaw, 10);
if (qRaw && !Number.isFinite(q)) { errors.push(`Row ${i + 1}: opening “${qRaw}” isn't a number — that size was left as it was`); skipped++; continue; }
if (!qRaw && !ro) { skipped++; continue; }
if (qRaw) await upsertLevel(prisma, fid, it.id, si, { opening: Math.max(0, q) });
if (ro) await upsertLevel(prisma, fid, it.id, si, { reorder: Math.max(0, parseInt(ro, 10) || 0) });
}
else { const ro = parseInt(g(r, "reorder", "reorderat", "reorderlevel", "level"), 10); if (!Number.isFinite(ro)) { skipped++; continue; } await upsertLevel(prisma, fid, it.id, si, { reorder: Math.max(0, ro) }); }
created++;
} catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
}
} else throw new OpError("Unknown import kind");
return { created, updated, skipped, styles, errors: errors.slice(0, 40) };
}
export async function exportBackup(user: SessionUser) {
const fid = user.facilityId;
const [fac, items, barcodes, stock, moves, depts, suppliers, staff, approvals, alterations, issues, orders, pickups, stocktakes, handins, photos, locations, costs, requests, waitlist, kitChecks, damage, disputes, notices] = await Promise.all([
prisma.facility.findUniqueOrThrow({ where: { id: fid } }),
prisma.catalogItem.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
prisma.barcode.findMany({ where: { facilityId: fid } }),
prisma.stockLevel.findMany({ where: { facilityId: fid } }),
prisma.stockMove.findMany({ where: { facilityId: fid } }),
prisma.department.findMany({ where: { facilityId: fid } }),
// In the directory's own order: the first supplier is the facility's default, and the file has
// to carry that rather than whatever order the rows happen to come back in.
prisma.supplier.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
prisma.staff.findMany({ where: { facilityId: fid } }),
prisma.approval.findMany({ where: { facilityId: fid } }),
prisma.alteration.findMany({ where: { facilityId: fid } }),
prisma.issue.findMany({ where: { facilityId: fid } }),
prisma.order.findMany({ where: { facilityId: fid }, include: { lines: true, receipts: { include: { lines: true } } } }),
prisma.pickup.findMany({ where: { facilityId: fid }, include: { lines: true } }),
prisma.stocktake.findMany({ where: { facilityId: fid }, include: { lines: true } }),
prisma.handIn.findMany({ where: { facilityId: fid }, include: { lines: true } }),
prisma.photo.findMany({ where: { facilityId: fid } }),
prisma.location.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
prisma.costChange.findMany({ where: { facilityId: fid }, orderBy: { at: "asc" } }),
// The staff app's half of the facility. It used to be missing entirely, which made "one JSON
// file with everything in it" untrue in the worst possible way: restoring wiped every request,
// every message thread and every manager link, and none of it was in the file to come back.
prisma.request.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" }, include: { lines: { orderBy: { sort: "asc" } }, events: { orderBy: { at: "asc" } }, messages: { orderBy: { createdAt: "asc" } } } }),
prisma.waitlistEntry.findMany({ where: { facilityId: fid } }),
prisma.kitCheck.findMany({ where: { facilityId: fid }, include: { answers: true } }),
prisma.damageReport.findMany({ where: { facilityId: fid } }),
prisma.recordDispute.findMany({ where: { facilityId: fid } }),
prisma.linenNotice.findMany({ where: { facilityId: fid } }),
]);
/* Backups stay self-contained: images are read back off disk and embedded, so the format is the
* same as before the move and "take the data and go" still means everything.
*
* Everything except the images beyond what a restore will take back. A room that signs for a
* delivery round every day passes the 2000-photo cap inside a year or two and the import POST's
* size limit well before that, and neither end said a word: the export succeeded, Settings
* showed "Last backup: today", and the file turned out to be unrestorable months later when it
* was the only copy left. Records are the point of a backup and images are attachments to them,
* so the newest images travel, the rest are counted in `photosOmitted`, and the file restores. */
const newestFirst = [...photos].sort((a, c) => c.createdAt.getTime() - a.createdAt.getTime());
const photosOut: (typeof photos[number] & { data: string })[] = [];
let budget = BACKUP_PHOTO_BYTES, photosOmitted = 0;
for (const ph of newestFirst) {
const data = ph.path ? (await photoAsDataUrl(ph.path, ph.mime)) ?? "" : ph.data;
if (!data || photosOut.length >= BACKUP_PHOTO_MAX || data.length > budget) { photosOmitted++; continue; }
budget -= data.length;
photosOut.push({ ...ph, data });
}
await prisma.facility.update({ where: { id: fid }, data: { lastBackup: facilityToday(fac.timezone) } });
// Staff accounts are deliberately absent: the row is an email and a password hash, and a backup
// is a file that gets emailed to people. They survive a restore in place instead — see
// restoreBackup, which re-attaches them by staff number.
// Each garment carries its `groups` list and, beside it, `group` written the way the catalogue CSV
// writes it — the names joined by |, or All — so a file read by hand or by an older build still
// says who the garment is for. Restore reads the list whenever it is there.
// The plan label and note are ThreadCount's notes about the facility, not the
// facility's own record: they stay out of the file, and restore never reads them either.
const { plan: _plan, planNote: _planNote, ...facOut } = fac;
void _plan; void _planNote;
return { format: "threadcount-backup-v2", exportedAt: new Date().toISOString(), photosOmitted, facility: facOut, items: items.map((it) => ({ ...it, group: it.groups.length ? it.groups.join("|") : "All" })), barcodes, stock, moves, depts, suppliers, staff, approvals, alterations, issues, orders, pickups, stocktakes, handins, photos: photosOut, locations, costs, requests, waitlist, kitChecks, damage, disputes, notices };
}
/** How the starting kit was decided before a facility could list its own kit groups: any group with
* these letters in its name. Read only off a backup file written under that rule, so that it
* restores exactly as it ran. Nothing live asks it. */
const KIT_BEFORE_THE_LIST = /operational/i;
/* The FTE-table groups a facility was assumed to have before it could name its own — what an empty
* list stood for in a backup written before the starting-kit list existed. Only restore reads it, to
* read such a file the way the facility that wrote it was running; nothing offers these names to
* anybody, and a facility created now starts with an empty list that stays empty until it says. */
const NURSING_BEFORE_THE_LIST = ["Registered Nurse", "Enrolled Nurse", "Assistant in Nursing", "USINS"];
// Full restore into the current facility: wipes facility data (not users) and re-creates with fresh ids.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function restoreBackup(user: SessionUser, b: any) {
const cap = (arr: unknown, n: number, what: string) => { if (Array.isArray(arr) && arr.length > n) throw new OpError(`Backup has too many ${what} (${arr.length} > ${n})`); };
// Photos are trimmed rather than refused, unlike every other cap here. The rest of the file is
// the facility's records; images are attachments to them, and turning away a year of issues,
// orders and requests because an older file carries more signatures than we will take back
// leaves the room with nothing at all. The extras are dropped and counted, the records come back.
const photoRows: { id?: unknown; kind?: unknown; data?: unknown; createdAt?: unknown }[] = Array.isArray(b?.photos) ? b.photos.slice(0, BACKUP_PHOTO_MAX) : [];
const photosSkipped = Array.isArray(b?.photos) ? b.photos.length - photoRows.length : 0;
cap(b?.items, 5000, "items"); cap(b?.staff, 20000, "staff"); cap(b?.issues, 300000, "issues"); cap(b?.orders, 50000, "orders"); cap(b?.stocktakes, 5000, "stocktakes"); cap(b?.pickups, 50000, "pickups"); cap(b?.handins, 50000, "hand-ins"); cap(b?.moves, 100000, "moves"); cap(b?.barcodes, 50000, "barcodes"); cap(b?.stock, 100000, "stock lines"); cap(b?.locations, 5000, "locations"); cap(b?.costs, 100000, "cost changes");
cap(b?.requests, 200000, "requests"); cap(b?.waitlist, 50000, "waitlist entries"); cap(b?.kitChecks, 5000, "kit checks"); cap(b?.damage, 50000, "damage reports"); cap(b?.disputes, 50000, "queries"); cap(b?.notices, 1000, "notices");
admin(user);
if (!b || !["threadcount-backup-v1", "threadcount-backup-v2"].includes(b.format) || !Array.isArray(b.items)) throw new OpError("Not a ThreadCount backup file");
const fid = user.facilityId;
// Reached from the route directly, not through runOp, so the plan is asked here. A restore is the
// one write that could carry a whole register past the free tier's ceiling in one go.
const planRow = await prisma.facility.findUniqueOrThrow({ where: { id: fid }, select: PLAN_COLS });
const ent = entitlements(planRow);
if (ent.readOnly) throw new OpError(READ_ONLY_REFUSAL, 403);
if (ent.maxStaff !== null && Array.isArray(b.staff) && b.staff.length > ent.maxStaff) throw new OpError(`This backup holds ${b.staff.length} staff records and the plan allows ${ent.maxStaff}. Settings Plan has the next step.`, 403);
// Every image this restore lays down, so a restore that doesn't commit can take them back off
// disk again — see the catch below.
const written: string[] = [];
const orphanedFiles = await prisma.$transaction(async (tx) => {
// Read before anything is deleted. Staff accounts are the one part of the staff app that is
// not in the file and must never be — the row is an email and a password hash — so they are
// carried across the restore in place and re-attached by staff number, the only identifier
// that survives it. Without this a restore silently ends every wearer's login.
const keptAccounts = (await tx.staffAccount.findMany({ where: { facilityId: fid }, include: { staff: { select: { num: true } } } }))
.map((a) => ({ num: a.staff.num, email: a.email, passwordHash: a.passwordHash, createdAt: a.createdAt, lastSeenAt: a.lastSeenAt }));
const ourUsers = new Set((await tx.user.findMany({ where: { facilityId: fid }, select: { id: true } })).map((u) => u.id));
await tx.issue.deleteMany({ where: { facilityId: fid } });
await tx.pickup.deleteMany({ where: { facilityId: fid } });
await tx.order.deleteMany({ where: { facilityId: fid } });
await tx.stocktake.deleteMany({ where: { facilityId: fid } });
await tx.handIn.deleteMany({ where: { facilityId: fid } });
const orphans = await purgePhotoRows(tx, fid);
await tx.stockMove.deleteMany({ where: { facilityId: fid } });
await tx.stockLevel.deleteMany({ where: { facilityId: fid } });
await tx.barcode.deleteMany({ where: { facilityId: fid } });
await tx.approval.deleteMany({ where: { facilityId: fid } });
await tx.alteration.deleteMany({ where: { facilityId: fid } });
// The staff app's tables go explicitly rather than by cascade, so that what a restore removes
// is written down here where anyone reading it can see the whole list.
await tx.request.deleteMany({ where: { facilityId: fid } }); // events and messages cascade
await tx.waitlistEntry.deleteMany({ where: { facilityId: fid } });
await tx.kitCheck.deleteMany({ where: { facilityId: fid } }); // answers cascade
await tx.damageReport.deleteMany({ where: { facilityId: fid } });
await tx.recordDispute.deleteMany({ where: { facilityId: fid } });
await tx.linenNotice.deleteMany({ where: { facilityId: fid } });
await tx.catalogItem.deleteMany({ where: { facilityId: fid } });
await tx.department.deleteMany({ where: { facilityId: fid } });
await tx.supplier.deleteMany({ where: { facilityId: fid } });
await tx.staff.deleteMany({ where: { facilityId: fid } });
await tx.location.deleteMany({ where: { facilityId: fid } });
const f = b.facility || {};
/* The in-house barcode counter travels with the file. Left at zero in a fresh facility, the
* first press of "Generate a barcode" mints 2900000000018 — a code the restore has just put
* back — then walks through 49 more that are all taken and gives up; and because the throw
* aborts the transaction, the 50 increments roll back too, so the next press does exactly the
* same thing. Generating a label becomes permanently impossible and unlabelled stock can never
* be scanned into a count. Floored at the highest 29… code actually restored as well as the
* stored counter, because a file written before this column existed carries no counter at all. */
const restoredSeq: number = (Array.isArray(b.barcodes) ? b.barcodes : []).reduce((mx: number, bc: { code?: unknown }) => {
const c = str(bc?.code, 64);
return isInHouse(c) ? Math.max(mx, int(c.slice(2, 12))) : mx;
}, 0);
/* Which route each group is on. The export writes the whole facility row, so a file from now on
* carries both lists and they come back as written.
*
* The FTE-table list is left as it is when the file hasn't got one, rather than emptied like
* staffGroups. Such a file was written before the list existed, when a different test decided
* who was on the table, so the facility's own list as it stands is a better answer than the
* file's silence — and an empty list now means no group on the table at all, which would take
* every first kit the table proposes away with it.
*
* The starting-kit list, when the file hasn't got one, is read the way that file's facility was
* running when it was written. Until the list existed the kit went to any group with
* "operational" in its name, so exactly those groups — in the file's settings and on its staff
* records — go on it, which is what the migration that added the list did to every facility
* already running. Left empty instead, a restore would put every starting-kit wearer on manager
* approval, at the one moment nobody is checking who is on what.
*
* A group on both lists — only possible in a file edited by hand — comes off the kit list. The
* FTE table wins wherever the two meet (allowanceRoute in lib/sets), and a stored overlap would
* have the next settings save refused over a group nobody touched. */
// A file written before the starting-kit list existed has no kitGroups key, and in those files an
// empty or missing FTE list meant the nursing groups the product used to assume. It is read that
// way here, as the migration read every facility already running: read literally, restoring an old
// file — onto a fresh signup especially — would put every nurse on manager approval. A file written
// since means exactly what it says.
const beforeTheList = !Array.isArray(f.kitGroups);
const fileNursing: string[] | null = Array.isArray(f.nursingGroups) ? f.nursingGroups.map((s: unknown) => str(s, 80)) : null;
const nursingRestored: string[] = beforeTheList && !(fileNursing && fileNursing.length)
? [...NURSING_BEFORE_THE_LIST]
: fileNursing ?? (await tx.facility.findUniqueOrThrow({ where: { id: fid }, select: { nursingGroups: true } })).nursingGroups;
const kitFrom: unknown[] = Array.isArray(f.kitGroups)
? f.kitGroups
: [...(Array.isArray(f.staffGroups) ? f.staffGroups : []), ...(Array.isArray(b.staff) ? b.staff.map((s: { group?: unknown }) => s?.group) : [])]
.filter((g) => KIT_BEFORE_THE_LIST.test(str(g, 80)));
const kitRestored: string[] = [];
for (const raw of kitFrom) {
const g = str(raw, 80).trim();
if (groupKey(g) && !isNursingGroup(nursingRestored, g) && !isKitGroup(kitRestored, g)) kitRestored.push(g);
}
// capSets travels because the whole facility row is exported: leave it out of this half and a
// restore quietly hands everybody back the standing six, which is a different answer from the
// one the facility agreed with its managers. An older file carries no ceiling at all, and for
// that one the standing six is the honest reading.
await tx.facility.update({ where: { id: fid }, data: { barcodeSeq: Math.max(int(f.barcodeSeq, 0), restoredSeq), name: str(f.name, 120) || undefined, location: str(f.location, 120), coordinator: str(f.coordinator, 120), coordinatorEmail: str(f.coordinatorEmail, 160), coordinatorPhone: str(f.coordinatorPhone, 40), defaultEntitlement: int(f.defaultEntitlement, 5), initialSets: setsOnStart(int(f.initialSets, 0)), capSets: setsCap(int(f.capSets, 0)), nursingGroups: nursingRestored, kitGroups: kitRestored, defaultReorder: int(f.defaultReorder, 3), exceptionHigh: int(f.exceptionHigh, 10), varianceReason: Math.max(1, int(f.varianceReason, 5)), glAccount: str(f.glAccount, 40), journalDesc: str(f.journalDesc, 120) || "Uniform issues", logoData: typeof f.logoData === "string" && f.logoData.length <= LOGO_MAX * 1.4 && /^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(f.logoData) ? f.logoData : "", staffGroups: Array.isArray(f.staffGroups) ? f.staffGroups.map((s: unknown) => str(s, 80)) : [], orderSeq: int(f.orderSeq, 0), catalogSeq: int(f.catalogSeq, 0), requestSeq: int(f.requestSeq, 0), timezone: zoneOf(f.timezone), slipCollectionFooter: str(f.slipCollectionFooter, 400), slipDeliveryFooter: str(f.slipDeliveryFooter, 400), slipOrg: str(f.slipOrg, 120), barcodeLookup: !!f.barcodeLookup } });
// Suppliers: v2 rows, or v1 facility.suppliers[] names.
const supNames: string[] = Array.isArray(b.suppliers) ? b.suppliers.map((s: { name: string }) => str(s.name, 80)) : Array.isArray(f.suppliers) ? f.suppliers.map((s: unknown) => str(s, 80)) : [];
// `sort` is carried across rather than re-derived from the array position. It decides the
// facility's default supplier — the one a new catalogue item and an auto-created replenishment
// order are addressed to — and the export reads the rows in whatever order Postgres hands them
// back, which changes the moment somebody edits a supplier's phone number. Re-numbering on the
// way in was quietly promoting whichever supplier had been edited most recently, so a restored
// facility started sending its replenishment orders to the wrong company.
for (const [i, name] of supNames.entries()) if (name) { const src = Array.isArray(b.suppliers) ? b.suppliers[i] : {}; await tx.supplier.create({ data: { facilityId: fid, name, contact: str(src.contact, 120), phone: str(src.phone, 120), account: str(src.account, 120), lead: src.lead === null || src.lead === undefined ? null : int(src.lead), sort: src && src.sort !== undefined && src.sort !== null ? int(src.sort, i) : i } }); }
const photoMap: Record<string, string> = {};
// A restored backup carries its images as base64; they land on disk like any new capture, so a
// restore doesn't quietly reintroduce the database bloat this move removed.
for (const ph of photoRows) {
const d = str(ph.data, PHOTO_MAX + 200);
const parsed = parseDataUrl(d);
if (!parsed) continue;
const c = await tx.photo.create({ data: { facilityId: fid, kind: str(ph.kind, 20) || "photo", mime: parsed.mime, bytes: parsed.bytes.length, createdAt: stamp(ph.createdAt) ?? undefined } });
const rel = await writePhoto(fid, c.id, parsed);
written.push(rel);
await tx.photo.update({ where: { id: c.id }, data: { path: rel } });
photoMap[String(ph.id)] = c.id;
}
const pm = (id: unknown): string | null => (id ? photoMap[String(id)] || null : null);
const itemMap: Record<string, string> = {}; const staffMap: Record<string, string> = {}; const orderMap: Record<string, string> = {};
const issueMap: Record<string, string> = {}; const requestMap: Record<string, string> = {};
// A garment's groups: the list a file written since carries, or else its `group` — one name in an
// older file, names joined by | as the CSV writes them — with "All" or blank being every group,
// which garmentGroups() makes the empty list.
for (const it of b.items) { const c = await tx.catalogItem.create({ data: { facilityId: fid, sort: int(it.sort), item: str(it.item, 160), gender: str(it.gender, 20), type: str(it.type, 40), sku: str(it.sku, 60), supplier: str(it.supplier, 80), cost: num(it.cost), groups: garmentGroups(Array.isArray(it.groups) ? it.groups.map((s: unknown) => str(s, 80)) : str(it.group, 4000).split("|")), notes: str(it.notes, 400), sizes: (it.sizes || []).map((s: unknown) => str(s, 20)), archived: !!it.archived } }); itemMap[it.id] = c.id; }
for (const d of b.depts || []) await tx.department.create({ data: { facilityId: fid, name: str(d.name, 120), cc: str(d.cc, 40), sort: int(d.sort) } });
// Activation codes are globally unique because a code has to identify a person before we know
// which facility they are in. This facility's own codes went with the staff rows above; a code
// still held by some other facility is dropped rather than failing the whole restore.
const wantedCodes = (b.staff || []).map((s: { activateCode?: unknown }) => str(s.activateCode, 40)).filter(Boolean);
const takenCodes = new Set(wantedCodes.length
? (await tx.staff.findMany({ where: { activateCode: { in: wantedCodes } }, select: { activateCode: true } })).map((x) => x.activateCode as string)
: []);
for (const s of b.staff || []) {
const code = str(s.activateCode, 40);
// FTE comes back exactly as the file has it, not re-judged on the way in. It was written by
// this app in the first place, and a restore that quietly blanks a figure it no longer likes
// would move that nurse onto no proposed kit at all with nothing on the screen to say so —
// the one failure a backup exists to prevent. Approval.fte has always been carried across
// this way for the same reason: it is a record of what was signed, not a fresh entry.
// Uniform style is read through the same normaliser the register and the importer use rather
// than taken verbatim, because only the words the rule knows decide anything. A file written
// before the field existed carries none, and those records come back blank — offered every
// style, which is exactly what they were being offered on the day the backup was written.
const c = await tx.staff.create({ data: { facilityId: fid, num: str(s.num, 40), first: str(s.first, 80), last: str(s.last, 80), phone: str(s.phone, 40), group: str(s.group, 80), dept: str(s.dept, 120), top: str(s.top, 20), pants: str(s.pants, 20), ccOverride: str(s.ccOverride, 40), inactive: !!s.inactive, ent: s.ent === null || s.ent === undefined ? null : int(s.ent), fte: str(s.fte, 20), uniformStyle: normalUniformStyle(s.uniformStyle) ?? "", start: isoDate(s.start), notes: str(s.notes, 2000), wardDesk: !!s.wardDesk, activateCode: code && !takenCodes.has(code) ? code : null, activateCodeAt: stamp(s.activateCodeAt) } });
staffMap[s.id] = c.id;
}
// Manager links in a second pass: a manager can appear later in the file than the people who
// report to them, and until every row exists there is nothing to point at. Without this a
// restored facility has nobody who can approve anything, so no request can be raised at all.
// Somebody who is their own manager comes back as their own manager — staffMap maps their id
// to their own new row on both sides.
for (const s of b.staff || []) if (s.managerId && staffMap[s.id] && staffMap[s.managerId]) await tx.staff.update({ where: { id: staffMap[s.id] }, data: { managerId: staffMap[s.managerId] } });
// Their own logins, re-attached to the freshly minted staff rows by staff number.
const staffByNum = new Map((await tx.staff.findMany({ where: { facilityId: fid }, select: { id: true, num: true } })).map((x) => [x.num, x.id] as const));
for (const a of keptAccounts) {
const sid = staffByNum.get(a.num);
if (sid) await tx.staffAccount.create({ data: { facilityId: fid, staffId: sid, email: a.email, passwordHash: a.passwordHash, createdAt: a.createdAt, lastSeenAt: a.lastSeenAt } });
}
for (const bc of b.barcodes || []) if (itemMap[bc.itemId]) await tx.barcode.create({ data: { facilityId: fid, code: str(bc.code, 64), itemId: itemMap[bc.itemId], sizeIndex: int(bc.sizeIndex), source: str(bc.source, 20) } });
// Locations first, then their parents wired up, so a bay restored before its shelf still lands in the tree.
const locMapB: Record<string, string> = {};
for (const l of b.locations || []) { const c = await tx.location.create({ data: { facilityId: fid, name: str(l.name, 60), kind: LOCATION_KINDS.includes(str(l.kind)) ? str(l.kind) : "Shelf", sort: int(l.sort), archived: !!l.archived } }); locMapB[l.id] = c.id; }
for (const l of b.locations || []) if (l.parentId && locMapB[l.id] && locMapB[l.parentId]) await tx.location.update({ where: { id: locMapB[l.id] }, data: { parentId: locMapB[l.parentId] } });
for (const s of b.stock || []) if (itemMap[s.itemId]) await tx.stockLevel.create({ data: { facilityId: fid, itemId: itemMap[s.itemId], sizeIndex: Math.max(0, int(s.sizeIndex)), opening: int(s.opening), adj: int(s.adj), reorder: s.reorder === null || s.reorder === undefined ? null : int(s.reorder), preloved: Math.max(0, int(s.preloved)), locationId: s.locationId ? locMapB[s.locationId] ?? null : null } });
for (const m of b.moves || []) if (itemMap[m.itemId]) await tx.stockMove.create({ data: { facilityId: fid, date: isoDate(m.date), type: str(m.type, 20), itemId: itemMap[m.itemId], sizeIndex: int(m.sizeIndex), qty: int(m.qty), reason: str(m.reason, 120), byName: str(m.byName, 120) } });
// Cost history follows its garment; a row whose item didn't survive the restore is dropped
// rather than orphaned, exactly as movements are.
for (const c of b.costs || []) if (itemMap[c.itemId]) await tx.costChange.create({ data: { facilityId: fid, itemId: itemMap[c.itemId], cost: num(c.cost), previous: c.previous === null || c.previous === undefined ? null : num(c.previous), at: c.at ? new Date(c.at) : new Date(), byName: str(c.byName, 120) } });
for (const o of [...(b.orders || [])].sort((a, c) => String(a.createdAt).localeCompare(String(c.createdAt)))) {
const c = await tx.order.create({ data: { facilityId: fid, code: str(o.code, 20), date: isoDate(o.date), source: str(o.source, 40), orderFor: str(o.orderFor, 20), staffId: o.staffId ? staffMap[o.staffId] ?? null : null, supplier: str(o.supplier, 80), status: str(o.status, 20), ref: str(o.ref, 120), invoice: str(o.invoice, 120), tracking: str(o.tracking, 120), expected: isoDate(o.expected), received: isoDate(o.received), cc: str(o.cc, 120), notes: str(o.notes, 400), replenish: !!o.replenish, createdAt: o.createdAt ? new Date(o.createdAt) : undefined,
lines: { create: (o.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; size: string; qty: number; sort?: number }, i: number) => ({ itemId: itemMap[l.itemId], size: str(l.size, 20), qty: int(l.qty), sort: int(l.sort, i) })) } } });
orderMap[o.id] = c.id;
if (o.parentId && orderMap[o.parentId]) await tx.order.update({ where: { id: c.id }, data: { parentId: orderMap[o.parentId] } });
for (const r of o.receipts || []) await tx.receipt.create({ data: { orderId: c.id, date: isoDate(r.date), invoice: str(r.invoice, 120), note: str(r.note, 400), photoId: pm(r.photoId), lines: { create: (r.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; size: string; qty: number; dest: string; cost: number }) => ({ itemId: itemMap[l.itemId], size: str(l.size, 20), qty: int(l.qty), dest: str(l.dest, 10), cost: num(l.cost) })) } } });
}
// A zero cost means two different things on this row. On a row written before Issue.cost
// existed it means "unknown", and the garment's catalogue price is the best guess we have.
// On a pre-loved row it means free, deliberately, and guessing a price for it puts money on
// the ward's cost centre for a garment nobody bought. `preloved` survives the round trip, so
// the two are told apart here rather than being averaged into one wrong answer.
for (const i of [...(b.issues || [])].sort((a, c) => String(a.createdAt).localeCompare(String(c.createdAt)))) if (itemMap[i.itemId] && staffMap[i.staffId]) issueMap[i.id] = (await tx.issue.create({ data: { facilityId: fid, date: isoDate(i.date), staffId: staffMap[i.staffId], itemId: itemMap[i.itemId], sizeIndex: int(i.sizeIndex), qty: int(i.qty), cond: str(i.cond, 40), preloved: !!i.preloved, handedIn: isoDate(i.handedIn) || null, returnPhotoId: pm(i.returnPhotoId), cost: i.preloved ? 0 : (num(i.cost) > 0 ? num(i.cost) : num((b.items || []).find((x: { id: string }) => x.id === i.itemId)?.cost)), orderCode: str(i.orderCode, 20), receipt: !!i.receipt, returnedDate: isoDate(i.returnedDate) || null, returnedCond: i.returnedCond ? str(i.returnedCond, 40) : null, override: !!i.override, offGroup: !!i.offGroup, offStyle: !!i.offStyle, direct: !!i.direct, createdAt: i.createdAt ? new Date(i.createdAt) : undefined } })).id;
for (const pu of b.pickups || []) if (orderMap[pu.orderId] && staffMap[pu.staffId]) await tx.pickup.create({ data: { facilityId: fid, orderId: orderMap[pu.orderId], staffId: staffMap[pu.staffId], received: isoDate(pu.received), contacted: !!pu.contacted, pickedUp: isoDate(pu.pickedUp) || null, deliveredTo: str(pu.deliveredTo, 120), sigId: pm(pu.sigId), proofId: pm(pu.proofId), deliveredRound: !!pu.deliveredRound, lines: { create: (pu.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; size: string; qty: number }) => ({ itemId: itemMap[l.itemId], size: str(l.size, 20), qty: int(l.qty) })) } } });
for (const t of b.stocktakes || []) await tx.stocktake.create({ data: { facilityId: fid, date: isoDate(t.date), byName: str(t.byName, 120), counted: int(t.counted), variances: int(t.variances), mode: t.mode === "preloved" ? "preloved" : "shelf", locationId: t.locationId ? locMapB[t.locationId] ?? null : null, createdAt: t.createdAt ? new Date(t.createdAt) : undefined, lines: { create: (t.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; sizeIndex: number; sys: number; counted: number; reason?: string }) => ({ itemId: itemMap[l.itemId], sizeIndex: int(l.sizeIndex), sys: int(l.sys), counted: int(l.counted), reason: str(l.reason, 40) })) } } });
// The approver's link is remapped through the same staff map as the subject's, because the ids
// in the file belong to the facility it was exported from. A manager who was not in that export,
// or has since gone off the register, comes back as a signature with no link — which is exactly
// what an approval typed before the register search existed already is.
for (const a of b.approvals || []) if (staffMap[a.staffId]) await tx.approval.create({ data: { facilityId: fid, staffId: staffMap[a.staffId], date: isoDate(a.date), byName: str(a.byName, 120), byStaffId: (a.byStaffId && staffMap[a.byStaffId]) || null, sets: int(a.sets), fte: str(a.fte, 10), notes: str(a.notes, 400), used: int(a.used), photoId: pm(a.photoId), createdAt: a.createdAt ? new Date(a.createdAt) : undefined } });
for (const h of b.handins || []) if (staffMap[h.staffId]) await tx.handIn.create({ data: { facilityId: fid, staffId: staffMap[h.staffId], date: isoDate(h.date), byName: str(h.byName, 120), credit: !!h.credit, createdAt: h.createdAt ? new Date(h.createdAt) : undefined, lines: { create: (h.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; sizeIndex: number; qty: number; cond: string; laundered: boolean; credited?: number }) => ({ itemId: itemMap[l.itemId], sizeIndex: int(l.sizeIndex), qty: int(l.qty), cond: l.cond === "Rag" ? "Rag" : "Good", laundered: l.laundered !== false, credited: Math.max(0, int(l.credited)) })) } } });
for (const a of b.alterations || []) if (staffMap[a.staffId]) await tx.alteration.create({ data: { facilityId: fid, staffId: staffMap[a.staffId], date: isoDate(a.date), garment: str(a.garment, 120), desc: str(a.desc, 400), status: str(a.status, 40), createdAt: a.createdAt ? new Date(a.createdAt) : undefined } });
/* ---------- the staff app's half
*
* Same id-remap as everything above: every row is created fresh and pointed at the new staff,
* catalogue, issue and request ids. A backup written before these keys existed simply has none
* of them, and `|| []` restores it exactly as it always did. */
for (const rq of [...(b.requests || [])].sort((x: { createdAt?: string }, y: { createdAt?: string }) => String(x.createdAt).localeCompare(String(y.createdAt)))) {
if (!staffMap[rq.subjectId]) continue; // the wearer didn't survive the restore
/* The garments. A file written before requests carried lines has the single garment on the
* request itself, so one line is built from those fields — otherwise restoring last week's
* backup would bring back a queue of requests with nothing on them. Its line inherits the
* request's own answer, which is the only one it can have had: a request that was accepted
* had that garment approved, and one that was declined had it refused. */
const legacy = rq.itemId ? [{ itemId: rq.itemId, sizeIndex: rq.sizeIndex, qty: rq.qty, status: rq.status === "awaiting" || rq.status === "declined" ? rq.status : "approved", declineReason: rq.declineReason, sort: 0 }] : [];
const rawLines: { itemId?: unknown; sizeIndex?: unknown; qty?: unknown; status?: unknown; declineReason?: unknown; sort?: unknown }[] =
(Array.isArray(rq.lines) && rq.lines.length ? rq.lines : legacy).slice(0, REQUEST_MAX_LINES);
const lineRows = rawLines
.filter((l) => itemMap[str(l.itemId)])
.map((l, i) => ({
itemId: itemMap[str(l.itemId)], sizeIndex: Math.max(0, int(l.sizeIndex)), qty: Math.max(1, int(l.qty, 1)),
status: LINE_STATUSES.includes(str(l.status, 20) as never) ? str(l.status, 20) : "awaiting",
declineReason: l.declineReason ? str(l.declineReason, 120) : null,
sort: int(l.sort, i),
}));
if (!lineRows.length) continue; // none of its garments survived the restore
const c = await tx.request.create({ data: {
facilityId: fid, code: str(rq.code, 20),
subjectId: staffMap[rq.subjectId],
raisedByStaffId: rq.raisedByStaffId ? staffMap[rq.raisedByStaffId] ?? null : null,
// Coordinator logins are not in the backup and are not touched by a restore, so the id is
// kept only when it is still one of this facility's own users. The name on the request
// survives either way, which is what the order actually shows.
raisedByUserId: rq.raisedByUserId && ourUsers.has(String(rq.raisedByUserId)) ? String(rq.raisedByUserId) : null,
raisedByName: str(rq.raisedByName, 120),
lines: { create: lineRows },
reason: str(rq.reason, 40), note: str(rq.note, 400),
status: REQ_STATUSES.has(str(rq.status, 20)) ? str(rq.status, 20) : "awaiting",
managerId: rq.managerId ? staffMap[rq.managerId] ?? null : null, managerName: str(rq.managerName, 120),
decidedAt: stamp(rq.decidedAt), declineReason: rq.declineReason ? str(rq.declineReason, 60) : null,
route: rq.route ? str(rq.route, 20) : null, collectCode: rq.collectCode ? str(rq.collectCode, 10) : null, holdUntil: str(rq.holdUntil, 40),
signerName: rq.signerName ? str(rq.signerName, 120) : null, signerRole: rq.signerRole ? str(rq.signerRole, 120) : null,
signedAt: stamp(rq.signedAt), claimedAt: stamp(rq.claimedAt),
createdAt: stamp(rq.createdAt) ?? undefined,
events: { create: (rq.events || []).map((e: { label?: unknown; meta?: unknown; actorName?: unknown; at?: unknown }) => ({ label: str(e.label, 120), meta: str(e.meta, 200), actorName: str(e.actorName, 120), at: stamp(e.at) ?? undefined })) },
messages: { create: (rq.messages || []).map((m: { fromStaff?: unknown; authorName?: unknown; body?: unknown; readAt?: unknown; createdAt?: unknown }) => ({ fromStaff: !!m.fromStaff, authorName: str(m.authorName, 120), body: str(m.body, 2000), readAt: stamp(m.readAt), createdAt: stamp(m.createdAt) ?? undefined })) },
} });
requestMap[rq.id] = c.id;
}
for (const w of b.waitlist || []) if (staffMap[w.staffId] && itemMap[w.itemId]) await tx.waitlistEntry.create({ data: { facilityId: fid, staffId: staffMap[w.staffId], itemId: itemMap[w.itemId], sizeIndex: int(w.sizeIndex), offeredAt: stamp(w.offeredAt), acceptedAt: stamp(w.acceptedAt), leftAt: stamp(w.leftAt), createdAt: stamp(w.createdAt) ?? undefined } });
for (const k of b.kitChecks || []) await tx.kitCheck.create({ data: {
facilityId: fid, dueBy: isoDate(k.dueBy), openedAt: stamp(k.openedAt) ?? undefined, closedAt: stamp(k.closedAt), openedBy: str(k.openedBy, 120),
answers: { create: (k.answers || []).filter((a: { staffId: string; itemId: string }) => staffMap[a.staffId] && itemMap[a.itemId]).map((a: { staffId: string; itemId: string; sizeIndex: number; onRecord: number; confirmed: number; answeredAt?: unknown }) => ({ staffId: staffMap[a.staffId], itemId: itemMap[a.itemId], sizeIndex: int(a.sizeIndex), onRecord: int(a.onRecord), confirmed: int(a.confirmed), answeredAt: stamp(a.answeredAt) ?? undefined })) },
} });
for (const d of b.damage || []) if (staffMap[d.staffId]) await tx.damageReport.create({ data: { facilityId: fid, staffId: staffMap[d.staffId], issueId: d.issueId ? issueMap[d.issueId] ?? null : null, kind: str(d.kind, 40), note: str(d.note, 400), photoId: pm(d.photoId), requestId: d.requestId ? requestMap[d.requestId] ?? null : null, handedInAt: stamp(d.handedInAt), createdAt: stamp(d.createdAt) ?? undefined } });
for (const d of b.disputes || []) if (staffMap[d.staffId]) await tx.recordDispute.create({ data: { facilityId: fid, staffId: staffMap[d.staffId], itemId: d.itemId ? itemMap[d.itemId] ?? null : null, sizeIndex: d.sizeIndex === null || d.sizeIndex === undefined ? null : int(d.sizeIndex), body: str(d.body, 2000), resolvedAt: stamp(d.resolvedAt), resolvedBy: str(d.resolvedBy, 120), createdAt: stamp(d.createdAt) ?? undefined } });
for (const n of b.notices || []) await tx.linenNotice.create({ data: { facilityId: fid, body: str(n.body, 400), startsAt: isoDate(n.startsAt), endsAt: isoDate(n.endsAt), createdAt: stamp(n.createdAt) ?? undefined } });
return orphans;
}, { timeout: 120000 }).catch(async (e) => {
// The files are written outside the database, so a rollback cannot reach them. A big restore
// that hits the 120-second transaction timeout leaves the facility untouched — and, without
// this, several hundred signatures and photographs of damaged uniforms sitting in the photo
// directory with no Photo row naming them: nothing in the product could ever find or delete
// them, and every retry of the restore added another set.
await unlinkAll(written);
throw e;
});
// After the commit, never before: a rolled-back restore that had already unlinked the files
// would leave every surviving row pointing at an image that is no longer there.
await unlinkAll(orphanedFiles);
return { ok: true, photosSkipped };
}