344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
182 lines
9.7 KiB
TypeScript
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,
|
|
};
|
|
}
|