ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { resetDemo } from "../demo";
import { PLANS, isPlanCode } from "../plan";
import { deletePhotoDir } from "../photostore";
import { SWITCH_ROW } from "../switches";
import { alertFacilityDeleted } from "./alerts";
import { logOperatorEvent, type OperatorSession } from "./session";
import { verifyOperatorCode } from "./totp";
/* The console's writes. Four of them, and every one is a control-plane act on the console's own
* records or on a facility's configuration — never on a facility's content.
*
* switches — sign-ups open/closed, demo in/out of service. One row; the environment overrides.
* demo reset — the product's own resetDemo(), on demand instead of on the twenty-minute clock.
* plan — a label and a note on a facility: the billing scaffold. Display only, elsewhere.
* delete — a whole facility, the way the product's own "delete my account" does it: the
* cascade from Facility, then the images. Owner, typed name, live code, and a
* notice to every owner. The one act here that cannot be undone.
*
* All go through the main client on purpose: ops_ro cannot write, and these are the writes the
* console is for. Everything is written to the trail. */
export class ControlError extends Error {
constructor(message: string, public status = 400) { super(message); }
}
export type SwitchKey = "signupsDisabled" | "demoDisabled" | "plansLive";
export async function setSwitch(op: OperatorSession, key: SwitchKey, value: boolean, ip: string) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can change a platform switch.", 403);
await prisma.platformSwitch.upsert({
where: { id: SWITCH_ROW },
create: { id: SWITCH_ROW, [key]: value },
update: { [key]: value },
});
const detail = key === "plansLive" ? (value ? "live" : "off") : value ? "closed" : "open";
logOperatorEvent({ operatorId: op.id, action: "ops:switch", subject: key, detail, ip });
}
export async function resetDemoNow(op: OperatorSession, ip: string) {
await resetDemo();
logOperatorEvent({ operatorId: op.id, action: "ops:demo.reset", ip });
}
export const PLAN_NOTE_MAX = 400;
/* The billing desk. Five acts, each one a change to the six plan columns lib/plan.ts reads, and
* each one written to the trail with what it did. No money moves here: an invoice is raised in the
* accounting tool and its payment recorded with `paid`. Dates are set relative to today so the
* operator never types one. */
export type PlanAct =
| { act: "set"; plan: string; planNote: string; grandfathered?: boolean }
| { act: "trial"; days: number } // start, or extend by, this many days
| { act: "paid"; months: number } // a payment covering this many months from today or from paidUntil
| { act: "readonly"; on: boolean }
| { act: "free" }; // back to free on the current plan (a pilot ended, a refund)
const DAY = 86_400_000;
async function planFacility(facilityId: string) {
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true } });
if (!f) throw new ControlError("No such facility", 404);
if (f.isDemo) throw new ControlError("The demo facility has no plan.");
return f;
}
export async function planControl(op: OperatorSession, facilityId: string, a: PlanAct, ip: string) {
const f = await planFacility(facilityId);
let data: Prisma.FacilityUpdateInput = {};
let detail = "";
switch (a.act) {
case "set": {
if (a.plan !== "" && !isPlanCode(a.plan)) throw new ControlError("Not a plan.");
const n = a.planNote.trim().slice(0, PLAN_NOTE_MAX);
data = { plan: a.plan, planNote: n };
// The promise is an owner's to give or take; a support operator may change the plan and the note.
if (a.grandfathered !== undefined && a.grandfathered !== f.grandfathered) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can change whether a facility is grandfathered.", 403);
data.grandfathered = a.grandfathered;
}
detail = `${a.plan ? PLANS[a.plan as keyof typeof PLANS].label : "no plan"}${data.grandfathered !== undefined ? (data.grandfathered ? " · grandfathered" : " · grandfathering removed") : ""}${n ? " — " + n : ""}`;
break;
}
case "trial": {
const days = Math.min(365, Math.max(1, Math.floor(a.days || 0)));
// Extends a trial still running; starts one from today otherwise. A facility with no paid
// plan code yet gets Hosted Facility — a trial of Hosted Small would be a trial of free.
const from = f.planStatus === "trial" && f.trialEndsAt && f.trialEndsAt > new Date() ? f.trialEndsAt : new Date();
const ends = new Date(from.getTime() + days * DAY);
data = { planStatus: "trial", trialEndsAt: ends, plan: f.plan && f.plan !== "hosted_small" ? f.plan : "hosted_facility" };
detail = `trial to ${ends.toISOString().slice(0, 10)}`;
break;
}
case "paid": {
const months = Math.min(36, Math.max(1, Math.floor(a.months || 0)));
const from = f.planStatus === "active" && f.paidUntil && f.paidUntil > new Date() ? f.paidUntil : new Date();
const until = new Date(from); until.setUTCMonth(until.getUTCMonth() + months);
data = { planStatus: "active", paidUntil: until, plan: f.plan && f.plan !== "hosted_small" ? f.plan : "hosted_facility" };
detail = `paid to ${until.toISOString().slice(0, 10)}`;
break;
}
case "readonly": {
if (a.on) { data = { planStatus: "read_only" }; detail = "read-only"; }
else {
// Back to whatever the dates say: a paid year still running is active, a trial still
// running is a trial, anything else is free.
const now = new Date();
const status = f.paidUntil && f.paidUntil > now ? "active" : f.trialEndsAt && f.trialEndsAt > now ? "trial" : "free";
data = { planStatus: status }; detail = `writable again · ${status}`;
}
break;
}
case "free":
data = { planStatus: "free", trialEndsAt: null, paidUntil: null };
detail = "set free";
break;
}
await prisma.facility.update({ where: { id: f.id }, data });
logOperatorEvent({ operatorId: op.id, action: "ops:plan", facilityId: f.id, subject: f.name, detail, ip });
}
/** Owner + the facility's exact name + a live code from the operator's own second factor. */
export async function deleteFacility(op: OperatorSession, facilityId: string, confirm: string, code: string, ip: string) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can delete a facility.", 403);
const o = await prisma.operator.findUnique({ where: { id: op.id }, select: { totpSecret: true, totpEnabledAt: true } });
if (!o?.totpEnabledAt) throw new ControlError("Enrol a second factor before deleting anything.", 403);
if (!verifyOperatorCode(o, code.replace(/\s+/g, ""))) throw new ControlError("That code isn't right. Use the current one from your app.", 401);
const f = await prisma.facility.findUnique({
where: { id: facilityId },
select: { id: true, name: true, isDemo: true, _count: { select: { users: true, staff: true, issues: true } } },
});
if (!f) throw new ControlError("No such facility", 404);
if (f.isDemo) throw new ControlError("The demo facility is rebuilt, not deleted — use Reset demo.");
if (confirm.trim() !== f.name) throw new ControlError(`Type the facility name exactly — ${f.name} — to confirm.`);
const counts = `${f._count.users} coordinators, ${f._count.staff} staff, ${f._count.issues} issues`;
// Trail first: if the delete fails half-way the record says it was attempted; if it succeeds the
// record outlives the row, because facilityId here is a plain string and not a relation.
logOperatorEvent({ operatorId: op.id, action: "ops:facility.delete", facilityId: f.id, subject: f.name, detail: counts, ip });
await prisma.facility.delete({ where: { id: f.id } }); // every relation cascades from Facility
await deletePhotoDir(f.id);
alertFacilityDeleted({ name: f.name, by: `${op.name} (${op.email})`, ip, counts });
return { name: f.name };
}