Files
threadcount-community/lib/ops/projections.ts
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

182 lines
9.7 KiB
TypeScript

import { readdirSync } from "fs";
import path from "path";
import { opsDb } from "./db";
import { entitlements, type Entitlements, type PlanRow } from "../plan";
/* Everything the operations console knows about the facilities, in one place.
*
* This is the only module in the product that reads across facilities, and it reads through
* opsDb() — the ops_ro role, which the database itself restricts to control-plane columns. So the
* shape of what leaves here is decided twice: by the SELECTs below, and by grants that would
* refuse a wider one. Nothing in a projection is a person: no name, no email, no photo, no note.
* Group lists come out as lengths, the GL account as set-or-not, and contacts do not come out at
* all — a reveal is a separate, narrow, trailed path.
*
* The demo facility is rebuilt every twenty minutes and would otherwise dominate every count, so
* every projection splits it out rather than mixing it in.
*
* scripts/check-ops.sh asserts this file never imports the main client. */
const DAY = 24 * 60 * 60 * 1000;
/** The facility columns the console may read — the same allow-list as the ops_ro grant. */
const FACILITY_COLS = {
id: true, name: true, timezone: true, defaultEntitlement: true, initialSets: true, capSets: true,
defaultReorder: true, exceptionHigh: true, varianceReason: true, glAccount: true, journalDesc: true,
lastBackup: true, barcodeLookup: true, staffGroups: true, nursingGroups: true, kitGroups: true,
orderSeq: true, catalogSeq: true, requestSeq: true, rev: true, barcodeSeq: true, slipOrg: true,
isDemo: true, demoResetAt: true, createdAt: true, plan: true, planNote: true, ssoEnabled: true, ssoRequired: true, ssoStaff: true,
planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true,
} as const;
/** The plan as the console shows it — the raw columns for the form, and what they mean today. */
export type PlanView = {
plan: string; planNote: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean;
label: string; state: Entitlements["state"]; readOnly: boolean; maxStaff: number | null;
endsAt: Date | null; graceEndsAt: Date | null;
};
function planView(f: PlanRow & { plan: string; planNote: string }): PlanView {
const e = entitlements(f);
return {
plan: f.plan, planNote: f.planNote, planStatus: f.planStatus, trialEndsAt: f.trialEndsAt, paidUntil: f.paidUntil, grandfathered: f.grandfathered,
label: e.label, state: e.state, readOnly: e.readOnly, maxStaff: e.maxStaff, endsAt: e.endsAt, graceEndsAt: e.graceEndsAt,
};
}
export type FacilityRow = {
id: string; name: string; createdAt: Date; isDemo: boolean; timezone: string;
rev: number; lastBackup: string; backupAgeDays: number | null;
plan: string; planNote: string;
planView: PlanView;
users: number; staff: number; items: number; issues: number; requests: number;
groups: number; setUp: boolean;
state: "live" | "dormant" | "unconfigured" | "demo";
};
/** "YYYY-MM-DD" in the facility's zone → whole days ago, or null when never. */
function ageDays(lastBackup: string): number | null {
if (!lastBackup) return null;
const t = Date.parse(lastBackup + "T00:00:00Z");
return Number.isFinite(t) ? Math.max(0, Math.floor((Date.now() - t) / DAY)) : null;
}
function stateOf(f: { isDemo: boolean; rev: number; staffGroups: string[] }): FacilityRow["state"] {
if (f.isDemo) return "demo";
if (f.staffGroups.length === 0) return "unconfigured";
return f.rev > 0 ? "live" : "dormant";
}
export async function facilities(): Promise<FacilityRow[]> {
const rows = await opsDb().facility.findMany({
select: { ...FACILITY_COLS, _count: { select: { users: true, staff: true, items: true, issues: true, requests: true } } },
orderBy: { createdAt: "desc" },
});
return rows.map((f) => ({
id: f.id, name: f.name, createdAt: f.createdAt, isDemo: f.isDemo, timezone: f.timezone,
rev: f.rev, lastBackup: f.lastBackup, backupAgeDays: ageDays(f.lastBackup), plan: f.plan, planNote: f.planNote, planView: planView(f),
users: f._count.users, staff: f._count.staff, items: f._count.items, issues: f._count.issues, requests: f._count.requests,
groups: f.staffGroups.length, setUp: f.staffGroups.length > 0,
state: stateOf(f),
}));
}
export type FacilityDetail = FacilityRow & {
config: {
capSets: number; initialSets: number; defaultEntitlement: number; defaultReorder: number;
exceptionHigh: number; varianceReason: number;
fteGroups: number; kitGroups: number; groups: number;
glAccountSet: boolean; journalDesc: string; barcodeLookup: boolean; slipOrgSet: boolean;
sso: "off" | "optional" | "required"; ssoStaff: boolean;
};
counts: {
users: number; admins: number; usersWith2fa: number; inactiveUsers: number;
staff: number; activeStaff: number; staffAccounts: number; accountsSeen7d: number;
items: number; issues: number; orders: number; requests: number; events24h: number;
};
seq: { orders: number; catalogue: number; requests: number; barcodes: number };
};
export async function facility(id: string): Promise<FacilityDetail | null> {
const db = opsDb();
const f = await db.facility.findUnique({ where: { id }, select: FACILITY_COLS });
if (!f) return null;
const since7d = new Date(Date.now() - 7 * DAY);
const since24h = new Date(Date.now() - DAY);
const [users, admins, usersWith2fa, inactiveUsers, staff, activeStaff, staffAccounts, accountsSeen7d, items, issues, orders, requests, events24h] = await Promise.all([
db.user.count({ where: { facilityId: id } }),
db.user.count({ where: { facilityId: id, role: "ADMIN", inactive: false } }),
db.user.count({ where: { facilityId: id, totpEnabledAt: { not: null } } }),
db.user.count({ where: { facilityId: id, inactive: true } }),
db.staff.count({ where: { facilityId: id } }),
db.staff.count({ where: { facilityId: id, inactive: false } }),
db.staffAccount.count({ where: { facilityId: id } }),
db.staffAccount.count({ where: { facilityId: id, lastSeenAt: { gte: since7d } } }),
db.catalogItem.count({ where: { facilityId: id } }),
db.issue.count({ where: { facilityId: id } }),
db.order.count({ where: { facilityId: id } }),
db.request.count({ where: { facilityId: id } }),
db.auditEvent.count({ where: { facilityId: id, at: { gte: since24h } } }),
]);
return {
id: f.id, name: f.name, createdAt: f.createdAt, isDemo: f.isDemo, timezone: f.timezone,
rev: f.rev, lastBackup: f.lastBackup, backupAgeDays: ageDays(f.lastBackup), plan: f.plan, planNote: f.planNote, planView: planView(f),
users, staff, items, issues, requests,
groups: f.staffGroups.length, setUp: f.staffGroups.length > 0, state: stateOf(f),
config: {
capSets: f.capSets, initialSets: f.initialSets, defaultEntitlement: f.defaultEntitlement,
defaultReorder: f.defaultReorder, exceptionHigh: f.exceptionHigh, varianceReason: f.varianceReason,
fteGroups: f.nursingGroups.length, kitGroups: f.kitGroups.length, groups: f.staffGroups.length,
glAccountSet: !!f.glAccount, journalDesc: f.journalDesc, barcodeLookup: f.barcodeLookup, slipOrgSet: !!f.slipOrg,
sso: f.ssoEnabled ? (f.ssoRequired ? "required" : "optional") : "off", ssoStaff: f.ssoStaff,
},
counts: { users, admins, usersWith2fa, inactiveUsers, staff, activeStaff, staffAccounts, accountsSeen7d, items, issues, orders, requests, events24h },
seq: { orders: f.orderSeq, catalogue: f.catalogSeq, requests: f.requestSeq, barcodes: f.barcodeSeq },
};
}
export type Overview = {
facilities: number; live: number; dormant: number; unconfigured: number; demo: number;
signupsThisMonth: number; reachedFirstIssue: number;
overdueBackups: FacilityRow[]; neverIssued: FacilityRow[]; unconfiguredList: FacilityRow[];
enquiriesUnhandled: number;
migrations: { applied: number; inRepo: number };
demoRebuiltAgoMin: number | null;
};
/** How many migration folders the repo ships, read from disk on the box that is running. */
function migrationsInRepo(): number {
try {
return readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true }).filter((d) => d.isDirectory()).length;
} catch { return 0; }
}
export async function overview(): Promise<Overview> {
const db = opsDb();
const all = await facilities();
const real = all.filter((f) => !f.isDemo);
const monthStart = new Date(); monthStart.setUTCDate(1); monthStart.setUTCHours(0, 0, 0, 0);
const [enquiriesUnhandled, appliedRows, demo] = await Promise.all([
db.contactMessage.count({ where: { handled: false } }),
db.$queryRaw<{ n: bigint }[]>`SELECT count(*)::bigint AS n FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL`,
db.facility.findFirst({ where: { isDemo: true }, select: { demoResetAt: true } }),
]);
const signups = real.filter((f) => f.createdAt >= monthStart);
return {
facilities: real.length,
live: real.filter((f) => f.state === "live").length,
dormant: real.filter((f) => f.state === "dormant").length,
unconfigured: real.filter((f) => f.state === "unconfigured").length,
demo: all.length - real.length,
signupsThisMonth: signups.length,
reachedFirstIssue: signups.filter((f) => f.issues > 0).length,
// Overdue: live facilities with no export in seven days, or never — the figure the Terms used to promise a reminder on.
overdueBackups: real.filter((f) => f.state === "live" && (f.backupAgeDays === null || f.backupAgeDays > 7)),
neverIssued: real.filter((f) => f.state === "dormant"),
unconfiguredList: real.filter((f) => f.state === "unconfigured"),
enquiriesUnhandled,
migrations: { applied: Number(appliedRows[0]?.n ?? 0), inRepo: migrationsInRepo() },
demoRebuiltAgoMin: demo?.demoResetAt ? Math.floor((Date.now() - demo.demoResetAt.getTime()) / 60000) : null,
};
}