// 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)[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)[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 | 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>>, as: string) { return f.users.find((u) => u.role === (as === "issuer" ? "ISSUER" : "ADMIN")) || f.users[0]; }