057da00fd2
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
57 lines
3.0 KiB
TypeScript
57 lines
3.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { currentStaff } from "@/lib/staffsession";
|
|
import { StaffOpError, runStaffOp } from "@/lib/staffops";
|
|
import { sameOriginJson } from "@/lib/csrf";
|
|
import { allow, clientIp } from "@/lib/ratelimit";
|
|
import { recordStaffAudit } from "@/lib/audit";
|
|
import { bumpRev } from "@/lib/ops";
|
|
import { report } from "@/lib/glitchtip";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/* The one door for everything a wearer, manager or ward clerk changes.
|
|
*
|
|
* Separate from /api/mutate, and reached only with a staff session. The two never share a handler:
|
|
* a single endpoint that accepted either kind of caller would put the whole coordinator op table
|
|
* one authorisation slip away from a wearer's phone.
|
|
*/
|
|
export async function POST(req: NextRequest) {
|
|
const sess = await currentStaff();
|
|
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
|
const csrf = sameOriginJson(req);
|
|
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
|
|
|
let body: { op?: string; payload?: unknown };
|
|
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
|
|
const op = String(body.op || "");
|
|
|
|
if (!allow("staff-mutate:" + sess.accountId, 120, 60 * 1000)) {
|
|
return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
|
|
}
|
|
// Requests are the expensive ones: each sends an email to a manager. A tighter budget stops a
|
|
// stuck retry loop turning into a mailbox full of the same approval.
|
|
// damage.report and waitlist.accept raise a request (and mail the manager) through the same
|
|
// path, so they draw on the same budget — otherwise the loop just picks a different door.
|
|
if (["request.create", "damage.report", "waitlist.accept"].includes(op) && !allow("staff-request:" + sess.staffId, 12, 60 * 60 * 1000)) {
|
|
return NextResponse.json({ error: "That's a lot of requests in an hour — talk to the linen room." }, { status: 429 });
|
|
}
|
|
|
|
const ip = clientIp(req.headers);
|
|
try {
|
|
const payload = (body.payload || {}) as Record<string, unknown>;
|
|
const result = await runStaffOp(sess, op, payload);
|
|
// The same discipline as /api/mutate, and for the same reason: a uniform issued to a ward is
|
|
// authorised here as often as it is in the linen room, and "who approved this" is the question
|
|
// the trail exists to answer. Recorded only after the op actually succeeded.
|
|
recordStaffAudit(sess, op, payload, ip, result);
|
|
// Handed back so the screen that made this change does not bounce again when it next polls.
|
|
const rev = await bumpRev(sess.facilityId);
|
|
return NextResponse.json({ ok: true, result, rev });
|
|
} catch (e) {
|
|
if (e instanceof StaffOpError) return NextResponse.json({ error: e.message }, { status: e.status });
|
|
report({ error: e, where: "server", url: "/api/staff/mutate", tags: { op } });
|
|
console.error(`[staff mutate ${op}]`, e, "ip=", ip);
|
|
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
|
|
}
|
|
}
|