ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user