Files
threadcount-community/lib/ops/reveal.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

104 lines
4.8 KiB
TypeScript

import { prisma } from "../db";
import { revealDb } from "./db";
import { logOperatorEvent, type OperatorSession } from "./session";
import { sendTo } from "../mail";
/* Revealing a facility's coordinator contacts.
*
* The console's ordinary role cannot read a coordinator's name, email or phone — the production
* probe proves it. Reading one is therefore not a display option but a separate act with four
* parts, all of them here and nowhere else:
*
* 1. a typed reason, kept in full;
* 2. a RevealGrant row on the server — the window is thirty minutes and it is the row that says
* so, not a claim in a cookie, so it can be shortened or revoked by deleting it;
* 3. an OperatorEvent, in the trail that outlives the facility;
* 4. an email to the owner, every time. A trail nobody reads is a record, not a control.
*
* The read itself goes through revealDb(), the ops_reveal role: four columns of one table. A
* future edit that widens the select fails at the database, exactly as it would in a projection.
*
* Scope is the three contact columns of a Facility. Nothing about a wearer is revealable at any
* level, and nothing in this module knows a wearer's table exists. */
export const REVEAL_MINUTES = 30;
/** One grant covers the three contact columns together; they are one decision and one email. */
export const REVEAL_FIELD = "contacts";
export const REASON_MIN = 8;
export const REASON_MAX = 400;
export type Revealed = {
coordinator: string;
coordinatorEmail: string;
coordinatorPhone: string;
reason: string;
at: Date;
expiresAt: Date;
};
/** The operator's own unexpired grant for this facility, if any. Grants are per operator: one
* person's reason does not open the contacts for another. */
export async function activeReveal(operatorId: string, facilityId: string) {
return prisma.revealGrant.findFirst({
where: { operatorId, facilityId, field: REVEAL_FIELD, expiresAt: { gt: new Date() } },
orderBy: { expiresAt: "desc" },
select: { id: true, reason: true, at: true, expiresAt: true },
});
}
/** The contacts, only while a grant is active. Without one this returns null without touching
* the reveal role at all — the grant check comes first, and the read is conditional on it. */
export async function revealedContacts(operatorId: string, facilityId: string): Promise<Revealed | null> {
const g = await activeReveal(operatorId, facilityId);
if (!g) return null;
const f = await revealDb().facility.findUnique({
where: { id: facilityId },
select: { coordinator: true, coordinatorEmail: true, coordinatorPhone: true },
});
if (!f) return null;
return { ...f, reason: g.reason, at: g.at, expiresAt: g.expiresAt };
}
/** Where the copy of every reveal goes. The owner's address, settable apart from the operator
* who did the revealing so that a second operator's reveals still reach the owner. */
export function alertAddress(fallback: string) {
return (process.env.OPS_ALERT_TO || fallback).trim();
}
export async function grantReveal(args: {
operator: OperatorSession;
facilityId: string;
facilityName: string;
reason: string;
ip: string;
}): Promise<{ expiresAt: Date; mailed: boolean }> {
const { operator, facilityId, facilityName, ip } = args;
const reason = args.reason.trim().slice(0, REASON_MAX);
const now = new Date();
const expiresAt = new Date(now.getTime() + REVEAL_MINUTES * 60 * 1000);
await prisma.revealGrant.create({
data: { operatorId: operator.id, facilityId, field: REVEAL_FIELD, reason, at: now, expiresAt },
});
logOperatorEvent({ operatorId: operator.id, action: "ops:reveal", facilityId, subject: facilityName, detail: reason, ip });
// The email says who, which facility, why and until when — never the contacts themselves. It
// is a notice that a reveal happened, and it must be safe to sit in a mailbox.
const when = now.toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
const until = expiresAt.toLocaleTimeString("en-AU", { timeZone: "Australia/Brisbane", hour: "2-digit", minute: "2-digit" });
const text = [
`${operator.name} (${operator.email}) revealed the coordinator contacts of a facility on the ThreadCount operations console.`,
"",
`Facility: ${facilityName}`,
`When: ${when} (Brisbane)`,
`Until: ${until}`,
`From: ${ip || "unknown address"}`,
`Reason: ${reason}`,
"",
"This is written to the operator trail. If it wasn't you, sign the operator out and change the password.",
].join("\n");
const mailed = await sendTo(alertAddress(operator.email), `[ops] Contacts revealed — ${facilityName}`, text);
if (!mailed) logOperatorEvent({ operatorId: operator.id, action: "ops:reveal.unmailed", facilityId, subject: facilityName, ip });
return { expiresAt, mailed };
}