Просмотр исходного кода

Show the tire hotel on the demo, and close two demo gaps

The module was invisible on demo.torqvoice.com because it is opt-in per
workshop, so the seed turns it on and fills it: a storeroom of ten shelves
at varying fullness, sixteen sets on the demo's own vehicles and their
owners, and up to three winters of tread readings behind each one.

The history is the point. Shelf bars, wear rates and the replacement
forecast are all derived, so a set checked in yesterday shows none of them.
Six sets carry three rounds of readings, six carry two and three carry one,
which puts the empty state, a half-drawn trend and a full forecast on the
same screen. One set sits under the shop's winter limit and one has been
scrapped at 2 mm, so the buying list and a disposal both have something in
them. Dates are relative rather than pinned to April and October, so the
demo reads the same in February as in August.

Tire sets outlive the reset without this: vehicleId and customerId are both
SetNull, and warehouses only cascade from the organization, which the reset
never deletes. Reseeding twice now leaves exactly one storeroom.

Two demo gaps found while checking the guards, neither in the tire hotel:

sendStatusReport had none. The transports refuse, so nothing ever left the
building, but sendSmsToCustomer is itself a withAuth action, so its refusal
came back as a returned value rather than a throw. Nothing read that value,
so the report was marked sent over a channel that sent nothing.

And setSettings takes an arbitrary key while only payment.* was blocked, so
a visitor could write a real Twilio token, bot token or API key straight
past the guarded settings page into a shared database. Credentials are
blocked now; templates and the enabled flags stay open.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
54bcb1c150

+ 426 - 0
prisma/seed_dummy_data.ts

@@ -1,4 +1,8 @@
 import { PrismaClient } from "../src/generated/prisma/client";
+import {
+  TIRE_ROAD_POSITIONS,
+  gradeTread,
+} from "../src/features/tire-hotel/Lib/tireConstants";
 import { PrismaPg } from "@prisma/adapter-pg";
 import { randomBytes, scryptSync } from "node:crypto";
 import * as fs from "fs";
@@ -68,6 +72,10 @@ const ORG_ID = "cmmh0vczm0000oiyan37vuyqf";
 const DEMO_EMAIL = process.env.DEMO_USER_EMAIL || "demo@torqvoice.com";
 const DEMO_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
 const DEMO_ORG_NAME = process.env.DEMO_ORG_NAME || "Demo Auto Workshop";
+/// What this workshop calls worn out. Stored in millimetres whatever the
+/// unit system, and used both for the settings rows and for grading the
+/// seeded readings, so the two can never disagree.
+const DEMO_TREAD_LIMITS = { summerReplace: 1.6, winterReplace: 4, warnMargin: 1 };
 const DATA_ROOT = process.env.DATA_ROOT || path.join(process.cwd(), "data");
 const UPLOAD_DIR = path.join(DATA_ROOT, "uploads", ORG_ID, "vehicles");
 // Image assets bundled with the repo — preferred over live URL downloads so
