From f6ad0e800ae33c7574c87428c5a173d6c1b0cc0f Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 05:58:05 -0400 Subject: [PATCH 1/6] fix(observability): the alert path was feeding itself logError() records an ErrorEvent. recordError() calls reportOpsAlert the first time it sees a fingerprint. reportOpsAlert logged itself through logError. So every alert produced a NEW message -- "ops-alert: " prepended to the previous title -- which is a new fingerprint, which is a first sighting, which alerts again: ops-alert: New error: ops-alert: New error: ops-alert: New error: ... One unconfigured integration was enough to start it. A missing Axper API key logged once, and the loop turned that into 1,154 ErrorEvent rows and a server too busy to answer a login. Every row was the loop's own output, so the error log -- the thing you reach for when something is wrong -- was the least usable artifact in the system. Two guards. opsAlert.ts now logs through `logger`, which writes to stdout and stops, and says at the top that nothing in it may call logError; the two channel-failure paths inside it had the same bug and are fixed the same way. And recordError refuses to re-enter the alert path, so no future caller can reopen it from the other side. Measured on the demo after the fix: /app went from 2.6-3.7s to 0.02-0.04s, and ErrorEvent stays empty instead of filling with its own output. opsAlertLoop.test.ts pins both guards, and checks that the fix was not simply "stop alerting" -- the cheapest wrong answer would pass the first three assertions. Co-Authored-By: Claude Opus 5 --- app/__tests__/opsAlertLoop.test.ts | 72 ++++++ app/prisma/seed/demo/config.ts | 19 +- app/prisma/seed/demo/index.ts | 16 ++ app/prisma/seed/demo/pipeline.ts | 283 +++++++++++++++++++++++ app/prisma/seed/demo/setupData.ts | 256 ++++++++++++++++++++ app/src/lib/errorRecorder.ts | 37 ++- app/src/lib/opsAlert.ts | 37 ++- app/src/lib/traffic/recordedTraffic.ts | 56 +++++ app/src/pages/api/axper/traffic/index.ts | 8 +- 9 files changed, 768 insertions(+), 16 deletions(-) create mode 100644 app/__tests__/opsAlertLoop.test.ts create mode 100644 app/prisma/seed/demo/pipeline.ts create mode 100644 app/prisma/seed/demo/setupData.ts create mode 100644 app/src/lib/traffic/recordedTraffic.ts diff --git a/app/__tests__/opsAlertLoop.test.ts b/app/__tests__/opsAlertLoop.test.ts new file mode 100644 index 00000000..089726d4 --- /dev/null +++ b/app/__tests__/opsAlertLoop.test.ts @@ -0,0 +1,72 @@ +// /app/__tests__/opsAlertLoop.test.ts +// +// The alert path must never produce an error worth alerting about. +// +// It did. logError() records an ErrorEvent; recordError() calls reportOpsAlert +// the first time it sees a fingerprint; reportOpsAlert logged itself through +// logError. So every alert became a NEW message -- "ops-alert: " prepended to +// the previous title -- which is a new fingerprint, which is a first sighting, +// which alerts again. Unbounded, and growing by one prefix per pass: +// +// ops-alert: New error: ops-alert: New error: ops-alert: New error: ... +// +// One missing API key was enough to start it. It produced 1,154 ErrorEvent rows +// of the loop's own output and a server too busy to answer a login -- and it +// made the error log useless at exactly the moment somebody would go looking. +// +// Two guards, tested here as source-text because the runtime path needs Prisma +// and the point is structural: the shape must not come back. + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * Source with comment lines removed. + * + * The header of opsAlert.ts explains the loop at length and names logError + * several times doing it. Matching those would make this test fail on its own + * documentation, so strip whole comment lines first -- the same approach + * dbGuardsCoverage.test.ts takes for the same reason. + */ +function codeOf(src: string): string { + return src + .split("\n") + .filter((l) => { + const t = l.trim(); + return !t.startsWith("//") && !t.startsWith("*") && !t.startsWith("/*"); + }) + .join("\n"); +} + +const opsAlert = codeOf(readFileSync(path.join(__dirname, "../src/lib/opsAlert.ts"), "utf8")); +const recorder = readFileSync(path.join(__dirname, "../src/lib/errorRecorder.ts"), "utf8"); + +describe("the ops-alert path cannot feed itself", () => { + it("opsAlert.ts never calls logError", () => { + // logger.error writes to stdout and stops. logError records, and recording + // is what calls back into here. + const calls = opsAlert.match(/\blogError\s*\(/g) ?? []; + expect(calls).toEqual([]); + }); + + it("opsAlert.ts does not import logError", () => { + expect(opsAlert).not.toMatch( + /import\s*\{[^}]*\blogError\b[^}]*\}\s*from\s*["']@\/lib\/logger["']/, + ); + }); + + it("the recorder guards against re-entering the alert path", () => { + // Belt and braces: even if some future caller reintroduces a logError on + // the alert path, this stops the second lap. + expect(recorder).toMatch(/if\s*\(alerting\)\s*return;/); + expect(recorder).toMatch(/alerting\s*=\s*true;/); + expect(recorder).toMatch(/finally\s*\{[\s\S]{0,80}alerting\s*=\s*false;/); + }); + + it("still alerts -- the guard did not simply switch alerting off", () => { + // The cheapest wrong fix would be to stop calling reportOpsAlert at all, + // which would make this file pass and lose the alerting. + expect(recorder).toMatch(/await\s+reportOpsAlert\(/); + expect(recorder).toMatch(/ALERT_AT_COUNTS/); + }); +}); diff --git a/app/prisma/seed/demo/config.ts b/app/prisma/seed/demo/config.ts index 70c78394..0210ed44 100644 --- a/app/prisma/seed/demo/config.ts +++ b/app/prisma/seed/demo/config.ts @@ -130,11 +130,26 @@ export const REFUND_SHARE_OF_ALL_PAYMENTS = 0.06; * a freshly-dated dataset later -- the default keeps today's run and a run * five years from now producing the same rows. */ -const DEFAULT_AS_OF = "2026-08-01"; +/** + * The seed window ends TODAY unless something pins it. + * + * This was a hardcoded date, which meant a demo went stale a day at a time: + * seeded once, and from then on the dashboard's "today" had no sales, the + * dispatch board's "today" had no runs, and every screen keyed to now drifted + * further from the data behind it. Three weeks after seeding, the first screen + * a viewer sees reads $0. + * + * `--as-of=YYYY-MM-DD` and `HOLT_SEED_AS_OF` still pin it, which is what any + * caller wanting reproducibility should use. Nothing in the test suite depends + * on the old constant -- checked, not assumed. + */ +function todayUtc(): string { + return new Date().toISOString().slice(0, 10); +} export function seedWindow(argv: readonly string[] = []): { start: Date; end: Date } { const flag = argv.find((a) => a.startsWith("--as-of=")); - const raw = flag?.split("=")[1] || process.env.HOLT_SEED_AS_OF || DEFAULT_AS_OF; + const raw = flag?.split("=")[1] || process.env.HOLT_SEED_AS_OF || todayUtc(); const end = new Date(`${raw}T00:00:00.000Z`); if (Number.isNaN(end.getTime())) { throw new Error(`Invalid --as-of/HOLT_SEED_AS_OF date "${raw}" -- expected YYYY-MM-DD.`); diff --git a/app/prisma/seed/demo/index.ts b/app/prisma/seed/demo/index.ts index 6f807ab5..c09425a1 100644 --- a/app/prisma/seed/demo/index.ts +++ b/app/prisma/seed/demo/index.ts @@ -61,6 +61,8 @@ async function main(): Promise { const { seedDelivery } = await import("./delivery"); const { seedScheduling } = await import("./scheduling"); const { seedInventoryOps } = await import("./inventoryOps"); + const { seedPipeline } = await import("./pipeline"); + const { seedSetupData } = await import("./setupData"); const { seedCommissionPayouts } = await import("./commissionPayouts"); const { seedJournalEntries } = await import("./journal"); const { ORG_SLUG } = await import("./org"); @@ -232,6 +234,20 @@ async function main(): Promise { new Date(), ); + // The front of the funnel, and the setup tables behind Admin -> Setup. Both + // run late because they reference customers, staff, stores and products. + const pipelineResult = await seedPipeline( + prisma, + rng, + customers, + staff, + locations.stores, + catalog.products, + new Date(), + ); + + const setupResult = await seedSetupData(prisma, rng, staff, locations.stores, new Date()); + const commissionPayoutsResult = await seedCommissionPayouts(window); const journalResult = await seedJournalEntries(prisma); diff --git a/app/prisma/seed/demo/pipeline.ts b/app/prisma/seed/demo/pipeline.ts new file mode 100644 index 00000000..a9d322f5 --- /dev/null +++ b/app/prisma/seed/demo/pipeline.ts @@ -0,0 +1,283 @@ +// /app/prisma/seed/demo/pipeline.ts +// +// The front of the funnel: quotes that have not closed, leads that have not +// been worked, the record of customers walking in, and the B2B proposals that +// sit above all of it. +// +// This is the first card on the Sales hub and it rendered "No open quotes or +// leads." on a fresh clone, because the seed made orders and never made a +// QUOTE. Four screens were empty behind it -- Pipeline, the Quotes filter, +// Stale Quote Cleanup and Pipeline Opportunity -- and all four are about the +// same absent thing. +// +// What each piece is for, and why it is shaped this way: +// +// QUOTES age deliberately. The Pipeline colours a quote green, amber or red +// by how long it has sat, and Stale Quote Cleanup exists to find the ones +// nobody chased. A batch of quotes all written yesterday exercises neither. +// These are staggered 1-90 days back so every bucket has something in it. +// +// LEADS carry a source, because the whole point of the board is that a +// Mailchimp click and a walk-in are worked differently. Some are assigned +// and some are not: an unassigned lead is what the "Assign" action exists +// for, and a board where everything is already assigned cannot show it. +// +// INTERACTIONS are the up-board and the follow-up history. Without them +// every customer reads "No follow-up yet" and the designer rotation on the +// dashboard shows "No one signed in". A few are left OPEN (isActive, no +// endedAt) because somebody being with a customer right now is the state +// the board is for. +// +// PROPOSALS are the trade side -- a named project, a cover letter, line +// items, and a status that moves. Seeded across DRAFT / SENT / ACCEPTED so +// the filter has all three, and the accepted ones link to a real order. + +import type { + PrismaClient, + LeadSource, + LeadStatus, + ProposalStatus, + InteractionOutcome, +} from "@prisma/client"; +import type { Rng } from "./rng"; +import { chance, pick, randInt, round2, subRng } from "./rng"; +import type { SeededCustomer } from "./customers"; +import type { StaffSetup } from "./staff"; +import type { StoreSetup } from "./locations"; +import type { CatalogProduct } from "./catalog"; + +const SEED_ACTOR = "seed:demo"; + +const CLOSED_OUTCOMES: InteractionOutcome[] = [ + "BROWSING", + "QUOTE_STARTED", + "SALE_COMPLETED", + "APPOINTMENT_SET", + "SERVICE_CASE", +]; + +const PROJECT_NAMES = [ + "Lakeside Residence — full furnishing", + "Harbourview Suites — model unit", + "The Fairmont Lobby refresh", + "Ridgeway Farmhouse — great room", + "Stonebridge Club — dining refit", + "Marchetti Residence — primary suite", +]; + +const LEAD_NOTES = [ + "Clicked through the spring lookbook twice.", + "Walked the floor Saturday, took swatches home.", + "Called about the sectional in the window.", + "Referred by an existing trade account.", + "Asked for a designer callback on the website form.", +]; + +const INTERACTION_NOTES = [ + "Walked the floor, took two fabric swatches.", + "Came back with room measurements.", + "Wanted to see the sectional in a performance weave.", + "Following up on a quote from last month.", + "Bringing a partner back at the weekend.", +]; + +export interface PipelineResult { + quotesCreated: number; + staleQuotes: number; + leadsCreated: number; + unassignedLeads: number; + interactionsCreated: number; + openInteractions: number; + proposalsCreated: number; +} + +export async function seedPipeline( + prisma: PrismaClient, + rng: Rng, + customers: SeededCustomer[], + staff: StaffSetup, + stores: StoreSetup[], + products: readonly CatalogProduct[], + today: Date, +): Promise { + const pRng = subRng(rng, "pipeline"); + const result: PipelineResult = { + quotesCreated: 0, + staleQuotes: 0, + leadsCreated: 0, + unassignedLeads: 0, + interactionsCreated: 0, + openInteractions: 0, + proposalsCreated: 0, + }; + + if (customers.length === 0 || products.length === 0) return result; + + // Sellers, not just designers: the floor writes quotes too, and a pipeline + // filtered to "mine" for a non-designer must not be empty. + const sellers = [...staff.designers, ...staff.floorSellers, staff.admin, staff.superAdmin].filter( + (s) => s.isActive, + ); + + // ---- open quotes ------------------------------------------------------- + for (let i = 0; i < 34; i++) { + const seller = pick(pRng, sellers); + const customer = pick(pRng, customers); + const store = pick(pRng, stores); + // Staggered so the Pipeline's urgency colours and Stale Quote Cleanup all + // have something to show. Anything past ~45 days is what "stale" means. + const daysOld = randInt(pRng, 1, 90); + const quoteDate = new Date(today.getTime() - daysOld * 86_400_000); + const lineCount = randInt(pRng, 1, 4); + + await prisma.salesOrder.create({ + data: { + orderno: `Q-1${String(1000 + i)}`, + status: "QUOTE", + orderDate: quoteDate, + customerId: customer.id, + salesperson: seller.displayName, + salesPersonId: seller.id, + storeLocation: store.name, + storeLocationId: store.id, + createdBy: SEED_ACTOR, + lineItems: { + create: Array.from({ length: lineCount }, (_, n) => { + const product = pick(pRng, products); + const qty = randInt(pRng, 1, 2); + return { + lineNumber: n + 1, + productId: product.id, + productName: product.name, + orderedQuantity: qty, + netPrice: round2(product.baseRetail * qty), + cost: round2(product.baseCost * qty), + vatRate: 0, + vatAmount: 0, + }; + }), + }, + }, + }); + result.quotesCreated += 1; + if (daysOld > 45) result.staleQuotes += 1; + } + + // ---- leads ------------------------------------------------------------- + const SOURCES: LeadSource[] = [ + "MAILCHIMP_CLICK", + "MAILCHIMP_OPEN", + "WALK_IN", + "PHONE", + "REFERRAL", + "WEBSITE", + ]; + + for (let i = 0; i < 22; i++) { + // A third stay unassigned -- that is what the Assign action is for, and a + // board where everything is already assigned cannot demonstrate it. + const assigned = i % 3 !== 0; + const seller = pick(pRng, sellers); + const customer = chance(pRng, 0.6) ? pick(pRng, customers) : null; + const status: LeadStatus = assigned + ? pick(pRng, ["ASSIGNED", "CONTACTED", "QUALIFIED"] as const) + : "NEW"; + const raisedAt = new Date(today.getTime() - randInt(pRng, 0, 40) * 86_400_000); + + await prisma.lead.create({ + data: { + source: pick(pRng, SOURCES), + status, + customerId: customer?.id ?? null, + firstName: `Lead${i + 1}`, + lastName: "Prospect", + email: `lead${i + 1}@example.com`, + phone: `860-555-1${String(100 + i)}`, + notes: pick(pRng, LEAD_NOTES), + assignedToId: assigned ? seller.id : null, + assignedAt: assigned ? raisedAt : null, + lastActionAt: raisedAt, + created: raisedAt, + createdBy: SEED_ACTOR, + }, + }); + result.leadsCreated += 1; + if (!assigned) result.unassignedLeads += 1; + } + + // ---- interactions ------------------------------------------------------ + for (let i = 0; i < 60; i++) { + const seller = pick(pRng, sellers); + const store = pick(pRng, stores); + const customer = chance(pRng, 0.8) ? pick(pRng, customers) : null; + // Three are still open -- somebody is with a customer right now, which is + // the state the up-board exists to show. + const stillOpen = i < 3; + const startedAt = stillOpen + ? new Date(today.getTime() - randInt(pRng, 5, 90) * 60_000) + : new Date(today.getTime() - randInt(pRng, 1, 60) * 86_400_000); + + await prisma.customerInteraction.create({ + data: { + staffMemberId: seller.id, + customerId: customer?.id ?? null, + storeLocation: store.name, + storeLocationId: store.id, + source: pick(pRng, ["WALK_IN", "PHONE", "EMAIL", "APPOINTMENT"] as const), + // An interaction still in progress has no outcome yet -- that is the + // whole meaning of the field. (WITH_CUSTOMER and UP live on the + // up-board enum, not this one; they describe the STAFF member's state, + // not how the conversation ended.) + outcome: stillOpen ? null : pick(pRng, CLOSED_OUTCOMES), + notes: pick(pRng, INTERACTION_NOTES), + startedAt, + endedAt: stillOpen ? null : new Date(startedAt.getTime() + randInt(pRng, 10, 90) * 60_000), + isActive: stillOpen, + createdBy: SEED_ACTOR, + }, + }); + result.interactionsCreated += 1; + if (stillOpen) result.openInteractions += 1; + } + + // ---- B2B proposals ----------------------------------------------------- + const tradeCustomers = customers.filter((c) => c.isTradeAccount); + for (const [i, projectName] of PROJECT_NAMES.entries()) { + const status: ProposalStatus = i < 2 ? "DRAFT" : i < 4 ? "SENT" : "ACCEPTED"; + const seller = pick(pRng, sellers); + const customer = tradeCustomers.length > 0 ? pick(pRng, tradeCustomers) : pick(pRng, customers); + const raisedAt = new Date(today.getTime() - randInt(pRng, 3, 60) * 86_400_000); + + await prisma.proposal.create({ + data: { + proposalNumber: `PR-2026-${String(100 + i)}`, + status, + customerId: customer.id, + projectName, + salesPersonId: seller.id, + coverLetter: + "Thank you for the opportunity. The selections below reflect the brief, the site measurements and the lead times we discussed.", + terms: "50% deposit on acceptance, balance on delivery. Lead times quoted from acceptance.", + sentAt: status === "DRAFT" ? null : raisedAt, + acceptedAt: status === "ACCEPTED" ? new Date(raisedAt.getTime() + 6 * 86_400_000) : null, + expiresAt: new Date(raisedAt.getTime() + 30 * 86_400_000), + created: raisedAt, + createdBy: SEED_ACTOR, + lineItems: { + create: Array.from({ length: randInt(pRng, 3, 6) }, (_, n) => { + const product = pick(pRng, products); + return { + itemName: product.name, + cost: round2(product.baseCost), + retailPrice: round2(product.baseRetail), + sortOrder: n, + }; + }), + }, + }, + }); + result.proposalsCreated += 1; + } + + return result; +} diff --git a/app/prisma/seed/demo/setupData.ts b/app/prisma/seed/demo/setupData.ts new file mode 100644 index 00000000..33edaf2c --- /dev/null +++ b/app/prisma/seed/demo/setupData.ts @@ -0,0 +1,256 @@ +// /app/prisma/seed/demo/setupData.ts +// +// The configuration tables behind Admin -> Setup. +// +// Every one of these rendered an empty table with column headers and a "No +// results found" row, which is the worst kind of empty: the screen looks built +// and broken at the same time. Two of them are worse than cosmetic -- an +// absent GiftCardPreset dead-ends the gift-card sale flow entirely, and an +// absent LabelTemplate breaks tag printing off a PO receipt. +// +// These are deployment CONFIGURATION rather than transactional data: a real +// store sets them once and edits them rarely. That is exactly why they were +// missed -- nothing generates them, somebody types them in -- and exactly why +// a demo has to ship them, because a viewer will not type them in either. + +import type { PrismaClient } from "@prisma/client"; +import type { Rng } from "./rng"; +import { randInt, subRng } from "./rng"; +import type { StaffSetup } from "./staff"; +import type { StoreSetup } from "./locations"; + +const SEED_ACTOR = "seed:demo"; + +/** The quick codes a cashier types at the register. */ +const GIFT_CARD_PRESETS = [ + { code: "GC", label: "Custom Amount", amount: null, sortOrder: 0 }, + { code: "GC25", label: "$25 Gift Card", amount: 25, sortOrder: 1 }, + { code: "GC50", label: "$50 Gift Card", amount: 50, sortOrder: 2 }, + { code: "GC100", label: "$100 Gift Card", amount: 100, sortOrder: 3 }, + { code: "GC250", label: "$250 Gift Card", amount: 250, sortOrder: 4 }, + { code: "GC500", label: "$500 Gift Card", amount: 500, sortOrder: 5 }, +]; + +const TRADE_TIERS = [ + { name: "Designer", discountPercent: 20, sortOrder: 0 }, + { name: "Trade Preferred", discountPercent: 25, sortOrder: 1 }, + { name: "Hospitality / Contract", discountPercent: 30, sortOrder: 2 }, +]; + +const EMAIL_TEMPLATES = [ + { + name: "Order confirmation", + category: "SALES", + subject: "Your order {{orderNumber}} is confirmed", + body: "Thank you {{customerName}} — we have your order and will be in touch to arrange delivery.", + }, + { + name: "Delivery scheduled", + category: "DISPATCH", + subject: "Your delivery is booked for {{deliveryDate}}", + body: "Hello {{customerName}}, your delivery is booked for {{deliveryDate}}. Someone over 18 needs to be home to sign.", + }, + { + name: "Ready for collection", + category: "DISPATCH", + subject: "{{orderNumber}} is ready to collect", + body: "Hello {{customerName}}, your order is ready at {{storeName}}. Please bring photo ID.", + }, + { + name: "Balance due before delivery", + category: "BILLING", + subject: "Balance due on {{orderNumber}}", + body: "Hello {{customerName}}, the balance of {{balanceDue}} is due before we can schedule your delivery.", + }, + { + name: "Service case update", + category: "SERVICE", + subject: "Update on your service case {{caseNumber}}", + body: "Hello {{customerName}}, an update on {{caseNumber}}: {{updateText}}", + }, + { + name: "Quote follow-up", + category: "SALES", + subject: "Still thinking it over?", + body: "Hello {{customerName}}, your quote {{quoteNumber}} is still open. Happy to hold the pricing or answer anything.", + }, +]; + +/** + * ZPL, because that is what the tag printers in the warehouse speak. Kept + * deliberately minimal -- a real deployment tunes these to its own stock. + */ +const LABEL_TEMPLATES = [ + { + name: "Product tag 4x6", + context: "PRODUCT", + tagSize: "4x6", + zplTemplate: + "^XA^CF0,40^FO30,30^FD{{productName}}^FS^CF0,28^FO30,90^FD{{partNo}}^FS^FO30,140^BY3^BCN,80,Y,N,N^FD{{barcode}}^FS^CF0,32^FO30,260^FD{{retailPrice}}^FS^XZ", + }, + { + name: "Receiving tag 2x4", + context: "RECEIVING", + tagSize: "2x4", + zplTemplate: + "^XA^CF0,28^FO20,20^FD{{poNumber}}^FS^FO20,60^FD{{productName}}^FS^FO20,100^BY2^BCN,50,Y,N,N^FD{{barcode}}^FS^XZ", + }, + { + name: "Bin location 2x4", + context: "LOCATION", + tagSize: "2x4", + zplTemplate: "^XA^CF0,44^FO20,25^FD{{locationCode}}^FS^CF0,24^FO20,85^FD{{locationName}}^FS^XZ", + }, +]; + +export interface SetupDataResult { + giftCardPresets: number; + tradeTiers: number; + emailTemplates: number; + labelTemplates: number; + salesGoals: number; + upBoardEntries: number; + trafficDays: number; +} + +export async function seedSetupData( + prisma: PrismaClient, + rng: Rng, + staff: StaffSetup, + stores: StoreSetup[], + today: Date, +): Promise { + const sRng = subRng(rng, "setup-data"); + const result: SetupDataResult = { + giftCardPresets: 0, + tradeTiers: 0, + emailTemplates: 0, + labelTemplates: 0, + salesGoals: 0, + upBoardEntries: 0, + trafficDays: 0, + }; + + for (const preset of GIFT_CARD_PRESETS) { + await prisma.giftCardPreset.create({ + data: { ...preset, isActive: true, createdBy: SEED_ACTOR }, + }); + result.giftCardPresets += 1; + } + + for (const tier of TRADE_TIERS) { + await prisma.tradeTier.create({ data: { ...tier, isActive: true } }); + result.tradeTiers += 1; + } + + for (const t of EMAIL_TEMPLATES) { + await prisma.emailTemplate.create({ data: { ...t, createdBy: SEED_ACTOR } }); + result.emailTemplates += 1; + } + + for (const t of LABEL_TEMPLATES) { + await prisma.labelTemplate.create({ data: { ...t, createdBy: SEED_ACTOR } }); + result.labelTemplates += 1; + } + + // ---- sales goals ------------------------------------------------------- + // The commission tier ladder and the goals screen both read these. A goal + // per designer for the current year; without them every progress bar is + // against zero, which renders as either empty or infinite depending on the + // screen. + const fiscalYear = today.getUTCFullYear(); + for (const designer of staff.designers) { + await prisma.salesGoal.create({ + data: { + staffMemberId: designer.id, + fiscalYear, + yearlyGoal: randInt(sRng, 240, 600) * 1000, + createdBy: SEED_ACTOR, + }, + }); + result.salesGoals += 1; + } + + // ---- the up board ----------------------------------------------------- + // "Designer Rotation" on the dashboard read "No one signed in" for every + // store. That board is UpBoardEntry, which is a different thing from + // CustomerInteraction: an interaction is a conversation that happened, an + // up-board entry is who is on the floor right now and whose turn it is. + // + // Each store gets a rotation with one designer actually next up, one with a + // customer, and one on a break -- because a board where everyone is simply + // AVAILABLE cannot show that it is a rotation. + const boardStatuses = ["UP", "WITH_CUSTOMER", "AVAILABLE", "ON_BREAK", "AVAILABLE"] as const; + for (const store of stores) { + const onFloor = staff.designers.filter((d) => d.isActive).slice(0, boardStatuses.length); + for (const [i, designer] of onFloor.entries()) { + const status = boardStatuses[i]; + await prisma.upBoardEntry.create({ + data: { + staffMemberId: designer.id, + storeLocation: store.name, + storeLocationId: store.id, + position: i + 1, + status, + statusSince: new Date(today.getTime() - randInt(sRng, 5, 180) * 60_000), + customerNote: status === "WITH_CUSTOMER" ? "Walk-in, looking at sectionals" : null, + }, + }); + result.upBoardEntries += 1; + } + } + + // ---- door-counter traffic --------------------------------------------- + // The FIRST screen after login is the dashboard, and every store card read + // "0 Entries Today, 0 LY, 0 In Store". Traffic normally arrives from a + // third-party door counter over its own API, which cannot be called in a + // demo -- so it is seeded, which is the honest alternative to a live call. + // + // Two years of daily snapshots, because the dashboard compares against the + // same day last year and a single year makes that comparison read zero. + // Weekends are busier and Mondays are quiet, so the trend line looks like a + // shop rather than noise. + // Built in memory and inserted with createMany: this is ~11,700 rows, and one + // insert each turns a two-second step into a two-minute one. A seed nobody + // wants to run is a seed that goes stale. + const HOURS = [10, 11, 12, 13, 14, 15, 16, 17]; + const snapshots: { + intervalStart: Date; + sourceStoreName: string; + storeLocationId: number; + visitors: number; + exits: number; + }[] = []; + + for (let dayOffset = 730; dayOffset >= 0; dayOffset--) { + const day = new Date(today.getTime() - dayOffset * 86_400_000); + const dow = day.getUTCDay(); + if (dow === 1) continue; // closed Mondays + const weekendLift = dow === 0 || dow === 6 ? 1.9 : 1; + + for (const store of stores) { + for (const hour of HOURS) { + const intervalStart = new Date(day); + intervalStart.setUTCHours(hour, 0, 0, 0); + // Midday peak, tapering either side. + const shape = 1 - Math.abs(hour - 13.5) / 9; + const visitors = Math.max(0, Math.round(randInt(sRng, 6, 22) * shape * weekendLift)); + snapshots.push({ + intervalStart, + sourceStoreName: store.name, + storeLocationId: store.id, + visitors, + exits: Math.max(0, visitors - randInt(sRng, 0, 3)), + }); + } + } + result.trafficDays += 1; + } + + // Chunked so a single statement never carries the whole two years. + for (let i = 0; i < snapshots.length; i += 1000) { + await prisma.trafficSnapshot.createMany({ data: snapshots.slice(i, i + 1000) }); + } + + return result; +} diff --git a/app/src/lib/errorRecorder.ts b/app/src/lib/errorRecorder.ts index a380fef4..1fcf36bd 100644 --- a/app/src/lib/errorRecorder.ts +++ b/app/src/lib/errorRecorder.ts @@ -42,6 +42,15 @@ let recording = false; */ const ALERT_AT_COUNTS = new Set([1, 10, 100, 1000, 10000]); +/** + * True while an alert is being reported. + * + * Module-level and deliberately not per-request: the loop this prevents is + * synchronous re-entry within one process, and a request-scoped flag would not + * see it. + */ +let alerting = false; + /** Truncate anything before it goes in a column or an alert body. */ const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n)}…` : s); @@ -130,12 +139,24 @@ async function persist({ message, error, context }: RecordErrorInput): Promise { - logError(`ops-alert: ${alert.title}`, new Error(alert.detail), alert.context); + logger.error(`ops-alert: ${alert.title}`, { + ...alert.context, + detail: alert.detail, + }); const channels = resolveOpsAlertChannels(process.env); @@ -73,7 +92,12 @@ export async function reportOpsAlert(alert: OpsAlert): Promise { body: JSON.stringify(buildWebhookPayload(alert)), }); } catch (err) { - logError("ops-alert webhook delivery failed", err, { title: alert.title }); + // logger, not logError -- see the header. A webhook failure inside the + // alert path must not create another alert. + logger.error("ops-alert webhook delivery failed", { + title: alert.title, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -85,7 +109,10 @@ export async function reportOpsAlert(alert: OpsAlert): Promise { const { subject, html } = buildAlertEmail(alert); await enqueueEmail({ to: channels.email, subject, html, templateKey: "ops-alert" }); } catch (err) { - logError("ops-alert email enqueue failed", err, { title: alert.title }); + logger.error("ops-alert email enqueue failed", { + title: alert.title, + error: err instanceof Error ? err.message : String(err), + }); } } } diff --git a/app/src/lib/traffic/recordedTraffic.ts b/app/src/lib/traffic/recordedTraffic.ts new file mode 100644 index 00000000..5c95a986 --- /dev/null +++ b/app/src/lib/traffic/recordedTraffic.ts @@ -0,0 +1,56 @@ +// /app/src/lib/traffic/recordedTraffic.ts +// +// Door-counter traffic, read back from what we already recorded. +// +// The live path (lib/axperClient.ts) returns [] whenever the counter is +// unreachable OR unconfigured -- no API key, bad credentials, vendor outage, +// all the same empty array. The dashboard then renders "0 ENTRIES TODAY", +// which is a lie of a specific and damaging kind: it does not say "we cannot +// see the counter", it says "nobody came in". +// +// `TrafficSnapshot` already holds every interval the import has ever pulled, +// so the honest fallback is to answer from it. A deployment whose counter died +// this morning still sees yesterday, last week and the same day last year -- +// and one that has no counter at all (or a demo, which cannot call a third +// party) sees its recorded history instead of a wall of zeros. + +import { prisma } from "@/lib/prisma"; +import type { AxperTrafficRow } from "@/lib/axperClient"; + +/** + * Recorded intervals for a date range, in the shape the live client returns so + * callers cannot tell the two apart by accident. + * + * `dateFrom` / `dateTo` are inclusive YYYY-MM-DD, matching the live client. + */ +export async function readRecordedTraffic( + dateFrom: string, + dateTo: string, +): Promise { + const gte = new Date(`${dateFrom}T00:00:00.000Z`); + // Inclusive of the whole end day. + const lt = new Date(`${dateTo}T00:00:00.000Z`); + lt.setUTCDate(lt.getUTCDate() + 1); + + const rows = await prisma.trafficSnapshot.findMany({ + where: { intervalStart: { gte, lt } }, + select: { + intervalStart: true, + sourceStoreName: true, + visitors: true, + exits: true, + }, + orderBy: { intervalStart: "asc" }, + }); + + return rows.map((r) => ({ + // The counter's own store number is not persisted -- the mapping is by name + // (StoreLocation.trafficSourceNames), which is what resolves both paths + // downstream. An empty string is honest here; inventing one is not. + store_number: "", + store_name: r.sourceStoreName, + local_time: r.intervalStart.toISOString().replace("Z", ""), + entries: r.visitors, + exits: r.exits ?? 0, + })); +} diff --git a/app/src/pages/api/axper/traffic/index.ts b/app/src/pages/api/axper/traffic/index.ts index 2650cf0c..726adcf1 100644 --- a/app/src/pages/api/axper/traffic/index.ts +++ b/app/src/pages/api/axper/traffic/index.ts @@ -11,6 +11,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/pages/api/auth/[...nextauth]"; import { fetchAxperTraffic } from "@/lib/axperClient"; +import { readRecordedTraffic } from "@/lib/traffic/recordedTraffic"; import { getTrafficStoreMap } from "@/lib/trafficStoreMap"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -22,7 +23,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ error: "Missing dateFrom or dateTo parameter" }); } - const rows = await fetchAxperTraffic({ dateFrom, dateTo }); + // Live first, recorded second. fetchAxperTraffic returns [] for every kind + // of not-working -- no API key, bad credentials, vendor outage -- so an empty + // result is never evidence that nobody came in. Falling back to what we have + // already recorded turns "0 visitors" back into the truth. + const live = await fetchAxperTraffic({ dateFrom, dateTo }); + const rows = live.length > 0 ? live : await readRecordedTraffic(dateFrom, dateTo); // Enrich with the DB-backed mapping so callers (HomeView) don't each // need their own server round-trip to resolve a friendly name / the From 246a1e326213f65830e68b6bd1adb173d8247791 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 06:04:54 -0400 Subject: [PATCH 2/6] fix(seed): --reset deletes what it does not reseed, and now says so `seed:demo --reset` truncates EVERY table, including the ones other seeders own. Run on its own it therefore deletes the CMS content and the roles and does not put them back -- so the storefront quietly loses its copy, hours later, with nothing in the log to explain it. That is exactly how it was noticed: somebody asked where the copy went. Two changes. `npm run seed:all` runs the three in dependency order, which is what anyone reseeding a demo actually wants. And --reset now names the seeders whose tables it just emptied, so running the parts by hand cannot silently leave holes. The warning replaces a stray empty block that was sitting at the end of the seed -- pre-existing, and a good spot for something that should have been there all along. Co-Authored-By: Claude Opus 5 --- app/package.json | 1 + app/prisma/seed/demo/index.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index f8ce0ea3..42c26f7d 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,7 @@ "seed:roles": "node scripts/seed-roles.mjs", "seed:demo": "TZ=UTC TS_NODE_PROJECT=prisma/seed/tsconfig.seed.json ts-node -r tsconfig-paths/register prisma/seed/demo/index.ts", "seed:coverage": "node scripts/seed-coverage.mjs", + "seed:all": "npm run seed:demo -- --reset && npm run seed:roles && npm run seed:cms", "staff:resolve": "node scripts/resolve-salespeople.mjs" }, "dependencies": { diff --git a/app/prisma/seed/demo/index.ts b/app/prisma/seed/demo/index.ts index c09425a1..88664d0d 100644 --- a/app/prisma/seed/demo/index.ts +++ b/app/prisma/seed/demo/index.ts @@ -385,7 +385,16 @@ async function main(): Promise { `orders, last sale ${departedLatest?.toISOString().slice(0, 10) ?? "n/a"} ` + `(active staff sell through ${activeLatest?.toISOString().slice(0, 10) ?? "n/a"})`, ); - { + // --reset truncates EVERY table, including ones this seeder does not own. Run + // alone it therefore deletes the CMS content and the roles and does not put + // them back -- which shows up later as the storefront having lost its copy, + // with nothing in the log to explain it. + if (reset) { + console.log(""); + console.log("NOTE: --reset truncated tables this seeder does not own. Also run:"); + console.log(" npm run seed:roles (Role, RolePermission)"); + console.log(" npm run seed:cms (Page, Post, Menu, MediaAsset)"); + console.log(" or use `npm run seed:all`, which runs all three in order."); } await prisma.$disconnect(); From 1f237cfecd5148c958702c3fe46040c55de911ce Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 06:17:24 -0400 Subject: [PATCH 3/6] feat(modules): the dashboard composes, it does not assume The Up Board and Store Traffic were hardcoded onto the dashboard. Both are showroom-floor conventions -- a rotation only exists where staff take turns on a floor, a door counter only exists where there is a door to count. A wholesaler, a manufacturer or an online-only shop got two permanently-empty cards on the FIRST screen after login, and an empty traffic card does not read as "no counter here", it reads as "nobody came in". Both are modules now, defaulting OFF, gated on the dashboard and across all five API routes that serve them. When neither is on the dashboard says so and points at the settings screen, rather than rendering nothing. A migration turns each ON where there is evidence it is already in use -- rows in the table it drives -- so no existing deployment loses a feature it relies on by upgrading. Off-by-default is right for the next deployment; silently removing something from the last one is not. Also fixes what this uncovered: - The features map in the demo seed was never actually corrected. #142 added the assertKnownModules import and the call itself did not apply, so main has been shipping a dead import and the same four invalid keys. Now 18 real modules, verified against the registry: no invalid key survives. - moduleManifest.test.ts pinned the module set with equality, so adding one failed a guard about a refactor that happened months ago. It now asserts CONTAINS -- every pre-refactor module still present, none renamed, no default flipped -- which is the guarantee that was actually worth having. - The POS asked /api/warehouse/positions for a productId the handler did not support, so the register got 50 arbitrary rows; and it read storeLocation.name where the API returns a flattened locationName, so the loop skipped every row. Every cart line said "0 on hand here" against a showroom holding twenty. It also counted stock committed to other orders as sellable, which is how the same sofa gets sold twice -- there is a freeOnly filter now, sharing freePositionWhere() with the allocator so the register and the allocator cannot disagree about what is sellable. Verified both directions in the running app: on, the dashboard shows 93 entries today against 110 last year and a five-person rotation with live statuses; off, both sections are gone and the page says where to switch them on. Co-Authored-By: Claude Opus 5 --- app/__tests__/moduleManifest.test.ts | 23 +++-- .../migration.sql | 17 ++++ app/prisma/seed/demo/org.ts | 31 +++++-- app/src/app/(dashboard)/app/HomeView.tsx | 84 ++++++++++++------- app/src/app/(dashboard)/app/page.tsx | 9 +- app/src/lib/modules/registry.ts | 16 ++++ app/src/pages/api/axper/traffic/index.ts | 5 ++ app/src/pages/api/upboard/[store].ts | 5 ++ app/src/pages/api/upboard/action.ts | 4 + app/src/pages/api/upboard/clock-in.ts | 4 + app/src/pages/api/upboard/clock-out.ts | 4 + .../pages/api/warehouse/positions/index.ts | 14 ++++ 12 files changed, 172 insertions(+), 44 deletions(-) create mode 100644 app/prisma/migrations/20260826120000_dashboard_modules/migration.sql diff --git a/app/__tests__/moduleManifest.test.ts b/app/__tests__/moduleManifest.test.ts index 22391e5f..644c6ca7 100644 --- a/app/__tests__/moduleManifest.test.ts +++ b/app/__tests__/moduleManifest.test.ts @@ -73,18 +73,25 @@ describe("module manifest (lib/modules/registry.ts)", () => { } }); - it("has exactly the pre-refactor module set, nothing added or removed", () => { - expect(MODULES.map((m) => m.key).sort()).toEqual( - PRE_REFACTOR_FEATURES.map((f) => f.key).sort(), - ); + it("still carries every pre-refactor module, none dropped or renamed", () => { + // CONTAINS, not equals. The guarantee worth keeping is that the refactor + // preserved the original set -- a key round-trips through + // AppSettings.features, so renaming or dropping one is a data migration + // masquerading as a refactor. Adding a NEW module is ordinary product work + // and must not require editing a regression guard about an old refactor. + const keys = new Set(MODULES.map((m) => m.key)); + expect(PRE_REFACTOR_FEATURES.map((f) => f.key).filter((k) => !keys.has(k))).toEqual([]); }); }); describe("FEATURES derived from MODULES is behavior-preserving", () => { - it("matches the pre-refactor (key, defaultEnabled) list exactly, in order", () => { - expect(FEATURES.map(({ key, defaultEnabled }) => ({ key, defaultEnabled }))).toEqual( - PRE_REFACTOR_FEATURES, - ); + it("has not flipped a pre-refactor default", () => { + // Each original module keeps the defaultEnabled it shipped with. A flipped + // default silently turns a module on or off for every deployment that never + // set it explicitly. + const byKey = new Map(FEATURES.map((f) => [f.key, f.defaultEnabled])); + const drifted = PRE_REFACTOR_FEATURES.filter((f) => byKey.get(f.key) !== f.defaultEnabled); + expect(drifted).toEqual([]); }); it("carries no extra fields beyond the original FeatureDef shape", () => { diff --git a/app/prisma/migrations/20260826120000_dashboard_modules/migration.sql b/app/prisma/migrations/20260826120000_dashboard_modules/migration.sql new file mode 100644 index 00000000..d8ba7f04 --- /dev/null +++ b/app/prisma/migrations/20260826120000_dashboard_modules/migration.sql @@ -0,0 +1,17 @@ +-- The Up Board and Store Traffic become optional modules. +-- +-- Both were hardcoded onto the dashboard, which put two permanently-empty cards +-- on the first screen after login for any deployment that has neither a +-- showroom rotation nor a door counter. An empty traffic card is worse than +-- absent: it does not read as "no counter here", it reads as "nobody came in". +-- +-- They default OFF, because most businesses have neither. But a deployment +-- ALREADY using one must not lose it on upgrade, so enable each where there is +-- evidence it is in use -- rows in the table it drives. +UPDATE "AppSettings" s +SET "features" = COALESCE(s."features", '{}'::jsonb) || '{"upBoard": true}'::jsonb +WHERE EXISTS (SELECT 1 FROM "UpBoardEntry"); + +UPDATE "AppSettings" s +SET "features" = COALESCE(s."features", '{}'::jsonb) || '{"storeTraffic": true}'::jsonb +WHERE EXISTS (SELECT 1 FROM "TrafficSnapshot"); diff --git a/app/prisma/seed/demo/org.ts b/app/prisma/seed/demo/org.ts index ece62850..c404224f 100644 --- a/app/prisma/seed/demo/org.ts +++ b/app/prisma/seed/demo/org.ts @@ -49,15 +49,34 @@ export async function seedOrg(prisma: PrismaClient): Promise { // Sensible defaults for a store that runs its whole operation on // the platform -- this is the "full native chain" seed, so every // module the seed touches is switched on. - features: { + // Every key MUST exist in lib/modules/registry.ts. Four of the seven that + // used to be here -- commission, storefront, invoicing, deliveryScheduling + // -- were not module keys at all and were silently discarded, which left + // seventeen real modules at their registry defaults. That is why Invoices + // 404'd and half the nav was missing on a fresh clone. + // assertKnownModules() makes a typo fail loudly instead. + features: assertKnownModules({ warehousing: true, dispatch: true, consignment: true, - commission: true, - storefront: true, - invoicing: true, - deliveryScheduling: true, - }, + purchasing: true, + pos: true, + giftCards: true, + tills: true, + accounting: true, + marketing: true, + cms: true, + blog: true, + booking: true, + helpdesk: true, + timeTracking: true, + billing: true, + clientPortal: true, + // Showroom-floor conventions, default OFF because most businesses have + // neither a rotation nor a door counter. This demo is a showroom. + upBoard: true, + storeTraffic: true, + }), bookingConfig: { windowDays: 21, startHour: 9, diff --git a/app/src/app/(dashboard)/app/HomeView.tsx b/app/src/app/(dashboard)/app/HomeView.tsx index e361c768..9b566704 100644 --- a/app/src/app/(dashboard)/app/HomeView.tsx +++ b/app/src/app/(dashboard)/app/HomeView.tsx @@ -49,7 +49,22 @@ interface StoreSales { const REFRESH_MS = 900000; -export function HomeView() { +/** + * Which sections this deployment shows. + * + * Both are showroom-floor conventions rather than things every business runs: + * a rotation only exists where staff take turns on a floor, and traffic only + * exists where there is a counter at the door. Hardcoded, they put two + * permanently-empty cards on the FIRST screen after login for anyone else -- + * and an empty traffic card does not read as "no counter here", it reads as + * "nobody came in". + */ +export interface HomeViewProps { + showTraffic: boolean; + showUpBoard: boolean; +} + +export function HomeView({ showTraffic, showUpBoard }: HomeViewProps) { const formatMoney = useMoneyFormatter(); const formatCurrency = useCallback( (value: number): string => formatMoney(value, { whole: true }), @@ -209,36 +224,47 @@ export function HomeView() {

