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:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
// Public working demo: one fictional facility ("Riverside General Hospital") that anyone can enter
// as Admin or Issuer without signing up. All visitors share it; a timer resets it every 20 minutes.
// Everything is seeded through the real ops layer so the ledger, drafts, pickups and reports are
// exactly what the app would have produced. Nothing here is real facility data.
import { randomBytes } from "crypto";
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import { runOp } from "./ops";
import type { SessionUser } from "./session";
import { addDays, facilityToday } from "./compute";
import { deletePhotoDir } from "./photostore";
export const DEMO_FACILITY = "Riverside General Hospital";
export const DEMO_RESET_MINUTES = 20;
/** The demo room stands somewhere: the seeded dates are the ones this zone calls today. */
const DEMO_TZ = "Australia/Brisbane";
const ADMIN_EMAIL = "demo-admin@threadcount.tech";
const ISSUER_EMAIL = "demo-issuer@threadcount.tech";
const TOPS = "XS|S|M|L|XL|2XL|3XL";
const PANTS = "XS|S|M|L|XL|2XL|3XL";
const TROUSERS = "77R|82R|87R|92R|97R|102R|107R";
const NL = "Northline Workwear", HE = "Harbour Embroidery";
// The demo's staff groups, spread over the three routes a facility chooses between so that a
// visitor meets all three — on the register, at the counter and on the printed order form: the
// nurses on the FTE table, Support Services on the starting kit, Kitchen and Security on manager
// approval. Plain names any Australian employer would recognise, because this is the facility every
// prospect walks into, and it must not read as any one hospital's organisation chart.
const FTE_GROUPS = ["Registered Nurse", "Enrolled Nurse"];
const KIT_GROUPS = ["Support Services"];
const APPROVAL_GROUPS = ["Kitchen", "Security"];
// sku, item, gender, supplier, cost, groups, sizes. Groups go in through the CSV importer, so several
// are separated by | exactly as a coordinator's file writes them: the scrub pant is worn by both
// nursing groups, the vest by Support Services and Security, and "All" is every group.
//
// All three cuts are stocked, because the uniform-style rule is invisible in a catalogue that is
// entirely unisex: the scrub top and the softshell come in a women's cut as well, the security
// shirt and trouser are the men's, and everything else is worn by anybody. So a visitor who opens
// the counter for a nurse set to Men's sees the men's and unisex garments and not the women's cut,
// and a visitor who blanks that nurse's style sees the whole catalogue again.
const CATALOG: [string, string, string, string, number, string, string][] = [
["RN-TOP", "RN Active Scrub Top", "Unisex", NL, 31.5, "Registered Nurse", TOPS],
["RN-PANT", "Elastic Waist Scrub Pant", "Unisex", NL, 28.0, "Registered Nurse|Enrolled Nurse", PANTS],
["EN-TOP", "EN Scrub Top", "Unisex", NL, 29.5, "Enrolled Nurse", TOPS],
["EN-PANT", "EN Scrub Pant", "Unisex", NL, 27.0, "Enrolled Nurse", PANTS],
["SS-POLO", "Support Services Polo", "Unisex", HE, 22.5, "Support Services", TOPS],
["SS-CARGO", "Support Services Cargo Pant", "Unisex", NL, 34.0, "Support Services", TROUSERS],
["SS-VEST", "Hi-Vis Safety Vest", "Unisex", HE, 16.5, "Support Services|Security", TOPS],
["SEC-SHIRT", "Security Shirt", "Male", HE, 39.0, "Security", TOPS],
["SEC-PANT", "Security Trouser", "Male", NL, 42.0, "Security", TROUSERS],
["KIT-JKT", "Chef Jacket", "Unisex", NL, 36.0, "Kitchen", TOPS],
["KIT-PANT", "Kitchen Check Pant", "Unisex", NL, 29.0, "Kitchen", PANTS],
["ALL-JKT", "Softshell Jacket", "Unisex", HE, 58.0, "All", TOPS],
["ALL-CARD", "Knit Cardigan", "Unisex", HE, 44.0, "All", TOPS],
["RN-TOP-W", "RN Active Scrub Top", "Female", NL, 31.5, "Registered Nurse", TOPS],
["ALL-JKT-W", "Softshell Jacket", "Female", HE, 58.0, "All", TOPS],
];
const DEPTS: [string, string][] = [
["Willow Ward", "RGH-3010"], ["Alder Ward", "RGH-3020"], ["ICU", "RGH-4010"], ["Emergency", "RGH-4020"],
["Theatres", "RGH-4030"], ["Support Services", "RGH-5010"], ["Security", "RGH-5020"], ["Kitchen", "RGH-5030"],
];
// num, first, last, group, dept, top, pants, phone, fte, uniform style. The nurses carry an FTE
// because on the FTE table it is what proposes their starting kit — without one, every nurse in the
// demo would read "no FTE recorded" and the route would have nothing to show. The other two routes
// never read it.
//
// The last column is the cut of uniform each one is offered, and the demo carries all four states a
// register holds: most people set to Men's or Women's, Thanh and Sam on Either because they wear
// whichever fits, and Jordan and Zoe left blank — nobody has said, which offers them everything and
// is what every row on a real register reads until a coordinator goes through it. The three
// security officers are on the men's cut because the shirt and trouser their group wears are only
// made in it; a women's-cut security shirt is the sort of gap this column is meant to expose.
const STAFF: [string, string, string, string, string, string, string, string, string, string][] = [
["100231", "Maya", "Whitfield", "Registered Nurse", "Willow Ward", "M", "M", "0400 111 201", "1.0", "Women's"],
["100244", "Kofi", "Osei", "Registered Nurse", "ICU", "L", "L", "0400 111 202", "0.8", "Men's"],
["100251", "Thanh", "Nguyen", "Enrolled Nurse", "Alder Ward", "L", "L", "0400 111 203", "1.0", "Either"],
["100263", "Riya", "Patel", "Support Services", "Support Services", "S", "87R", "0400 111 204", "", "Women's"],
["100270", "Liam", "Cartwright", "Security", "Security", "XL", "97R", "0400 111 205", "", "Men's"],
["100288", "Sofia", "Marino", "Registered Nurse", "Emergency", "S", "S", "0400 111 206", "1.0", "Women's"],
["100293", "Jordan", "Blake", "Support Services", "Willow Ward", "M", "87R", "0400 111 207", "", ""],
["100301", "Aisha", "Rahman", "Registered Nurse", "Theatres", "M", "M", "0400 111 208", "0.6", "Women's"],
["100315", "Ethan", "Kowalski", "Kitchen", "Kitchen", "L", "L", "0400 111 209", "", "Men's"],
["100322", "Grace", "O'Neill", "Enrolled Nurse", "ICU", "M", "M", "0400 111 210", "0.8", "Women's"],
["100337", "Daniel", "Park", "Support Services", "Support Services", "L", "92R", "0400 111 211", "", "Men's"],
["100349", "Priya", "Sharma", "Registered Nurse", "Alder Ward", "XS", "XS", "0400 111 212", "0.6", "Women's"],
["100356", "Marcus", "Reid", "Security", "Security", "2XL", "102R", "0400 111 213", "", "Men's"],
["100361", "Hannah", "Lindqvist", "Support Services", "Emergency", "S", "82R", "0400 111 214", "", "Women's"],
["100378", "Yusuf", "Demir", "Kitchen", "Kitchen", "M", "M", "0400 111 215", "", "Men's"],
["100384", "Chloe", "Bennett", "Registered Nurse", "ICU", "M", "M", "0400 111 216", "1.0", "Women's"],
["100392", "Noah", "Fitzgerald", "Enrolled Nurse", "Theatres", "L", "L", "0400 111 217", "0.5", "Men's"],
["100405", "Isabella", "Russo", "Registered Nurse", "Willow Ward", "S", "S", "0400 111 218", "0.9", "Women's"],
["100417", "Sam", "Taylor", "Support Services", "Support Services", "M", "87R", "0400 111 219", "", "Either"],
["100423", "Amara", "Okafor", "Support Services", "Alder Ward", "L", "92R", "0400 111 220", "", "Women's"],
["100438", "Lucas", "Moreau", "Security", "Security", "L", "92R", "0400 111 221", "", "Men's"],
["100446", "Zoe", "Campbell", "Registered Nurse", "Emergency", "M", "M", "0400 111 222", "0.5", ""],
["100459", "Oliver", "Hughes", "Kitchen", "Kitchen", "XL", "XL", "0400 111 223", "", "Men's"],
["100467", "Mei", "Tanaka", "Enrolled Nurse", "Willow Ward", "S", "S", "0400 111 224", "0.4", "Women's"],
];
function sizesOf(sku: string) { return CATALOG.find((c) => c[0] === sku)![6].split("|"); }
function si(sku: string, size: string) { return sizesOf(sku).indexOf(size); }
function topSku(group: string) { return ({ "Registered Nurse": "RN-TOP", "Enrolled Nurse": "EN-TOP", "Support Services": "SS-POLO", Security: "SEC-SHIRT", Kitchen: "KIT-JKT" } as Record<string, string>)[group]; }
function pantSku(group: string) { return ({ "Registered Nurse": "RN-PANT", "Enrolled Nurse": "EN-PANT", "Support Services": "SS-CARGO", Security: "SEC-PANT", Kitchen: "KIT-PANT" } as Record<string, string>)[group]; }
export async function findDemoFacility() {
return prisma.facility.findFirst({ where: { isDemo: true }, include: { users: true } });
}
/** Drop and rebuild the demo facility. Safe to call at any time; visitors mid-session simply see fresh data. */
export async function resetDemo() {
// The images go with the rows. Visitors sign for deliveries in the demo like anyone else, and a
// facility that is rebuilt three times an hour would otherwise leave a new orphaned directory of
// anonymous uploads on disk every twenty minutes, with nothing left in the database to name them.
const old = await prisma.facility.findMany({ where: { isDemo: true }, select: { id: true } });
await prisma.facility.deleteMany({ where: { isDemo: true } }); // every relation cascades from Facility
for (const o of old) await deletePhotoDir(o.id);
const today = facilityToday(DEMO_TZ);
// The groups and both route lists are written here, on a row built fresh every reset, rather than
// patched onto the old one: a visitor who moved a group between routes in the last twenty minutes
// leaves nothing behind, and a demo still carrying groups from before these were changed takes
// the new ones at its next reset.
const fac = await prisma.facility.create({ data: {
name: DEMO_FACILITY, location: "Linen Room, Level B1", coordinator: "Alex Demo", timezone: DEMO_TZ,
staffGroups: [...FTE_GROUPS, ...KIT_GROUPS, ...APPROVAL_GROUPS], nursingGroups: FTE_GROUPS, kitGroups: KIT_GROUPS,
glAccount: "631020", journalDesc: "Uniform issues", exceptionHigh: 8, slipOrg: "Riverside Health", isDemo: true, demoResetAt: null, // stamped when seeding completes, so a half-built facility is always rebuilt
lastBackup: addDays(today, -9),
} });
const pw = async () => bcrypt.hash(randomBytes(24).toString("hex"), 8); // nobody logs in with these — entry is via /api/auth/demo
const adminU = await prisma.user.create({ data: { facilityId: fac.id, email: ADMIN_EMAIL, passwordHash: await pw(), first: "Alex", last: "Demo", title: "Uniform Coordinator", role: "ADMIN" } });
await prisma.user.create({ data: { facilityId: fac.id, email: ISSUER_EMAIL, passwordHash: await pw(), first: "Sam", last: "Demo", title: "Linen Room Assistant", role: "ISSUER" } });
const me: SessionUser = { id: adminU.id, facilityId: fac.id, email: adminU.email, first: adminU.first, last: adminU.last, title: adminU.title, role: "ADMIN", isDemo: true, viaSso: false };
const op = (name: string, payload: unknown) => runOp(me, name, payload);
for (const [name, cc] of DEPTS) await op("dept.save", { name, cc });
for (const [name, contact, phone, account, lead] of [[NL, "Dana Whitlock", "1300 555 010", "RGH-4471", 10], [HE, "Priya Nair", "07 3555 0190", "RGH-EMB-22", 21]] as const) {
const r = (await op("supplier.add", { name })) as { id: string };
await op("supplier.update", { id: r.id, contact, phone, account, lead: String(lead) });
}
await op("import.rows", { kind: "catalog", rows: CATALOG.map(([sku, item, gender, supplier, cost, group, sizes]) => ({ sku, item, gender, supplier, cost: String(cost), group, sizes })) });
// The style column goes in through the importer like every other one — a register arrives as a
// file, and the demo is seeded the way a real facility is loaded.
await op("import.rows", { kind: "staff", rows: STAFF.map(([num, first, last, group, dept, top, pants, phone, fte, style]) => ({ num, first, last, group, dept, top, pants, phone, fte, style, start: addDays(today, -(200 + (parseInt(num, 10) % 900))) })) });
// Opening stock: a healthy shelf, with a few sizes deliberately thin so reorder flags and "sizes out" have something to show.
const opening: { sku: string; size: string; opening: string; reorder: string }[] = [];
const thin = new Set(["RN-TOP:S", "KIT-JKT:L", "SEC-SHIRT:XL", "SEC-PANT:97R", "SS-CARGO:107R", "ALL-JKT:M"]);
for (const [sku, , , , , , sizes] of CATALOG) for (const size of sizes.split("|")) {
const k = `${sku}:${size}`; const core = ["S", "M", "L", "XL", "82R", "87R", "92R"].includes(size);
opening.push({ sku, size, opening: thin.has(k) ? (k === "ALL-JKT:M" ? "0" : "2") : String(core ? 14 + ((sku.length + size.length) % 7) : 5), reorder: core ? "4" : "2" });
}
await op("import.rows", { kind: "opening", rows: opening });
await op("import.rows", { kind: "barcodes", rows: [
{ sku: "RN-TOP", size: "M", barcode: "9312345000011" }, { sku: "RN-TOP", size: "L", barcode: "9312345000028" }, { sku: "RN-PANT", size: "M", barcode: "9312345000035" },
{ sku: "EN-TOP", size: "L", barcode: "9312345000042" }, { sku: "SS-POLO", size: "M", barcode: "9312345000059" }, { sku: "SEC-SHIRT", size: "XL", barcode: "9312345000066" },
] });
const items = Object.fromEntries((await prisma.catalogItem.findMany({ where: { facilityId: fac.id } })).map((i) => [i.sku, i.id]));
const staff = Object.fromEntries((await prisma.staff.findMany({ where: { facilityId: fac.id } })).map((s) => [s.num, s.id]));
const byNum = Object.fromEntries(STAFF.map((s) => [s[0], s]));
const line = (sku: string, size: string, qty: number, src: "stock" | "order" | "preloved" = "stock") => ({ itemId: items[sku], si: si(sku, size), qty, src });
const set = (num: string, qty = 1, src: "stock" | "order" = "stock") => { const s = byNum[num]; return [line(topSku(s[3]), s[5], qty, src), line(pantSku(s[3]), s[6], qty, src)]; };
// Signed order forms for nurses on the FTE table, each against the hours on the form. One is
// signed above what the table proposes, so the note a manager's discretion leaves on the record
// has an example to show.
const approvals: [string, string, number, string][] = [["100231", "J. Barnes, Manager 3A", 5, "1.0"], ["100244", "R. Achebe, Manager ICU", 4, "0.8"], ["100288", "L. Torres, Manager ED", 3, "1.0"], ["100349", "M. Quinn, Manager 3B", 4, "0.6"], ["100384", "R. Achebe, Manager ICU", 5, "1.0"], ["100446", "L. Torres, Manager ED", 3, "0.5"]];
for (const [num, by, sets, fte] of approvals) await op("approval.add", { staffId: staff[num], by, sets: String(sets), fte, date: addDays(today, -20 - (parseInt(num, 10) % 15)) });
// Kitchen and Security are on manager approval: no starting kit, and every set they hold was signed
// for. Without these their records would tell a visitor each set needs a manager's signature and
// then show sets issued with none. One form is for two sets with one still to collect, so a
// balance from this route shows beside the FTE table's.
const approvalForms: [string, string, number][] = [["100270", "D. Walsh, Security Manager", 1], ["100356", "D. Walsh, Security Manager", 1], ["100438", "D. Walsh, Security Manager", 1], ["100315", "K. Byrne, Catering Manager", 1], ["100378", "K. Byrne, Catering Manager", 1], ["100459", "K. Byrne, Catering Manager", 2]];
for (const [num, by, sets] of approvalForms) await op("approval.add", { staffId: staff[num], by, sets: String(sets), date: addDays(today, -20 - (parseInt(num, 10) % 15)) });
// Issues. Nurses with a signed form draw it down; the other nurses take sets from the kit their
// hours propose. Support Services take a set from their starting kit, and Riya comes back for two
// more tops — more as needed, nothing handed back first. Kitchen and Security draw their signed sets.
const issued: [string, number][] = [["100231", 2], ["100244", 1], ["100288", 2], ["100349", 1], ["100384", 3], ["100446", 1]];
for (const [num, sets] of issued) await op("issue.create", { staffId: staff[num], apDeduct: sets, lines: set(num, sets) });
for (const num of ["100251", "100322", "100392", "100467"]) await op("issue.create", { staffId: staff[num], apDeduct: 0, lines: set(num, 1) });
for (const num of ["100263", "100293", "100337", "100361", "100417", "100423"]) await op("issue.create", { staffId: staff[num], lines: set(num, 1) });
for (const [num] of approvalForms) await op("issue.create", { staffId: staff[num], apDeduct: 1, lines: set(num, 1) });
await op("issue.create", { staffId: staff["100263"], lines: [line("SS-POLO", "S", 2)] });
await op("issue.create", { staffId: staff["100301"], apDeduct: 0, lines: [line("ALL-CARD", "M", 1)] });
// Order-in lines create staff orders; two have arrived and sit on the pickup call list, one is still with the supplier.
await op("issue.create", { staffId: staff["100244"], apDeduct: 0, lines: [line("ALL-JKT", "L", 1, "order")] });
await op("issue.create", { staffId: staff["100251"], apDeduct: 0, lines: [line("EN-TOP", "L", 2, "order")] });
await op("issue.create", { staffId: staff["100263"], lines: [line("SS-CARGO", "87R", 1, "order")] });
await op("issue.create", { staffId: staff["100405"], apDeduct: 0, lines: [line("ALL-JKT", "S", 1, "order")] });
const staffOrders = await prisma.order.findMany({ where: { facilityId: fac.id, orderFor: "Staff Member" }, include: { lines: true }, orderBy: { createdAt: "asc" } });
const so = (num: string) => staffOrders.find((o) => o.staffId === staff[num])!;
for (const [num, days, contacted] of [["100244", 16, true], ["100251", 4, false], ["100263", 1, false]] as const) {
const o = so(num);
await prisma.order.update({ where: { id: o.id }, data: { ref: `NL-${48100 + parseInt(num.slice(-3), 10)}`, date: addDays(today, -days - 12) } });
await op("order.receive", { id: o.id, date: addDays(today, -days), invoice: `INV-${77120 + parseInt(num.slice(-3), 10)}`, lines: o.lines.map((l) => ({ lineId: l.id, arrived: l.qty, dest: "pickup" })) });
const pk = await prisma.pickup.findFirst({ where: { orderId: o.id } });
if (pk) await prisma.pickup.update({ where: { id: pk.id }, data: { received: addDays(today, -days), contacted } });
}
await prisma.order.update({ where: { id: so("100405").id }, data: { ref: "HE-20931", expected: addDays(today, 9) } });
// Replenishment drafts were built automatically from the shelf issues. Send one, receive one short (→ back order), leave one overdue.
const drafts = await prisma.order.findMany({ where: { facilityId: fac.id, replenish: true, status: "Draft" }, include: { lines: true }, orderBy: { supplier: "asc" } });
const nl = drafts.find((d) => d.supplier === NL), he = drafts.find((d) => d.supplier === HE);
if (nl) {
await op("order.status", { id: nl.id, status: "Ordered" });
await prisma.order.update({ where: { id: nl.id }, data: { ref: "NL-48211", tracking: "AP7731920021", date: addDays(today, -18), expected: addDays(today, -6) } });
}
if (he) {
await op("order.status", { id: he.id, status: "Ordered" });
await prisma.order.update({ where: { id: he.id }, data: { ref: "HE-20877", date: addDays(today, -25), expected: addDays(today, -4) } });
await op("order.receive", { id: he.id, date: addDays(today, -3), invoice: "INV-77044", lines: he.lines.map((l, i) => ({ lineId: l.id, arrived: i === 0 ? Math.max(0, l.qty - 1) : l.qty, dest: "shelf" })) });
}
// A stock order placed by hand that has fully landed.
const landed = (await op("order.create", { orderFor: "Stock", supplier: NL, lines: [{ itemId: items["RN-TOP"], size: "M", qty: 12 }, { itemId: items["RN-PANT"], size: "M", qty: 12 }, { itemId: items["RN-TOP"], size: "L", qty: 8 }], notes: "Winter top-up" })) as { id: string };
await op("order.status", { id: landed.id, status: "Ordered" });
const lo = await prisma.order.findUniqueOrThrow({ where: { id: landed.id }, include: { lines: true } });
await prisma.order.update({ where: { id: lo.id }, data: { ref: "NL-47960", date: addDays(today, -40) } });
await op("order.receive", { id: lo.id, date: addDays(today, -28), invoice: "INV-76902", lines: lo.lines.map((l) => ({ lineId: l.id, arrived: l.qty, dest: "shelf" })) });
// A filed stocktake with a couple of variances, and one return.
await op("stocktake.apply", { lines: [
{ itemId: items["RN-TOP"], si: si("RN-TOP", "M"), counted: 21 }, { itemId: items["RN-PANT"], si: si("RN-PANT", "M"), counted: 20 },
{ itemId: items["SS-POLO"], si: si("SS-POLO", "M"), counted: 15 }, { itemId: items["SEC-SHIRT"], si: si("SEC-SHIRT", "L"), counted: 17 },
] });
const ret = await prisma.issue.findFirst({ where: { facilityId: fac.id, staffId: staff["100337"] } });
if (ret) await op("issue.return", { id: ret.id, cond: "Returned - Good" });
await op("alteration.add", { staffId: staff["100301"], garment: "Scrub pant (M)", desc: "Hem 4 cm" });
// Pre-loved pool: old try-on range added directly, a hand-in from a leaver, and one free reissue from the pool.
await op("stock.moves", { mode: "Pre-loved", lines: [line("RN-TOP", "M", 3), line("RN-PANT", "M", 2), line("SS-POLO", "L", 2), line("ALL-CARD", "S", 1)] });
await op("handin.add", { staffId: staff["100392"], credit: true, lines: [{ ...line("EN-TOP", "L", 2), cond: "Good", laundered: true }, { ...line("EN-PANT", "L", 1), cond: "Rag", laundered: false }] });
// Mei is an Enrolled Nurse, so her free reissue is the scrub pant both nursing groups wear — the
// RN-only top would need the coordinator's off-group override at the counter.
await op("issue.create", { staffId: staff["100467"], apDeduct: 0, lines: [line("RN-PANT", "M", 1, "preloved")] });
// Spread issue dates back across the last three months so the reports and trend bars have shape.
const iss = await prisma.issue.findMany({ where: { facilityId: fac.id, direct: false }, orderBy: { createdAt: "asc" } });
for (let i = 0; i < iss.length; i++) await prisma.issue.update({ where: { id: iss[i].id }, data: { date: addDays(today, -((i * 37) % 80)) } });
const st = await prisma.stocktake.findFirst({ where: { facilityId: fac.id } });
if (st) await prisma.stocktake.update({ where: { id: st.id }, data: { date: addDays(today, -12) } });
return prisma.facility.update({ where: { id: fac.id }, data: { demoResetAt: new Date() } });
}
/** The demo facility, rebuilt if it is missing or if the reset timer has slipped.
*
* The window is half again the reset interval rather than an hour: everyone shares this facility,
* so whatever one visitor types is on the screen of every visitor behind them, and the promise on
* /demo is that it resets every twenty minutes. A stalled systemd timer should cost a few extra
* minutes, not the rest of the hour. */
const DEMO_STALE_MS = DEMO_RESET_MINUTES * 90 * 1000;
let resetting: Promise<unknown> | null = null;
export async function ensureDemo() {
const f = await findDemoFacility();
if (f && f.demoResetAt && Date.now() - f.demoResetAt.getTime() < DEMO_STALE_MS) return f;
// Concurrent visitors share one rebuild instead of racing several.
if (!resetting) resetting = resetDemo().finally(() => { resetting = null; });
await resetting;
return (await findDemoFacility())!;
}
export function demoUserFor(f: NonNullable<Awaited<ReturnType<typeof findDemoFacility>>>, as: string) {
return f.users.find((u) => u.role === (as === "issuer" ? "ISSUER" : "ADMIN")) || f.users[0];
}