Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions app/__tests__/moduleManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
72 changes: 72 additions & 0 deletions app/__tests__/opsAlertLoop.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
31 changes: 16 additions & 15 deletions app/prisma/seed/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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" },
Expand All @@ -103,7 +103,7 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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:
Expand All @@ -112,7 +112,7 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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" },
Expand All @@ -133,8 +133,8 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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.",
Expand Down Expand Up @@ -192,9 +192,9 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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" },
Expand All @@ -209,7 +209,7 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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" },
Expand Down Expand Up @@ -245,20 +245,21 @@ export const SEED_COVERAGE: Record<string, SeedCoverageEntry> = {
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:
"Axper traffic-counter data — one vendor's feed, and the column names still carry its brand.",
},
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" },
Expand Down
19 changes: 17 additions & 2 deletions app/prisma/seed/demo/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
Expand Down
27 changes: 26 additions & 1 deletion app/prisma/seed/demo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ async function main(): Promise<void> {
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");
Expand Down Expand Up @@ -232,6 +234,20 @@ async function main(): Promise<void> {
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);
Expand Down Expand Up @@ -369,7 +385,16 @@ async function main(): Promise<void> {
`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();
Expand Down
31 changes: 25 additions & 6 deletions app/prisma/seed/demo/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,34 @@ export async function seedOrg(prisma: PrismaClient): Promise<OrgSetup> {
// 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,
Expand Down
Loading
Loading