1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
45 lines
2.6 KiB
TypeScript
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 });
|
|
}
|