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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { readApprovalToken } from "@/lib/approvallink";
|
||||
import { StaffOpError, decideRequest } from "@/lib/staffops";
|
||||
import { recordFor } from "@/lib/audit";
|
||||
import { bumpRev } from "@/lib/ops";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Deciding a request from the emailed link, without signing in.
|
||||
*
|
||||
* This is a POST and only a POST. The link in the email is a GET that renders /my/approve, and the
|
||||
* decision is made from that page — because corporate mail scanners and link-preview crawlers
|
||||
* fetch every URL in every message, and a GET that approved a uniform request would be approved by
|
||||
* the mail gateway before the manager ever saw it.
|
||||
*
|
||||
* The decision itself is decideRequest()'s, not this route's. A request now carries a line per
|
||||
* garment, and settling it means settling every line and then rolling the request up from them;
|
||||
* an approval made here that moved only the request would leave every line `awaiting`, so the
|
||||
* linen room's bag would come out empty and the wearer's order would show no decision at all.
|
||||
* There is no room on this page for a garment-by-garment answer — there is no signed-in person to
|
||||
* check one against — so it takes the whole-request shorthand, `approveAll`, which is the reason
|
||||
* that argument exists.
|
||||
*
|
||||
* Single use falls out of the state machine rather than a table of spent tokens: decideRequest's
|
||||
* update is conditional on the request still being `awaiting`, so the approve link and the decline
|
||||
* link in the same email both stop working the moment either is used.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("staff-decide:" + ip, 200, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { token?: unknown; action?: unknown; reason?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
const claim = readApprovalToken(String(body.token ?? ""));
|
||||
if (!claim) return NextResponse.json({ error: "That link has expired. Open the app and use the approvals queue." }, { status: 400 });
|
||||
|
||||
const action = String(body.action ?? "");
|
||||
if (action !== "approve" && action !== "decline") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
|
||||
let done: Awaited<ReturnType<typeof decideRequest>>;
|
||||
try {
|
||||
done = await decideRequest({
|
||||
requestId: claim.rid,
|
||||
managerId: claim.mid,
|
||||
approveAll: action === "approve",
|
||||
reason: body.reason,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof StaffOpError)) throw e;
|
||||
/* The refusals are worded for somebody standing in their mail client, not in the app.
|
||||
*
|
||||
* A 403 here is decideRequest re-reading the register and finding the manager off it, or the
|
||||
* wearer off it — the check that makes a fortnight-old token in a mailbox that has since been
|
||||
* closed or handed on safe. Neither the sacked manager nor a stranger reading their mail is
|
||||
* told which of the two it was; "ask the linen room" is where that conversation belongs.
|
||||
*
|
||||
* A 409 is the link already spent, and keeps the `already` flag the page reads to show the
|
||||
* decision that was made rather than an error. */
|
||||
if (e.status === 403) return NextResponse.json({ error: "That link is no longer valid — ask the linen room." }, { status: 403 });
|
||||
if (e.status === 404) return NextResponse.json({ error: "That request is no longer there." }, { status: 404 });
|
||||
if (e.status === 409) return NextResponse.json({ error: "That request has already been decided.", already: true }, { status: 409 });
|
||||
return NextResponse.json({ error: e.message }, { status: e.status });
|
||||
}
|
||||
|
||||
// Filed under the manager's own Staff id, exactly as the in-app approval is, so the log names
|
||||
// the same person either way; the op says which door the decision came through, because "an
|
||||
// email link, from an address we can't see" is part of the answer to who authorised this.
|
||||
recordFor(
|
||||
{ facilityId: done.facilityId, userId: claim.mid, userName: done.managerName },
|
||||
done.status === "accepted" ? "staff:request.approve.email" : "staff:request.decline.email",
|
||||
{ id: claim.rid }, ip,
|
||||
);
|
||||
|
||||
// The third door into the facility's data, so the third place the revision has to move: a manager
|
||||
// approving from their mail is exactly the change the linen room's screen is waiting to see.
|
||||
await bumpRev(done.facilityId);
|
||||
|
||||
return NextResponse.json({ ok: true, status: done.status, notified: done.notified });
|
||||
}
|
||||
Reference in New Issue
Block a user