cbf533d90b
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
734 lines
46 KiB
TypeScript
734 lines
46 KiB
TypeScript
import { prisma } from "./db";
|
|
import { Prisma } from "@prisma/client";
|
|
import bcrypt from "bcryptjs";
|
|
import { setStaffCookie, type StaffSession } from "./staffsession";
|
|
import {
|
|
DAMAGE_KINDS, DECLINE_REASONS, REQUEST_REASONS, WAITLIST_HOLD_HOURS, canMove, decisionSummary,
|
|
holdExpired, requestCode, rollUpRequestStatus, roundWard, type LineStatus,
|
|
} from "./staffreq";
|
|
import { approvalEmail, approvalUrl, decisionEmail, signApprovalToken } from "./approvallink";
|
|
import { sendTo, transactionalConfigured } from "./mail";
|
|
import { OpError, handOverRequestStock, offGroupGarments, offStyleGarments, readRequestLines } from "./ops";
|
|
import { facilityToday, genderLabel, groupsLabel } from "./compute";
|
|
import { PLAN_COLS, entitlements } from "./plan";
|
|
|
|
/* Everything a staff member, ward manager or ward clerk can change.
|
|
*
|
|
* Separate from lib/ops.ts on purpose. That file's ops all begin from a coordinator session and
|
|
* may touch anything in the facility; these all begin from a *staff* session and must never be
|
|
* able to. Keeping them in one switch statement with one authorisation model each would mean the
|
|
* only thing standing between a wearer and the stock ledger was remembering which branch they
|
|
* came in through.
|
|
*
|
|
* The rule every op here obeys: **the facility is not the unit of authority, the person is.**
|
|
* A staff session may act on its own record; a manager may act on requests addressed to them and
|
|
* raise for the people who report to them; a ward clerk may sign for a bag coming to their own
|
|
* ward. Nothing here takes a facility id from the caller.
|
|
*/
|
|
|
|
export class StaffOpError extends Error {
|
|
status: number;
|
|
constructor(msg: string, status = 400) { super(msg); this.status = status; }
|
|
}
|
|
|
|
/** Same floor as activation, so a password change can't be a downgrade of the one they set. */
|
|
const MIN_PASSWORD = 8;
|
|
|
|
const str = (v: unknown, max = 500) => (v === undefined || v === null ? "" : String(v)).slice(0, max);
|
|
const int = (v: unknown, d = 0) => { const n = parseInt(String(v), 10); return Number.isFinite(n) ? n : d; };
|
|
|
|
/** Load a request the caller is allowed to see: their own, or one they approve, or one they
|
|
* raised for somebody else. Anything else is a 404 — not a 403, which would confirm it exists. */
|
|
async function visibleRequest(sess: StaffSession, id: string) {
|
|
const [r, me] = await Promise.all([
|
|
prisma.request.findFirst({
|
|
where: { id, facilityId: sess.facilityId },
|
|
include: {
|
|
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
|
subject: { select: { first: true, last: true, dept: true } },
|
|
events: { select: { label: true, meta: true } },
|
|
},
|
|
}),
|
|
prisma.staff.findUnique({ where: { id: sess.staffId }, select: { dept: true, wardDesk: true } }),
|
|
]);
|
|
/* The same three parties lib/staffdata.ts requestData admits, plus the desk holding the bag:
|
|
* /my/round offers that desk a "Nudge" into this thread, and the message it sends has to be
|
|
* accepted here or the button is a 404 with a different face. Ward from the timeline, and a
|
|
* blank ward matches nobody — the fence round.sign and round.claim use. */
|
|
const party = !!r && (r.subjectId === sess.staffId || r.managerId === sess.staffId || r.raisedByStaffId === sess.staffId);
|
|
const onMyDesk = !!r && !!me?.wardDesk && !!me.dept.trim() && (r.status === "round" || r.status === "delivered") && roundWard(r.events) === me.dept;
|
|
if (!r || (!party && !onMyDesk)) throw new StaffOpError("No such request", 404);
|
|
return r;
|
|
}
|
|
|
|
/** The staff app speaks one error type — /api/staff/mutate turns anything else into a bare
|
|
* "something went wrong", which tells the person on the ward nothing. The shared helpers in
|
|
* lib/ops.ts speak the coordinator's, so a call into one is translated rather than let through. */
|
|
function asStaffError(e: unknown): never {
|
|
if (e instanceof OpError) throw new StaffOpError(e.message, e.status);
|
|
throw e;
|
|
}
|
|
|
|
/** What one op calling another inside this file may say that a payload never can. The API route
|
|
* calls runStaffOp with three arguments, so nothing a phone sends reaches this.
|
|
*
|
|
* `replacing` is the garment a damage report is asking to replace (damage.report). It is the one
|
|
* garment outside somebody's staff group, or outside the cut they are offered, that a request may
|
|
* carry — see offGroupGarments() and offStyleGarments() in lib/ops.ts, which also check they are
|
|
* still holding it. */
|
|
type Internal = { replacing?: string };
|
|
|
|
/** The refusal: each garment with the groups it is for, then whose group it isn't. */
|
|
function offGroupMessage(off: readonly { item: string; groups: string[] }[], who: { first: string; group: string }, self: boolean): string {
|
|
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)} only`).join("; ");
|
|
const g = (who.group || "").trim();
|
|
const tail = self
|
|
? g ? `You're in ${g} — ask the linen room if you need ${off.length === 1 ? "it" : "them"}.` : "You have no staff group recorded — ask the linen room."
|
|
: g ? `${who.first} is in ${g}.` : `${who.first} has no staff group recorded — ask the linen room.`;
|
|
return `${each}. ${tail}`;
|
|
}
|
|
|
|
/** The same refusal for the wrong cut: each garment with the cut it is, then the style they are set
|
|
* to. There is no blank case — a blank style, and Either, are offered every cut, so this is only
|
|
* ever built for somebody a coordinator has set to Men's or Women's. */
|
|
function offStyleMessage(off: readonly { item: string; gender: string }[], who: { first: string; uniformStyle: string }, self: boolean): string {
|
|
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
|
|
const tail = self
|
|
? `You're set to ${who.uniformStyle} — ask the linen room if you need ${off.length === 1 ? "it" : "them"}.`
|
|
: `${who.first} is set to ${who.uniformStyle}.`;
|
|
return `${each}. ${tail}`;
|
|
}
|
|
|
|
async function ownStaffInFacility(sess: StaffSession, id: string) {
|
|
const s = await prisma.staff.findFirst({ where: { id, facilityId: sess.facilityId } });
|
|
if (!s) throw new StaffOpError("No such staff member", 404);
|
|
return s;
|
|
}
|
|
|
|
/** Append a timeline row. Every status change goes through here, so the history can't be
|
|
* half-written by a branch that forgot. */
|
|
async function event(tx: Prisma.TransactionClient, requestId: string, label: string, meta: string, actorName: string) {
|
|
await tx.requestEvent.create({ data: { requestId, label, meta: meta.slice(0, 200), actorName: actorName.slice(0, 120) } });
|
|
}
|
|
|
|
/** Fire-and-forget: a request must not fail because the mail server was slow. */
|
|
function mail(to: string | null | undefined, subject: string, text: string) {
|
|
if (!to || !transactionalConfigured()) return;
|
|
void sendTo(to, subject, text).catch((e) => console.error("[staff mail]", (e as Error).message));
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- the decision
|
|
*
|
|
* The manager settles the whole request in one action. They may approve all of it, decline all of
|
|
* it, or approve some garments and knock others back — the tunic and the trousers yes, the fleece
|
|
* no, over allowance. Every line ends up `approved` or `declined`, and the request's own status is
|
|
* the rollup: something survived means `accepted` and there is a pick to do, nothing survived means
|
|
* `declined`. One code, one pick, one collection either way.
|
|
*
|
|
* It lives in one exported function because two doors reach it — the manager tapping Approve in
|
|
* the app, and the emailed link at /api/staff/decide, which has no session behind it. When the two
|
|
* were written separately they drifted, and a decision made by email recorded a different timeline
|
|
* row from the same decision made in the app.
|
|
*/
|
|
export type LineDecision = { id: string; decision: LineStatus; reason: string };
|
|
|
|
/** Read a per-line decision payload: `[{ id, decision: "approved" | "declined", reason? }]`.
|
|
* Null when the caller sent none, which means the whole-request shorthand applies. Every line of
|
|
* the request has to appear exactly once — a decision that leaves a garment undecided would move
|
|
* the request to the linen room with a line nobody has answered. */
|
|
function readLineDecisions(raw: unknown, lines: readonly { id: string }[]): LineDecision[] | null {
|
|
if (raw === undefined || raw === null) return null;
|
|
if (!Array.isArray(raw)) throw new StaffOpError("Say what you decided about each garment");
|
|
const seen = new Set<string>();
|
|
const out: LineDecision[] = [];
|
|
for (const row of raw) {
|
|
const r = (row || {}) as { id?: unknown; decision?: unknown; reason?: unknown };
|
|
const id = str(r.id);
|
|
if (!lines.some((l) => l.id === id) || seen.has(id)) throw new StaffOpError("That decision doesn't match the request — reopen it and try again");
|
|
seen.add(id);
|
|
const decision = str(r.decision, 20);
|
|
if (decision !== "approved" && decision !== "declined") throw new StaffOpError("Approve or decline each garment");
|
|
let reason = "";
|
|
if (decision === "declined") {
|
|
reason = str(r.reason, 60);
|
|
if (!DECLINE_REASONS.includes(reason as never)) throw new StaffOpError("Pick a reason for each garment you decline — the staff member is told what it was");
|
|
}
|
|
out.push({ id, decision, reason });
|
|
}
|
|
if (out.length !== lines.length) throw new StaffOpError("Decide every garment on the request before you send it");
|
|
return out;
|
|
}
|
|
|
|
/** Settle a request. `approveAll` is the whole-request shorthand the emailed link uses, where
|
|
* there is no room for a per-line answer; `lines` overrides it when the manager has decided
|
|
* garment by garment.
|
|
*
|
|
* The facility, the code and the manager's name come back with the decision so the emailed-link
|
|
* door can file its audit row without reading the request a second time. */
|
|
export async function decideRequest(d: {
|
|
requestId: string; managerId: string; facilityId?: string;
|
|
approveAll: boolean; reason?: unknown; lines?: unknown; actorName?: string;
|
|
}): Promise<{ ok: true; status: "accepted" | "declined"; summary: string; code: string; facilityId: string; managerName: string; selfApproved: boolean; notified: boolean }> {
|
|
const r = await prisma.request.findFirst({
|
|
where: { id: d.requestId, managerId: d.managerId, ...(d.facilityId ? { facilityId: d.facilityId } : {}) },
|
|
include: {
|
|
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
|
subject: { select: { id: true, first: true, inactive: true } },
|
|
facility: { select: { name: true } },
|
|
},
|
|
});
|
|
if (!r) throw new StaffOpError("No such request", 404);
|
|
|
|
/* Whoever is asking names a manager; that does not make them one. The session re-reads the
|
|
* register on every call, but an emailed link lives for a fortnight in a mailbox that may since
|
|
* have been closed or handed on, so the register is read here too — and the wearer with it,
|
|
* because approving garments for somebody who has left the register is the same mistake pointed
|
|
* the other way. Request.managerId carries no foreign key, so a deleted manager leaves an id
|
|
* that still satisfies the query above; this lookup is what turns that back into a refusal. */
|
|
const mgr = await prisma.staff.findFirst({ where: { id: d.managerId, facilityId: r.facilityId, inactive: false }, select: { id: true } });
|
|
if (!mgr) throw new StaffOpError("You're no longer recorded as a manager here — ask the linen room.", 403);
|
|
/* Anybody may approve their own request — the owner's decision, and it replaced a test that
|
|
* allowed it only to somebody with a report on the register. A self-approval is never mistaken
|
|
* later for an ordinary one: the timeline row written below says "Self-approved" in words, and
|
|
* the result carries selfApproved so both doors can say it too.
|
|
*
|
|
* What does stand is the raise rule: nobody approves a request they raised for somebody else.
|
|
* request.create and request.reassign both refuse to address one that way; this is the same
|
|
* rule at the moment it would matter, so a request addressed some other way — a restored backup
|
|
* — cannot slip past it. */
|
|
const selfApproval = r.subject.id === d.managerId;
|
|
if (r.raisedByStaffId && r.raisedByStaffId === d.managerId && !selfApproval) {
|
|
throw new StaffOpError("You raised this request, so somebody else has to approve it — ask the linen room to re-address it.", 403);
|
|
}
|
|
if (r.subject.inactive) throw new StaffOpError("That person is no longer on the register, so nothing can be approved for them.", 403);
|
|
if (!r.lines.length) throw new StaffOpError("That request has no garments on it — ask the linen room to raise it again.");
|
|
|
|
const perLine = readLineDecisions(d.lines, r.lines);
|
|
// The shorthand: the op name, or the emailed link, answers for every garment at once.
|
|
let wholeReason = "";
|
|
if (!perLine && !d.approveAll) {
|
|
wholeReason = str(d.reason, 60);
|
|
if (!DECLINE_REASONS.includes(wholeReason as never)) throw new StaffOpError("Pick a reason — the staff member is told what it was");
|
|
}
|
|
const decisions: LineDecision[] = perLine ?? r.lines.map((l) => ({
|
|
id: l.id, decision: d.approveAll ? "approved" : "declined", reason: d.approveAll ? "" : wholeReason,
|
|
}));
|
|
|
|
const to = rollUpRequestStatus(decisions.map((x) => ({ status: x.decision })));
|
|
// Every line has just been decided, so the rollup is never "awaiting" here; the check is the
|
|
// state machine's, and it is what makes the emailed link single-use — both links in the same
|
|
// message stop working the moment either one is spent.
|
|
if (to === "awaiting" || !canMove(r.status, to)) throw new StaffOpError("That request has already been decided", 409);
|
|
|
|
const refusals = decisions.filter((x) => x.decision === "declined");
|
|
// The request-level reason is the whole request's reason, so it is set only when one reason
|
|
// covers the whole refusal. Where the refusals differ, each line carries its own and the
|
|
// request has none — inventing one for it would put a reason on the record nobody gave.
|
|
const shared = refusals.length === decisions.length && refusals.every((x) => x.reason === refusals[0].reason) ? refusals[0].reason : "";
|
|
|
|
const actorName = d.actorName || r.managerName || "Your manager";
|
|
const sizeOf = (l: (typeof r.lines)[number]) => String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex);
|
|
// A request can carry the same garment in two sizes, so the size is named only when the garment
|
|
// alone would be ambiguous: "Fleece declined" beats "Fleece M declined" when there is one fleece.
|
|
const nameOf = (l: (typeof r.lines)[number]) => (r.lines.filter((x) => x.itemId === l.itemId).length > 1 ? `${l.item.item} ${sizeOf(l)}` : l.item.item);
|
|
const lineById = new Map(r.lines.map((l) => [l.id, l]));
|
|
const refusalList = refusals.map((x) => `${nameOf(lineById.get(x.id)!)} declined: ${x.reason}`).join("; ");
|
|
|
|
// One timeline row for the decision as a whole. A row per line would bury the request's own
|
|
// history under its garments, and the manager did one thing, once.
|
|
const yes = decisions.length - refusals.length;
|
|
// A manager deciding her own request is marked in the label, not tucked into the meta: the label
|
|
// is the line a timeline shows at a glance and the first thing anyone auditing the request
|
|
// reads, and it is the one thing about the decision nobody could otherwise tell from it. The
|
|
// meta then says it again in full, because the label is also quoted on screens that show no
|
|
// meta at all.
|
|
const own = selfApproval ? " — their own request" : "";
|
|
const label = yes === decisions.length ? `Approved by ${actorName}${own}`
|
|
: yes === 0 ? `Declined by ${actorName}${own}`
|
|
: `${yes} of ${decisions.length} approved by ${actorName}${own}`;
|
|
const plain = yes === decisions.length ? "Ward manager" : shared || refusalList;
|
|
// Worded for what actually happened: nothing was approved, so nothing was self-approved, but the
|
|
// decision was still the wearer's own and that is what the row has to show.
|
|
const selfNote = yes
|
|
? `Self-approved — ${actorName} is the manager on this request and the person it is for`
|
|
: `Decided by ${actorName}, who is the manager on this request and the person it is for`;
|
|
const meta = selfApproval
|
|
? [selfNote, yes === decisions.length ? "" : plain].filter(Boolean).join(" · ")
|
|
: plain;
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
// Conditional on the status we read, so two taps — or a tap and an email link — can't both win.
|
|
const moved = await tx.request.updateMany({
|
|
where: { id: r.id, status: "awaiting" },
|
|
data: { status: to, decidedAt: new Date(), declineReason: to === "declined" && shared ? shared : null },
|
|
});
|
|
if (moved.count !== 1) throw new StaffOpError("That request has already been decided", 409);
|
|
for (const x of decisions) {
|
|
await tx.requestLine.updateMany({
|
|
where: { id: x.id, requestId: r.id },
|
|
data: { status: x.decision, declineReason: x.decision === "declined" ? x.reason : null },
|
|
});
|
|
}
|
|
await event(tx, r.id, label, meta, actorName);
|
|
});
|
|
|
|
const subjAccount = await prisma.staffAccount.findUnique({ where: { staffId: r.subject.id }, select: { email: true } });
|
|
const decided = new Map(decisions.map((x) => [x.id, x]));
|
|
const em = decisionEmail({
|
|
staffFirst: r.subject.first, managerName: actorName, approved: to === "accepted",
|
|
reason: to === "declined" ? shared : "",
|
|
lines: r.lines.map((l) => ({
|
|
qty: l.qty, item: l.item.item, size: sizeOf(l),
|
|
status: decided.get(l.id)?.decision, declineReason: decided.get(l.id)?.reason || null,
|
|
})),
|
|
facility: r.facility.name,
|
|
});
|
|
mail(subjAccount?.email, em.subject, em.text);
|
|
|
|
return {
|
|
ok: true, status: to, summary: decisionSummary(decisions.map((x) => ({ status: x.decision }))) || label,
|
|
code: r.code, facilityId: r.facilityId, managerName: actorName,
|
|
// Whether the wearer was actually emailed — same meaning as request.create's flag. The
|
|
// emailed-link page said "has been told" regardless, which is how a wearer with no account
|
|
// waited on an approval nobody mentioned to them.
|
|
notified: !!subjAccount?.email && transactionalConfigured(),
|
|
// Handed back so the two doors can say it as well: the app's confirmation, and the audit row
|
|
// the emailed link files, which would otherwise read as an ordinary approval by a manager who
|
|
// happens to have the same name as the wearer.
|
|
selfApproved: selfApproval,
|
|
};
|
|
}
|
|
|
|
export async function runStaffOp(sess: StaffSession, op: string, p: Record<string, unknown>, internal: Internal = {}): Promise<unknown> {
|
|
p = p || {};
|
|
const me = await prisma.staff.findUniqueOrThrow({
|
|
where: { id: sess.staffId },
|
|
select: {
|
|
id: true, first: true, last: true, dept: true, wardDesk: true, managerId: true, group: true, uniformStyle: true,
|
|
facility: { select: { id: true, name: true, timezone: true, ...PLAN_COLS } },
|
|
},
|
|
});
|
|
// A read-only room refuses a wearer's writes too — in the room's words, since the plan is the
|
|
// linen room's business and not the wearer's. Reading (my kit, the shelf) is untouched.
|
|
if (entitlements(me.facility).readOnly) throw new StaffOpError("The linen room's ThreadCount is read-only at the moment. Ask the linen room.", 403);
|
|
const myName = `${me.first} ${me.last}`.trim();
|
|
const fid = sess.facilityId;
|
|
|
|
switch (op) {
|
|
/* ---------------------------------------------------------------- requests */
|
|
case "request.create": {
|
|
/* Who it is for. One person other than the wearer may raise: a manager, for the people who
|
|
* report to them. That fence is the reporting line, which is the same relationship that
|
|
* makes them the approver — and the reason a raise of theirs goes up a level below.
|
|
*
|
|
* A ward clerk on the desk used to be able to raise for anyone on their own ward, on the
|
|
* grounds that half a ward will never install anything. That is gone: everyone in the
|
|
* building carries a phone, and the manager route covers the person who genuinely cannot.
|
|
* The desk flag itself stays — it is what signs for a bag on the ward round. */
|
|
const subjectId = str(p.subjectId) || me.id;
|
|
const other = me.id === subjectId ? null : await ownStaffInFacility(sess, subjectId);
|
|
if (other && other.managerId !== me.id) {
|
|
throw new StaffOpError("Only somebody's own manager can raise a request for them", 403);
|
|
}
|
|
const subject = other ?? (await ownStaffInFacility(sess, me.id));
|
|
if (subject.inactive) throw new StaffOpError("That person is no longer on the register");
|
|
|
|
// The approver is the subject's own manager, never the clerk's.
|
|
if (!subject.managerId) {
|
|
throw new StaffOpError(
|
|
subject.id === me.id
|
|
? "Your manager isn't set yet — the linen room has to record who approves your requests."
|
|
: `${subject.first} has no manager recorded, so there is nobody to approve this. Ask the linen room to set one.`,
|
|
);
|
|
}
|
|
// Off the register means off the register. A deactivated manager cannot sign in to approve
|
|
// anything, so addressing a request to one parks it where nobody can reach it — and it would
|
|
// mint an approval link that outlives their access by a fortnight.
|
|
let manager = await prisma.staff.findFirst({ where: { id: subject.managerId, facilityId: fid, inactive: false } });
|
|
if (!manager) throw new StaffOpError("The recorded manager is no longer on the register. Ask the linen room.");
|
|
|
|
/* Nobody approves a raise they made for somebody else. A manager raising for one of their
|
|
* own reports is the ordinary case, and the subject's approver is that same manager — so the
|
|
* request goes up a level instead: to the raiser's own manager, if they have one. If they do
|
|
* not, it is created with no approver at all and surfaces on the linen room's queue (Needs
|
|
* an approver), which already has request.reassign to give it one. Either way it never comes
|
|
* back to the person who raised it, and the timeline says where it went and why.
|
|
*
|
|
* Only a raise for somebody else. A person who is their own manager raising for themselves
|
|
* lands on their own desk, which is allowed and is marked Self-approved when they decide. */
|
|
let escalation = "";
|
|
if (other && manager.id === me.id) {
|
|
const found = me.managerId ? await prisma.staff.findFirst({ where: { id: me.managerId, facilityId: fid, inactive: false } }) : null;
|
|
/* A level up can be the raiser again: somebody who is their own manager has nobody above
|
|
* them but themselves. That is "nobody above" — Needs an approver — exactly as if no
|
|
* manager were recorded, because landing it back on them is the one thing this branch is
|
|
* here to prevent.
|
|
*
|
|
* A level up can also be the wearer. Two ward managers at the top of the tree are often
|
|
* recorded as each other's approver, and escalating a raise for one of them lands the
|
|
* request back on the person the garments are for. It goes to them: that is a person
|
|
* approving their own kit, which anybody may do, and the note below says so on the
|
|
* request's own timeline, so it is read as a self-approval rather than as a routine
|
|
* approval by somebody who happens to share the name. */
|
|
const above = found && found.id !== me.id ? found : null;
|
|
const aboveName = above ? `${above.first} ${above.last}`.trim() : "";
|
|
escalation = above
|
|
? above.id === subject.id
|
|
? `Raised by ${myName}, who approves ${subject.first}'s requests — sent up to ${aboveName}, the person it is for, to approve themselves`
|
|
: `Raised by ${myName}, who approves ${subject.first}'s requests — sent to ${aboveName} instead`
|
|
: `Raised by ${myName}, who approves ${subject.first}'s requests — the linen room will address it`;
|
|
manager = above;
|
|
}
|
|
|
|
const reason = str(p.reason, 40);
|
|
if (reason && !REQUEST_REASONS.includes(reason as never)) throw new StaffOpError("Unknown reason");
|
|
const lines = await readRequestLines(fid, p.lines).catch(asStaffError);
|
|
// Only the SUBJECT's own staff group's garments, plus those for every group — whoever raises it.
|
|
// A waitlist offer comes through here and is measured the same. So does a swap from the app's
|
|
// Swap a size screen, which sends nothing to tell it from a fresh request: holding another
|
|
// group's garment is no reason to be handed another, so a size swap of one is the counter's
|
|
// (issue.exchange). The one exception is a damage replacement for a garment they hold, which
|
|
// only damage.report can ask for.
|
|
const offGroup = await offGroupGarments(fid, subject.group, lines.map((l) => l.itemId),
|
|
internal.replacing ? { staffId: subject.id, itemId: internal.replacing } : undefined);
|
|
if (offGroup.length) throw new StaffOpError(offGroupMessage(offGroup, subject, subject.id === me.id));
|
|
// And only the cut the SUBJECT is offered, measured the same way and exempting the same one
|
|
// damage replacement. Blank — nobody has said which cut they wear — and Either take every
|
|
// garment, so nothing changes for a record no coordinator has set.
|
|
const offStyle = await offStyleGarments(fid, subject.uniformStyle, lines.map((l) => l.itemId),
|
|
internal.replacing ? { staffId: subject.id, itemId: internal.replacing } : undefined);
|
|
if (offStyle.length) throw new StaffOpError(offStyleMessage(offStyle, subject, subject.id === me.id));
|
|
const note = str(p.note, 400);
|
|
const managerName = manager ? `${manager.first} ${manager.last}`.trim() : "";
|
|
|
|
const created = await prisma.$transaction(async (tx) => {
|
|
const f = await tx.facility.update({ where: { id: fid }, data: { requestSeq: { increment: 1 } }, select: { requestSeq: true } });
|
|
const r = await tx.request.create({
|
|
data: {
|
|
facilityId: fid, code: requestCode(f.requestSeq),
|
|
subjectId: subject.id,
|
|
raisedByStaffId: subject.id === me.id ? null : me.id,
|
|
raisedByName: subject.id === me.id ? "" : myName,
|
|
reason, note,
|
|
status: "awaiting",
|
|
managerId: manager?.id ?? null, managerName,
|
|
lines: { create: lines.map((l, i) => ({ itemId: l.itemId, sizeIndex: l.sizeIndex, qty: l.qty, sort: i })) },
|
|
},
|
|
});
|
|
// The escalation note already names the raiser, so it stands in for the plain one.
|
|
const raised = subject.id === me.id ? "" : `Raised by ${myName}`;
|
|
await event(tx, r.id, "Requested", escalation || raised, subject.id === me.id ? "You" : myName);
|
|
return r;
|
|
});
|
|
|
|
// Nothing to send while the request has no approver — the linen room addresses it first.
|
|
const mgrAccount = manager ? await prisma.staffAccount.findUnique({ where: { staffId: manager.id }, select: { email: true } }) : null;
|
|
if (manager) {
|
|
const em = approvalEmail({
|
|
managerFirst: manager.first,
|
|
subjectName: `${subject.first} ${subject.last}`.trim(),
|
|
raisedByName: subject.id === me.id ? "" : myName,
|
|
lines, reason, note,
|
|
url: approvalUrl(signApprovalToken(created.id, manager.id)),
|
|
facility: me.facility.name,
|
|
});
|
|
mail(mgrAccount?.email, em.subject, em.text);
|
|
}
|
|
|
|
// `notified` says an email actually went out, not that the manager happens to have an
|
|
// account: mail() is a no-op with no transactional mail configured, and a screen that says
|
|
// "we've told them" when nothing was sent is the reason a request sits for three weeks.
|
|
const notified = !!mgrAccount?.email && transactionalConfigured();
|
|
// `selfApproves` is for the screen the raiser is standing at: their request has gone up to
|
|
// the person it is for, which is allowed and is the only way the top of the tree gets
|
|
// dressed, but they should be told that is where it went rather than find out later.
|
|
return { id: created.id, code: created.code, manager: managerName, escalated: !!escalation, notified, selfApproves: !!manager && manager.id === subject.id };
|
|
}
|
|
|
|
case "request.approve":
|
|
case "request.decline": {
|
|
// The manager's own door into the shared decision. `lines` decides garment by garment when
|
|
// the screen sends it; without it the op name settles the whole request, which is what the
|
|
// emailed link does through the same function.
|
|
return decideRequest({
|
|
requestId: str(p.id), managerId: me.id, facilityId: fid,
|
|
approveAll: op === "request.approve", reason: p.reason, lines: p.lines, actorName: myName,
|
|
});
|
|
}
|
|
|
|
case "request.message": {
|
|
const r = await visibleRequest(sess, str(p.id));
|
|
const body = str(p.body, 2000).trim();
|
|
if (!body) throw new StaffOpError("Write something first");
|
|
const m = await prisma.requestMessage.create({
|
|
data: { requestId: r.id, fromStaff: true, authorName: myName, body },
|
|
});
|
|
return { id: m.id, at: m.createdAt.toISOString() };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- ward round */
|
|
case "round.sign": {
|
|
// Anyone on the ward may sign, and whoever does is named on the requester's order — so a
|
|
// bag that never arrived has a name against it.
|
|
const r = await prisma.request.findFirst({ where: { id: str(p.id), facilityId: fid, status: "round" }, include: { lines: true, events: { select: { label: true, meta: true } } } });
|
|
if (!r) throw new StaffOpError("No such bag waiting on the round", 404);
|
|
// The ward the trolley left the bag on, off the timeline — not the ward the wearer is on
|
|
// today. Fencing on the wearer's ward made a bag follow a nurse who transferred: /my/round
|
|
// still lists it to the desk that is physically holding it (lib/deskdata.ts reads the same
|
|
// event), and this refused that desk. Since round.sign is the only way out of `round`, the
|
|
// bag was then stuck for good, and the ward it had moved to on screen could have signed for
|
|
// a handover that never happened.
|
|
//
|
|
// A blank ward is not a ward, so it matches nothing. Comparing the two sides as empty
|
|
// strings put everybody whose ward was never filled in on one ward together — a clerk with
|
|
// no ward could sign for any other ward-less person's bag in the facility. Both sides have
|
|
// to be a real ward name, and lib/ops.ts refuses to route a ward-less bag onto a round at all.
|
|
const bagWard = roundWard(r.events);
|
|
if (!me.dept || !bagWard || bagWard !== me.dept) throw new StaffOpError("That bag is for another ward", 403);
|
|
|
|
// Signing for the bag is the moment the garment leaves the linen room's shelf, and the ward
|
|
// round has to record that exactly as the counter does — an Issue against the wearer and a
|
|
// replenishment line — or the shelf count quietly loses a garment on every round while the
|
|
// counter's figures stay honest. Shared with request.collected so the two cannot drift.
|
|
try {
|
|
await handOverRequestStock(fid, facilityToday(me.facility.timezone), { subjectId: r.subjectId, lines: r.lines }, async (tx) => {
|
|
const moved = await tx.request.updateMany({
|
|
where: { id: r.id, status: "round" },
|
|
data: { status: "delivered", signerName: myName, signerRole: me.wardDesk ? "ward clerk" : (me.dept || "ward"), signedAt: new Date() },
|
|
});
|
|
if (moved.count !== 1) throw new StaffOpError("Someone has already signed for that bag");
|
|
await event(tx, r.id, `Delivered to ${me.dept || "the ward"}`, `Signed by ${myName}`, myName);
|
|
}, "ward"); // the bag has already left the shelf: record the hand-over, never refuse it here
|
|
} catch (e) {
|
|
// The shared helper speaks the coordinator's error type — an empty shelf has to reach the
|
|
// person on the ward as those words, not as a bare "something went wrong".
|
|
asStaffError(e);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
case "round.claim": {
|
|
// The bag has left the desk. Either the requester says so — "I've got it" on their own
|
|
// order — or the clerk who is looking at the pile marks it collected, because the person who
|
|
// knows a bag is gone is usually the one standing next to where it was. A desk clerk may
|
|
// only do that for their own ward, the same fence round.sign works behind.
|
|
const r = await prisma.request.findFirst({
|
|
where: { id: str(p.id), facilityId: fid, status: "delivered" },
|
|
include: { subject: { select: { id: true } }, events: { select: { label: true, meta: true } } },
|
|
});
|
|
if (!r) throw new StaffOpError("No such delivery", 404);
|
|
if (r.subject.id !== me.id) {
|
|
if (!me.wardDesk) throw new StaffOpError("That delivery is somebody else's", 403);
|
|
// The ward the bag was delivered to, off the timeline, exactly as round.sign and
|
|
// /my/round read it: the clerk looking at the pile is the one who may clear it, and a
|
|
// wearer transferring afterwards does not move the bag off their desk.
|
|
//
|
|
// The same blank-ward rule as round.sign: no ward recorded on either side is no match, or
|
|
// a clerk whose ward was never filled in could claim for every other ward-less person.
|
|
const bagWard = roundWard(r.events);
|
|
if (!me.dept || !bagWard || bagWard !== me.dept) throw new StaffOpError("That bag is for another ward", 403);
|
|
}
|
|
// Conditional on it still being unclaimed, so the requester and the desk both tapping it
|
|
// leaves one claim with the first person's name on the timeline rather than two.
|
|
const claimed = await prisma.request.updateMany({ where: { id: r.id, claimedAt: null }, data: { claimedAt: new Date() } });
|
|
if (claimed.count === 1) {
|
|
await event(prisma, r.id, "Collected from the ward", r.subject.id === me.id ? "" : `Marked by ${myName}`, r.subject.id === me.id ? "You" : myName);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- damage */
|
|
case "damage.report": {
|
|
const kind = str(p.kind, 40);
|
|
if (!DAMAGE_KINDS.includes(kind as never)) throw new StaffOpError("Pick what happened");
|
|
// handedIn as well as returnedDate: a garment handed back at the counter is off the person
|
|
// even though nothing marks it returned, and the line below this one promises the damaged
|
|
// garment stays on the record only *until* it is handed in.
|
|
const issue = await prisma.issue.findFirst({ where: { id: str(p.issueId), facilityId: fid, staffId: me.id, returnedDate: null, handedIn: null } });
|
|
if (!issue) throw new StaffOpError("Pick something you're holding", 404);
|
|
// A replacement is a separate act with its own approval — reporting damage does not quietly
|
|
// issue anything, and the damaged garment stays on the record until it is handed in.
|
|
//
|
|
// It is also the half that can fail: somebody with no manager recorded cannot raise anything.
|
|
// So it goes first. Written the other way round, a nurse with no approver got an error over
|
|
// a report that had already been saved, and one more orphan for every time she tried again.
|
|
//
|
|
// A discontinued garment is still on somebody's back. A request may not carry an archived
|
|
// catalogue entry — nobody should be able to order from a withdrawn range — but that refusal
|
|
// used to take the damage report down with it: a nurse holding a torn tunic from last year's
|
|
// range was told "That garment isn't available" about the garment in her hand, and nothing at
|
|
// all was recorded. The report is the half that matters, so it is saved either way and what
|
|
// she gets instead becomes the linen room's to settle.
|
|
const cat = await prisma.catalogItem.findFirst({ where: { id: issue.itemId, facilityId: fid }, select: { archived: true } });
|
|
let replacement: { id: string } | null = null;
|
|
let replacementNote = "";
|
|
if (p.replace && cat?.archived) {
|
|
replacementNote = "That garment has been discontinued, so a replacement can't be asked for here. Your report has gone to the linen room, who will sort out what you get instead.";
|
|
} else if (p.replace) {
|
|
// Like for like, so a garment outside their staff group that they were issued (on the
|
|
// counter's override, or before the rule) can still be replaced — and nothing else can.
|
|
replacement = await runStaffOp(sess, "request.create", {
|
|
lines: [{ itemId: issue.itemId, si: issue.sizeIndex, qty: 1 }], reason: "Damaged",
|
|
note: `Replacement for a ${kind.toLowerCase()} garment`,
|
|
}, { replacing: issue.itemId }) as { id: string };
|
|
}
|
|
const rep = await prisma.damageReport.create({
|
|
data: {
|
|
facilityId: fid, staffId: me.id, issueId: issue.id, kind, note: str(p.note, 400),
|
|
photoId: str(p.photoId) || null, requestId: replacement?.id ?? null,
|
|
},
|
|
});
|
|
return { id: rep.id, replacement, replacementNote };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- the record is wrong */
|
|
case "dispute.raise": {
|
|
const body = str(p.body, 1000).trim();
|
|
if (!body) throw new StaffOpError("Say what doesn't look right");
|
|
const d = await prisma.recordDispute.create({
|
|
data: {
|
|
facilityId: fid, staffId: me.id, body,
|
|
itemId: str(p.itemId) || null,
|
|
sizeIndex: p.si === undefined || p.si === null || p.si === "" ? null : int(p.si),
|
|
},
|
|
});
|
|
return { id: d.id };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- waitlist */
|
|
case "waitlist.join": {
|
|
const item = await prisma.catalogItem.findFirst({ where: { id: str(p.itemId), facilityId: fid } });
|
|
const si = int(p.si, -1);
|
|
if (!item || si < 0 || si >= item.sizes.length) throw new StaffOpError("Pick a size", 404);
|
|
// A place in the queue ends in a request, which would be refused for a garment outside their
|
|
// staff group — so it is refused here, before they wait for an offer they cannot accept.
|
|
const offGroup = await offGroupGarments(fid, me.group, [item.id]);
|
|
if (offGroup.length) throw new StaffOpError(offGroupMessage(offGroup, me, true));
|
|
// The same for the cut, before they wait for an offer their own request would refuse.
|
|
const offStyle = await offStyleGarments(fid, me.uniformStyle, [item.id]);
|
|
if (offStyle.length) throw new StaffOpError(offStyleMessage(offStyle, me, true));
|
|
|
|
// Leaving is a soft delete — the row stays, with leftAt set — but one place in a queue per
|
|
// garment and size is a hard constraint, so a second create could never succeed and told
|
|
// whoever tried "you're already on the list", which was the opposite of true. Coming back
|
|
// revives the row she already has, at the back of the queue: leaving forfeits her place,
|
|
// which is the honest reading of having left.
|
|
const existing = await prisma.waitlistEntry.findUnique({
|
|
where: { staffId_itemId_sizeIndex: { staffId: me.id, itemId: item.id, sizeIndex: si } },
|
|
});
|
|
if (existing && !existing.leftAt && !existing.acceptedAt) throw new StaffOpError("You're already on the list for that size");
|
|
if (existing) {
|
|
const w = await prisma.waitlistEntry.update({
|
|
where: { id: existing.id },
|
|
data: { leftAt: null, offeredAt: null, acceptedAt: null, createdAt: new Date() },
|
|
});
|
|
return { id: w.id };
|
|
}
|
|
try {
|
|
// Joining needs no approval — a queue is not a request. Approval happens if and when the
|
|
// item lands and they accept it.
|
|
const w = await prisma.waitlistEntry.create({ data: { facilityId: fid, staffId: me.id, itemId: item.id, sizeIndex: si } });
|
|
return { id: w.id };
|
|
} catch (e) {
|
|
// Two taps racing each other past the read above — by then it is true.
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") throw new StaffOpError("You're already on the list for that size");
|
|
throw e;
|
|
}
|
|
}
|
|
case "waitlist.leave": {
|
|
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), staffId: me.id, leftAt: null } });
|
|
if (!w) throw new StaffOpError("Not on that list", 404);
|
|
await prisma.waitlistEntry.update({ where: { id: w.id }, data: { leftAt: new Date() } });
|
|
return { ok: true };
|
|
}
|
|
case "waitlist.accept": {
|
|
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), staffId: me.id, leftAt: null, acceptedAt: null, offeredAt: { not: null } } });
|
|
if (!w) throw new StaffOpError("Nothing to accept", 404);
|
|
// The hold the screen and the offer email both promise. Enforced rather than described: the
|
|
// garment goes back on the shelf for whoever is next, and she keeps her place in the queue.
|
|
if (holdExpired(w.offeredAt)) {
|
|
throw new StaffOpError(`That was held for ${WAITLIST_HOLD_HOURS} hours and the hold has run out. You're still on the list — ask the linen room if it's still there.`);
|
|
}
|
|
// A withdrawn range can still have garments on the shelf, so an offer can outlive the
|
|
// catalogue entry — and a request may not carry an archived one. Refuse before the offer is
|
|
// claimed rather than after: rolling the acceptance back and saying "That garment isn't
|
|
// available" about a garment the linen room has physically held for her is a dead end she
|
|
// cannot tap her way out of. Her place on the list is untouched, and the counter can still
|
|
// hand it over by hand.
|
|
const offered = await prisma.catalogItem.findFirst({ where: { id: w.itemId, facilityId: fid }, select: { archived: true } });
|
|
if (offered?.archived) {
|
|
throw new StaffOpError("That garment has been discontinued since you joined the list, so it can't be requested here. Ask the linen room — they're holding it for you at the counter.");
|
|
}
|
|
// Claim the offer before raising the request, so two taps can't turn one held garment into
|
|
// two requests — and hand it straight back if the request can't be raised. An entry stamped
|
|
// accepted with no request behind it is a queue place that can never be used again: accept
|
|
// refuses it forever, and the linen room's waiting list has already dropped her.
|
|
const claimed = await prisma.waitlistEntry.updateMany({ where: { id: w.id, acceptedAt: null }, data: { acceptedAt: new Date() } });
|
|
if (claimed.count !== 1) throw new StaffOpError("Nothing to accept", 404);
|
|
try {
|
|
const req = await runStaffOp(sess, "request.create", { lines: [{ itemId: w.itemId, si: w.sizeIndex, qty: 1 }], reason: "Extra for shifts", note: "Accepted from the waitlist" });
|
|
return { ok: true, request: req };
|
|
} catch (e) {
|
|
await prisma.waitlistEntry.updateMany({ where: { id: w.id }, data: { acceptedAt: null } });
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- kit check */
|
|
case "kit.answer": {
|
|
const cycle = await prisma.kitCheck.findFirst({ where: { facilityId: fid, closedAt: null }, orderBy: { openedAt: "desc" } });
|
|
if (!cycle) throw new StaffOpError("No kit check is open", 404);
|
|
const item = await prisma.catalogItem.findFirst({ where: { id: str(p.itemId), facilityId: fid } });
|
|
const si = int(p.si, -1);
|
|
if (!item || si < 0 || si >= item.sizes.length) throw new StaffOpError("Unknown garment", 404);
|
|
// "On record" is the linen room's figure, so it is read from the record and not from the
|
|
// phone that is being asked about it. The screen is sent the same number and posts it back,
|
|
// which is convenient and worthless as evidence — these answers are what a coordinator
|
|
// adjusts the register against, and a shortfall the client chose is not a shortfall.
|
|
const held = await prisma.issue.aggregate({
|
|
where: { facilityId: fid, staffId: me.id, itemId: item.id, sizeIndex: si, returnedDate: null, handedIn: null },
|
|
_sum: { qty: true },
|
|
});
|
|
const onRecord = held._sum.qty || 0;
|
|
if (onRecord <= 0) throw new StaffOpError("You're not holding that garment", 404);
|
|
const confirmed = Math.max(0, Math.min(onRecord, int(p.confirmed, 0)));
|
|
const a = await prisma.kitCheckAnswer.upsert({
|
|
where: { kitCheckId_staffId_itemId_sizeIndex: { kitCheckId: cycle.id, staffId: me.id, itemId: item.id, sizeIndex: si } },
|
|
create: { kitCheckId: cycle.id, staffId: me.id, itemId: item.id, sizeIndex: si, onRecord, confirmed },
|
|
update: { onRecord, confirmed, answeredAt: new Date() },
|
|
});
|
|
// Nothing is written off here. The answers are the linen room's evidence; they adjust the
|
|
// record. A screen that silently removed garments from someone's name would be a screen
|
|
// people learn to lie to.
|
|
return { id: a.id, short: onRecord - confirmed };
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- their own sign-in */
|
|
case "account.password": {
|
|
/* The one thing a staff member can change about their account, and the only revocation they
|
|
* have. A staff token carries a fingerprint of the password hash, so a new password ends
|
|
* every session signed against the old one — a phone left on a ward, a cookie copied off it
|
|
* — without a session table to keep. Signing out clears one cookie; this clears the lot.
|
|
*
|
|
* Deleting the account is deliberately not here. Access is the linen room's to grant and
|
|
* theirs to remove: a wearer who could delete their own account would take the record of
|
|
* what they were issued with it. */
|
|
const current = str(p.current, 200);
|
|
const next = str(p.next, 200);
|
|
if (next.length < MIN_PASSWORD) throw new StaffOpError(`Use at least ${MIN_PASSWORD} characters for your new password.`);
|
|
if (next === current) throw new StaffOpError("That's the password you already have.");
|
|
const acc = await prisma.staffAccount.findUnique({ where: { id: sess.accountId }, select: { id: true, passwordHash: true } });
|
|
if (!acc) throw new StaffOpError("No such account", 404);
|
|
if (!(await bcrypt.compare(current, acc.passwordHash))) throw new StaffOpError("That isn't your current password.", 403);
|
|
const passwordHash = await bcrypt.hash(next, 12);
|
|
await prisma.staffAccount.update({ where: { id: acc.id }, data: { passwordHash } });
|
|
// Re-issued in the same breath, so the person who just changed it is the one session that
|
|
// survives rather than the one that gets thrown out.
|
|
await setStaffCookie(acc.id, passwordHash);
|
|
return { ok: true };
|
|
}
|
|
|
|
default:
|
|
throw new StaffOpError("Unknown action", 400);
|
|
}
|
|
}
|