Files
threadcount-community/app/api/ops/reveal/route.ts
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

45 lines
2.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { currentOperator } from "@/lib/ops/session";
import { opsDb } from "@/lib/ops/db";
import { grantReveal, REASON_MIN, REASON_MAX, REVEAL_MINUTES } from "@/lib/ops/reveal";
export const dynamic = "force-dynamic";
/* Open a thirty-minute window on one facility's coordinator contacts. The whole act — grant row,
* trail row, email — is lib/ops/reveal.ts; this route only checks the operator, the facility and
* the reason, and answers. The contacts are not in the response: the page reads them, through the
* reveal role, on its next render. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const op = await currentOperator();
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
// Ten an hour: a reveal is a considered act, and a run of them across facilities is exactly the
// pattern the limit exists to slow down.
if (!allow("ops-reveal:" + op.id, 10, 60 * 60 * 1000)) {
return NextResponse.json({ error: "Too many reveals in the last hour." }, { status: 429 });
}
let body: { facilityId?: unknown; reason?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const facilityId = String(body.facilityId ?? "").trim();
const reason = String(body.reason ?? "").trim();
if (!/^[a-z0-9]{20,40}$/.test(facilityId)) return NextResponse.json({ error: "Bad request" }, { status: 400 });
if (reason.length < REASON_MIN) {
return NextResponse.json({ error: `Give a reason — at least ${REASON_MIN} characters. It goes in the trail and in the email.` }, { status: 400 });
}
if (reason.length > REASON_MAX) return NextResponse.json({ error: `Keep the reason under ${REASON_MAX} characters.` }, { status: 400 });
// The facility's name and kind come from the ordinary role; nothing here reads a contact.
const f = await opsDb().facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true } });
if (!f) return NextResponse.json({ error: "No such facility" }, { status: 404 });
if (f.isDemo) return NextResponse.json({ error: "The demo facility has no real contacts to reveal." }, { status: 400 });
const r = await grantReveal({ operator: op, facilityId: f.id, facilityName: f.name, reason, ip });
return NextResponse.json({ ok: true, minutes: REVEAL_MINUTES, expiresAt: r.expiresAt.toISOString(), mailed: r.mailed });
}