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/__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/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/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/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" }, 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..88664d0d 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); @@ -369,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(); 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/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/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/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..44506bdb 100644 --- a/app/src/pages/api/axper/traffic/index.ts +++ b/app/src/pages/api/axper/traffic/index.ts @@ -8,21 +8,32 @@ // 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"; +import { readRecordedTraffic } from "@/lib/traffic/recordedTraffic"; import { getTrafficStoreMap } from "@/lib/trafficStoreMap"; 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" }); } - 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 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({ diff --git a/docs/domains/delivery-integrations.md b/docs/domains/delivery-integrations.md new file mode 100644 index 00000000..35d13cae --- /dev/null +++ b/docs/domains/delivery-integrations.md @@ -0,0 +1,371 @@ +# 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. + +--- + +--- + +# Configuration (both halves) + +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. + +| 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.