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 }; }