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 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { normaliseCode, setStaffCookie } from "@/lib/staffsession";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
import { bumpRev } from "@/lib/ops";
|
||||
import { SLIP_DAYS, facilityToday, slipLive } from "@/lib/compute";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MIN_PW = 8;
|
||||
|
||||
/* Claiming your own record with the code the linen room printed for you.
|
||||
*
|
||||
* The code alone identifies the person, because it is presented before we know anything about them
|
||||
* — there is no facility to scope it to and no email to look up yet. That is why it is globally
|
||||
* unique, why it is 58 bits wide, and why this route is throttled to the point where working
|
||||
* through the space is not a strategy.
|
||||
*
|
||||
* It is spent in the same update that finds it, so two people racing the same slip can't both
|
||||
* claim the record; the loser gets the ordinary "code isn't right" message.
|
||||
*
|
||||
* It also goes stale on its own after fourteen days, because the far more likely way a slip is
|
||||
* misused is not a guessed code but a printed one nobody ever collected.
|
||||
*/
|
||||
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-activate:" + ip, 200, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { code?: unknown; email?: unknown; password?: unknown; cfToken?: unknown; agreed?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
const code = normaliseCode(String(body.code ?? ""));
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(body.password ?? "").slice(0, 200);
|
||||
|
||||
if (!code) return NextResponse.json({ error: "That code isn't right. It's twelve characters, in three groups." }, { status: 400 });
|
||||
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) return NextResponse.json({ error: "Enter an email address you can get to." }, { status: 400 });
|
||||
if (password.length < MIN_PW) return NextResponse.json({ error: `Use at least ${MIN_PW} characters for your password.` }, { status: 400 });
|
||||
// The agreement is collected where the account is created. The screen's tick is what sets it,
|
||||
// and the door checks it too so a client that skips the box gets the same answer.
|
||||
if (body.agreed !== true) return NextResponse.json({ error: "Tick the box to agree to the terms of use and privacy policy." }, { status: 400 });
|
||||
|
||||
// Checked before the code is looked up, so a bot working through the code space is stopped by
|
||||
// Cloudflare rather than by the per-IP throttle alone.
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
const staff = await prisma.staff.findUnique({
|
||||
where: { activateCode: code },
|
||||
select: { id: true, facilityId: true, first: true, last: true, inactive: true, activateCodeAt: true, account: { select: { id: true } }, facility: { select: { timezone: true } } },
|
||||
});
|
||||
// One message for every way this can fail, so the response can't be used to tell a real code from
|
||||
// a spent one.
|
||||
const nope = () => NextResponse.json({ error: "That code isn't right, or it has already been used. Ask the linen room for a new one." }, { status: 400 });
|
||||
if (!staff || staff.inactive || staff.account) return nope();
|
||||
|
||||
/* An old slip is refused whether or not anyone ever claimed it. It is a bearer token on paper:
|
||||
* whoever picks one out of a folder months later can bind their own email and password to this
|
||||
* person's record and from then on be them — their issues, their requests, their signature on the
|
||||
* ward round, and their approvals queue if they manage anyone. Fourteen days is long enough for
|
||||
* someone on leave to come back to it and short enough that a forgotten one is dead by the time
|
||||
* it turns up.
|
||||
*
|
||||
* An unstamped code counts as stale: its age is unknown, so it has to be assumed old. This leans
|
||||
* on staff.selfCode in lib/ops.ts stamping activateCodeAt as it prints — if that stamp ever stops
|
||||
* being written, every new slip is dead on arrival.
|
||||
*
|
||||
* This says plainly that the slip has expired rather than joining the deliberately vague message
|
||||
* above. Landing here means the code was right, and a code is 58 bits behind a throttle and a
|
||||
* Turnstile — so anyone who gets this far is holding a real slip and needs to be told that a
|
||||
* reprint, not a retype, is the fix.
|
||||
*
|
||||
* The age is asked of slipLive() in lib/compute, the same test the staff register and the requests
|
||||
* queue use to say whether a slip is still worth chasing, counted in whole days on the facility's
|
||||
* own calendar. The day a coordinator's screen calls a slip expired is therefore the day this
|
||||
* refuses it — never a few hours later, with a nurse who was told it was dead finding it still
|
||||
* works, or one who was told it was fine being turned away. */
|
||||
const tz = staff.facility.timezone;
|
||||
if (!slipLive(staff.activateCodeAt, facilityToday(tz), tz)) {
|
||||
return NextResponse.json(
|
||||
{ error: `That code was printed ${SLIP_DAYS} or more days ago, so it has expired. Ask the linen room to print you a new slip.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// The email has to be free across staff accounts. Coordinator accounts live in a different table
|
||||
// and a person may legitimately be both — a linen-room supervisor who also wears the uniform.
|
||||
const taken = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
|
||||
if (taken) return NextResponse.json({ error: "That email is already on an account here. Sign in instead." }, { status: 400 });
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Spend the code first, conditionally. If it has gone in the meantime, nothing was created.
|
||||
// The stamp goes with the code, so a spent row can't be read as a slip still waiting out there.
|
||||
const spent = await prisma.staff.updateMany({ where: { id: staff.id, activateCode: code }, data: { activateCode: null, activateCodeAt: null } });
|
||||
if (spent.count !== 1) return nope();
|
||||
|
||||
let account;
|
||||
try {
|
||||
account = await prisma.staffAccount.create({
|
||||
data: { facilityId: staff.facilityId, staffId: staff.id, email, passwordHash },
|
||||
select: { id: true, passwordHash: true },
|
||||
});
|
||||
} catch {
|
||||
// The code is gone but the account didn't happen — put the code back rather than stranding
|
||||
// someone with a dead slip. The original print date goes back with it: a failed attempt is not
|
||||
// a reprint and must not restart the fourteen days.
|
||||
await prisma.staff.update({ where: { id: staff.id }, data: { activateCode: code, activateCodeAt: staff.activateCodeAt } }).catch(() => {});
|
||||
return NextResponse.json({ error: "That didn't work — try again." }, { status: 500 });
|
||||
}
|
||||
|
||||
await setStaffCookie(account.id, account.passwordHash);
|
||||
recordAuthEvent(
|
||||
{ facilityId: staff.facilityId, userId: staff.id, userName: `${staff.first} ${staff.last}`.trim() || email },
|
||||
"staff:activate", ip,
|
||||
);
|
||||
// The fourth door that changes a facility's data, and the only one outside the three mutate
|
||||
// routes. Without this the coordinator standing over the nurse while she activates keeps seeing
|
||||
// "code outstanding" until some unrelated edit moves the revision, and reissues a code that
|
||||
// can't be reissued.
|
||||
await bumpRev(staff.facilityId);
|
||||
return NextResponse.json({ ok: true, name: `${staff.first} ${staff.last}` });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { signInStaff, staffThrottled } from "@/lib/staffauth";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin.
|
||||
*
|
||||
* The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts
|
||||
* as a match, what a deactivated record is told, and what lands in the audit trail are decided in
|
||||
* one place for both. This route is the HTTP shape of it: the origin check, the security check, and
|
||||
* the throttle asked in that order. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
let body: { email?: unknown; password?: unknown; cfToken?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(body.password ?? "").slice(0, 200);
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
// Asked before the security check, because a Turnstile token is good for one use and somebody who
|
||||
// is already throttled should not spend theirs to be told so.
|
||||
if (staffThrottled(email, ip)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
}
|
||||
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
|
||||
|
||||
// The same bot check the coordinator door has. A ward account opens one person's uniform record,
|
||||
// and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone
|
||||
// meant a list of hospital addresses and enough patience was the whole attack.
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
// `true`: this is the register's own door, so an address with no account here is a plain wrong
|
||||
// answer and is counted as one.
|
||||
const r = await signInStaff(email, password, ip, true);
|
||||
if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name });
|
||||
if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status });
|
||||
// Unreachable at this door: `none` is only returned when the caller asked not to be counted.
|
||||
return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clearStaffCookie, currentStaff } from "@/lib/staffsession";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
// Read the session before dropping it, so the trail can say who left. Signing out still succeeds
|
||||
// when there was nothing to sign out of.
|
||||
const sess = await currentStaff();
|
||||
await clearStaffCookie();
|
||||
if (sess) {
|
||||
recordAuthEvent(
|
||||
{ facilityId: sess.facilityId, userId: sess.staffId, userName: `${sess.first} ${sess.last}`.trim() || sess.email },
|
||||
"staff:signout", clientIp(req.headers),
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user