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