@@ -595,6 +603,14 @@ async function cleanup() {
   await prisma.vehicle.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.customer.deleteMany({ where: { organizationId: ORG_ID } });
   await prisma.inventoryPart.deleteMany({ where: { organizationId: ORG_ID } });
+  // Tire sets survive the vehicle and customer wipe above, because both of
+  // those relations are SetNull rather than Cascade. Warehouses and shelves
+  // only cascade from the organization, which the reset never touches. Left
+  // alone, every reseed would stack another storeroom on top of the last.
+  // Measurements, movements, treatments and attachments cascade from the set.
+  await prisma.tireSet.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.tireLocation.deleteMany({ where: { organizationId: ORG_ID } });
+  await prisma.tireWarehouse.deleteMany({ where: { organizationId: ORG_ID } });
 
   cleanupUploads();
   console.log("  Done.\n");
@@ -678,6 +694,22 @@ async function seed() {
     "sms.twilio.authToken": "demo-token-not-a-real-credential",
     "telegram.botToken": "0000000000:DEMO-not-a-real-bot-token",
     "telegram.botUsername": "EgelandAutoDemoBot",
+    // Tire hotel is opt-in per workshop, so the sidebar entry and the
+    // routes stay hidden until this is set.
+    "tireHotel.enabled": "true",
+    "tireHotel.summerReplaceMm": String(DEMO_TREAD_LIMITS.summerReplace),
+    "tireHotel.winterReplaceMm": String(DEMO_TREAD_LIMITS.winterReplace),
+    "tireHotel.defaultCapacity": "8",
+    "tireHotel.capacityWarnPercent": "90",
+    "tireHotel.defaultSeasonalPrice": "45",
+    "tireHotel.treatmentPrices": JSON.stringify({
+      wash_tires: 15,
+      wash_rims: 25,
+      balance: 40,
+      tpms_service: 30,
+      new_valves: 20,
+      repair: 35,
+    }),
   };
   await Promise.all(
     Object.entries(settings).map(([key, value]) =>
@@ -2184,6 +2216,400 @@ async function seed() {
   ]);
   console.log(`  Created ${statusReports.length} status reports`);
 
+  // -- Tire hotel --
+  // Seasonal tire storage. The interesting parts of this module only exist
+  // once there is history behind a set: shelves filling up, tread wearing down
+  // over successive winters, and the replacement forecast that falls out of
+  // those two numbers. A set checked in yesterday shows none of it, so most of
+  // these carry two or three years of readings.
+  //
+  // Dates are relative rather than pinned to real April and October, so the
+  // demo reads the same in February as it does in August. A shop in the
+  // southern hemisphere sees the same story with the seasons the other way up.
+  console.log("\nCreating tire hotel...");
+
+  const tireWarehouse = await prisma.tireWarehouse.create({
+    data: {
+      name: "Tire store",
+      address: "1450 Foundry Road, Denver, CO 80216 (rear building)",
+      notes: "Unheated but dry. Racks A and B, floor level to head height.",
+      isDefault: true,
+      organizationId: ORG_ID,
+      userId: USER_ID,
+    },
+  });
+
+  // Two racks. The A bays are the small ones by the door and fill up first,
+  // which is why several of them read full while rack B has room.
+  const tireLocations = await Promise.all(
+    [
+      { code: "A-1-1-L", zone: "Rack A", rack: "1", shelf: "1", position: "Left", capacity: 8 },
+      { code: "A-1-1-R", zone: "Rack A", rack: "1", shelf: "1", position: "Right", capacity: 8 },
+      { code: "A-1-2-L", zone: "Rack A", rack: "1", shelf: "2", position: "Left", capacity: 8 },
+      { code: "A-1-2-R", zone: "Rack A", rack: "1", shelf: "2", position: "Right", capacity: 8 },
+      { code: "A-2-1-L", zone: "Rack A", rack: "2", shelf: "1", position: "Left", capacity: 8 },
+      { code: "A-2-1-R", zone: "Rack A", rack: "2", shelf: "1", position: "Right", capacity: 8 },
+      { code: "B-1-1-L", zone: "Rack B", rack: "1", shelf: "1", position: "Left", capacity: 12 },
+      { code: "B-1-1-R", zone: "Rack B", rack: "1", shelf: "1", position: "Right", capacity: 12 },
+      { code: "B-1-2-L", zone: "Rack B", rack: "1", shelf: "2", position: "Left", capacity: 12 },
+      {
+        code: "B-1-2-R",
+        zone: "Rack B",
+        rack: "1",
+        shelf: "2",
+        position: "Right",
+        capacity: 12,
+        notes: "Kept clear for walk-ins during the changeover weeks.",
+      },
+    ].map((spec) =>
+      prisma.tireLocation.create({
+        data: { ...spec, warehouseId: tireWarehouse.id, organizationId: ORG_ID },
+      }),
+    ),
+  );
+  const shelf = new Map(tireLocations.map((l) => [l.code, l]));
+
+  /**
+   * One stored set, described the way the workshop would describe it.
+   *
+   * `rounds` are tread readings in millimetres, newest first, four per round
+   * in the order a technician walks the car: front left, front right, rear
+   * left, rear right. Each round is one check-in a year earlier than the last,
+   * so the gaps between them are a winter's driving.
+   */
+  type TireSetSpec = {
+    vehicle: number;
+    customer: number;
+    season: "summer" | "winter" | "all_season";
+    studded?: boolean;
+    brand: string;
+    model: string;
+    size: string;
+    dotCode: string;
+    loadSpeedIndex?: string;
+    withRims: boolean;
+    rimType?: string;
+    hasTpms?: boolean;
+    quantity?: number;
+    /** Shelf code, or null for a set that is no longer on a shelf. */
+    location: string | null;
+    /** Where it used to sit, for one that has left. History still shows it. */
+    lastShelf?: string;
+    /** Bar, as found at check-in. Vans and trucks run higher than cars. */
+    pressureBar?: number;
+    status?: "stored" | "released" | "disposed";
+    /** Days ago the most recent check-in happened. */
+    storedDaysAgo: number;
+    /** Days ago the set left, for one that is no longer here. */
+    goneDaysAgo?: number;
+    rounds: number[][];
+    treatments?: Partial<Record<"wash_tires" | "wash_rims" | "balance" | "tpms_service" | "new_valves" | "repair", "pending" | "done" | "skipped">>;
+    notes?: string;
+    damage?: string;
+  };
+
+  const TIRE_SETS: TireSetSpec[] = [
+    // Three winters of readings: the forecast has real data to work from.
+    {
+      vehicle: 0, customer: 1, season: "winter",
+      brand: "Nokian", model: "Hakkapeliitta R5", size: "235/45R18", dotCode: "3623",
+      loadSpeedIndex: "98T", withRims: true, rimType: 'Alloy, 18" OEM', hasTpms: true,
+      location: "A-1-1-L", storedDaysAgo: 118,
+      rounds: [[4.6, 4.4, 5.1, 5.0], [6.0, 5.9, 6.4, 6.3], [7.4, 7.3, 7.7, 7.6]],
+      treatments: { wash_rims: "done", balance: "done" },
+      notes: "Fronts wearing faster than the rears, as they always do on this car.",
+    },
+    {
+      vehicle: 4, customer: 3, season: "winter",
+      brand: "Continental", model: "WinterContact TS 870 P", size: "225/45R18", dotCode: "1024",
+      loadSpeedIndex: "95V", withRims: true, rimType: 'Alloy, 18" winter set', hasTpms: true,
+      location: "A-1-1-L", storedDaysAgo: 116,
+      rounds: [[5.2, 5.3, 5.8, 5.9], [6.5, 6.6, 6.9, 7.0], [7.8, 7.9, 8.1, 8.2]],
+      treatments: { wash_tires: "done", wash_rims: "done" },
+    },
+    // Down to the limit. Turns up on the forecast as needing replacing before
+    // the next season, which is the whole point of keeping the history.
+    {
+      vehicle: 5, customer: 9, season: "winter",
+      brand: "Bridgestone", model: "Blizzak WS90", size: "235/40R18", dotCode: "4122",
+      loadSpeedIndex: "95H", withRims: false,
+      location: "A-1-1-R", storedDaysAgo: 121,
+      rounds: [[3.1, 3.0, 3.4, 3.3], [4.6, 4.5, 4.8, 4.7], [6.1, 6.0, 6.3, 6.2]],
+      treatments: { wash_tires: "done", balance: "pending" },
+      notes: "Told the owner at check-in these will not pass another winter.",
+    },
+    {
+      vehicle: 6, customer: 5, season: "winter",
+      brand: "Michelin", model: "X-Ice Snow", size: "235/45R19", dotCode: "0824",
+      loadSpeedIndex: "99H", withRims: true, rimType: 'Alloy, 19" aero', hasTpms: true,
+      location: "A-1-1-R", storedDaysAgo: 110,
+      rounds: [[6.3, 6.4, 6.6, 6.7], [7.6, 7.7, 7.8, 7.9]],
+      treatments: { wash_rims: "done", tpms_service: "done" },
+      notes: "Aero covers in the boot with the tires. Do not fit without them.",
+    },
+    {
+      vehicle: 7, customer: 13, season: "winter",
+      brand: "Pirelli", model: "Scorpion Winter 2", size: "255/45R19", dotCode: "1525",
+      loadSpeedIndex: "104V", withRims: true, rimType: 'Alloy, 19" Gemini', hasTpms: true,
+      location: "A-1-2-L", storedDaysAgo: 104,
+      rounds: [[7.4, 7.5, 7.6, 7.6]],
+      treatments: { wash_rims: "done" },
+      notes: "First season with us. One year of readings, so no wear rate yet.",
+    },
+    {
+      vehicle: 10, customer: 7, season: "winter",
+      brand: "Vredestein", model: "Wintrac Pro", size: "245/40R18", dotCode: "3722",
+      loadSpeedIndex: "97V", withRims: false,
+      location: "A-1-2-L", storedDaysAgo: 126,
+      rounds: [[4.9, 4.8, 5.5, 5.4], [6.2, 6.1, 6.7, 6.6], [7.5, 7.4, 7.9, 7.8]],
+      treatments: { wash_tires: "done", balance: "done", new_valves: "done" },
+    },
+    {
+      vehicle: 11, customer: 11, season: "winter", studded: true,
+      brand: "Nokian", model: "Hakkapeliitta 10 SUV", size: "285/70R17", dotCode: "2823",
+      loadSpeedIndex: "121T", withRims: true, rimType: 'Steel, 17" black', hasTpms: true,
+      location: "A-1-2-R", storedDaysAgo: 133,
+      rounds: [[7.1, 7.0, 7.4, 7.3], [8.4, 8.3, 8.6, 8.5]],
+      treatments: { wash_tires: "done", repair: "done" },
+      notes: "Studded. Owner drives over the pass every weekend through winter.",
+      damage: "Sidewall scuff on the near-side rear, cosmetic only. Photographed at check-in.",
+    },
+    {
+      vehicle: 12, customer: 12, season: "winter",
+      brand: "Goodyear", model: "UltraGrip Performance +", size: "245/75R17", dotCode: "0524",
+      loadSpeedIndex: "121S", withRims: false,
+      location: "A-2-1-L", storedDaysAgo: 99,
+      rounds: [[6.6, 6.5, 6.9, 6.8], [8.0, 7.9, 8.2, 8.1]],
+      treatments: { wash_tires: "pending" },
+    },
+    {
+      vehicle: 13, customer: 3, season: "winter",
+      brand: "Michelin", model: "Pilot Alpin 5", size: "245/35R20", dotCode: "1124",
+      loadSpeedIndex: "95V", withRims: true, rimType: 'Alloy, staggered 20/21"', hasTpms: true,
+      location: "A-2-1-L", storedDaysAgo: 112,
+      rounds: [[5.8, 5.9, 5.1, 5.0], [7.0, 7.1, 6.4, 6.3]],
+      treatments: { wash_rims: "done", balance: "done", tpms_service: "done" },
+      notes: 'Staggered set: fronts 245/35R20, rears 305/30R21. Keep them paired.',
+    },
+    {
+      vehicle: 14, customer: 7, season: "winter",
+      brand: "Continental", model: "WinterContact TS 860", size: "225/40R18", dotCode: "3921",
+      loadSpeedIndex: "92H", withRims: true, rimType: 'Alloy, 18" Nogaro', hasTpms: true,
+      location: "A-2-1-R", storedDaysAgo: 129,
+      rounds: [[4.1, 4.0, 4.6, 4.5], [5.5, 5.4, 5.9, 5.8], [6.9, 6.8, 7.2, 7.1]],
+      treatments: { wash_tires: "done", wash_rims: "done", balance: "done" },
+      notes: "One more winter in them at this rate, then they need doing.",
+    },
+    {
+      vehicle: 15, customer: 17, season: "winter",
+      brand: "Bridgestone", model: "Blizzak DM-V2", size: "275/55R20", dotCode: "2224",
+      loadSpeedIndex: "117T", withRims: false, quantity: 5, pressureBar: 2.8,
+      location: "B-1-1-L", storedDaysAgo: 95,
+      rounds: [[7.8, 7.7, 8.0, 7.9]],
+      treatments: { wash_tires: "done" },
+      notes: "Five tires: four plus a matching full-size spare.",
+    },
+    {
+      vehicle: 16, customer: 19, season: "winter",
+      brand: "Toyo", model: "Observe GSi-6", size: "265/70R16", dotCode: "1723",
+      loadSpeedIndex: "112H", withRims: true, rimType: 'Steel, 16" with trims',
+      location: "B-1-1-L", storedDaysAgo: 138,
+      rounds: [[6.0, 6.1, 6.5, 6.4], [7.3, 7.4, 7.6, 7.5]],
+      treatments: { wash_tires: "done", new_valves: "done" },
+    },
+    {
+      vehicle: 1, customer: 0, season: "winter",
+      brand: "Firestone", model: "Winterforce 2 UV", size: "275/60R20", dotCode: "3123",
+      loadSpeedIndex: "115S", withRims: true, rimType: 'Steel, 20" fleet spec', hasTpms: true,
+      pressureBar: 2.8,
+      location: "B-1-1-R", storedDaysAgo: 141,
+      rounds: [[5.4, 5.3, 5.9, 5.8], [6.8, 6.7, 7.1, 7.0], [8.1, 8.0, 8.3, 8.2]],
+      treatments: { wash_tires: "done", balance: "done" },
+      notes: "Fleet vehicle. Invoice goes on the Summit Construction account.",
+    },
+    {
+      vehicle: 2, customer: 11, season: "all_season",
+      brand: "Michelin", model: "CrossClimate 2", size: "265/70R17", dotCode: "0225",
+      loadSpeedIndex: "115T", withRims: false,
+      location: "B-1-2-L", storedDaysAgo: 87,
+      rounds: [[6.7, 6.6, 7.0, 6.9]],
+      treatments: { wash_tires: "pending", balance: "pending" },
+      notes: "Second vehicle, so the all-seasons come off over the summer too.",
+    },
+    // Collected. Off the shelf, still in the history.
+    {
+      vehicle: 3, customer: 4, season: "winter",
+      brand: "Hankook", model: "Winter i*cept evo3 X", size: "275/55R20", dotCode: "4023",
+      loadSpeedIndex: "117V", withRims: true, rimType: 'Alloy, 20" platinum', hasTpms: true,
+      location: null, lastShelf: "B-1-1-R", status: "released", storedDaysAgo: 300, goneDaysAgo: 21,
+      rounds: [[6.2, 6.3, 6.7, 6.6], [7.5, 7.6, 7.8, 7.7]],
+      treatments: { wash_tires: "done", wash_rims: "done", balance: "done" },
+      notes: "Collected when the fleet contract moved depot.",
+    },
+    // Written off at the limit. Shows what a disposal looks like afterwards.
+    {
+      vehicle: 9, customer: 15, season: "winter",
+      brand: "Semperit", model: "Van-Grip 3", size: "235/65R16C", dotCode: "1219",
+      loadSpeedIndex: "115R", withRims: false,
+      location: null, lastShelf: "B-1-2-L", status: "disposed", storedDaysAgo: 240, goneDaysAgo: 34,
+      pressureBar: 3.2,
+      rounds: [[2.2, 2.1, 2.6, 2.5], [3.9, 3.8, 4.2, 4.1]],
+      notes: "Scrapped with the owner's agreement. New set fitted the same day.",
+    },
+  ];
+
+  /** Roughly a season between a set going out and coming back. */
+  const SEASON_DAYS = 182;
+  /** A whole year between one check-in and the next. */
+  const YEAR_DAYS = 365;
+
+  let tireReference = 0;
+  const tireSetIds: string[] = [];
+
+  for (const spec of TIRE_SETS) {
+    tireReference += 1;
+    const status = spec.status ?? "stored";
+    const quantity = spec.quantity ?? 4;
+    const location = spec.location ? shelf.get(spec.location) : undefined;
+    // Where the movement trail says it went. A set that has been collected
+    // still spent its winters on a real shelf, and the history should say so.
+    const historyShelf = shelf.get(spec.location ?? spec.lastShelf ?? "");
+    const checkedInAt = days(-spec.storedDaysAgo);
+    const checkedOutAt = spec.goneDaysAgo ? days(-spec.goneDaysAgo) : null;
+
+    const set = await prisma.tireSet.create({
+      data: {
+        reference: String(tireReference),
+        season: spec.season,
+        studded: spec.studded ?? false,
+        brand: spec.brand,
+        model: spec.model,
+        size: spec.size,
+        dotCode: spec.dotCode,
+        loadSpeedIndex: spec.loadSpeedIndex ?? null,
+        withRims: spec.withRims,
+        rimType: spec.rimType ?? null,
+        hasTpms: spec.hasTpms ?? false,
+        quantity,
+        status,
+        notes: spec.notes ?? null,
+        locationId: location?.id ?? null,
+        vehicleId: vehicles[spec.vehicle].id,
+        customerId: customers[spec.customer].id,
+        organizationId: ORG_ID,
+        userId: USER_ID,
+        checkedInAt,
+        checkedOutAt,
+      },
+    });
+    tireSetIds.push(set.id);
+
+    // Oldest first, so the movement trail reads forwards: arrived, left,
+    // arrived again. Each round is one check-in, a year apart.
+    for (let i = spec.rounds.length - 1; i >= 0; i -= 1) {
+      const arrivedAt = days(-(spec.storedDaysAgo + i * YEAR_DAYS));
+      const movement = await prisma.tireMovement.create({
+        data: {
+          type: "check_in",
+          toCode: historyShelf?.code ?? null,
+          toLocationId: historyShelf?.id ?? null,
+          note: i === spec.rounds.length - 1 ? "First check-in for this set." : null,
+          tireSetId: set.id,
+          organizationId: ORG_ID,
+          performedById: USER_ID,
+          createdAt: arrivedAt,
+        },
+      });
+
+      await prisma.tireMeasurement.createMany({
+        data: TIRE_ROAD_POSITIONS.map((position, index) => ({
+          position,
+          treadDepthMm: spec.rounds[i][index],
+          pressureBar: spec.pressureBar ?? 2.4,
+          // Left as the grade the reading implies at the shop's own limits.
+          // The app derives what it shows from the depth, so this is only a
+          // fallback for a reading taken by eye.
+          condition: gradeTread(spec.rounds[i][index], spec.season, DEMO_TREAD_LIMITS) ?? "good",
+          damage: i === 0 && position === "rear_left" ? (spec.damage ?? null) : null,
+          tireSetId: set.id,
+          movementId: movement.id,
+          measuredById: USER_ID,
+          measuredAt: arrivedAt,
+        })),
+      });
+
+      // Everything except the most recent round went back on the car for a
+      // season before coming in again.
+      if (i > 0) {
+        await prisma.tireMovement.create({
+          data: {
+            type: "check_out",
+            fromCode: historyShelf?.code ?? null,
+            fromLocationId: historyShelf?.id ?? null,
+            note: "Refitted for the season.",
+            tireSetId: set.id,
+            organizationId: ORG_ID,
+            performedById: USER_ID,
+            createdAt: days(-(spec.storedDaysAgo + i * YEAR_DAYS - SEASON_DAYS)),
+          },
+        });
+      }
+    }
+
+    if (status !== "stored" && spec.goneDaysAgo) {
+      await prisma.tireMovement.create({
+        data: {
+          type: status === "disposed" ? "dispose" : "check_out",
+          fromCode: historyShelf?.code ?? null,
+          fromLocationId: historyShelf?.id ?? null,
+          note:
+            status === "disposed"
+              ? "Written off at the tread limit, with the owner's agreement."
+              : "Collected by the customer.",
+          tireSetId: set.id,
+          organizationId: ORG_ID,
+          performedById: USER_ID,
+          createdAt: days(-spec.goneDaysAgo),
+        },
+      });
+    }
+
+    if (spec.treatments) {
+      await prisma.tireTreatment.createMany({
+        data: Object.entries(spec.treatments).map(([type, treatmentStatus]) => ({
+          type,
+          status: treatmentStatus,
+          tireSetId: set.id,
+          organizationId: ORG_ID,
+          completedAt: treatmentStatus === "done" ? checkedInAt : null,
+          completedById: treatmentStatus === "done" ? USER_ID : null,
+        })),
+      });
+    }
+  }
+
+  // One set moved between shelves, so the history shows a relocation and not
+  // just arrivals and departures.
+  await prisma.tireMovement.create({
+    data: {
+      type: "relocate",
+      fromCode: "A-2-1-R",
+      toCode: "A-1-2-R",
+      fromLocationId: shelf.get("A-2-1-R")?.id ?? null,
+      toLocationId: shelf.get("A-1-2-R")?.id ?? null,
+      note: "Moved off the top shelf, owner collects these himself.",
+      tireSetId: tireSetIds[6],
+      organizationId: ORG_ID,
+      performedById: USER_ID,
+      createdAt: days(-40),
+    },
+  });
+
+  console.log(
+    `  Created ${tireLocations.length} shelves and ${TIRE_SETS.length} tire sets`,
+  );
+
+
   // -- Audit log --
   // Written by the app in normal use; seeded here so the page is not blank.
   console.log("\nCreating audit log entries...");

+ 123 - 0
src/__tests__/lib/demo-guards.test.ts

@@ -0,0 +1,123 @@
+/**
+ * The two ways a demo guard goes missing.
+ *
+ * One is a new action that sends: the transports refuse, so nothing leaves the
+ * building, but a nested send is a withAuth action whose refusal comes back as
+ * a returned value rather than a thrown one. An action that does not read that
+ * return carries on and records a message as sent that was never sent.
+ *
+ * The other is the settings back door. Each provider's settings action guards
+ * itself, but setSettings takes an arbitrary key, so a credential can be
+ * written straight past the guarded page into a database twenty strangers
+ * share.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { readFileSync, readdirSync, statSync } from 'node:fs'
+import path from 'node:path'
+import { isDemoBlockedSettingKey } from '@/lib/demo'
+
+/** Source of every server action file under src/features. */
+function actionFiles(): { file: string; source: string }[] {
+  const found: { file: string; source: string }[] = []
+  const walk = (dir: string) => {
+    for (const entry of readdirSync(dir, { withFileTypes: true })) {
+      const full = path.join(dir, entry.name)
+      if (entry.isDirectory()) {
+        walk(full)
+        continue
+      }
+      if (!entry.name.endsWith('.ts')) continue
+      if (!full.includes(`${path.sep}Actions${path.sep}`)) continue
+      found.push({ file: full, source: readFileSync(full, 'utf-8') })
+    }
+  }
+  walk('src/features')
+  return found
+}
+
+/** Names that put a message on a wire. */
+const SENDERS = [
+  'sendEmail',
+  'sendNotificationEmail',
+  'sendSms',
+  'sendSmsToCustomer',
+  'sendTelegramMessage',
+  'sendTelegramToCustomer',
+]
+
+describe('actions that send', () => {
+  it('finds the action files at all', () => {
+    // Without this the sweep below can pass by looking at nothing.
+    expect(statSync('src/features').isDirectory()).toBe(true)
+    expect(actionFiles().length).toBeGreaterThan(20)
+  })
+
+  it('every one of them refuses in demo mode', () => {
+    const unguarded = actionFiles()
+      .filter(({ source }) => SENDERS.some((fn) => source.includes(`${fn}(`)))
+      // The call, not the import. A file can import the guard and never
+      // reach it, which is exactly the state a bad refactor leaves behind.
+      .filter(({ source }) => !/\bdemoGuard\(\)/.test(source))
+      .map(({ file }) => file)
+
+    expect(unguarded, `these send without a demo guard:\n${unguarded.join('\n')}`).toEqual([])
+  })
+})
+
+describe('the settings back door', () => {
+  const CREDENTIALS = [
+    'payment.stripe.secretKey',
+    'payment.vipps.clientSecret',
+    'payment.providersEnabled',
+    'sms.twilio.authToken',
+    'sms.twilio.accountSid',
+    'sms.vonage.apiSecret',
+    'sms.telnyx.apiKey',
+    'sms.provider',
+    'sms.phoneNumber',
+    'sms.webhookSecret',
+    'telegram.botToken',
+    'telegram.webhookSecret',
+    'email.smtp.pass',
+    'email.smtp.host',
+    'email.resend.apiKey',
+    'email.sendgrid.apiKey',
+    'email.mailgun.apiKey',
+    'email.postmark.apiKey',
+    'email.ses.secretAccessKey',
+    'email.provider',
+    'ai.apiKey',
+  ]
+
+  const PLAYABLE = [
+    // The demo is meant to be played with. Blocking these would make it a
+    // screenshot, and none of them can put anything on a wire.
+    'workshop.name',
+    'workshop.currencyCode',
+    'invoice.footerNote',
+    'tireHotel.enabled',
+    'tireHotel.defaultSeasonalPrice',
+    'telegram.enabled',
+    'telegram.template.quoteReady',
+    'sms.template.quoteReady',
+    'email.enabled',
+    'email.fromName',
+    'ai.enabled',
+    'ai.model',
+    'portal.enabled',
+    'maintenance.enabled',
+  ]
+
+  it('refuses every credential', () => {
+    for (const key of CREDENTIALS) {
+      expect(isDemoBlockedSettingKey(key), `${key} should be blocked`).toBe(true)
+    }
+  })
+
+  it('leaves the rest of the app playable', () => {
+    for (const key of PLAYABLE) {
+      expect(isDemoBlockedSettingKey(key), `${key} should not be blocked`).toBe(false)
+    }
+  })
+})

+ 7 - 0
src/features/status-reports/Actions/sendStatusReport.ts

@@ -2,6 +2,7 @@
 
 import { db } from "@/lib/db";
 import { withAuth } from "@/lib/with-auth";
+import { demoGuard } from "@/lib/demo";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { getTranslations } from "next-intl/server";
 import { sendStatusReportSchema } from "../Schema/statusReportSchema";
@@ -12,6 +13,12 @@ import { sendTelegramToCustomer } from "@/features/telegram/Actions/telegramActi
 export async function sendStatusReport(input: unknown) {
   return withAuth(
     async ({ organizationId }) => {
+      // The transports refuse to send in demo mode, but sendSmsToCustomer and
+      // sendTelegramToCustomer are withAuth actions, so their refusal comes
+      // back as a returned error rather than a thrown one. This function does
+      // not read those returns, so without this guard the report would be
+      // marked sent over a channel that sent nothing.
+      demoGuard();
       const data = sendStatusReportSchema.parse(input);
 
       // Get the status report with service record and customer info

+ 13 - 0
src/lib/demo.ts

@@ -52,6 +52,19 @@ export function assertOutboundAllowed(channel: "email" | "sms" | "telegram"): vo
 const DEMO_BLOCKED_SETTING_KEY_PATTERNS: RegExp[] = [
   /^payment\.(stripe|vipps|paypal)\./,
   /^payment\.providersEnabled$/,
+  // Each provider's own settings action already refuses in demo mode, but
+  // setSettings is a plain server action that takes any key at all, so
+  // without these the guarded page is a locked front door beside an open
+  // window. Nothing outbound can leave the demo either way; the point is that
+  // a visitor's real credentials never land in a database twenty strangers
+  // share. Templates and the enabled flags stay open, so the demo is still
+  // something you can play with.
+  /^sms\.(twilio|vonage|telnyx)\./,
+  /^sms\.(provider|phoneNumber|webhookSecret)$/,
+  /^telegram\.(botToken|webhookSecret)$/,
+  /^email\.(smtp|resend|sendgrid|mailgun|postmark|ses)\./,
+  /^email\.provider$/,
+  /^ai\.apiKey$/,
 ];
 
 export function isDemoBlockedSettingKey(key: string): boolean {