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,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import QRCode from "qrcode";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import {
|
||||
decryptSecret, encryptSecret, hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify,
|
||||
} from "@/lib/totp";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Turning a second factor on and off, for your own account only.
|
||||
*
|
||||
* Three steps rather than one, because a secret that is stored the moment it is generated leaves
|
||||
* an account half-enrolled if the person never finishes — and then their next sign-in asks for
|
||||
* codes from an app they never set up.
|
||||
*
|
||||
* setup — generate a secret and show the QR. Stored, but not yet in force.
|
||||
* enable — prove a code from it works, then switch it on and hand back recovery codes.
|
||||
* disable — password required, because turning a factor off is a privileged act.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const u = await prisma.user.findUnique({ where: { id: user.id }, select: { totpEnabledAt: true } });
|
||||
const left = await prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } });
|
||||
return NextResponse.json({ enabled: !!u?.totpEnabledAt, enabledAt: u?.totpEnabledAt ?? null, recoveryLeft: left });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("2fa-manage:" + user.id, 30, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
// Turning a second factor on or off is one of the few changes to an account that leaves no trace
|
||||
// in the records themselves, so it is one of the few worth recording on its own.
|
||||
const actor = {
|
||||
facilityId: user.facilityId, userId: user.id,
|
||||
userName: `${user.first} ${user.last}`.trim() || user.email,
|
||||
};
|
||||
|
||||
let body: { action?: unknown; code?: unknown; password?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const action = String(body.action ?? "");
|
||||
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
|
||||
});
|
||||
if (!u) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
if (action === "setup") {
|
||||
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
|
||||
const secret = newTotpSecret();
|
||||
await prisma.user.update({ where: { id: u.id }, data: { totpSecret: encryptSecret(secret) } });
|
||||
const url = otpauthUrl(secret, u.email);
|
||||
// SVG, generated here rather than in the browser: it keeps a QR library out of the bundle that
|
||||
// ward phones download, and the secret never has to be handed to client-side code to render.
|
||||
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
|
||||
recordAuthEvent(actor, "2fa:setup", ip);
|
||||
return NextResponse.json({ ok: true, secret, url, qr });
|
||||
}
|
||||
|
||||
if (action === "enable") {
|
||||
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
|
||||
const secret = decryptSecret(u.totpSecret);
|
||||
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
|
||||
if (!totpVerify(secret, String(body.code ?? ""))) {
|
||||
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
|
||||
}
|
||||
const codes = newRecoveryCodes();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: new Date() } });
|
||||
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
|
||||
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
|
||||
});
|
||||
recordAuthEvent(actor, "2fa:enable", ip);
|
||||
// The only time these are ever readable. They are stored hashed, so there is no second chance.
|
||||
return NextResponse.json({ ok: true, codes });
|
||||
}
|
||||
|
||||
if (action === "disable") {
|
||||
if (!u.totpEnabledAt) return NextResponse.json({ ok: true });
|
||||
const pw = String(body.password ?? "");
|
||||
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
|
||||
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: null, totpSecret: "" } });
|
||||
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
|
||||
});
|
||||
recordAuthEvent(actor, "2fa:disable", ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (action === "regenerate") {
|
||||
if (!u.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
|
||||
const pw = String(body.password ?? "");
|
||||
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
|
||||
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
|
||||
}
|
||||
const codes = newRecoveryCodes();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
|
||||
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
|
||||
});
|
||||
recordAuthEvent(actor, "2fa:regenerate", ip);
|
||||
return NextResponse.json({ ok: true, codes });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
/* The audit trail, read back.
|
||||
*
|
||||
* Admin only, and scoped to the caller's own facility by the query rather than by a filter the
|
||||
* client sends — the client never gets to say which facility it wants. Paged by cursor rather
|
||||
* than offset so a busy room's log doesn't shift under you as new rows land while you read.
|
||||
*
|
||||
* The cursor is (timestamp, id), not timestamp alone. Prisma stores DateTime at millisecond
|
||||
* precision, and two events sharing a millisecond is ordinary rather than exotic — two coordinators
|
||||
* saving at once, or two ops committed inside one transaction. A strict `at < cursor` dropped every
|
||||
* row that shared the last one's millisecond, so the log looked complete with an event missing from
|
||||
* it, which is the one failure an audit trail cannot have.
|
||||
*/
|
||||
|
||||
/** `<iso>|<id>` — one opaque string, because the client only ever hands it straight back. */
|
||||
function readCursor(raw: string | null): { at: Date; id: string } | null {
|
||||
if (!raw) return null;
|
||||
const cut = raw.lastIndexOf("|");
|
||||
const iso = cut === -1 ? raw : raw.slice(0, cut);
|
||||
const id = cut === -1 ? "" : raw.slice(cut + 1);
|
||||
if (Number.isNaN(Date.parse(iso))) return null;
|
||||
return { at: new Date(iso), id: id.slice(0, 40) };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
// SessionUser.role is the database enum ("ADMIN"), not the snapshot's display form ("Admin").
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
|
||||
|
||||
const cursor = readCursor(req.nextUrl.searchParams.get("before"));
|
||||
|
||||
const rows = await prisma.auditEvent.findMany({
|
||||
where: {
|
||||
facilityId: user.facilityId,
|
||||
// Everything strictly older, plus the rest of the millisecond we stopped in the middle of.
|
||||
...(cursor ? { OR: [{ at: { lt: cursor.at } }, { at: cursor.at, id: { lt: cursor.id } }] } : {}),
|
||||
},
|
||||
orderBy: [{ at: "desc" }, { id: "desc" }],
|
||||
take: PAGE + 1,
|
||||
select: { id: true, at: true, userName: true, op: true, target: true },
|
||||
});
|
||||
|
||||
const more = rows.length > PAGE;
|
||||
const page = rows.slice(0, PAGE);
|
||||
const last = page[page.length - 1];
|
||||
return NextResponse.json({
|
||||
events: page.map((r) => ({ id: r.id, at: r.at.toISOString(), who: r.userName, op: r.op, target: r.target })),
|
||||
nextBefore: more && last ? `${last.at.toISOString()}|${last.id}` : null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { COMMUNITY } from "@/lib/edition";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* What the Android apps ask a server before they will point at it.
|
||||
*
|
||||
* The apps open threadcount.tech unless told otherwise; a room running the Community edition types
|
||||
* its own address into the app's first screen, and the app calls this first. It proves the address
|
||||
* is a ThreadCount server (not a look-alike, not a typo), says which edition and build, and carries
|
||||
* the oldest app version this build still works with, so an app can say "update me" instead of
|
||||
* breaking quietly. Public and unauthenticated on purpose: nothing here is about a facility. */
|
||||
function version(): string {
|
||||
try { return readFileSync(path.join(process.cwd(), "COMMUNITY_VERSION"), "utf8").trim(); } catch { /* hosted: no file */ }
|
||||
return process.env.NEXT_PUBLIC_RELEASE || "hosted";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
product: "threadcount",
|
||||
edition: COMMUNITY ? "community" : "hosted",
|
||||
version: version(),
|
||||
paths: { counter: "/m", staff: "/my" },
|
||||
// The oldest Play versionCode of each app this server still serves correctly.
|
||||
minApp: { counter: 9, staff: 7 },
|
||||
}, { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pwVersion, setSessionCookie } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp";
|
||||
import { readTicket } from "@/lib/twofactor";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Second step of sign-in: the code from the authenticator, or one recovery code.
|
||||
*
|
||||
* Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if
|
||||
* guessing is expensive — unthrottled, a million tries is minutes of work. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
let body: { ticket?: unknown; code?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
const t = readTicket(String(body.ticket ?? ""));
|
||||
if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
||||
|
||||
// Per account and per address: one stolen ticket can't be brute-forced, and one machine can't
|
||||
// work through several accounts at once.
|
||||
if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: t.uid },
|
||||
select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
|
||||
});
|
||||
if (!u || u.inactive || !u.totpEnabledAt) {
|
||||
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
||||
}
|
||||
// The password changed between the two steps — the ticket is stale for the same reason a session
|
||||
// would be.
|
||||
if (pwVersion(u.passwordHash) !== t.pv) {
|
||||
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = String(body.code ?? "").trim();
|
||||
const secret = decryptSecret(u.totpSecret);
|
||||
let good = !!secret && totpVerify(secret, raw);
|
||||
let usedRecovery = false;
|
||||
|
||||
if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) {
|
||||
// A recovery code. Single use: consumed in the same conditional update that finds it, so two
|
||||
// simultaneous attempts can't both spend it.
|
||||
const hash = hashRecoveryCode(raw);
|
||||
const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } });
|
||||
if (hit) {
|
||||
const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } });
|
||||
good = consumed.count === 1;
|
||||
usedRecovery = good;
|
||||
}
|
||||
}
|
||||
|
||||
if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 });
|
||||
|
||||
await setSessionCookie(u.id, u.passwordHash);
|
||||
// How they got in matters more here than anywhere else: a recovery code means the phone is gone,
|
||||
// and a run of them means something else is going on.
|
||||
recordAuthEvent(
|
||||
{ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email },
|
||||
"auth:signin", ip, usedRecovery ? "recovery" : "totp",
|
||||
);
|
||||
const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } });
|
||||
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { sendTo, transactionalConfigured } from "@/lib/mail";
|
||||
import { RESET_TTL_MS, newResetToken, resetEmail, resetUrl } from "@/lib/reset";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Request a password reset.
|
||||
*
|
||||
* Before this existed a facility whose only admin forgot their password was locked out for good —
|
||||
* the sign-in screen told them to ask an admin, and they were the admin. Deleting the last admin
|
||||
* deletes the whole facility, so there was no way back in at all.
|
||||
*
|
||||
* The response is identical whether or not the address has an account. Anything else turns this
|
||||
* into a way to ask "does this hospital use ThreadCount, and is this person a coordinator there?"
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
let body: { email?: 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);
|
||||
|
||||
// A slow ceiling per IP, so one machine can't walk a staff list to find out which addresses exist
|
||||
// by watching how long each request takes. The per-address ceiling deliberately lives further
|
||||
// down, past the bot check — see the note beside it.
|
||||
if (!allow("forgot-ip:" + ip, 60, 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ ok: true });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, first: true, inactive: true, ssoBreakGlass: true, facility: { select: { ssoRequired: true } } } });
|
||||
// A facility that requires single sign-on has no password door for most of its people, so a
|
||||
// reset link would be a way round its identity provider. Break-glass admins keep theirs. The
|
||||
// answer to the caller is the same either way.
|
||||
const ssoOnly = !!user && user.facility.ssoRequired && !user.ssoBreakGlass;
|
||||
if (user && !user.inactive && !ssoOnly) {
|
||||
// The per-address ceiling counts mail actually sent, not requests received, and it is only
|
||||
// consulted once the bot check has passed. Spent on requests, it handed a stranger a way to
|
||||
// hold a facility's only coordinator out of their own account: four anonymous posts with no
|
||||
// Turnstile token filled the bucket, and every later attempt by the coordinator was answered
|
||||
// with "a reset link is on its way" and no mail. Counted this way the only way to exhaust the
|
||||
// budget is to have four reset mails delivered to that same inbox, so whoever forgot their
|
||||
// password always has a working link waiting for them.
|
||||
const mailKey = "forgot-email:" + email;
|
||||
if (over(mailKey, 4, 60 * 60 * 1000)) {
|
||||
console.warn("[forgot] four reset mails already sent this hour — suppressing another for user", user.id);
|
||||
} else {
|
||||
const { token, tokenHash } = newResetToken();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Asking again supersedes anything outstanding, so a forwarded older email goes dead.
|
||||
await tx.passwordReset.updateMany({
|
||||
where: { userId: user.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
await tx.passwordReset.create({
|
||||
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + RESET_TTL_MS), requestIp: ip },
|
||||
});
|
||||
});
|
||||
const { subject, text } = resetEmail(user.first, resetUrl(token));
|
||||
const sent = await sendTo(email, subject, text);
|
||||
// Only a mail that left the building counts. A send that failed gave the coordinator nothing,
|
||||
// so charging them for it would shut them out for an hour over a mail outage.
|
||||
if (sent) fail(mailKey, 60 * 60 * 1000);
|
||||
else console.error("[forgot] reset requested but mail could not be sent for user", user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Told to the caller regardless, so the answer carries no information about the address.
|
||||
return NextResponse.json({ ok: true, mail: transactionalConfigured() });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pwVersion, setSessionCookie } from "@/lib/session";
|
||||
import { mintTicket } from "@/lib/twofactor";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clientIp, fail, over } from "@/lib/ratelimit";
|
||||
import { signInStaff } from "@/lib/staffauth";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Simple in-memory throttle per IP+email (per process).
|
||||
const attempts = new Map<string, { n: number; t: number }>();
|
||||
|
||||
/** The trail names the person, not the address they typed — see lib/audit.ts. */
|
||||
const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) =>
|
||||
({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email });
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
let body: { email?: string; password?: string; cfToken?: string };
|
||||
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);
|
||||
// Spray protection independent of the per-(ip,email) counter below. Both buckets count only the
|
||||
// attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a
|
||||
// ceiling on attempts would have to lock that ward out to be worth anything against an attacker.
|
||||
const ipKey = clientIp(req.headers);
|
||||
if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) 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 });
|
||||
|
||||
// nginx appends the real client IP last; earlier entries are client-supplied and spoofable.
|
||||
const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
|
||||
const ip = xff[xff.length - 1] || "local";
|
||||
if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk);
|
||||
const k = `${ip}|${email}`;
|
||||
const a = attempts.get(k);
|
||||
if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
const u = await prisma.user.findUnique({ where: { email } });
|
||||
/* One box, both kinds of account.
|
||||
*
|
||||
* A wearer reaches the product the way anyone else does — the home page, then Log in — and types
|
||||
* the details they set up in the staff app. So when this address has no coordinator account, the
|
||||
* register is asked before the answer is called wrong.
|
||||
*
|
||||
* A coordinator account always wins: it is the one with the counter, the orders and the register
|
||||
* behind it, and a coordinator who also wears a uniform can open their own record from inside the
|
||||
* app. One address therefore has one destination, every time.
|
||||
*
|
||||
* This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff
|
||||
* one" would score a failure against every single staff sign-in, and these ceilings count
|
||||
* failures — behind one hospital's NAT address at shift change that is a locked-out ward.
|
||||
*/
|
||||
if (!u) {
|
||||
const s = await signInStaff(email, password, ipKey, false);
|
||||
if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true });
|
||||
if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status });
|
||||
// `none`: no staff account either, so this falls through to the answer below, which counts the
|
||||
// failure once and says the same thing it has always said.
|
||||
}
|
||||
const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u");
|
||||
if (!u || !ok) {
|
||||
attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() });
|
||||
fail("login-ip:" + ipKey, 15 * 60 * 1000);
|
||||
if (email) fail("login-email:" + email, 15 * 60 * 1000);
|
||||
// An address with no account here is recorded nowhere: there is no facility to file it under,
|
||||
// and a log of attempts on addresses that don't exist would be a list of other people's email
|
||||
// addresses that nobody asked us to keep.
|
||||
if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey);
|
||||
return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 });
|
||||
}
|
||||
attempts.delete(k);
|
||||
if (u.inactive) {
|
||||
// The right password on an account that has been taken away is worth knowing about.
|
||||
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive");
|
||||
return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 });
|
||||
}
|
||||
const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } });
|
||||
if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts can’t log in here — open the demo from the home page." }, { status: 403 });
|
||||
// The facility has decided its people sign in through its own identity provider. The password
|
||||
// was right, and it is still refused — except for the admin the facility keeps as its fire
|
||||
// escape. The box sends them on to single sign-on rather than reporting a failure.
|
||||
if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) {
|
||||
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required");
|
||||
return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 });
|
||||
}
|
||||
// With a second factor on the account the password alone opens nothing. The ticket says only
|
||||
// "this password was correct", is accepted by no other endpoint, and expires in five minutes.
|
||||
if (u.totpEnabledAt) {
|
||||
return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) });
|
||||
}
|
||||
|
||||
await setSessionCookie(u.id, u.passwordHash);
|
||||
recordAuthEvent(actorFor(u), "auth:signin", ipKey, "password");
|
||||
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { clearSessionCookie, currentUser } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
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, false); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
// Read the session before dropping it, so the trail can say who left. An unauthenticated call
|
||||
// still clears the cookie and still answers ok — signing out must never fail.
|
||||
const user = await currentUser();
|
||||
await clearSessionCookie();
|
||||
if (user) {
|
||||
recordAuthEvent(
|
||||
{ facilityId: user.facilityId, userId: user.id, userName: `${user.first} ${user.last}`.trim() || user.email },
|
||||
"auth:signout", clientIp(req.headers),
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { pwVersion, setSessionCookie } from "@/lib/session";
|
||||
import { mintTicket } from "@/lib/twofactor";
|
||||
import { hashResetToken } from "@/lib/reset";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/* Complete a password reset.
|
||||
*
|
||||
* Changing the hash invalidates every existing session for that user on its own — the session
|
||||
* cookie carries a version derived from the password hash — so a reset also kicks out whoever
|
||||
* prompted it, which is the behaviour you want if the reason was a shared or stolen password. */
|
||||
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("reset-ip:" + ip, 100, 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again later." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { token?: unknown; password?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const token = String(body.token ?? "").trim().slice(0, 400);
|
||||
const password = String(body.password ?? "");
|
||||
|
||||
if (!token) return NextResponse.json({ error: "That link is incomplete. Ask for a new one." }, { status: 400 });
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return NextResponse.json({ error: `Use at least ${MIN_PASSWORD} characters.` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Looked up by hash, so the raw token never has to be compared against stored material.
|
||||
const row = await prisma.passwordReset.findUnique({
|
||||
where: { tokenHash: hashResetToken(token) },
|
||||
select: {
|
||||
id: true, userId: true, expiresAt: true, usedAt: true,
|
||||
user: { select: { inactive: true, passwordHash: true, totpEnabledAt: true, facilityId: true, first: true, last: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const dead = !row || row.usedAt || row.expiresAt.getTime() < Date.now() || row.user.inactive;
|
||||
if (dead) {
|
||||
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Consume the token in the same write as the password change, so a double submit can't set the
|
||||
// password twice or leave a live token behind.
|
||||
const consumed = await tx.passwordReset.updateMany({
|
||||
where: { id: row.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (consumed.count !== 1) throw new Error("token already consumed");
|
||||
await tx.user.update({ where: { id: row.userId }, data: { passwordHash: hash } });
|
||||
// Any other outstanding requests for this account die with it.
|
||||
await tx.passwordReset.updateMany({ where: { userId: row.userId, usedAt: null }, data: { usedAt: new Date() } });
|
||||
}).catch(() => null);
|
||||
|
||||
const fresh = await prisma.user.findUnique({ where: { id: row.userId }, select: { passwordHash: true } });
|
||||
if (!fresh || fresh.passwordHash !== hash) {
|
||||
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
|
||||
}
|
||||
|
||||
const actor = {
|
||||
facilityId: row.user.facilityId,
|
||||
userId: row.userId,
|
||||
userName: [row.user.first, row.user.last].filter(Boolean).join(" ").trim() || row.user.email,
|
||||
};
|
||||
|
||||
// A second factor is a second factor here too. Control of the mailbox is one proof, and on an
|
||||
// account with TOTP the front door refuses to open on one proof — so this door must not either,
|
||||
// or resetting the password would be the supported way around the authenticator, and the new
|
||||
// password would then be enough to turn it off for good.
|
||||
//
|
||||
// The same five-minute ticket the sign-in screen uses, accepted by the same endpoint: nothing new
|
||||
// to keep, nothing new to get wrong.
|
||||
if (row.user.totpEnabledAt) {
|
||||
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
|
||||
return NextResponse.json({ need2fa: true, ticket: mintTicket(row.userId, pwVersion(hash)) });
|
||||
}
|
||||
|
||||
// Otherwise sign them straight in: they have just proven control of the mailbox and chosen a
|
||||
// password, and making them type it again immediately is friction with no security value.
|
||||
await setSessionCookie(row.userId, hash);
|
||||
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
|
||||
recordAuthEvent(actor, "auth:signin", ip, "reset");
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { setSessionCookie } from "@/lib/session";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { sendTo, transactionalConfigured } from "@/lib/mail";
|
||||
import { switches } from "@/lib/switches";
|
||||
import { alertNewSignup } from "@/lib/ops/alerts";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
import { TRIAL_DAYS } from "@/lib/plan";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Creating a facility.
|
||||
*
|
||||
* The address typed here is not verified, and deliberately isn't: a confirmation step in front of
|
||||
* a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link
|
||||
* is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a
|
||||
* stranger and the owner identically, so a typo produces no signal at all until the day the
|
||||
* password is forgotten, and by then the facility is unreachable and undeletable.
|
||||
*
|
||||
* So the address is exercised immediately instead. A note goes to it saying, in as many words,
|
||||
* that this is the address that recovers the account, and the answer here says whether it was
|
||||
* sent — which is what lets the sign-up screen show the address back and tell someone who never
|
||||
* receives it what to do about it while they are still signed in and can still act.
|
||||
*/
|
||||
function welcomeEmail(first: string, facility: string) {
|
||||
const subject = "Your ThreadCount facility is set up";
|
||||
const text = [
|
||||
`Hi ${first || "there"},`,
|
||||
"",
|
||||
`${facility} is set up on ThreadCount, and this address is the coordinator account for it.`,
|
||||
"",
|
||||
"Keep this message. This is the address a password reset is sent to, and it is the only way",
|
||||
"back into the facility if the password is forgotten — so if it is wrong, sign in and add a",
|
||||
"second admin with an address that works, under Settings → Users.",
|
||||
"",
|
||||
`${process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech"}/app`,
|
||||
"",
|
||||
"— ThreadCount",
|
||||
].join("\n");
|
||||
return { subject, text };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const sw = await switches();
|
||||
if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 });
|
||||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 });
|
||||
let b: Record<string, string>;
|
||||
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80);
|
||||
const facility = String(b.facility || "").trim().slice(0, 120);
|
||||
const email = String(b.email || "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(b.password || "");
|
||||
if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 });
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 });
|
||||
if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 });
|
||||
const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 });
|
||||
|
||||
const u = await prisma.$transaction(async (tx) => {
|
||||
// No staff groups: the facility names its own. Any list handed over here would be one employer's
|
||||
// organisation chart on another employer's register, and a group sitting on a route nobody
|
||||
// chose decides who is handed a starting kit. Both route lists start empty with it, so until the
|
||||
// coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval
|
||||
// and nobody has been promised a kit the counter would not hand over.
|
||||
// Until plans are live the page still says free, so a facility created today is grandfathered:
|
||||
// free with everything, for good. Once they are live a new room starts on the plan it chose —
|
||||
// Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else
|
||||
// sent as `plan` is Hosted Small: the free room is the safe misreading.
|
||||
const trial = sw.plansLive && b.plan === "hosted_facility";
|
||||
const planData = !sw.plansLive
|
||||
? { grandfathered: true, planStatus: "free" }
|
||||
: trial
|
||||
? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) }
|
||||
: { plan: "hosted_small", planStatus: "free" };
|
||||
const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData } });
|
||||
return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } });
|
||||
});
|
||||
await setSessionCookie(u.id, u.passwordHash);
|
||||
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers));
|
||||
alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person
|
||||
|
||||
const em = welcomeEmail(first, facility);
|
||||
const mailed = await sendTo(email, em.subject, em.text);
|
||||
if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id);
|
||||
// `mailed` is false when no SMTP is configured at all, which is a different thing from a bad
|
||||
// address — the screen says so rather than pretending the address has been proven.
|
||||
return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportBackup } from "@/lib/ops";
|
||||
import { facilityToday } from "@/lib/compute";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
|
||||
const data = await exportBackup(user);
|
||||
// The date on the filename is the day where the linen room stands, not where the box is. It has
|
||||
// to agree with the lastBackup stamp exportBackup writes against the same facility, or a room
|
||||
// taking a backup at eight in the morning ends up with a file named for yesterday sitting beside
|
||||
// a settings screen that says it was taken today.
|
||||
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { timezone: true } });
|
||||
return new NextResponse(JSON.stringify(data, null, 1), {
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="threadcount-backup-${facilityToday(fac.timezone)}.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Is this server actually able to do its job?
|
||||
*
|
||||
* The deploy probes /app, which proves the process is serving HTML — but /app renders a redirect to
|
||||
* the sign-in page whether or not Prisma can reach the database, so the one failure that takes the
|
||||
* whole product down is exactly the one that probe cannot see. This asks the database a question
|
||||
* instead, and answers 503 when it cannot.
|
||||
*
|
||||
* No auth and no cache on purpose: it is watched continuously by an uptime monitor with no account,
|
||||
* and it must never answer from a cached success. It is listed in proxy.ts's `publicApi` for the
|
||||
* same reason. Nothing about the facility, the schema or the error is returned — a monitor needs a
|
||||
* status code, and an unauthenticated caller is owed nothing more.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } });
|
||||
} catch (e) {
|
||||
console.error("[health] database unreachable:", (e as Error).message);
|
||||
return NextResponse.json({ ok: false }, { status: 503, headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Serves the signed-in user's facility logo (stored as a data URL). */
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return new NextResponse(null, { status: 401 });
|
||||
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { logoData: true } });
|
||||
const m = /^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(fac?.logoData || "");
|
||||
if (!m) return new NextResponse(null, { status: 404 });
|
||||
return new NextResponse(Buffer.from(m[2], "base64"), { headers: { "content-type": m[1], "cache-control": "private, no-cache", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { allow } from "@/lib/ratelimit";
|
||||
import { gtinInfo } from "@/lib/compute";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export type LookupResult = {
|
||||
code: string;
|
||||
gtin: ReturnType<typeof gtinInfo>;
|
||||
enabled: boolean; // is public lookup turned on for this facility
|
||||
found: boolean;
|
||||
name?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
note?: string; // why there's no result, in plain words
|
||||
};
|
||||
|
||||
const TIMEOUT_MS = 4500;
|
||||
const cache = new Map<string, { at: number; v: Omit<LookupResult, "enabled" | "gtin" | "code"> }>();
|
||||
const CACHE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
async function getJson(url: string): Promise<unknown | null> {
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json", "user-agent": "ThreadCount/1.0 (uniform stock management)" }, cache: "no-store" });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; } finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/** UPCitemdb's keyless trial tier — small daily quota per server IP, so misses are expected. */
|
||||
async function upcItemDb(gtin: string) {
|
||||
const j = await getJson(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(gtin)}`) as { items?: { title?: string; brand?: string; category?: string }[] } | null;
|
||||
const it = j?.items?.[0];
|
||||
if (!it?.title) return null;
|
||||
return { name: String(it.title).slice(0, 160), brand: String(it.brand || "").slice(0, 80), category: String(it.category || "").slice(0, 80), source: "UPCitemdb" };
|
||||
}
|
||||
|
||||
/** Open Products Facts — the non-food sibling of Open Food Facts; open data, no key. */
|
||||
async function openProductsFacts(gtin: string) {
|
||||
const j = await getJson(`https://world.openproductsfacts.org/api/v2/product/${encodeURIComponent(gtin)}.json?fields=product_name,brands,categories`) as { status?: number; product?: { product_name?: string; brands?: string; categories?: string } } | null;
|
||||
const pr = j?.product;
|
||||
if (j?.status !== 1 || !pr?.product_name) return null;
|
||||
return { name: String(pr.product_name).slice(0, 160), brand: String(pr.brands || "").slice(0, 80), category: String(pr.categories || "").slice(0, 80), source: "Open Products Facts" };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
|
||||
|
||||
const gtin = gtinInfo(req.nextUrl.searchParams.get("code") || "");
|
||||
const base = { code: gtin.code, gtin, found: false } as LookupResult;
|
||||
if (!gtin.code) return NextResponse.json({ ...base, enabled: false, note: "No barcode given." });
|
||||
|
||||
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { barcodeLookup: true } });
|
||||
const enabled = !!fac?.barcodeLookup;
|
||||
if (!enabled) return NextResponse.json({ ...base, enabled: false, note: "Product lookup is off. Turn it on in Settings → Data if you want ThreadCount to ask a public barcode database for a name." });
|
||||
// Only real retail GTINs are worth sending anywhere; a mis-read or an in-house code never matches.
|
||||
if (!gtin.valid || !["EAN-13", "UPC-A", "EAN-8", "GTIN-14"].includes(gtin.kind)) {
|
||||
return NextResponse.json({ ...base, enabled, note: gtin.kind ? "The check digit doesn't match, so this wasn't looked up — scan it again." : "Not a standard retail barcode, so there's nothing to look up. Type the details in." });
|
||||
}
|
||||
if (!allow("lookup:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ ...base, enabled, note: "Too many lookups this hour — type the details in for now." }, { status: 429 });
|
||||
|
||||
const hit = cache.get(gtin.digits);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return NextResponse.json({ ...base, enabled, ...hit.v });
|
||||
|
||||
let found = await upcItemDb(gtin.digits);
|
||||
if (!found) found = await openProductsFacts(gtin.digits);
|
||||
const v = found
|
||||
? { found: true, ...found }
|
||||
: { found: false, note: "No public listing for this barcode — normal for workwear and hospital uniforms. Type the details in once and the barcode stays bound." };
|
||||
cache.set(gtin.digits, { at: Date.now(), v });
|
||||
if (cache.size > 500) for (const k of [...cache.keys()].slice(0, 100)) cache.delete(k);
|
||||
return NextResponse.json({ ...base, enabled, ...v });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { OpError, bumpRev, demoGuard, restoreBackup, runOp } from "@/lib/ops";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { recordAudit } from "@/lib/audit";
|
||||
import { report } from "@/lib/glitchtip";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
if (parseInt(req.headers.get("content-length") || "0", 10) > 60 * 1024 * 1024) return NextResponse.json({ error: "Request too large" }, { status: 413 });
|
||||
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("mutate:" + user.id, 600, 60 * 1000)) return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
|
||||
if (op === "photo.put" && !allow("photo:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ error: "Photo limit reached for this hour." }, { status: 429 });
|
||||
if ((op === "backup.restore" || op === "import.rows") && !allow("bulk:" + user.id, 20, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many imports — wait a few minutes." }, { status: 429 });
|
||||
try {
|
||||
if (op === "backup.restore") demoGuard(user, op);
|
||||
const result = op === "backup.restore" ? await restoreBackup(user, body.payload) : await runOp(user, op, body.payload);
|
||||
// Only after it actually succeeded, and only from here: every one of the 57 ops passes through
|
||||
// this one function, so the trail can't be forgotten in a new case branch later.
|
||||
recordAudit(user, op, body.payload, clientIp(req.headers));
|
||||
// Handed back so the screen that made this change does not bounce again when it next polls.
|
||||
const rev = await bumpRev(user.facilityId);
|
||||
return NextResponse.json({ ok: true, result, rev });
|
||||
} catch (e) {
|
||||
if (e instanceof OpError) return NextResponse.json({ error: e.message }, { status: e.status });
|
||||
// Reported from here, not from instrumentation.ts: onRequestError only sees what Next itself
|
||||
// catches, and an exception caught in this handler never reaches it. Every write in the product
|
||||
// comes through this line, so without it the whole write path fails invisibly.
|
||||
report({ error: e, where: "server", url: "/api/mutate", tags: { op } });
|
||||
console.error(`[mutate ${op}]`, e);
|
||||
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { parseDataUrl, readPhoto } from "@/lib/photostore";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Serves a stored capture or signature to a signed-in user of the same facility.
|
||||
*
|
||||
* Images live on disk now; rows written before that move still carry a base64 data URL, so both
|
||||
* are handled and old records keep working without a flag day. */
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const { id } = await ctx.params;
|
||||
|
||||
// Scoped by facility in the query: a photo id from another room is simply not found.
|
||||
const ph = await prisma.photo.findFirst({
|
||||
where: { id, facilityId: user.facilityId },
|
||||
select: { data: true, path: true, mime: true },
|
||||
});
|
||||
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
let mime = ph.mime;
|
||||
let bytes: Buffer | null = null;
|
||||
|
||||
if (ph.path) {
|
||||
bytes = await readPhoto(ph.path);
|
||||
} else if (ph.data) {
|
||||
const parsed = parseDataUrl(ph.data);
|
||||
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
|
||||
}
|
||||
|
||||
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
|
||||
|
||||
return new NextResponse(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"content-type": mime,
|
||||
"cache-control": "private, max-age=3600",
|
||||
"content-disposition": "inline",
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy": "sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { bagLines, linesSummary, reqLines } from "@/lib/staffdata";
|
||||
import { decisionSummary, garmentCount } from "@/lib/staffreq";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The linen room's view of staff requests.
|
||||
*
|
||||
* Its own endpoint rather than part of the snapshot, for the same reason the audit trail is: this
|
||||
* grows without limit, and putting it in the snapshot would make every page in the app heavier
|
||||
* forever to serve one screen.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
/* One person's requests, or the whole facility's.
|
||||
*
|
||||
* `?staff=` is how a staff record asks for its own order-form history. Without it that screen
|
||||
* pulled the facility's last 400 requests — every line, message and event on each — and kept the
|
||||
* handful belonging to one person, which is a large answer to a small question on a busy
|
||||
* register. Worse, that person's older requests fell off the end of the 400 and simply were not
|
||||
* on their record any more. Scoped, the ceiling is per person, and either way it is reported
|
||||
* back so a screen can say it has been reached rather than ending a history without a word.
|
||||
*/
|
||||
const staffId = (req.nextUrl.searchParams.get("staff") || "").trim().slice(0, 64);
|
||||
const requestLimit = staffId ? 200 : 400;
|
||||
|
||||
// One more row than is returned, so "there are older ones than these" is something we know
|
||||
// rather than something guessed from a full page.
|
||||
const found = await prisma.request.findMany({
|
||||
where: { facilityId: user.facilityId, ...(staffId ? { subjectId: staffId } : {}) },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: requestLimit + 1,
|
||||
include: {
|
||||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||||
subject: { select: { first: true, last: true, num: true, dept: true } },
|
||||
messages: { orderBy: { createdAt: "asc" }, select: { id: true, fromStaff: true, authorName: true, body: true, createdAt: true } },
|
||||
events: { orderBy: { at: "asc" }, select: { id: true, label: true, meta: true, actorName: true, at: true } },
|
||||
},
|
||||
});
|
||||
const requests = found.slice(0, requestLimit);
|
||||
const moreRequests = found.length > requestLimit;
|
||||
|
||||
const mapped = requests.map((r) => {
|
||||
/* Every line, and separately the ones that are actually a pick.
|
||||
*
|
||||
* The linen room needs both. `lines` is the record — a declined fleece still belongs on the
|
||||
* order the wearer will read — while `bag` is the work: what to take off the shelf, put in
|
||||
* the bag and hand across the counter. Picking from `lines` would put a garment the manager
|
||||
* refused into somebody's hands, so the two are never the same field. */
|
||||
const lines = reqLines(r.lines);
|
||||
const bag = bagLines(lines);
|
||||
return {
|
||||
id: r.id, code: r.code, status: r.status,
|
||||
staffId: r.subjectId,
|
||||
staffName: `${r.subject.first} ${r.subject.last}`.trim(),
|
||||
staffNum: r.subject.num, ward: r.subject.dept,
|
||||
lines, bag,
|
||||
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
|
||||
decision: decisionSummary(lines),
|
||||
reason: r.reason, note: r.note,
|
||||
managerName: r.managerName,
|
||||
/* Who the approver is, not just how their name is spelled. A manager may now approve a
|
||||
* request raised for herself, and the only thing that can show that happened is this id
|
||||
* beside the subject's — the name on its own would have any screen comparing two spellings
|
||||
* of the same person, which is precisely how a self-approval goes unnoticed. */
|
||||
managerId: r.managerId,
|
||||
declineReason: r.declineReason,
|
||||
route: r.route, collectCode: r.collectCode, holdUntil: r.holdUntil,
|
||||
signerName: r.signerName, signerRole: r.signerRole,
|
||||
signedAt: r.signedAt?.toISOString() ?? null,
|
||||
claimedAt: r.claimedAt?.toISOString() ?? null,
|
||||
/* Who raised it, and which person on the register that is.
|
||||
*
|
||||
* The name alone is not enough for the queue screen: it builds the list of people a stranded
|
||||
* request can be handed to, and the one name certain to be refused is the person who raised
|
||||
* it — a manager asking for one of her own reports' garments is exactly why the request
|
||||
* escalated with nobody to approve it. Told only her name, the screen would have to match
|
||||
* her by spelling against a ward where two people share one, which is how the wrong person
|
||||
* drops out of a dropdown.
|
||||
*
|
||||
* Only the staff column, because only it can ever name somebody who could approve anything.
|
||||
* A raise at the counter is stamped with the coordinator's own account instead, and a
|
||||
* coordinator is not on the ward register at all; a wearer raising for herself is stamped
|
||||
* with neither. Both arrive here as null, which is right — neither is a name this queue
|
||||
* could offer. */
|
||||
raisedById: r.raisedByStaffId,
|
||||
raisedByName: r.raisedByName,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
decidedAt: r.decidedAt?.toISOString() ?? null,
|
||||
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
|
||||
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
|
||||
};
|
||||
});
|
||||
|
||||
/* Everything below is the linen room's queue screen — open disputes, the kit check, the
|
||||
* waitlist, damage nobody has handed back. A staff record asks for one person's order forms and
|
||||
* reads none of it, so a scoped ask stops here instead of running four more facility-wide
|
||||
* queries whose answers are thrown away. Those keys are absent from a scoped reply rather than
|
||||
* empty: an empty list would read as "there are none", which nobody asked and nobody knows. */
|
||||
if (staffId) return NextResponse.json({ requests: mapped, requestLimit, moreRequests });
|
||||
|
||||
const [disputes, cycle, waiting, damage] = await Promise.all([
|
||||
prisma.recordDispute.findMany({
|
||||
where: { facilityId: user.facilityId, resolvedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { staff: { select: { first: true, last: true, num: true, dept: true } } },
|
||||
}),
|
||||
prisma.kitCheck.findFirst({
|
||||
where: { facilityId: user.facilityId, closedAt: null },
|
||||
orderBy: { openedAt: "desc" },
|
||||
select: { id: true, dueBy: true, openedAt: true, openedBy: true, _count: { select: { answers: true } } },
|
||||
}),
|
||||
prisma.waitlistEntry.findMany({
|
||||
where: { facilityId: user.facilityId, leftAt: null, acceptedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
staff: { select: { first: true, last: true, num: true, dept: true } },
|
||||
item: { select: { item: true, sizes: true } },
|
||||
},
|
||||
}),
|
||||
// Damage reports the counter has not yet taken the garment back for. Reporting damage and
|
||||
// asking for a replacement are two separate acts in the staff app, so a report can arrive with
|
||||
// no request behind it — and until this list existed nothing in the product ever showed one to
|
||||
// anybody, which made the Damage screen's promise ("it comes off your record when you hand it
|
||||
// in at the counter") a promise no screen could keep.
|
||||
prisma.damageReport.findMany({
|
||||
where: { facilityId: user.facilityId, handedInAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: {
|
||||
staff: { select: { first: true, last: true, num: true, dept: true } },
|
||||
issue: { select: { sizeIndex: true, item: { select: { item: true, sizes: true } } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
/* What the open kit check has actually turned up.
|
||||
*
|
||||
* The cycle used to be reported to the linen room as a bare count of answers, which is the one
|
||||
* thing about it that doesn't matter: nobody opens a kit check to find out how many people
|
||||
* replied. The answers are the point — every one where somebody could not account for what the
|
||||
* record says they hold — and until this query existed no screen, export or report in the
|
||||
* product read them, so the whole cycle collected evidence into a table nothing looked at.
|
||||
*
|
||||
* Only the shortfalls, and only for the cycle still open. An answer that matches the record is
|
||||
* the record agreeing with itself; a closed cycle is history and belongs with the rest of it.
|
||||
*/
|
||||
const answers = cycle
|
||||
? await prisma.kitCheckAnswer.findMany({
|
||||
where: { kitCheckId: cycle.id, confirmed: { lt: prisma.kitCheckAnswer.fields.onRecord } },
|
||||
orderBy: { answeredAt: "desc" },
|
||||
take: 400,
|
||||
include: {
|
||||
staff: { select: { id: true, first: true, last: true, num: true, dept: true } },
|
||||
item: { select: { item: true, sizes: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
// DamageReport.requestId is a plain column rather than a relation, so the replacement's code is
|
||||
// looked up here. It is what the linen room actually needs: "torn, and she has asked for R-0042"
|
||||
// is a different job from "torn, and she has not".
|
||||
const replacementCodes = new Map<string, string>();
|
||||
const replacementIds = damage.map((d) => d.requestId).filter((x): x is string => !!x);
|
||||
if (replacementIds.length) {
|
||||
const reps = await prisma.request.findMany({
|
||||
where: { facilityId: user.facilityId, id: { in: replacementIds } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
for (const r of reps) replacementCodes.set(r.id, r.code);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
requests: mapped, requestLimit, moreRequests,
|
||||
disputes: disputes.map((d) => ({
|
||||
id: d.id, body: d.body,
|
||||
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
|
||||
staffNum: d.staff.num, ward: d.staff.dept,
|
||||
at: d.createdAt.toISOString(),
|
||||
})),
|
||||
cycle: cycle && {
|
||||
id: cycle.id, dueBy: cycle.dueBy, openedBy: cycle.openedBy,
|
||||
openedAt: cycle.openedAt.toISOString(), answers: cycle._count.answers,
|
||||
},
|
||||
shortfalls: answers.map((a) => ({
|
||||
id: a.id,
|
||||
staffId: a.staff.id,
|
||||
staffName: `${a.staff.first} ${a.staff.last}`.trim(),
|
||||
staffNum: a.staff.num, ward: a.staff.dept,
|
||||
item: a.item.item, size: String(a.item.sizes[a.sizeIndex] ?? a.sizeIndex),
|
||||
onRecord: a.onRecord, confirmed: a.confirmed, short: a.onRecord - a.confirmed,
|
||||
at: a.answeredAt.toISOString(),
|
||||
})),
|
||||
waiting: waiting.map((w) => ({
|
||||
id: w.id,
|
||||
staffName: `${w.staff.first} ${w.staff.last}`.trim(),
|
||||
staffNum: w.staff.num, ward: w.staff.dept,
|
||||
item: w.item.item, size: String(w.item.sizes[w.sizeIndex] ?? w.sizeIndex),
|
||||
since: w.createdAt.toISOString(),
|
||||
offeredAt: w.offeredAt?.toISOString() ?? null,
|
||||
})),
|
||||
damage: damage.map((d) => ({
|
||||
id: d.id, kind: d.kind, note: d.note, photoId: d.photoId,
|
||||
staffId: d.staffId,
|
||||
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
|
||||
staffNum: d.staff.num, ward: d.staff.dept,
|
||||
// The garment comes off the Issue the report was raised against. That issue can be deleted
|
||||
// (a wipe, a correction) and the column is SetNull, so an older report may name no garment.
|
||||
item: d.issue ? d.issue.item.item : "",
|
||||
size: d.issue ? String(d.issue.item.sizes[d.issue.sizeIndex] ?? d.issue.sizeIndex) : "",
|
||||
requestCode: d.requestId ? replacementCodes.get(d.requestId) ?? "" : "",
|
||||
at: d.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* "Has anything changed?", answered in one integer.
|
||||
*
|
||||
* Every screen in the product already knows how to reload itself — a mutation ends in
|
||||
* router.refresh(). What it could not know was that somebody ELSE had changed something, so a
|
||||
* phone left open on a ward showed whatever the catalogue looked like when it was opened, and a
|
||||
* coordinator adding a garment at the desk had to tell the counter to pull down to refresh.
|
||||
*
|
||||
* The obvious fix — poll the snapshot and diff it — is the expensive one: that is the facility's
|
||||
* catalogue, staff register, stock and history, re-read on a timer by every open device to learn,
|
||||
* almost always, that nothing happened. This returns the counter that the three mutating routes
|
||||
* bump, so the cost of asking is a primary-key lookup, and the cost of the real reload is paid only
|
||||
* when the number has actually moved.
|
||||
*
|
||||
* Both session kinds answer here. A coordinator at the desk and a wearer on a ward are watching the
|
||||
* same facility, and there is nothing in a bare revision number to keep apart — it says that
|
||||
* something changed, never what. Anyone with no session at all gets 401 rather than a number,
|
||||
* because even "this facility is busy" is not ours to hand out.
|
||||
*/
|
||||
export async function GET(_req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
const facilityId = user?.facilityId || (await currentStaff())?.facilityId;
|
||||
if (!facilityId) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { rev: true } });
|
||||
if (!f) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
// Never from a cache: a stale revision is indistinguishable from nothing having happened, which
|
||||
// is the one wrong answer this endpoint can give.
|
||||
return NextResponse.json({ rev: f.rev }, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
@@ -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