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