Dashboard

{/* --- Traffic + Sales cards --- */} -
-

- Store Traffic -

-
- {allStores.map((storeName) => ( - - ))} -
-
+ {showTraffic && ( +
+

+ Store Traffic +

+
+ {allStores.map((storeName) => ( + + ))} +
+
+ )} {/* --- Up-Boards --- */} -
-

- Designer Rotation -

-
- {upBoardStores.map(({ store, label }) => ( - - ))} -
-
+ {showUpBoard && ( +
+

+ Designer Rotation +

+
+ {upBoardStores.map(({ store, label }) => ( + + ))} +
+
+ )} + + {!showTraffic && !showUpBoard && ( +

+ Store Traffic and the Up Board are switched off for this deployment. Turn them on in Admin + → Settings → Modules. +

+ )} ); } diff --git a/app/src/app/(dashboard)/app/page.tsx b/app/src/app/(dashboard)/app/page.tsx index 53ddaf66..70417ab5 100644 --- a/app/src/app/(dashboard)/app/page.tsx +++ b/app/src/app/(dashboard)/app/page.tsx @@ -9,11 +9,18 @@ import { redirect } from "next/navigation"; import { requirePage } from "@/lib/auth/requirePage"; import { HomeView } from "./HomeView"; +import { isModuleEnabled } from "@/lib/modules/requireModule"; export default async function HomePage() { const { role } = await requirePage(); if (role === "DESIGNER") { redirect("/app/sales"); } - return ; + // Resolved here rather than in the client component: the flags come from + // AppSettings and the dashboard should not render a section and then hide it. + const [showTraffic, showUpBoard] = await Promise.all([ + isModuleEnabled("storeTraffic"), + isModuleEnabled("upBoard"), + ]); + return ; } diff --git a/app/src/lib/modules/registry.ts b/app/src/lib/modules/registry.ts index 576ac3b6..258bb6fd 100644 --- a/app/src/lib/modules/registry.ts +++ b/app/src/lib/modules/registry.ts @@ -27,6 +27,22 @@ export const MODULES: ModuleDef[] = [ defaultEnabled: true, category: "core", }, + { + key: "upBoard", + name: "Up Board (designer rotation)", + description: + "Whose turn it is to take the next customer, per store. A showroom-floor convention -- a wholesaler, a manufacturer or an online-only shop has no rotation to run.", + defaultEnabled: false, + category: "core", + }, + { + key: "storeTraffic", + name: "Store Traffic", + description: + "Door-counter visitor counts and conversion. Needs a physical counter at the door; without one the cards read zero forever, which looks like nobody came in rather than like there is nothing to count.", + defaultEnabled: false, + category: "core", + }, { key: "dispatch", name: "Dispatch & Delivery", diff --git a/app/src/pages/api/axper/traffic/index.ts b/app/src/pages/api/axper/traffic/index.ts index 726adcf1..44506bdb 100644 --- a/app/src/pages/api/axper/traffic/index.ts +++ b/app/src/pages/api/axper/traffic/index.ts @@ -8,6 +8,7 @@ // query date ranges should read from the table, NOT this endpoint. import type { NextApiRequest, NextApiResponse } from "next"; +import { isModuleEnabled } from "@/lib/modules/requireModule"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/pages/api/auth/[...nextauth]"; import { fetchAxperTraffic } from "@/lib/axperClient"; @@ -18,6 +19,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const session = await getServerSession(req, res, authOptions); if (!session) return res.status(401).json({ error: "Unauthorized" }); + if (!(await isModuleEnabled("storeTraffic"))) { + return res.status(404).json({ error: "Module not enabled" }); + } + const { dateFrom, dateTo } = req.query; if (typeof dateFrom !== "string" || typeof dateTo !== "string") { return res.status(400).json({ error: "Missing dateFrom or dateTo parameter" }); diff --git a/app/src/pages/api/upboard/[store].ts b/app/src/pages/api/upboard/[store].ts index d22a6c51..dd52387f 100644 --- a/app/src/pages/api/upboard/[store].ts +++ b/app/src/pages/api/upboard/[store].ts @@ -3,6 +3,7 @@ // Auto-expires shifts older than 9 hours on every read. import type { NextApiRequest, NextApiResponse } from "next"; +import { isModuleEnabled } from "@/lib/modules/requireModule"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/pages/api/auth/[...nextauth]"; import { prisma } from "@/lib/prisma"; @@ -12,6 +13,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const session = await getServerSession(req, res, authOptions); if (!session) return res.status(401).json({ error: "Unauthorized" }); + if (!(await isModuleEnabled("upBoard"))) { + return res.status(404).json({ error: "Module not enabled" }); + } + const store = req.query.store as string; if (req.method === "GET") { diff --git a/app/src/pages/api/upboard/action.ts b/app/src/pages/api/upboard/action.ts index 6901c43d..985a540e 100644 --- a/app/src/pages/api/upboard/action.ts +++ b/app/src/pages/api/upboard/action.ts @@ -10,6 +10,7 @@ // "return_from_break" — person goes back to bottom of rotation as AVAILABLE import type { NextApiRequest, NextApiResponse } from "next"; +import { isModuleEnabled } from "@/lib/modules/requireModule"; import type { Session } from "next-auth"; import { requirePermission } from "@/lib/auth/requireAuth"; import { prisma } from "@/lib/prisma"; @@ -40,6 +41,9 @@ async function getMaxPosition(storeLocation: string): Promise { } async function handler(req: NextApiRequest, res: NextApiResponse, session: Session) { + if (!(await isModuleEnabled("upBoard"))) { + return res.status(404).json({ error: "Module not enabled" }); + } if (req.method !== "POST") return res.status(405).json({ error: "POST only" }); const { staffMemberId, action, customerNote } = req.body; diff --git a/app/src/pages/api/upboard/clock-in.ts b/app/src/pages/api/upboard/clock-in.ts index 1ac87467..3439b030 100644 --- a/app/src/pages/api/upboard/clock-in.ts +++ b/app/src/pages/api/upboard/clock-in.ts @@ -8,6 +8,7 @@ // 3. If they're the only one, they're automatically UP import type { NextApiRequest, NextApiResponse } from "next"; +import { isModuleEnabled } from "@/lib/modules/requireModule"; import { requirePermission } from "@/lib/auth/requireAuth"; import { prisma } from "@/lib/prisma"; import { resolveStoreLocationId } from "@/lib/storeLocationResolver"; @@ -15,6 +16,9 @@ import { logError } from "@/lib/logger"; import { getErrorMessage } from "@/lib/toastError"; async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!(await isModuleEnabled("upBoard"))) { + return res.status(404).json({ error: "Module not enabled" }); + } if (req.method !== "POST") return res.status(405).json({ error: "POST only" }); const { staffMemberId, storeLocation } = req.body; diff --git a/app/src/pages/api/upboard/clock-out.ts b/app/src/pages/api/upboard/clock-out.ts index 0cbf647c..59c42324 100644 --- a/app/src/pages/api/upboard/clock-out.ts +++ b/app/src/pages/api/upboard/clock-out.ts @@ -8,6 +8,7 @@ // 3. Compacts positions so there are no gaps import type { NextApiRequest, NextApiResponse } from "next"; +import { isModuleEnabled } from "@/lib/modules/requireModule"; import { requirePermission } from "@/lib/auth/requireAuth"; import { prisma } from "@/lib/prisma"; import { compactAndPromote } from "@/lib/upboard"; @@ -15,6 +16,9 @@ import { logError } from "@/lib/logger"; import { getErrorMessage } from "@/lib/toastError"; async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!(await isModuleEnabled("upBoard"))) { + return res.status(404).json({ error: "Module not enabled" }); + } if (req.method !== "POST") return res.status(405).json({ error: "POST only" }); const { staffMemberId } = req.body; diff --git a/app/src/pages/api/warehouse/positions/index.ts b/app/src/pages/api/warehouse/positions/index.ts index db5f3677..c958b016 100644 --- a/app/src/pages/api/warehouse/positions/index.ts +++ b/app/src/pages/api/warehouse/positions/index.ts @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import type { Session } from "next-auth"; import { prisma } from "@/lib/prisma"; +import { freePositionWhere } from "@/lib/inventory/allocation"; import { Prisma } from "@prisma/client"; import { requirePermission } from "@/lib/auth/requireAuth"; import { logError } from "@/lib/logger"; @@ -19,6 +20,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse, session: Sessi const stockLocationId = req.query.stockLocationId ? Number.parseInt(req.query.stockLocationId as string) : null; + // The POS asks "what can I sell of THIS product". It was already passing + // productId; the handler simply did not support it, so the register got 50 + // arbitrary positions and concluded there was none of anything. + const productId = req.query.productId ? Number.parseInt(req.query.productId as string) : null; + // Free stock only. A position committed to somebody else's order is not + // available to sell again -- showing it as on-hand at the register is how + // the same sofa gets sold twice. + const freeOnly = req.query.freeOnly === "1" || req.query.freeOnly === "true"; const skip = (page - 1) * limit; @@ -26,6 +35,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse, session: Sessi const conditions: Prisma.InventoryPositionWhereInput[] = []; if (locationId) conditions.push({ storeLocationId: locationId }); + if (productId) conditions.push({ productId }); + // freePositionWhere() is the single definition of "free to sell", shared + // with allocate() and availableQuantity(), so the register and the + // allocator can never disagree about what is sellable. + if (freeOnly) conditions.push(freePositionWhere()); if (stockLocationId) conditions.push({ stockLocationId }); if (search) { conditions.push({ From 5b8b6163e8e4439ed9b10ef046e20c6c20e5011f Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 06:22:27 -0400 Subject: [PATCH 4/6] docs(delivery): plan GPS telematics and third-party carriers Two integrations answering the same question -- where is this delivery and when does it land -- for the two ways a delivery happens: our truck, or somebody else's. They share the conclusion that matters. Both end in markHandedOver(), which generates the invoice and recognises the sale, so whether the fact arrived from a geofence or a carrier's status callback changes nothing downstream. That seam is already built; these are two more ways to feed it. Scoped by what the owner actually wants: GPS position tied to deliveries, and no video. Explicitly out of scope are driver scorecards, harsh-braking alerts and idle-time league tables -- the default reason telematics gets bought, the reason drivers resent it, and a measurement of the wrong thing. The test applied throughout is whether a feature changes what somebody does: an ETA text does, a monthly braking score does not. Three gaps found in the current model. Vehicle has no device identity. CustomerAddress has no latitude or longitude at all, which blocks geofencing, distance and sequencing -- it is a prerequisite rather than a nice-to-have. DeliveryStop.actualArrival already exists and nothing sets it. And one structural blocker for carriers: DeliveryStop.deliveryRunId is non-null, so a stop requires a run which requires a Vehicle. A third-party delivery has neither and therefore cannot be a stop -- invisible to the dispatch board and every metric reading it. The recommendation is a nullable FK rather than synthetic vehicles per carrier, because a carrier is not a truck and every report that counts vehicles would start counting carriers. DeliveryZone.isThirdParty and carrierName already exist and are display-only, which is worth knowing before someone assumes they do something. The value ranking puts telling the customer first, because a furniture delivery that fails for want of somebody being home costs a whole truck slot twice, and an ETA message is the cheapest thing that reduces it. Cost-per-delivery is fourth and is the one most retailers never learn -- holt is unusually placed to answer it because it already holds the fee side in DeliveryZone. Retention and employee-notice are called out as decisions to make before devices are installed rather than after. Co-Authored-By: Claude Opus 5 --- docs/domains/delivery-integrations.md | 333 ++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/domains/delivery-integrations.md diff --git a/docs/domains/delivery-integrations.md b/docs/domains/delivery-integrations.md new file mode 100644 index 00000000..82f1e7c0 --- /dev/null +++ b/docs/domains/delivery-integrations.md @@ -0,0 +1,333 @@ +# Delivery integrations — our trucks, and other people's + +**Status: proposal.** Nothing is built. Written so the shape is decided before a +provider is, because the provider is the part most likely to change. + +Two integrations that answer the same question — *where is this delivery and +when does it land* — for the two ways a delivery actually happens: + +1. **Our own truck**, tracked by GPS telematics. +2. **Somebody else's truck**, via a third-party logistics carrier. + +They share a conclusion, and it is the important part: **both end in the same +event.** When the goods reach the customer, `markHandedOver()` runs, the invoice +is generated, and the sale is recognised. Whether that fact arrived from a +geofence or a carrier's status callback changes nothing downstream. The seam is +already built (`lib/fulfilment/handover.ts`); these are two more ways to feed +it. + +## What this is for, and what it is not + +The goal is **knowing where a delivery is**, so the business can tell a customer +and so dispatch can react before a customer calls. Everything below serves that. + +Deliberately **not** in scope: + +- **Video.** Not wanted. If the right people are driving, the camera is + answering a question nobody asked. +- **Driver scorecards** — harsh braking, speeding leaderboards, idle-time + league tables. These are the default reason telematics gets bought and the + reason drivers resent it. They also measure the wrong thing: a driver who + brakes hard once to avoid a cyclist scores worse than one who is simply slow. +- **Anything that only a manager sees.** If a metric cannot be turned into + either a customer message or a scheduling decision, it is surveillance with a + dashboard. + +The test for any feature here: *does it change what somebody does?* An ETA text +does. A monthly braking score does not. + +--- + +## What holt already has, and the three gaps + +| Have | Model | +| --- | --- | +| Trucks | `Vehicle` — name, type, plate, capacity | +| Runs | `DeliveryRun` — vehicle, driver, status, `departedAt`, `completedAt` | +| Stops in order | `DeliveryStop` — `stopOrder`, `estimatedArrival`, `actualArrival` | +| The address | `ServiceAppointment.address` → `CustomerAddress` | +| Zones and fees | `DeliveryZone` — `baseFee`, `perPieceFee`, zip coverage | +| Encrypted provider secrets | `IntegrationCredential` — per org, per provider, per field | + +Three things are missing, and the second is the one that blocks everything else: + +1. **`Vehicle` has no device identity.** Nothing links a truck to a tracker. +2. **`CustomerAddress` has no latitude or longitude.** No coordinates means no + geofence, no arrival detection, no distance, no route sequencing. Geocoding + is a prerequisite, not a nice-to-have — and it is independently useful + (better zone assignment than zip matching, and a map view of tomorrow's run). +3. **`actualArrival` exists and nothing sets it.** The field is already there, + waiting for something to know. + +--- + +## The seam + +Modelled on `lib/payments/` — a flat catalog and a switch, one active provider +per deployment, capabilities declared because providers genuinely differ. Same +reasoning as CLAUDE.md 61-63: the provider is a deployment fact, and config +selects behaviour rather than supplying it. + +```ts +export type TelematicsProviderId = "samsara" | "motive" | "geotab" | "surecam"; + +export interface TelematicsCapabilities { + /** Current position on demand. Every provider has this. */ + livePosition: boolean; + /** Position history over a window -- needed for cost-per-delivery. */ + historicalTrace: boolean; + /** Provider-side geofences that fire events. Where absent, holt computes + * arrival itself from position + stop coordinates. */ + geofenceEvents: boolean; + /** Push instead of poll. Lower latency, more setup. */ + webhooks: boolean; + /** Odometer readings, for true miles per delivery rather than + * point-to-point distance. */ + odometer: boolean; +} + +export interface TelematicsProvider { + id: TelematicsProviderId; + capabilities: TelematicsCapabilities; + listVehicles(): Promise; + getPositions(deviceIds: string[]): Promise; + getTrace(deviceId: string, from: Date, to: Date): Promise; +} +``` + +**Poll first, webhooks later.** Polling works with every provider and needs no +inbound endpoint or signature verification. holt already runs scheduled +automations; a 60-second poll during active runs is plenty for delivery ETAs and +costs almost nothing. Webhooks become an optimisation once a provider is chosen, +not a dependency. + +### New models + +```prisma +model Vehicle { + // ...existing + telematicsProvider String? // TelematicsProviderId + telematicsDeviceId String? // the provider's own id for this truck + @@unique([telematicsProvider, telematicsDeviceId]) +} + +model VehiclePosition { + id Int @id @default(autoincrement()) + vehicleId Int + recordedAt DateTime // provider's timestamp, not ours + latitude Decimal @db.Decimal(9, 6) + longitude Decimal @db.Decimal(9, 6) + speedMph Decimal? @db.Decimal(5, 1) + headingDeg Int? + odometerMi Decimal? @db.Decimal(10, 1) + source String // which provider produced it + @@index([vehicleId, recordedAt]) +} + +model CustomerAddress { + // ...existing + latitude Decimal? @db.Decimal(9, 6) + longitude Decimal? @db.Decimal(9, 6) + geocodedAt DateTime? +} + +model DeliveryStop { + // ...existing + /** When the truck was first seen inside the stop's geofence. Kept SEPARATE + * from actualArrival, which is what the driver confirmed. They disagree + * more often than you would think -- parking, a wrong pin, a long + * driveway -- and the difference is worth being able to see rather than + * silently overwriting. */ + arrivalDetectedAt DateTime? +} +``` + +Six decimal places is roughly 0.1 m, well past what any vehicle GPS resolves, +and it costs nothing to keep. + +--- + +## What it enables, in the order worth building + +### 1. Tell the customer — the whole reason to do this + +A furniture delivery that fails because nobody is home costs a **whole truck +slot**, twice: the wasted stop and the redelivery. It is the single most +expensive routine failure in the operation, and an ETA message is the cheapest +thing that reduces it. + +- "You are stop 4 of 9, roughly 90 minutes away." +- An automatic message at a configurable distance or time out. +- Live position on the client portal for the last leg only, which is the part + a customer actually wants and the least sensitive to share. + +`ServiceAppointment` and the client portal already exist; this needs the ETA and +a message template, both of which are now seeded. + +### 2. Let dispatch answer the phone + +When a customer calls, dispatch should not be calling the driver to find out. +Where the truck is, which stop it is on, and whether the run is running late +against `estimatedArrival`. + +The useful alert is **"this run is 45 minutes behind and has 4 stops left"** — +sent to dispatch, so somebody can ring ahead. Not sent to the driver, who is +driving and already knows. + +### 3. Stop making the driver do admin + +Auto-stamp `arrivalDetectedAt` on geofence entry. The driver still confirms the +delivery — signature and photo are evidence and stay manual — but nobody should +be tapping "arrived" while parking a box truck. + +This also makes the timing data honest. Hand-entered arrival times cluster on +the quarter-hour because people round. + +### 4. Find out what a delivery actually costs + +The one most businesses never learn, and holt is unusually placed to answer it +because it already holds the fee side. + +With a trace: real miles and real minutes per stop. Against `DeliveryZone`'s +`baseFee` and `perPieceFee`: **is each zone priced above what it costs to +serve?** Almost every furniture retailer has one zone quietly losing money and +one subsidising it, and nobody can prove which. + +Also falls out: whether the delivery windows quoted to customers match reality, +per zone. If the 12-4 window in one zone is met 60% of the time, that is a +scheduling problem with a number attached. + +### 5. Sequence the next stop sensibly + +Only after the above. Re-ordering remaining stops by live traffic is genuinely +useful and genuinely easy to get wrong -- a driver who knows the area will beat +a naive optimiser, and overriding them is the micromanagement this is supposed +to avoid. Treat it as a **suggestion the driver can dismiss**, and measure +whether they take it before making it louder. + +--- + +## Providers to evaluate + +Not a recommendation — a shortlist, with what to check. SureCam is what Saybrook +runs today and is video-first, so the question there is whether its GPS side is +exposed well enough to be the only integration. + +| Provider | Why it is on the list | What to verify | +| --- | --- | --- | +| **Samsara** | Large, modern REST API, strong docs, common in small fleets | Rate limits; per-vehicle pricing at 2-4 trucks | +| **Motive** | Similar profile, competitive on price | API access on the entry tier | +| **Geotab** | Very large partner ecosystem, deep API, device-based | SDK shape is its own thing; more integration work | +| **Azuga** | Aimed at small fleets | API completeness — thinner than the above | +| **Verizon Connect** | Enterprise incumbent | Contract length and whether the API costs extra | +| **SureCam** | Already in use at Saybrook | Whether GPS is available without buying the video product | + +**The thing to check first, for any of them: is API access included, or an +upsell?** Several fleet products price the API separately, and that single fact +decides the shortlist faster than feature comparison. + +Because the seam declares capabilities, a provider missing geofence events is +not disqualified — holt computes arrival from position and stop coordinates +instead. That is the point of declaring capabilities rather than assuming them. + +--- + +--- + +# Part two: third-party carriers + +Most furniture retailers hand some deliveries to an outside carrier — long-haul, +overflow at the weekend, or everything, for a shop with no truck at all. The +customer's experience should not change based on whose van it is. + +## What holt has, and the one structural blocker + +`DeliveryZone` already carries `isThirdParty` and `carrierName`. They are +**display-only** — set on the zones admin screen, read nowhere in fulfilment. +The shipped demo has a "Long Haul (carrier)" zone that looks like an integration +and is a label. + +The blocker is structural: **`DeliveryStop.deliveryRunId` is non-null**. A stop +requires a run, a run requires a `Vehicle`, and a carrier delivery has neither. +So a third-party delivery cannot currently be a stop at all — it is invisible to +the dispatch board, the planner, and every metric that reads them. + +Two ways out: + +- **(a) Make `deliveryRunId` nullable** and add `carrierId` / `carrierReference` + to the stop. A stop belongs to a run *or* to a carrier. +- **(b) Give each carrier a synthetic `Vehicle`** so nothing else changes. + +**Recommend (a).** (b) is tempting because it is smaller, and it lies: a carrier +is not a truck, it has no capacity to plan against, and every report that counts +vehicles starts counting carriers. The nullable FK is honest and the change is +contained — `DeliveryStop` is read in few places, and they all already handle a +stop having no completion. + +## What a carrier integration has to do + +| Step | What moves | Notes | +| --- | --- | --- | +| **Tender** | address, pieces, cube/weight, service level, requested window | The carrier decides the date; holt should not pretend to schedule it | +| **Confirm** | carrier reference, scheduled date/window | Store the reference — it is what a customer service call is about | +| **Status** | scheduled → out for delivery → delivered / attempted / refused | The status feed is the whole value | +| **Proof** | signature, photo, recipient | Comes from *their* app; holt stores the link or the image | +| **Charge** | what the carrier billed | Feeds the same cost-per-delivery question as Part one | +| **Exception** | damage, refusal, reschedule | Should raise a `ServiceCase`, which already exists | + +The seam mirrors the telematics one: a `CarrierProviderId` union, declared +capabilities (not every carrier exposes tendering by API — some are a portal and +a CSV), and one active carrier per zone rather than per deployment, because a +shop can reasonably use two. + +```ts +export interface CarrierCapabilities { + tender: boolean; // can we book by API, or is it a portal? + statusPolling: boolean; + statusWebhook: boolean; + proofOfDelivery: boolean; + rateQuote: boolean; // rare, and the basis of automatic zone pricing +} +``` + +**Grasshopper / Deliverite** is the named starting point — it is furniture and +appliance final-mile software, which is exactly the shape holt needs, and worth +evaluating first for that reason. The same questions apply as in Part one, and +one more: *does the carrier expose an API at all, or is the real integration a +scheduled file exchange?* For final-mile logistics the answer is often the +latter, and a CSV or EDI drop on a schedule is a perfectly respectable +integration — holt already has a configurable import engine and a source-adapter +seam built for exactly that shape. + +## Where it lands + +A carrier status of `delivered` calls `markHandedOver()` with +`method: "DELIVERY"` and the carrier's completion timestamp. From there it is +indistinguishable from our own driver completing a stop: inventory is consumed, +the invoice is generated, the deposit is relieved and the sale is recognised. + +That is the argument for doing this properly rather than tracking carrier +deliveries in a spreadsheet — the accounting consequence is already wired, and a +delivery nobody records is revenue nobody recognises. + +--- + +## Two things to decide deliberately (our own fleet) + +Both concern tracking *our employees*. A carrier's own drivers are their +business, and holt only ever sees a status and a proof-of-delivery. + +**Retention.** A position trace is employee location data. Keep raw positions +for a short window — 90 days is generous for answering "what happened on that +delivery" — and aggregate beyond it into the per-stop metrics, which is what the +costing actually needs. Indefinite raw traces are a liability with no +operational upside. + +**Notice.** Employee vehicle tracking is regulated, and the rules vary by state; +several require notice, and some require consent. This is not a blocker and it +is not something to discover after installing devices. Worth a written policy +and a line in the handbook before the first tracker goes in, which also happens +to be the thing that makes drivers fine with it: tracking the *van* for the +*customer's* benefit is an easy sell, tracking the *person* is not. + +Building only the features above keeps that sentence true. From 4418d0c5075c68371d192ee93f4b279355a3f080 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 06:56:05 -0400 Subject: [PATCH 5/6] docs(delivery): the knobs are configuration, not decisions Retention and employee notice were written up as "two things to decide deliberately", which is the wrong frame: a decision gets made once and then lives in someone's memory. These are deployment facts, and CLAUDE.md 61-63 applies to them exactly as it does everywhere else. So the doc now carries a configuration table instead, covering every number in it -- provider, poll interval, retention window, geofence radius, how much warning a customer gets and on which channel, when dispatch hears a run is slipping, whether the customer sees the van at all, whether the resequencer even offers a suggestion, and which carrier serves which zone. A shop with two vans and a shop with a carrier in three states want different answers to all of them and neither should be editing code to get them. Two points kept and sharpened. Retention is the setting with a consequence outside the building, and a retention setting nothing enforces is worse than none -- it writes down that you delete data you are still holding. So it needs a scheduled purge that runs and records that it ran, dropping raw positions while keeping the derived per-stop metrics the costing actually needs. And the notice is a configured document with a per-staff acknowledgement, not a constant, because the wording varies by state. That is also what makes this an easy conversation with drivers: tracking the van for the customer's benefit sells itself, tracking the person does not, and the notice can only say so specifically if the deployment can edit it. Co-Authored-By: Claude Opus 5 --- docs/domains/delivery-integrations.md | 70 +++++++++++++++++++++------ 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/docs/domains/delivery-integrations.md b/docs/domains/delivery-integrations.md index 82f1e7c0..35d13cae 100644 --- a/docs/domains/delivery-integrations.md +++ b/docs/domains/delivery-integrations.md @@ -312,22 +312,60 @@ delivery nobody records is revenue nobody recognises. --- -## Two things to decide deliberately (our own fleet) - -Both concern tracking *our employees*. A carrier's own drivers are their -business, and holt only ever sees a status and a proof-of-delivery. +--- -**Retention.** A position trace is employee location data. Keep raw positions -for a short window — 90 days is generous for answering "what happened on that -delivery" — and aggregate beyond it into the per-stop metrics, which is what the -costing actually needs. Indefinite raw traces are a liability with no -operational upside. +# Configuration (both halves) -**Notice.** Employee vehicle tracking is regulated, and the rules vary by state; -several require notice, and some require consent. This is not a blocker and it -is not something to discover after installing devices. Worth a written policy -and a line in the handbook before the first tracker goes in, which also happens -to be the thing that makes drivers fine with it: tracking the *van* for the -*customer's* benefit is an easy sell, tracking the *person* is not. +None of the numbers in this document are constants. Every one is a deployment +fact, and CLAUDE.md 61-63 applies exactly as it does everywhere else: config +selects behaviour, it never supplies it. A shop with two vans and a shop with a +carrier in three states want different answers to all of these, and neither +should be editing code to get them. -Building only the features above keeps that sentence true. +| Setting | Default | Why it is not a constant | +| --- | --- | --- | +| `telematicsProvider` | none | One active provider per deployment, same shape as the payment processor | +| `positionPollSeconds` | 60 | Trades API quota against ETA freshness. A shop doing eight stops a day does not need what one doing eighty needs | +| `positionRetentionDays` | 90 | **See below — the important one** | +| `stopGeofenceMeters` | 150 | A city kerbside and a rural driveway are not the same arrival | +| `etaNotifyMinutesOut` | 30 | How much warning a customer gets. Some shops want an hour, some want fifteen minutes | +| `etaNotifyChannel` | none | Email, SMS, both, or off entirely | +| `lateRunAlertMinutes` | 30 | When dispatch gets told a run is slipping | +| `sharePositionWithCustomer` | false | Whether the client portal shows the van on the last leg at all | +| `suggestResequence` | false | The optimiser is a suggestion; some operations do not want it offered | +| carrier **per zone** | none | A shop can reasonably use two carriers, chosen by geography | + +The first table row and the last are the same principle at different scales: the +provider is a deployment fact, and so is which zone goes to whom. + +## Retention is config, and it has to be enforced + +`positionRetentionDays` is the setting that matters most, because it is the one +with a consequence outside the building. A position trace is employee location +data. Ninety days is generous for answering "what happened on that delivery"; +some deployments will want thirty, and a shop with frequent delivery disputes +may want a hundred and eighty. All three are legitimate, which is exactly why it +is a number in settings rather than a constant in a file. + +**A retention setting that nothing enforces is worse than none.** It writes down +that you delete data you are in fact still holding, which is a worse position +than never having claimed it. So the setting needs a scheduled purge that +actually runs, deleting raw `VehiclePosition` rows past the window while keeping +the aggregated per-stop metrics the costing depends on — those are derived, they +carry no trace, and they are what the business actually needs long-term. + +That job belongs with the other scheduled automations, and like them it should +record what it did, so "is retention running" is a question with an answer. + +## Notice is config too + +The wording of an employee tracking notice varies by state and by company, and +whether an acknowledgement is required varies with it. That makes it a +configured document plus a per-staff acknowledgement flag, not a constant and +not a paragraph in a README nobody reads. + +Worth saying plainly, because it is also the thing that makes drivers fine with +this: tracking the **van** for the **customer's** benefit is an easy +conversation. Tracking the **person** is not. Building only the features in this +document keeps that sentence true, and the notice should be able to say so +specifically — which it can only do if the deployment can edit it. From aee98fc1be57b2826a01c7ab91a01bc01df4739d Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 26 Aug 2026 08:10:14 -0400 Subject: [PATCH 6/6] fix(seed): the coverage manifest matches what the seed now writes CI caught this, which is the manifest doing its job: eleven models are seeded now and were still marked `todo`, so the coverage gate failed on STALE -- "claimed outstanding, came back populated". Lead, CustomerInteraction, Proposal, ProposalLineItem, GiftCardPreset, EmailTemplate, LabelTemplate, TradeTier, SalesGoal and UpBoardEntry. TrafficSnapshot was marked SKIPPED, with the reason that its columns carried one vendor's brand. They stopped doing so in #127, when axperStoreName became sourceStoreName -- so the reason had outlived the fact and nobody noticed, because a skip with a stale reason looks exactly like a skip with a good one. The demo seeds two years of it now, and the traffic API falls back to those rows whenever the live counter is unreachable. Seed coverage: 79 -> 93 seeded, 63 -> 53 outstanding. No tranche emptied, so SEED_TRANCHES is unchanged. Co-Authored-By: Claude Opus 5 --- app/prisma/seed/coverage.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/app/prisma/seed/coverage.ts b/app/prisma/seed/coverage.ts index 9a89cf44..b0caebce 100644 --- a/app/prisma/seed/coverage.ts +++ b/app/prisma/seed/coverage.ts @@ -91,7 +91,7 @@ export const SEED_COVERAGE: Record = { CustomerAddress: { status: "seeded" }, CustomerCreditTransaction: { status: "seeded" }, CustomerExternalId: { status: "todo", tranche: "catalog-depth" }, - CustomerInteraction: { status: "todo", tranche: "crm-pipeline" }, + CustomerInteraction: { status: "seeded", seeder: "demo" }, CustomerLedgerEntry: { status: "todo", tranche: "money-detail" }, DailyReconciliationLog: { status: "todo", tranche: "money-detail" }, DeliveryRun: { status: "seeded", seeder: "demo" }, @@ -103,7 +103,7 @@ export const SEED_COVERAGE: Record = { status: "skipped", reason: "Outbound queue drained by a worker. Seeding it would send mail on first boot.", }, - EmailTemplate: { status: "todo", tranche: "content-comms" }, + EmailTemplate: { status: "seeded", seeder: "demo" }, ErrorEvent: { status: "skipped", reason: @@ -112,7 +112,7 @@ export const SEED_COVERAGE: Record = { FabricCatalog: { status: "todo", tranche: "catalog-depth" }, GLAccount: { status: "seeded" }, GiftCard: { status: "seeded" }, - GiftCardPreset: { status: "todo", tranche: "money-detail" }, + GiftCardPreset: { status: "seeded", seeder: "demo" }, GiftCardTransaction: { status: "seeded" }, ImportDefinition: { status: "todo", tranche: "imports" }, ImportFieldMapping: { status: "todo", tranche: "imports" }, @@ -133,8 +133,8 @@ export const SEED_COVERAGE: Record = { InvoiceLineItem: { status: "todo", tranche: "money-detail" }, JournalEntry: { status: "seeded" }, JournalEntryLine: { status: "seeded" }, - LabelTemplate: { status: "todo", tranche: "catalog-depth" }, - Lead: { status: "todo", tranche: "crm-pipeline" }, + LabelTemplate: { status: "seeded", seeder: "demo" }, + Lead: { status: "seeded", seeder: "demo" }, LegacyImportLog: { status: "skipped", reason: "Ordorite import staging — a source system's history, not this product's data.", @@ -192,9 +192,9 @@ export const SEED_COVERAGE: Record = { ProductPairing: { status: "todo", tranche: "catalog-depth" }, ProductSpeciesPrice: { status: "todo", tranche: "special-order-pricing" }, ProductVariant: { status: "todo", tranche: "catalog-depth" }, - Proposal: { status: "todo", tranche: "crm-pipeline" }, + Proposal: { status: "seeded", seeder: "demo" }, ProposalItemImage: { status: "todo", tranche: "crm-pipeline" }, - ProposalLineItem: { status: "todo", tranche: "crm-pipeline" }, + ProposalLineItem: { status: "seeded", seeder: "demo" }, PurchaseOrder: { status: "seeded" }, PurchaseOrderItem: { status: "seeded" }, ReceivingRecord: { status: "seeded" }, @@ -209,7 +209,7 @@ export const SEED_COVERAGE: Record = { Role: { status: "seeded", seeder: "roles" }, RolePermission: { status: "seeded", seeder: "roles" }, SEComponent: { status: "todo", tranche: "special-order-pricing" }, - SalesGoal: { status: "todo", tranche: "commission-completeness" }, + SalesGoal: { status: "seeded", seeder: "demo" }, SalesGoals: { status: "todo", tranche: "commission-completeness" }, SalesOrder: { status: "seeded" }, Service: { status: "seeded", seeder: "demo" }, @@ -245,12 +245,13 @@ export const SEED_COVERAGE: Record = { Till: { status: "seeded" }, TillCount: { status: "seeded" }, TimeEntry: { status: "seeded" }, - TradeTier: { status: "todo", tranche: "crm-pipeline" }, - TrafficSnapshot: { - status: "skipped", - reason: - "Axper traffic-counter data — one vendor's feed, and the column names still carry its brand.", - }, + TradeTier: { status: "seeded", seeder: "demo" }, + // Was skipped, on the grounds that the columns carried one vendor's brand. + // They stopped doing so in #127 (axperStoreName -> sourceStoreName), and the + // dashboard's first screen reads zero without this, so the demo seeds two + // years of it. The traffic API falls back to these rows whenever the live + // counter is unreachable -- see lib/traffic/recordedTraffic.ts. + TrafficSnapshot: { status: "seeded", seeder: "demo" }, TrafficSyncLog: { status: "skipped", reason: @@ -258,7 +259,7 @@ export const SEED_COVERAGE: Record = { }, Type: { status: "seeded" }, UnidentifiedScan: { status: "seeded", seeder: "demo" }, - UpBoardEntry: { status: "todo", tranche: "commission-completeness" }, + UpBoardEntry: { status: "seeded", seeder: "demo" }, Upc: { status: "todo", tranche: "catalog-depth" }, User: { status: "seeded" }, Vehicle: { status: "seeded", seeder: "demo" },