From 8067a43befb2e1d59453f49cd0623a8b24b8d5af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 16:09:25 +0000 Subject: [PATCH] fix(data): give the demo accounts billing addresses so the territory rules match records (#638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #621 fixed the installation half of territory sharing: `north_america_territory` and `europe_territory` now compile and seed against the flat `crm_account.billing_country` column. The data half was still empty. Grepping the repo, `billing_address` appeared in object definitions, views, translations, the lead-conversion flow and the sharing rules — never in the seeds. So both rules evaluated correctly over nine accounts and covered ZERO of them: Setup listed two territories, two positions, and nothing behind either. All nine accounts now carry a billing address, distributed across all THREE outcomes the rules can produce rather than bulk-loaded into one territory: north_america_territory ["US","CA","MX"] 6 (5 × US, Stark Medical CA) europe_territory ["UK","DE","FR","IT","ES"] 2 (Northwind DE, Lattice UK) neither territory 1 (Apex Logistics SG) The account outside both is load-bearing, not filler: with every account inside a territory, a rule that degraded to match-all would look identical to a working one. `country` carries the two-letter code the rules name (`UK`, not the ISO `GB`) because the projection trims and upper-cases but never translates. The four relocated accounts' phone numbers moved with them. `billing_country` is deliberately NOT authored in the seeds. Measured on 17.0.0-rc.1 against a fresh install, hooks DO run over seed writes — all nine rows come out of `pnpm dev` carrying the projection that only `account_protection` writes: sqlite> SELECT name, billing_country FROM crm_account; Acme Corporation|US Stark Medical|CA Northwind Energy|DE Apex Logistics|SG Lattice Education|UK … That settles #617: the seed doctrine's premise ("the loader uses skipTriggers, hooks never run over seed rows") was wrong, and the platform says so itself — `SeedLoaderService.SEED_OPTIONS` documents `skipTriggers` as suppressing record-change AUTOMATION for seed writes while "Lifecycle HOOKS (derived/default fields, validation) still run". The doctrine block is rewritten around the corrected premise; its conclusion (a seeded value of a hook-owned field must equal what the hook computes) holds either way and is kept. A readonly, hook-derived column is now explicitly out of bounds for a seed: a hand-copied `billing_country` would be a second source of truth for a value the hook already owns. `test/territory-seed-coverage.test.ts` refuses to let this go quiet again. It walks the whole chain with the platform's own pieces — seed record → the real `account_protection` handler → `compileCelToFilter` (the call plugin-sharing makes) → which accounts each territory covers — and fails when any of the three buckets empties out. Verified non-vacuous: moving the SG account into the US turns it red. Seed fixtures are split by object family (#635). `src/data/index.ts` was 1.5KB under the 100KB source-hygiene cap, so nine addresses did not fit, and the cheapest way under the cap would have been deleting the comments the cap exists to protect. The file is now the aggregating `CrmSeedData` export over `_shared.ts` (doctrine + cross-family helpers), `catalog.seed.ts`, `sales.seed.ts`, `service.seed.ts`, `marketing.seed.ts` and `revenue.seed.ts`. The split is provably data-identical: importing both revisions in one process and diffing the aggregated datasets reports 16 datasets before and after with the only record differences being the nine addresses and four phone numbers. A second test fails if a family module's dataset is ever left out of `CrmSeedData`, which is the one failure mode the split introduces. Not fixed here: no user holds `na_sales_team` / `eu_sales_team` on a fresh install (`sys_user_position` is empty, so `sys_record_share` stays empty), which needs demo-user design rather than seed data. Filed separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf --- .../seed-billing-addresses-territory-demo.md | 30 + src/data/_shared.ts | 93 + src/data/catalog.seed.ts | 228 ++ src/data/index.ts | 2210 +---------------- src/data/marketing.seed.ts | 315 +++ src/data/revenue.seed.ts | 370 +++ src/data/sales.seed.ts | 926 +++++++ src/data/service.seed.ts | 403 +++ test/territory-seed-coverage.test.ts | 253 ++ 9 files changed, 2642 insertions(+), 2186 deletions(-) create mode 100644 .changeset/seed-billing-addresses-territory-demo.md create mode 100644 src/data/_shared.ts create mode 100644 src/data/catalog.seed.ts create mode 100644 src/data/marketing.seed.ts create mode 100644 src/data/revenue.seed.ts create mode 100644 src/data/sales.seed.ts create mode 100644 src/data/service.seed.ts create mode 100644 test/territory-seed-coverage.test.ts diff --git a/.changeset/seed-billing-addresses-territory-demo.md b/.changeset/seed-billing-addresses-territory-demo.md new file mode 100644 index 00000000..91ef1399 --- /dev/null +++ b/.changeset/seed-billing-addresses-territory-demo.md @@ -0,0 +1,30 @@ +--- +'hotcrm': patch +--- + +Give the nine demo accounts a billing address, so the two territory sharing +rules match real records instead of an empty set. `north_america_territory` and +`europe_territory` filter on `crm_account.billing_country`, and until now not +one seeded account carried a `billing_address` for that column to be projected +from — both rules installed correctly and then covered zero accounts, so Setup +showed two territories with nothing behind them. The addresses partition the +set deliberately across all three outcomes the rules can produce: six in North +America (five US, one CA), two in Europe (DE, UK) and one outside both (SG), +with the account phone numbers moved to match their new countries. A rule with +no matching record is indistinguishable from a rule that never seeded, so +`test/territory-seed-coverage.test.ts` now walks the whole chain — seed record → +the real `account_protection` projection → the seeder's own CEL compiler → which +accounts each territory covers — and fails when any bucket empties out. + +`billing_country` is deliberately still not authored in the seeds: hooks DO run +over seed writes (the loader's `skipTriggers` suppresses record-change +automation, not lifecycle hooks), so the projection is computed at seed time, +and the seed doctrine block that claimed the opposite has been corrected. + +The seed fixtures are now split by object family — `catalog`, `sales`, +`service`, `marketing` and `revenue` `*.seed.ts` modules with `src/data/index.ts` +reduced to the aggregating `CrmSeedData` export. The single file was 1.5KB under +the 100KB source-hygiene cap, so this change would not have fit; the split makes +where-to-add-a-record follow from the object, and a new test fails if a family +module's dataset is never wired into `CrmSeedData`. Fixes #638. Refs #635, #617, +#621. diff --git a/src/data/_shared.ts b/src/data/_shared.ts new file mode 100644 index 00000000..96846050 --- /dev/null +++ b/src/data/_shared.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Seed helpers shared by every `*.seed.ts` family module. + * + * The seed data used to be one 100KB `src/data/index.ts`, which sat ~1.5KB + * under the `pnpm hygiene` byte cap and turned the next demo record into a + * build failure (#635). It is now split by object family — `catalog`, `sales`, + * `service`, `marketing`, `revenue` — with `index.ts` reduced to the + * aggregating `CrmSeedData` export. This module holds what more than one + * family needs. + */ +import { cel } from '@objectstack/spec'; + +/** + * A note on system-computed fields in seeds (#490, #617). + * + * ### Hooks DO run over seed writes — automations do not + * + * The seed loader writes with `{ isSystem: true, skipTriggers: true, + * seedReplay: true }`, and the premise this file used to state — "hooks do NOT + * run over seed rows" — was wrong (#617). Measured against 17.0.0-rc.1, and + * matching `SeedLoaderService.SEED_OPTIONS`' own contract: + * + * - `isSystem` bypasses RBAC and DISABLES the security plugin's injection + * of `organization_id` / `owner_id` — which is why seeded rows + * land ownerless and `demo_bootstrap` has to claim them. + * - `skipTriggers` suppresses record-change AUTOMATION (autolaunched flows), + * not lifecycle hooks. Seed data is pre-existing end state, + * not a stream of user events. + * - `seedReplay` skips the `state_machine` entry/transition guards, so a + * mid-lifecycle row (a `closed_won` deal) is accepted. + * + * Lifecycle HOOKS — derived fields, defaults, validation — still run. The boot + * log shows them firing (`opportunity_amount_rollup`, `quote_total_rollup`), + * and `crm_account.billing_country` is only ever populated because + * `account_protection` projects it from `billing_address` on the seed insert + * (#638). `test/territory-seed-coverage.test.ts` pins that dependency. + * + * ### What that means for authoring + * + * The old conclusion survives the corrected premise, and is still the rule: + * + * 1. Seeding historical values into readonly fields (`created_date`, + * `stage_entry_date`, `last_contacted_date`, `actual_revenue`) is legitimate + * and load-bearing — it is the only way demo reports get history — and the + * platform explicitly preserves explicit seed values. + * 2. Every seeded value of a hook-owned field MUST equal what the hook would + * compute (`is_closed` ⇔ status, `resolution_time_hours` ⇔ closed−created, + * opportunity `probability`/`forecast_category`/`expected_revenue` ⇔ stage, + * forecast `period_label` ⇔ period_start). Under the corrected premise a + * mismatch is rewritten immediately, at seed time, instead of on the first + * user edit — either way the demo does not show what was authored. + * 3. A field the hook DERIVES and the schema marks `readonly` is not authored + * here at all (`crm_account.billing_country`): duplicating it would create a + * second source of truth for a value the hook already owns. + * + * Autonumber fields (`case_number`, `contract_number`, `quote_number`) are + * NEVER seeded: the runtime owns those sequences (the SQL driver bootstraps + * each counter past existing rows), so hand-numbering them only invites + * drift. Upsert identity uses a natural key instead (subject / name / + * description). + */ + +/** + * Build a CEL `daysAgo(N)` expression from a runtime number. Mirrors the + * existing tagged-template usage (`cel\`daysAgo(N)\``) so we can produce + * timestamps inside `.map()` generators without manufacturing fake template + * string arrays. + */ +export const celDaysAgo = (n: number) => cel`daysAgo(${n})`; +export const celDaysFromNow = (n: number) => cel`daysFromNow(${n})`; + +/** + * A configured product line. `unit_price` is the NEGOTIATED price (what the + * rep sold at); the catalog `list_price` is stamped separately from the + * product record, exactly as the price-fill hook would. + */ +export type LineSpec = { + readonly product: string; + readonly quantity: number; + readonly unit_price: number; + /** Line-level discount %, defaults to 0. */ + readonly discount?: number; + readonly description: string; +}; + +/** `quantity × unit_price × (1 − discount/100)`, rounded exactly as the rollup hooks round. */ +export const lineTotal = (l: LineSpec): number => + Math.round(l.quantity * l.unit_price * (1 - (l.discount ?? 0) / 100) * 100) / 100; + +export const linesTotal = (lines: readonly LineSpec[]): number => + Math.round(lines.reduce((sum, l) => sum + lineTotal(l), 0) * 100) / 100; diff --git a/src/data/catalog.seed.ts b/src/data/catalog.seed.ts new file mode 100644 index 00000000..973256aa --- /dev/null +++ b/src/data/catalog.seed.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Product catalogue seeds — and the two helpers that read prices back out of + * it, so a line item's `list_price` can never disagree with the catalogue. + * + * Split out of the former monolithic `src/data/index.ts` (#635). Seed doctrine + * lives in `./_shared.ts`. + */ +import { defineSeed } from '@objectstack/spec/data'; +import { Product } from '../objects/product.object'; +import type { LineSpec } from './_shared'; + +// ─── Products ───────────────────────────────────────────────────────── +// The catalog is what every line item below prices against, so it has to be +// wide enough to configure a realistic deal: an edition, an add-on, a +// per-seat subscription, support tiers and professional services. Four +// products could not (#591). +// +// `cost` is populated on every product: `cost_less_than_price` is a real +// validation rule that "has never once evaluated" while the field was blank +// everywhere, and margin is what the product-mix analytics are for. Every row +// keeps cost strictly below list price. +export const products = defineSeed(Product, { + mode: 'upsert', + externalId: 'name', + records: [ + { + name: 'ObjectStack Platform', + description: 'The enterprise edition: unlimited objects, AI agents, governance and audit.', + category: 'software', + family: 'enterprise', + sku: 'OS-PLAT-ENT', + list_price: 50000, + cost: 12000, + billing_type: 'annual', + unit_of_measure: 'license', + is_active: true, + }, + { + name: 'ObjectStack Platform (SMB Edition)', + description: 'The mid-market edition: core CRM objects and automation, capped agent usage.', + category: 'software', + family: 'smb', + sku: 'OS-PLAT-SMB', + list_price: 18000, + cost: 4500, + billing_type: 'annual', + unit_of_measure: 'license', + is_active: true, + }, + { + name: 'Cloud Hosting (Annual)', + description: 'Managed hosting with regional data residency and a 99.9% availability target.', + category: 'subscription', + family: 'cloud', + sku: 'OS-CLOUD-HOST', + list_price: 12000, + cost: 4200, + billing_type: 'annual', + unit_of_measure: 'each', + is_active: true, + }, + { + name: 'Sandbox Environment (Annual)', + description: 'A full-copy non-production environment for testing metadata changes before release.', + category: 'subscription', + family: 'cloud', + sku: 'OS-CLOUD-SBX', + list_price: 7500, + cost: 2600, + billing_type: 'annual', + unit_of_measure: 'each', + is_active: true, + }, + { + name: 'AI Agent Seat (Annual)', + description: 'A named-user seat for the Co-Pilot and agent surfaces, billed annually.', + category: 'subscription', + family: 'cloud', + sku: 'OS-AI-SEAT', + list_price: 1000, + cost: 260, + billing_type: 'annual', + unit_of_measure: 'seat', + is_active: true, + }, + { + name: 'Analytics Add-on', + description: 'Dashboards, cubes and scheduled reporting for the revenue organization.', + category: 'software', + family: 'enterprise', + sku: 'OS-ADDON-ANL', + list_price: 22000, + cost: 6500, + billing_type: 'annual', + unit_of_measure: 'license', + is_active: true, + }, + { + name: 'Integration Connector Pack', + description: 'Pre-built connectors for ERP, marketing automation and data warehouse targets.', + category: 'software', + family: 'enterprise', + sku: 'OS-ADDON-INT', + list_price: 16000, + cost: 5200, + billing_type: 'annual', + unit_of_measure: 'license', + is_active: true, + }, + { + name: 'Field Service Mobile', + description: 'Offline-capable mobile app for field technicians and route-based service work.', + category: 'software', + family: 'smb', + sku: 'OS-ADDON-FSM', + list_price: 14000, + cost: 4200, + billing_type: 'annual', + unit_of_measure: 'license', + is_active: true, + }, + { + name: 'Premium Support', + description: '24×7 support with a one-hour P1 response target and a named technical account manager.', + category: 'support', + family: 'services', + sku: 'OS-SUP-PREM', + list_price: 25000, + cost: 9000, + billing_type: 'annual', + unit_of_measure: 'each', + is_active: true, + }, + { + name: 'Standard Support', + description: 'Business-hours support with a next-business-day response target.', + category: 'support', + family: 'services', + sku: 'OS-SUP-STD', + list_price: 9000, + cost: 3600, + billing_type: 'annual', + unit_of_measure: 'each', + is_active: true, + }, + { + name: 'Implementation Services', + description: 'Guided implementation: discovery, metadata build, integration and go-live support.', + category: 'service', + family: 'services', + sku: 'OS-SVC-IMPL', + list_price: 75000, + cost: 41000, + billing_type: 'one_time', + unit_of_measure: 'each', + is_active: true, + }, + { + name: 'Data Migration Services', + description: 'Extraction, mapping and reconciliation of legacy CRM and spreadsheet data.', + category: 'service', + family: 'services', + sku: 'OS-SVC-MIGR', + list_price: 35000, + cost: 19000, + billing_type: 'one_time', + unit_of_measure: 'each', + is_active: true, + }, + { + name: 'Admin Training Workshop', + description: 'A one-day workshop for administrators on metadata, permissions and analytics.', + category: 'service', + family: 'services', + sku: 'OS-SVC-TRN', + list_price: 6000, + cost: 2400, + billing_type: 'one_time', + unit_of_measure: 'day', + is_active: true, + }, + ] +}); + +/** + * Catalog price of a seeded product, read back from the dataset above so the + * price lives in exactly one place. + * + * Line items carry `list_price` explicitly because hooks do NOT run over seeds + * (#490): the shared price-fill hook (`_line-item-price-fill.ts`) is what + * stamps `list_price` from `crm_product.list_price` on a real write, so a + * seeded line has to arrive already carrying what that hook would have + * written. Reading it from the catalog record makes that literally impossible + * to get wrong. + */ +export const catalogPrice = (productName: string): number => { + const product = products.records.find((r) => r.name === productName); + if (!product || typeof product.list_price !== 'number') { + throw new Error(`Seed error: no catalog product named "${productName}"`); + } + return product.list_price; +}; + +/** + * Flatten a `{ parent → lines }` table into seed records for one line-item + * object. `list_price` and `line_number` are the two fields a real write gets + * from machinery a seed cannot count on (the price-fill hook, and the quote / + * opportunity line editors), so both are materialised here — the row is then + * correct whether or not the hook fires over a seed write (#617). + */ +export const lineItemRecords = ( + parentField: K, + table: Record, +): Array> => + Object.entries(table).flatMap(([parent, lines]) => + lines.map((l, i) => ({ + [parentField]: parent, + crm_product: l.product, + description: l.description, + quantity: l.quantity, + list_price: catalogPrice(l.product), + unit_price: l.unit_price, + discount: l.discount ?? 0, + line_number: i + 1, + })), + ); diff --git a/src/data/index.ts b/src/data/index.ts index 83d75166..95523e14 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,2193 +1,31 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * CRM Seed Data - * - * Demo records for all core CRM objects. - * Uses defineSeed() for type-safe field name checking at compile time. - */ -import { defineSeed } from '@objectstack/spec/data'; -import { cel } from '@objectstack/spec'; -import { Account } from '../objects/account.object'; -import { Contact } from '../objects/contact.object'; -import { Lead } from '../objects/lead.object'; -import { Opportunity } from '../objects/opportunity.object'; -import { Product } from '../objects/product.object'; -import { Task } from '../objects/task.object'; -import { Case } from '../objects/case.object'; -import { Campaign } from '../objects/campaign.object'; -import { Contract } from '../objects/contract.object'; -import { Quote } from '../objects/quote.object'; -import { Forecast } from '../objects/forecast.object'; -import { KnowledgeArticle } from '../objects/knowledge_article.object'; -import { OpportunityLineItem } from '../objects/opportunity_line_item.object'; -import { QuoteLineItem } from '../objects/quote_line_item.object'; -import { CampaignMember } from '../objects/campaign_member.object'; - -/** - * Build a CEL `daysAgo(N)` expression from a runtime number. Mirrors the - * existing tagged-template usage (`cel\`daysAgo(N)\``) so we can produce - * timestamps inside `.map()` generators without manufacturing fake template - * string arrays. - */ -const celDaysAgo = (n: number) => cel`daysAgo(${n})`; -const celDaysFromNow = (n: number) => cel`daysFromNow(${n})`; - -/** - * A note on system-computed fields in seeds (#490). - * - * The seed loader writes with `{ isSystem: true, skipTriggers: true }`: hooks - * do NOT run over seed rows, and readonly stripping only guards user writes. - * Two consequences shape everything below: - * - * 1. Seeding historical values into readonly fields (`created_date`, - * `stage_entry_date`, `last_contacted_date`, `actual_revenue`) is legitimate - * and load-bearing — it is the only way demo reports get history — and the - * platform explicitly preserves explicit seed values. - * 2. BUT nothing recomputes derived fields for seed rows, so every seeded - * value of a hook-owned field MUST match what the hook would compute - * (`is_closed` ⇔ status, `resolution_time_hours` ⇔ closed−created, - * opportunity `probability`/`forecast_category`/`expected_revenue` ⇔ stage, - * forecast `period_label` ⇔ period_start). Otherwise the first genuine - * user edit "mysteriously" rewrites the record. - * - * Autonumber fields (`case_number`, `contract_number`, `quote_number`) are - * NEVER seeded: the runtime owns those sequences (the SQL driver bootstraps - * each counter past existing rows), so hand-numbering them only invites - * drift. Upsert identity uses a natural key instead (subject / name / - * description). - */ - -// ─── Accounts ───────────────────────────────────────────────────────── -const accounts = defineSeed(Account, { - mode: 'upsert', - externalId: 'name', - records: [ - { - name: 'Acme Corporation', - type: 'customer', - industry: 'technology', - annual_revenue: 5000000, - number_of_employees: 250, - phone: '+1-415-555-0100', - website: 'https://acme.example.com', - tier: 'enterprise', - segment: 'growth', - health_score: 'healthy', - next_renewal_date: cel`daysFromNow(45)`, - last_activity_date: cel`daysAgo(3)`, - description: `**Strategic Customer · Enterprise Tier** - -Acme Corporation is a Series-C robotics & industrial automation -company that switched from a competitor 18 months ago. They run -HotCRM as their system of record for sales + service across -three regional teams (NA, EMEA, APAC). - -**Relationship** -- Primary economic buyer: Jordan Park (CTO) — values our open - architecture and AI roadmap. -- Day-to-day champion: John Smith (VP Engineering) — owns the - technical evaluation and integration questions. -- Procurement: handled by Lisa Kim — prefers annual contracts, - net-30 terms. - -**Current state** -- ARR: $220K (signed Q1 2025 renewal). Up 22% YoY. -- 1 open enterprise opportunity ($150K platform upgrade) in proposal - stage, 1 service ticket open (login issues), 1 billing dispute - awaiting customer response. -- Renewal due in 45 days — they've already verbally committed but - want a workshop on AI agent governance before signing. - -**Red flags** -- Slipped one opportunity ($75K add-on) in the last quarter due - to slow procurement cycle on their side. -- Login issues ticket is approaching its SLA — needs eyes today.`, - }, - { - name: 'Globex Industries', - type: 'prospect', - industry: 'manufacturing', - annual_revenue: 12000000, - number_of_employees: 800, - phone: '+1-312-555-0200', - website: 'https://globex.example.com', - tier: 'enterprise', - segment: 'net_new', - last_activity_date: cel`daysAgo(8)`, - }, - { - name: 'Initech Solutions', - type: 'customer', - industry: 'finance', - annual_revenue: 3500000, - number_of_employees: 150, - phone: '+1-212-555-0300', - website: 'https://initech.example.com', - tier: 'mid_market', - segment: 'at_risk', - health_score: 'at_risk', - next_renewal_date: cel`daysFromNow(75)`, - last_activity_date: cel`daysAgo(21)`, - }, - { - name: 'Stark Medical', - type: 'partner', - industry: 'healthcare', - annual_revenue: 8000000, - number_of_employees: 400, - phone: '+1-617-555-0400', - website: 'https://starkmed.example.com', - tier: 'mid_market', - segment: 'stable', - last_activity_date: cel`daysAgo(5)`, - }, - { - name: 'Wayne Enterprises', - type: 'customer', - industry: 'technology', - annual_revenue: 25000000, - number_of_employees: 2000, - phone: '+1-650-555-0500', - website: 'https://wayne.example.com', - tier: 'strategic', - segment: 'growth', - health_score: 'healthy', - next_renewal_date: cel`daysFromNow(28)`, - last_activity_date: cel`daysAgo(1)`, - }, - { - name: 'Northwind Energy', - type: 'prospect', - industry: 'energy', - annual_revenue: 18000000, - number_of_employees: 1100, - phone: '+1-303-555-0600', - website: 'https://northwind.example.com', - tier: 'enterprise', - segment: 'net_new', - last_activity_date: cel`daysAgo(2)`, - description: 'A regional energy provider evaluating a unified operations and customer-data platform.', - }, - { - name: 'Vertex Analytics', - type: 'customer', - industry: 'software', - annual_revenue: 9500000, - number_of_employees: 420, - phone: '+1-512-555-0700', - website: 'https://vertex.example.com', - tier: 'enterprise', - segment: 'growth', - health_score: 'healthy', - next_renewal_date: cel`daysFromNow(62)`, - last_activity_date: cel`daysAgo(4)`, - description: 'An analytics SaaS customer expanding usage from the data team to its revenue organization.', - }, - { - name: 'Lattice Education', - type: 'customer', - industry: 'education', - annual_revenue: 6800000, - number_of_employees: 320, - phone: '+1-617-555-0800', - website: 'https://lattice.example.com', - tier: 'mid_market', - segment: 'stable', - health_score: 'healthy', - next_renewal_date: cel`daysFromNow(36)`, - last_activity_date: cel`daysAgo(6)`, - description: 'A higher-education technology company consolidating admissions, student-success and alumni workflows.', - }, - { - name: 'Apex Logistics', - type: 'prospect', - industry: 'logistics', - annual_revenue: 22000000, - number_of_employees: 1400, - phone: '+1-404-555-0900', - website: 'https://apexlogistics.example.com', - tier: 'enterprise', - segment: 'net_new', - last_activity_date: cel`daysAgo(9)`, - description: 'A fast-growing logistics provider assessing a modern data hub for its operations and commercial teams.', - }, - ] -}); - -// ─── Contacts ───────────────────────────────────────────────────────── -const contacts = defineSeed(Contact, { - mode: 'upsert', - externalId: 'email', - records: [ - { - salutation: 'mr', - first_name: 'John', - last_name: 'Smith', - email: 'john.smith@acme.example.com', - phone: '+1-415-555-0101', - title: 'VP of Engineering', - department: 'engineering', - crm_account: 'Acme Corporation', - is_primary: true, - }, - { - salutation: 'ms', - first_name: 'Sarah', - last_name: 'Johnson', - email: 'sarah.j@globex.example.com', - phone: '+1-312-555-0201', - title: 'Chief Procurement Officer', - department: 'executive', - crm_account: 'Globex Industries', - is_primary: true, - }, - { - salutation: 'dr', - first_name: 'Michael', - last_name: 'Chen', - email: 'mchen@initech.example.com', - phone: '+1-212-555-0301', - title: 'Director of Operations', - department: 'operations', - crm_account: 'Initech Solutions', - is_primary: true, - }, - { - salutation: 'ms', - first_name: 'Emily', - last_name: 'Davis', - email: 'emily.d@starkmed.example.com', - phone: '+1-617-555-0401', - title: 'Head of Partnerships', - department: 'sales', - crm_account: 'Stark Medical', - is_primary: true, - }, - { - salutation: 'mr', - first_name: 'Robert', - last_name: 'Wilson', - email: 'rwilson@wayne.example.com', - phone: '+1-650-555-0501', - title: 'CTO', - department: 'engineering', - crm_account: 'Wayne Enterprises', - is_primary: true, - }, - { - salutation: 'ms', - first_name: 'Olivia', - last_name: 'Chen', - email: 'olivia.chen@northwind.example.com', - phone: '+1-303-555-0601', - title: 'VP of Digital Operations', - department: 'operations', - crm_account: 'Northwind Energy', - is_primary: true, - }, - { - salutation: 'mr', - first_name: 'Ethan', - last_name: 'Brooks', - email: 'ethan.brooks@vertex.example.com', - phone: '+1-512-555-0701', - title: 'Chief Revenue Officer', - department: 'executive', - crm_account: 'Vertex Analytics', - is_primary: true, - }, - { - salutation: 'ms', - first_name: 'Priya', - last_name: 'Shah', - email: 'priya.shah@lattice.example.com', - phone: '+1-617-555-0801', - title: 'VP of Student Success', - department: 'operations', - crm_account: 'Lattice Education', - is_primary: true, - }, - { - salutation: 'mr', - first_name: 'Marcus', - last_name: 'Reed', - email: 'marcus.reed@apexlogistics.example.com', - phone: '+1-404-555-0901', - title: 'Director of Commercial Systems', - department: 'operations', - crm_account: 'Apex Logistics', - is_primary: true, - }, - ] -}); - -// ─── Leads ──────────────────────────────────────────────────────────── -/** - * Disqualification reasons for the generated `unqualified` demo leads, paired - * with the note that explains them. - * - * `crm_lead.disqualification_reason_required` rejects an unqualified lead that - * carries no reason, so every seeded unqualified row must supply one — the rows - * used to carry a budget-flavoured note and no reason at all. Rotated over four - * values so the demo's disqualification breakdown has more than one bar, and - * the note always matches the reason it sits next to. - */ -const DISQUALIFICATION_REASONS = [ - { reason: 'no_budget', note: 'No budget approved for this fiscal year — revisit next planning cycle.' }, - { reason: 'not_a_fit', note: 'Requirements sit outside what the product covers today.' }, - { reason: 'wrong_persona', note: 'Contact has no say in the buying decision and no path to an owner.' }, - { reason: 'unreachable', note: 'Six touches across email and phone over four weeks, no response.' }, -] as const; - -const leads = defineSeed(Lead, { - mode: 'upsert', - externalId: 'email', - records: [ - { - first_name: 'Alice', - last_name: 'Martinez', - company: 'NextGen Retail', - email: 'alice@nextgenretail.example.com', - phone: '+1-503-555-0600', - status: 'new', - lead_source: 'web', - industry: 'retail', - rating: 3, - next_followup_date: cel`daysFromNow(2)`, - }, - { - first_name: 'David', - last_name: 'Kim', - company: 'EduTech Labs', - email: 'dkim@edutechlabs.example.com', - phone: '+1-408-555-0700', - status: 'contacted', - lead_source: 'referral', - industry: 'education', - rating: 4, - next_followup_date: cel`daysFromNow(1)`, - last_contacted_date: cel`daysAgo(3)`, - }, - { - first_name: 'Lisa', - last_name: 'Thompson', - company: 'CloudFirst Inc', - email: 'lisa.t@cloudfirst.example.com', - phone: '+1-206-555-0800', - status: 'qualified', - lead_source: 'event', - industry: 'technology', - // WHOLE stars only. `lead.hook.ts` rounds its computed score to an - // integer because "half values rendered inconsistently in the star - // widget", and the hook leaves an explicitly seeded number alone — so a - // seeded 4.5 was the one rating in the system the contract could never - // produce, and it rendered as the broken half-star it was meant to - // prevent (#591). - rating: 5, - next_followup_date: cel`daysFromNow(0)`, - last_contacted_date: cel`daysAgo(1)`, - }, - // ─── Generated demo leads — spread across 6 months for monthly-bucket reports - // (`LeadInflowByMonthSourceReport`). Each `last_contacted_date` lives in a - // distinct month / source pair so the matrix has multiple cells populated. - ...[ - { fn: 'Noah', ln: 'Patel', co: 'Vertex Analytics', src: 'web', ind: 'technology', age: 7 }, - { fn: 'Maya', ln: 'Singh', co: 'BluePeak Logistics', src: 'referral', ind: 'logistics', age: 14 }, - { fn: 'Owen', ln: 'Becker', co: 'Northwind Energy', src: 'event', ind: 'energy', age: 21 }, - { fn: 'Sara', ln: 'Lopez', co: 'Helios Solar', src: 'partner', ind: 'energy', age: 28 }, - { fn: 'Leo', ln: 'Vance', co: 'CleanCart', src: 'web', ind: 'retail', age: 38 }, - { fn: 'Iris', ln: 'Okafor', co: 'PulseHealth', src: 'cold_call', ind: 'healthcare', age: 45 }, - { fn: 'Ravi', ln: 'Mehta', co: 'Foundry Robotics', src: 'event', ind: 'manufacturing',age: 52 }, - { fn: 'Tess', ln: 'Brown', co: 'Lattice Education', src: 'referral', ind: 'education', age: 67 }, - { fn: 'Marco', ln: 'Ricci', co: 'Aurora Travel', src: 'advertisement', ind: 'hospitality', age: 74 }, - { fn: 'Pia', ln: 'Anand', co: 'Citrine Finance', src: 'partner', ind: 'finance', age: 81 }, - { fn: 'Jonas', ln: 'Holt', co: 'Polar Cargo', src: 'web', ind: 'logistics', age: 95 }, - { fn: 'Anya', ln: 'Volkov', co: 'RedOak Realty', src: 'cold_call', ind: 'real_estate', age: 102 }, - { fn: 'Theo', ln: 'Park', co: 'Skyline Media', src: 'advertisement', ind: 'media', age: 116 }, - { fn: 'Wren', ln: 'Garcia', co: 'Maple Bakery Group', src: 'event', ind: 'retail', age: 123 }, - { fn: 'Hugo', ln: 'Dubois', co: 'Nimbus Aerospace', src: 'referral', ind: 'manufacturing',age: 138 }, - { fn: 'Lena', ln: 'Fischer', co: 'Granite Insurance', src: 'partner', ind: 'finance', age: 145 }, - { fn: 'Kai', ln: 'Watanabe', co: 'Coral Reef Hotels', src: 'web', ind: 'hospitality', age: 162 }, - { fn: 'Mira', ln: 'Costa', co: 'Atlas Construction', src: 'cold_call', ind: 'other', age: 175 }, - ].map((l, i) => { - const domain = `${l.co.toLowerCase().replace(/\s+/g, '')}.example.com`; - const status = (['new', 'contacted', 'qualified', 'unqualified'] as const)[i % 4]; - return { - first_name: l.fn, - last_name: l.ln, - company: l.co, - email: `${l.fn.toLowerCase()}.${l.ln.toLowerCase()}@${domain}`, - phone: `+1-555-01${String(i).padStart(2, '0')}-${String(1000 + i * 7)}`, - status, - lead_source: l.src, - industry: l.ind, - rating: 1 + ((i * 7) % 5), - last_contacted_date: celDaysAgo(l.age), - // The qualification fields were left blank on every generated lead, so - // the detail page's Contact / Lead Detail / Description sections had - // nothing to render (they also drop whatever the highlights strip - // already shows, so all four collapsed to nothing) and the demo read - // like a half-finished import. - title: (['VP Operations', 'Head of IT', 'Director of Sales', 'COO', 'Procurement Lead'] as const)[i % 5], - mobile: `+1-555-02${String(i).padStart(2, '0')}-${String(2000 + i * 3)}`, - website: `https://${domain}`, - annual_revenue: 2_000_000 + ((i * 3_700_000) % 90_000_000), - number_of_employees: 25 + ((i * 137) % 4_800), - description: - `Inbound via ${l.src.replace(/_/g, ' ')}. Evaluating a CRM to replace spreadsheets ` + - `across their ${l.ind.replace(/_/g, ' ')} operation.`, - ...(status === 'unqualified' - ? { - disqualification_reason: DISQUALIFICATION_REASONS[(i >> 2) % DISQUALIFICATION_REASONS.length].reason, - notes: DISQUALIFICATION_REASONS[(i >> 2) % DISQUALIFICATION_REASONS.length].note, - } - : {}), - }; - }), - ] -}); - -// ─── Line items: the one source of truth for deal value ─────────────── -/** - * A configured product line. `unit_price` is the NEGOTIATED price (what the - * rep sold at); the catalog `list_price` is stamped separately from the - * product record, exactly as the price-fill hook would. - */ -type LineSpec = { - readonly product: string; - readonly quantity: number; - readonly unit_price: number; - /** Line-level discount %, defaults to 0. */ - readonly discount?: number; - readonly description: string; -}; - -/** `quantity × unit_price × (1 − discount/100)`, rounded exactly as the rollup hooks round. */ -const lineTotal = (l: LineSpec): number => - Math.round(l.quantity * l.unit_price * (1 - (l.discount ?? 0) / 100) * 100) / 100; - -const linesTotal = (lines: readonly LineSpec[]): number => - Math.round(lines.reduce((sum, l) => sum + lineTotal(l), 0) * 100) / 100; - -/** - * Opportunity line items, keyed by the opportunity's name (its seed - * externalId). Every seeded deal is itemised — before #591 not one was, so the - * Products related list was empty on every opportunity in the demo and the CPQ - * story had nothing to show. - * - * These sums are AUTHORITATIVE: each opportunity's `amount` below is computed - * from its lines by `dealValue()` rather than typed in, which is the same - * answer `opportunity_amount_rollup` computes when a rep touches a line. There - * is therefore no seeded parent total that a first line-item edit can silently - * rewrite — the acceptance condition of #591 holds by construction rather than - * by anyone re-checking the arithmetic. - * - * Deriving rather than delegating is deliberate. Letting the rollup own the - * number would make every seeded `amount` depend on whether hooks fire over - * seed writes — which the doctrine block above says they do not and a boot log - * says they do (#617) — and on that hook surviving its own write call (#616). - * A derived literal is correct under every one of those combinations: if the - * rollup runs it recomputes the same figure, and if it never runs the figure - * was already right. - */ -const OPPORTUNITY_LINES: Record = { - 'Acme Platform Upgrade': [ - { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, discount: 10, description: 'Enterprise edition — NA + EMEA production tenants (multi-year discount).' }, - { product: 'Premium Support', quantity: 1, unit_price: 25000, description: '24×7 premium support with a named TAM.' }, - { product: 'AI Agent Seat (Annual)', quantity: 35, unit_price: 1000, description: 'Agent seats for the Ops organization.' }, - ], - 'Globex Manufacturing Suite': [ - { product: 'ObjectStack Platform', quantity: 6, unit_price: 50000, description: 'Enterprise edition across six manufacturing sites.' }, - { product: 'Implementation Services', quantity: 2, unit_price: 75000, description: 'Two-phase implementation: plant operations, then commercial.' }, - { product: 'Premium Support', quantity: 2, unit_price: 25000, description: 'Premium support for both production regions.' }, - ], - 'Wayne Enterprise License': [ - { product: 'ObjectStack Platform', quantity: 20, unit_price: 50000, discount: 15, description: 'Enterprise-wide license, volume discount at 20 tenants.' }, - { product: 'Premium Support', quantity: 4, unit_price: 25000, description: 'Premium support across four business units.' }, - { product: 'AI Agent Seat (Annual)', quantity: 250, unit_price: 1000, description: 'Agent seats for the global revenue organization.' }, - ], - 'Initech Cloud Migration': [ - { product: 'Data Migration Services', quantity: 1, unit_price: 35000, description: 'Migration off the legacy on-premise CRM.' }, - { product: 'Cloud Hosting (Annual)', quantity: 2, unit_price: 12000, description: 'Managed hosting, primary + DR region.' }, - { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Business-hours support for the first year.' }, - { product: 'AI Agent Seat (Annual)', quantity: 12, unit_price: 1000, description: 'Agent seats for the service desk.' }, - ], - 'Acme Annual Renewal 2025': [ - { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Renewal of the three production tenants.' }, - { product: 'Premium Support', quantity: 1, unit_price: 25000, description: 'Premium support renewal.' }, - { product: 'AI Agent Seat (Annual)', quantity: 45, unit_price: 1000, description: 'Seat expansion driven by the new EMEA team.' }, - ], - 'Stark Medical Pilot': [ - { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Pilot tenants for two clinical service lines.' }, - { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support during the pilot.' }, - { product: 'Admin Training Workshop', quantity: 6, unit_price: 6000, description: 'Six workshop days for the clinical operations admins.' }, - ], - 'Wayne Q1 Expansion': [ - { product: 'ObjectStack Platform', quantity: 6, unit_price: 50000, description: 'Expansion into six additional subsidiaries.' }, - { product: 'Premium Support', quantity: 2, unit_price: 25000, description: 'Premium support for the expanded footprint.' }, - { product: 'AI Agent Seat (Annual)', quantity: 30, unit_price: 1000, description: 'Agent seats for the expansion teams.' }, - ], - 'Globex Training Package': [ - { product: 'Admin Training Workshop', quantity: 10, unit_price: 6000, description: 'Ten workshop days across the plant admin community.' }, - { product: 'AI Agent Seat (Annual)', quantity: 5, unit_price: 1000, description: 'Agent seats for the training sandbox.' }, - ], - 'Initech Phase 1': [ - { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Phase 1 implementation: accounts, contacts, pipeline.' }, - { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support for phase 1.' }, - { product: 'AI Agent Seat (Annual)', quantity: 6, unit_price: 1000, description: 'Agent seats for the phase-1 pilot group.' }, - ], - 'Acme Add-on (Lost)': [ - { product: 'Analytics Add-on', quantity: 2, unit_price: 22000, description: 'Marketing analytics for two business units.' }, - { product: 'Integration Connector Pack', quantity: 1, unit_price: 16000, description: 'Marketing-automation connector pack.' }, - { product: 'Sandbox Environment (Annual)', quantity: 2, unit_price: 7500, description: 'Two sandboxes for the marketing build-out.' }, - ], - 'Stark Expansion (Lost)': [ - { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Two additional clinical tenants.' }, - { product: 'Field Service Mobile', quantity: 1, unit_price: 14000, description: 'Field service mobile for the device-servicing team.' }, - { product: 'AI Agent Seat (Annual)', quantity: 6, unit_price: 1000, description: 'Agent seats for the service coordinators.' }, - ], - 'Northwind Grid Modernization': [ - { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Enterprise edition for grid, field and customer operations.' }, - { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Operational-data assessment and implementation.' }, - { product: 'AI Agent Seat (Annual)', quantity: 15, unit_price: 1000, description: 'Agent seats for the control-room pilot.' }, - ], - 'Lattice Student Success Platform': [ - { product: 'ObjectStack Platform (SMB Edition)', quantity: 5, unit_price: 18000, description: 'Student-success tenants for five colleges.' }, - { product: 'Admin Training Workshop', quantity: 5, unit_price: 6000, description: 'Five workshop days for the student-success advisers.' }, - { product: 'AI Agent Seat (Annual)', quantity: 5, unit_price: 1000, description: 'Agent seats for the advising team.' }, - ], - 'Vertex Analytics Expansion': [ - { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Two tenants for the revenue organization.' }, - { product: 'Analytics Add-on', quantity: 5, unit_price: 22000, description: 'Analytics for sales, marketing, service, finance and ops.' }, - { product: 'Premium Support', quantity: 2, unit_price: 25000, description: 'Premium support for the expanded footprint.' }, - { product: 'AI Agent Seat (Annual)', quantity: 60, unit_price: 1000, description: 'Agent seats for the full revenue organization.' }, - ], - 'Apex Logistics Data Hub': [ - { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Data-hub tenants for commercial, operations and finance.' }, - { product: 'Data Migration Services', quantity: 1, unit_price: 35000, description: 'Consolidation of four fragmented commercial systems.' }, - { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Architecture workshop through go-live.' }, - { product: 'AI Agent Seat (Annual)', quantity: 15, unit_price: 1000, description: 'Agent seats for the commercial operations team.' }, - ], - 'Lattice Education Renewal': [ - { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Renewal of the three institution tenants.' }, - { product: 'Analytics Add-on', quantity: 1, unit_price: 22000, description: 'Expanded analytics package for the renewal term.' }, - { product: 'Admin Training Workshop', quantity: 3, unit_price: 6000, description: 'Three workshop days for the new admissions admins.' }, - { product: 'AI Agent Seat (Annual)', quantity: 20, unit_price: 1000, description: 'Student-success automation seats.' }, - ], - 'Vertex Enterprise Rollout': [ - { product: 'ObjectStack Platform', quantity: 10, unit_price: 50000, discount: 20, description: 'Phased enterprise rollout, ten tenants (volume discount).' }, - { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Phased deployment with data-residency controls.' }, - { product: 'Premium Support', quantity: 4, unit_price: 25000, description: 'Premium support across four regions.' }, - { product: 'AI Agent Seat (Annual)', quantity: 100, unit_price: 1000, description: 'Agent seats for the first rollout wave.' }, - ], - // Campaign-attributed wins — see the campaign metrics note below. - 'Lattice Analytics Expansion': [ - { product: 'Analytics Add-on', quantity: 2, unit_price: 22000, description: 'Analytics for admissions and alumni engagement.' }, - { product: 'Admin Training Workshop', quantity: 1, unit_price: 6000, description: 'Analytics enablement day for the institutional-research team.' }, - { product: 'AI Agent Seat (Annual)', quantity: 20, unit_price: 1000, description: 'Agent seats for the admissions team.' }, - ], - 'Wayne Operations Module Rollout': [ - { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Operations tenants for two manufacturing divisions.' }, - { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support for the operations rollout.' }, - { product: 'AI Agent Seat (Annual)', quantity: 11, unit_price: 1000, description: 'Agent seats for the operations planners.' }, - ], - 'Vertex Developer Platform Adoption': [ - { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Developer platform tenants for three product teams.' }, - { product: 'Integration Connector Pack', quantity: 1, unit_price: 16000, description: 'Connector pack for the internal data warehouse.' }, - { product: 'Analytics Add-on', quantity: 1, unit_price: 22000, description: 'Product-usage analytics for the platform team.' }, - { product: 'AI Agent Seat (Annual)', quantity: 12, unit_price: 1000, description: 'Agent seats for the developer-experience team.' }, - ], -}; - -/** - * `amount` + `expected_revenue` for a seeded deal, both derived from its line - * items. `expected_revenue` uses opportunity.hook.ts's own expression - * (`Math.round(amount * probability) / 100`) so the two agree to the cent. - */ -const dealValue = (opportunityName: string, probability: number) => { - const lines = OPPORTUNITY_LINES[opportunityName]; - if (!lines) throw new Error(`Seed error: no line items authored for opportunity "${opportunityName}"`); - const amount = linesTotal(lines); - return { amount, expected_revenue: Math.round(amount * probability) / 100 }; -}; - -// ─── Opportunities ──────────────────────────────────────────────────── -// `probability`, `forecast_category` and `expected_revenue` are derived from -// `stage` by opportunity.hook.ts (STAGE_PROBABILITY / STAGE_FORECAST). Hooks -// don't run over seeds, so every row below carries the exact values the hook -// would compute — anything else gets silently rewritten on the first user -// edit (#490). -// -// `stage_entry_date` follows the same rule: the hook stamps it on every insert -// and stage change, so a seeded deal without one is a shape real data never -// has — and it is what the stagnation sweep filters on and what the -// `days_in_stage` FORMULA counts from, so it replaces the old hardcoded -// `days_in_stage` numbers (#489). Open deals carry an age that spans the -// 14-day stale threshold in both directions; settled deals entered their -// closed stage on their close date. -const opportunities = defineSeed(Opportunity, { - mode: 'upsert', - externalId: 'name', - records: [ - { - name: 'Acme Platform Upgrade', - crm_account: 'Acme Corporation', - ...dealValue('Acme Platform Upgrade', 60), - stage: 'proposal', - probability: 60, - close_date: cel`daysFromNow(30)`, - type: 'existing_upgrade', - forecast_category: 'commit', - lead_source: 'web', - stage_entry_date: celDaysAgo(12), - description: `Upgrade from Standard to Enterprise edition for the -NA + EMEA teams. Drivers: (1) AI agent governance becomes a hard -requirement after their internal compliance review, (2) advanced -analytics seats for the Ops org, (3) priority support SLA.`, - next_step: `Send the revised proposal (Enterprise edition, 18-month term, 12% multi-year discount) to Jordan Park by EOW. Schedule the AI governance workshop for the week of close_date - 14d.`, - }, - { - name: 'Globex Manufacturing Suite', - crm_account: 'Globex Industries', - ...dealValue('Globex Manufacturing Suite', 25), - stage: 'qualification', - probability: 25, - close_date: cel`daysFromNow(60)`, - type: 'new_business', - forecast_category: 'pipeline', - lead_source: 'referral', - stage_entry_date: celDaysAgo(45), - }, - { - name: 'Wayne Enterprise License', - crm_account: 'Wayne Enterprises', - ...dealValue('Wayne Enterprise License', 80), - stage: 'negotiation', - probability: 80, - close_date: cel`daysFromNow(14)`, - type: 'new_business', - forecast_category: 'commit', - lead_source: 'partner', - stage_entry_date: celDaysAgo(7), - }, - { - name: 'Initech Cloud Migration', - crm_account: 'Initech Solutions', - ...dealValue('Initech Cloud Migration', 40), - stage: 'needs_analysis', - probability: 40, - close_date: cel`daysFromNow(45)`, - type: 'existing_upgrade', - forecast_category: 'best_case', - lead_source: 'event', - stage_entry_date: celDaysAgo(38), - }, - // ─── Closed Won deals (powers KPIs & revenue trends) ──────────────── - { - name: 'Acme Annual Renewal 2025', - crm_account: 'Acme Corporation', - ...dealValue('Acme Annual Renewal 2025', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(15)`, - stage_entry_date: cel`daysAgo(15)`, - type: 'existing_renewal', - forecast_category: 'closed', - lead_source: 'partner', - description: `Annual renewal of the Acme Standard subscription (40 seats), signed two weeks ahead of the renewal date. 22% YoY uplift driven by seat expansion in the new EMEA team. Multi-year option declined this round — they want to see how the platform upgrade lands first.`, - }, - { - name: 'Stark Medical Pilot', - crm_account: 'Stark Medical', - ...dealValue('Stark Medical Pilot', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(50)`, - stage_entry_date: cel`daysAgo(50)`, - type: 'new_business', - forecast_category: 'closed', - lead_source: 'event', - }, - { - name: 'Wayne Q1 Expansion', - crm_account: 'Wayne Enterprises', - ...dealValue('Wayne Q1 Expansion', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(95)`, - stage_entry_date: cel`daysAgo(95)`, - type: 'existing_upgrade', - forecast_category: 'closed', - lead_source: 'web', - }, - { - name: 'Globex Training Package', - crm_account: 'Globex Industries', - ...dealValue('Globex Training Package', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(140)`, - stage_entry_date: cel`daysAgo(140)`, - type: 'new_business', - forecast_category: 'closed', - lead_source: 'referral', - }, - { - name: 'Initech Phase 1', - crm_account: 'Initech Solutions', - ...dealValue('Initech Phase 1', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(200)`, - stage_entry_date: cel`daysAgo(200)`, - type: 'new_business', - forecast_category: 'closed', - lead_source: 'web', - }, - // ─── Campaign-attributed wins ─────────────────────────────────────── - // `crm_campaign` is what `campaign_snapshot_metrics` counts when a campaign - // completes (`num_opportunities` / `num_won_opportunities` / the summed - // `actual_revenue`). Before #591 not one opportunity carried it, so those - // three metrics were structurally zero and every campaign's seeded - // `actual_revenue` was a number the hook would have erased on completion. - // These three wins are what make the marketing ROI numbers below TRUE. - { - name: 'Lattice Analytics Expansion', - crm_account: 'Lattice Education', - primary_contact: 'priya.shah@lattice.example.com', - crm_campaign: 'Q3 Enterprise Email Nurture', - ...dealValue('Lattice Analytics Expansion', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(6)`, - stage_entry_date: cel`daysAgo(6)`, - type: 'existing_expansion', - forecast_category: 'closed', - lead_source: 'email_campaign', - description: 'Analytics package for admissions and alumni engagement, sourced from the enterprise nurture track.', - }, - { - name: 'Wayne Operations Module Rollout', - crm_account: 'Wayne Enterprises', - primary_contact: 'rwilson@wayne.example.com', - crm_campaign: 'Operations Platform Launch', - ...dealValue('Wayne Operations Module Rollout', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(4)`, - stage_entry_date: cel`daysAgo(4)`, - type: 'existing_upgrade', - forecast_category: 'closed', - lead_source: 'content', - description: 'Operations module for two manufacturing divisions, closed off the operations-platform launch program.', - }, - { - name: 'Vertex Developer Platform Adoption', - crm_account: 'Vertex Analytics', - primary_contact: 'ethan.brooks@vertex.example.com', - crm_campaign: 'Developer Content Marketing Push', - ...dealValue('Vertex Developer Platform Adoption', 100), - stage: 'closed_won', - probability: 100, - close_date: cel`daysAgo(6)`, - stage_entry_date: cel`daysAgo(6)`, - type: 'existing_expansion', - forecast_category: 'closed', - lead_source: 'content', - description: 'Three product teams adopted the developer platform after the technical content series.', - }, - // Closed Lost deals (powers win-rate analytics) - { - name: 'Acme Add-on (Lost)', - crm_account: 'Acme Corporation', - ...dealValue('Acme Add-on (Lost)', 0), - stage: 'closed_lost', - probability: 0, - close_date: cel`daysAgo(25)`, - stage_entry_date: cel`daysAgo(25)`, - type: 'existing_upgrade', - forecast_category: 'omitted', - lead_source: 'cold_call', - description: `Tried to bolt on the Marketing Cloud module via cold outbound. Lost because Acme's marketing org is already on a 2-year HubSpot contract. Revisit in Q3 when that contract is up for renewal.`, - }, - { - name: 'Stark Expansion (Lost)', - crm_account: 'Stark Medical', - ...dealValue('Stark Expansion (Lost)', 0), - stage: 'closed_lost', - probability: 0, - close_date: cel`daysAgo(60)`, - stage_entry_date: cel`daysAgo(60)`, - type: 'new_business', - forecast_category: 'omitted', - lead_source: 'advertisement', - }, - // ─── Curated active pipeline ───────────────────────────────────── - // Two cards in each active stage make the kanban immediately legible in - // a customer demo. They also tell a coherent sales story instead of - // overwhelming the board with anonymous generated rows. - { - name: 'Northwind Grid Modernization', - crm_account: 'Northwind Energy', - primary_contact: 'olivia.chen@northwind.example.com', - ...dealValue('Northwind Grid Modernization', 10), - stage: 'prospecting', - probability: 10, - close_date: cel`daysFromNow(72)`, - type: 'new_business', - forecast_category: 'pipeline', - lead_source: 'webinar', - stage_entry_date: celDaysAgo(4), - description: 'Northwind is assessing a unified grid-modernization program after an executive webinar.', - next_step: 'Confirm discovery participants and agree the operational-data assessment scope.', - }, - { - name: 'Lattice Student Success Platform', - crm_account: 'Lattice Education', - primary_contact: 'priya.shah@lattice.example.com', - crm_campaign: 'Developer Content Marketing Push', - ...dealValue('Lattice Student Success Platform', 10), - stage: 'prospecting', - probability: 10, - close_date: cel`daysFromNow(96)`, - type: 'new_business', - forecast_category: 'pipeline', - lead_source: 'content', - stage_entry_date: celDaysAgo(11), - description: 'A new student-success initiative sourced from the higher-education content series.', - next_step: 'Invite the operations sponsor to the discovery workshop.', - }, - { - name: 'Vertex Analytics Expansion', - crm_account: 'Vertex Analytics', - primary_contact: 'ethan.brooks@vertex.example.com', - ...dealValue('Vertex Analytics Expansion', 25), - stage: 'qualification', - probability: 25, - close_date: cel`daysFromNow(52)`, - type: 'existing_expansion', - forecast_category: 'pipeline', - lead_source: 'partner', - stage_entry_date: celDaysAgo(8), - description: 'Vertex wants to extend the platform from its data team to the full revenue organization.', - next_step: 'Validate the buying committee, budget owner and expansion timeline.', - }, - { - name: 'Apex Logistics Data Hub', - crm_account: 'Apex Logistics', - primary_contact: 'marcus.reed@apexlogistics.example.com', - ...dealValue('Apex Logistics Data Hub', 40), - stage: 'needs_analysis', - probability: 40, - close_date: cel`daysFromNow(38)`, - type: 'new_business', - forecast_category: 'best_case', - lead_source: 'partner', - stage_entry_date: celDaysAgo(18), - description: 'Apex is mapping its fragmented commercial systems before committing to a data-hub program.', - next_step: 'Complete the architecture workshop and quantify the integration backlog.', - }, - { - name: 'Lattice Education Renewal', - crm_account: 'Lattice Education', - primary_contact: 'priya.shah@lattice.example.com', - crm_campaign: 'Q3 Enterprise Email Nurture', - ...dealValue('Lattice Education Renewal', 60), - stage: 'proposal', - probability: 60, - close_date: cel`daysFromNow(24)`, - type: 'existing_renewal', - forecast_category: 'commit', - lead_source: 'email_campaign', - stage_entry_date: celDaysAgo(6), - description: 'Renewal proposal adds student-success automation and an expanded analytics package.', - next_step: 'Review the two-year option with procurement before the executive sponsor meeting.', - }, - { - name: 'Vertex Enterprise Rollout', - crm_account: 'Vertex Analytics', - primary_contact: 'ethan.brooks@vertex.example.com', - ...dealValue('Vertex Enterprise Rollout', 80), - stage: 'negotiation', - probability: 80, - close_date: cel`daysFromNow(11)`, - type: 'existing_expansion', - forecast_category: 'commit', - lead_source: 'referral', - stage_entry_date: celDaysAgo(3), - description: 'An enterprise rollout negotiated around phased deployment and data-residency requirements.', - next_step: 'Resolve the final security addendum and confirm the procurement signature date.', - }, - ] -}); - -// ─── Products ───────────────────────────────────────────────────────── -// The catalog is what every line item below prices against, so it has to be -// wide enough to configure a realistic deal: an edition, an add-on, a -// per-seat subscription, support tiers and professional services. Four -// products could not (#591). -// -// `cost` is populated on every product: `cost_less_than_price` is a real -// validation rule that "has never once evaluated" while the field was blank -// everywhere, and margin is what the product-mix analytics are for. Every row -// keeps cost strictly below list price. -const products = defineSeed(Product, { - mode: 'upsert', - externalId: 'name', - records: [ - { - name: 'ObjectStack Platform', - description: 'The enterprise edition: unlimited objects, AI agents, governance and audit.', - category: 'software', - family: 'enterprise', - sku: 'OS-PLAT-ENT', - list_price: 50000, - cost: 12000, - billing_type: 'annual', - unit_of_measure: 'license', - is_active: true, - }, - { - name: 'ObjectStack Platform (SMB Edition)', - description: 'The mid-market edition: core CRM objects and automation, capped agent usage.', - category: 'software', - family: 'smb', - sku: 'OS-PLAT-SMB', - list_price: 18000, - cost: 4500, - billing_type: 'annual', - unit_of_measure: 'license', - is_active: true, - }, - { - name: 'Cloud Hosting (Annual)', - description: 'Managed hosting with regional data residency and a 99.9% availability target.', - category: 'subscription', - family: 'cloud', - sku: 'OS-CLOUD-HOST', - list_price: 12000, - cost: 4200, - billing_type: 'annual', - unit_of_measure: 'each', - is_active: true, - }, - { - name: 'Sandbox Environment (Annual)', - description: 'A full-copy non-production environment for testing metadata changes before release.', - category: 'subscription', - family: 'cloud', - sku: 'OS-CLOUD-SBX', - list_price: 7500, - cost: 2600, - billing_type: 'annual', - unit_of_measure: 'each', - is_active: true, - }, - { - name: 'AI Agent Seat (Annual)', - description: 'A named-user seat for the Co-Pilot and agent surfaces, billed annually.', - category: 'subscription', - family: 'cloud', - sku: 'OS-AI-SEAT', - list_price: 1000, - cost: 260, - billing_type: 'annual', - unit_of_measure: 'seat', - is_active: true, - }, - { - name: 'Analytics Add-on', - description: 'Dashboards, cubes and scheduled reporting for the revenue organization.', - category: 'software', - family: 'enterprise', - sku: 'OS-ADDON-ANL', - list_price: 22000, - cost: 6500, - billing_type: 'annual', - unit_of_measure: 'license', - is_active: true, - }, - { - name: 'Integration Connector Pack', - description: 'Pre-built connectors for ERP, marketing automation and data warehouse targets.', - category: 'software', - family: 'enterprise', - sku: 'OS-ADDON-INT', - list_price: 16000, - cost: 5200, - billing_type: 'annual', - unit_of_measure: 'license', - is_active: true, - }, - { - name: 'Field Service Mobile', - description: 'Offline-capable mobile app for field technicians and route-based service work.', - category: 'software', - family: 'smb', - sku: 'OS-ADDON-FSM', - list_price: 14000, - cost: 4200, - billing_type: 'annual', - unit_of_measure: 'license', - is_active: true, - }, - { - name: 'Premium Support', - description: '24×7 support with a one-hour P1 response target and a named technical account manager.', - category: 'support', - family: 'services', - sku: 'OS-SUP-PREM', - list_price: 25000, - cost: 9000, - billing_type: 'annual', - unit_of_measure: 'each', - is_active: true, - }, - { - name: 'Standard Support', - description: 'Business-hours support with a next-business-day response target.', - category: 'support', - family: 'services', - sku: 'OS-SUP-STD', - list_price: 9000, - cost: 3600, - billing_type: 'annual', - unit_of_measure: 'each', - is_active: true, - }, - { - name: 'Implementation Services', - description: 'Guided implementation: discovery, metadata build, integration and go-live support.', - category: 'service', - family: 'services', - sku: 'OS-SVC-IMPL', - list_price: 75000, - cost: 41000, - billing_type: 'one_time', - unit_of_measure: 'each', - is_active: true, - }, - { - name: 'Data Migration Services', - description: 'Extraction, mapping and reconciliation of legacy CRM and spreadsheet data.', - category: 'service', - family: 'services', - sku: 'OS-SVC-MIGR', - list_price: 35000, - cost: 19000, - billing_type: 'one_time', - unit_of_measure: 'each', - is_active: true, - }, - { - name: 'Admin Training Workshop', - description: 'A one-day workshop for administrators on metadata, permissions and analytics.', - category: 'service', - family: 'services', - sku: 'OS-SVC-TRN', - list_price: 6000, - cost: 2400, - billing_type: 'one_time', - unit_of_measure: 'day', - is_active: true, - }, - ] -}); - -/** - * Catalog price of a seeded product, read back from the dataset above so the - * price lives in exactly one place. - * - * Line items carry `list_price` explicitly because hooks do NOT run over seeds - * (#490): the shared price-fill hook (`_line-item-price-fill.ts`) is what - * stamps `list_price` from `crm_product.list_price` on a real write, so a - * seeded line has to arrive already carrying what that hook would have - * written. Reading it from the catalog record makes that literally impossible - * to get wrong. - */ -const catalogPrice = (productName: string): number => { - const product = products.records.find((r) => r.name === productName); - if (!product || typeof product.list_price !== 'number') { - throw new Error(`Seed error: no catalog product named "${productName}"`); - } - return product.list_price; -}; - -/** - * Flatten a `{ parent → lines }` table into seed records for one line-item - * object. `list_price` and `line_number` are the two fields a real write gets - * from machinery a seed cannot count on (the price-fill hook, and the quote / - * opportunity line editors), so both are materialised here — the row is then - * correct whether or not the hook fires over a seed write (#617). - */ -const lineItemRecords = ( - parentField: K, - table: Record, -): Array> => - Object.entries(table).flatMap(([parent, lines]) => - lines.map((l, i) => ({ - [parentField]: parent, - crm_product: l.product, - description: l.description, - quantity: l.quantity, - list_price: catalogPrice(l.product), - unit_price: l.unit_price, - discount: l.discount ?? 0, - line_number: i + 1, - })), - ); - -// ─── Opportunity line items ─────────────────────────────────────────── -// Identity is the COMPOSITE natural key (opportunity, product): the object has -// no single natural key — no name, and `line_number` is only unique within a -// parent — and a junction-shaped dataset without one can only run -// `mode: 'insert'`, which re-inserts every row on each replay boot and -// duplicates the table (framework#3434). The loader matches composite key -// fields by their RESOLVED ids, so this dedupes correctly across restarts. -// The practical constraint it imposes: a product appears at most once per -// deal, which is how these lines are authored anyway. -const opportunityLineItems = defineSeed(OpportunityLineItem, { - mode: 'upsert', - externalId: ['crm_opportunity', 'crm_product'], - records: lineItemRecords('crm_opportunity', OPPORTUNITY_LINES), -}); - -// ─── Tasks ──────────────────────────────────────────────────────────── -// Polymorphic parents need BOTH halves: `related_to_type` names the parent -// object and the matching `related_to_*` lookup carries the record. The -// Related tab and task.hook's activity bubble key off `related_to_type`, so a -// lookup without it is invisible to both (#490). -const tasks = defineSeed(Task, { - mode: 'upsert', - externalId: 'subject', - records: [ - { - subject: 'Follow up with Acme on proposal', - description: 'Send Jordan Park the revised Enterprise proposal and a 30-min calendar slot to walk through the AI governance section.', - status: 'not_started', - priority: 'high', - priority_rank: 3, - due_date: cel`daysFromNow(2)`, - related_to_type: 'crm_opportunity', - related_to_account: 'Acme Corporation', - related_to_opportunity: 'Acme Platform Upgrade', - }, - { - subject: 'Acme — schedule AI governance workshop', - description: 'Block a 90-min joint workshop with Acme’s compliance team to walk through how HotCRM agents handle data scoping, RBAC, audit trails, and human-in-the-loop. Pre-read: ADR-0007 + the governance demo deck.', - status: 'not_started', - priority: 'high', - priority_rank: 3, - due_date: cel`daysFromNow(7)`, - related_to_type: 'crm_opportunity', - related_to_account: 'Acme Corporation', - related_to_opportunity: 'Acme Platform Upgrade', - }, - { - subject: 'Acme — close out login-issues ticket before SLA', - description: 'Confirm engineering has the EMEA SSO clock-skew patch ready, deploy to Acme’s tenant, and send Lisa Kim a customer-facing post-mortem.', - status: 'in_progress', - priority: 'urgent', - priority_rank: 4, - due_date: cel`daysFromNow(1)`, - related_to_type: 'crm_case', - related_to_account: 'Acme Corporation', - related_to_case: 'Login issues after platform upgrade', - }, - { - subject: 'Schedule demo for Globex team', - status: 'in_progress', - priority: 'normal', - priority_rank: 2, - due_date: cel`daysFromNow(5)`, - related_to_type: 'crm_account', - related_to_account: 'Globex Industries', - }, - { - subject: 'Prepare contract for Wayne Enterprises', - status: 'not_started', - priority: 'urgent', - priority_rank: 4, - due_date: cel`daysFromNow(1)`, - related_to_type: 'crm_account', - related_to_account: 'Wayne Enterprises', - }, - { - subject: 'Send welcome package to Stark Medical', - status: 'completed', - priority: 'low', - priority_rank: 1, - completed_date: cel`daysAgo(2)`, - // Hooks don't run over seeds — mirror what task_completion would stamp. - is_completed: true, - progress_percent: 100, - related_to_type: 'crm_account', - related_to_account: 'Stark Medical', - }, - { - // Internal housekeeping task — deliberately unparented (the - // related_to_required rule is a warning, not an error). - subject: 'Update CRM pipeline report', - status: 'not_started', - priority: 'normal', - priority_rank: 2, - due_date: cel`daysFromNow(7)`, - }, - ] -}); - -// ─── Cases ──────────────────────────────────────────────────────────── -const cases = defineSeed(Case, { - mode: 'upsert', - externalId: 'subject', - records: [ - { - subject: 'Login issues after platform upgrade', - description: `Users in the EMEA office report intermittent 401 errors when logging in after the v4.2 upgrade rolled out Wednesday night. Pattern: only affects users authenticating via SAML through Okta, only between 09:00–10:30 UTC. NA and APAC users are unaffected. - -**Customer impact:** ~40 users blocked at peak, costing ~3 productive hours per affected user. - -**Initial triage:** Suspect a clock-skew issue on the EMEA SSO relay added during the upgrade window. Engineering is reproducing in staging.`, - crm_account: 'Acme Corporation', - crm_contact: 'john.smith@acme.example.com', - status: 'in_progress', - priority: 'high', - priority_rank: 3, - type: 'problem', - origin: 'email', - is_closed: false, - is_sla_violated: false, - is_escalated: false, - created_date: cel`daysAgo(2)`, - sla_due_date: cel`daysFromNow(1)`, - }, - { - subject: 'Data export timing out for large datasets', - description: 'CSV export fails for datasets over 10k rows.', - crm_account: 'Globex Industries', - crm_contact: 'sarah.j@globex.example.com', - status: 'escalated', - priority: 'critical', - priority_rank: 4, - type: 'bug', - origin: 'phone', - is_closed: false, - is_sla_violated: true, - is_escalated: true, - escalation_reason: 'Customer threatening churn', - created_date: cel`daysAgo(5)`, - sla_due_date: cel`daysAgo(2)`, - }, - { - subject: 'How to configure SSO with Okta?', - description: 'Customer needs guidance on SSO setup with Okta.', - crm_account: 'Initech Solutions', - crm_contact: 'mchen@initech.example.com', - status: 'resolved', - priority: 'medium', - priority_rank: 2, - type: 'question', - origin: 'web', - // Resolved-but-not-closed: case.hook keeps is_closed=false and stamps - // closed_date as the resolved-date proxy; resolution_time_hours is the - // closed−created delta the hook would compute (daysAgo() is day-granular, - // so deltas come in 24h steps). - is_closed: false, - is_sla_violated: false, - is_escalated: false, - resolution_time_hours: 24.0, - created_date: cel`daysAgo(3)`, - closed_date: cel`daysAgo(2)`, - sla_due_date: cel`daysFromNow(2)`, - }, - { - subject: 'API rate limit exceeded on production', - description: 'Production environment hitting rate limits during peak hours.', - crm_account: 'Wayne Enterprises', - crm_contact: 'rwilson@wayne.example.com', - status: 'closed', - priority: 'high', - priority_rank: 3, - type: 'problem', - origin: 'chat', - is_closed: true, - is_sla_violated: false, - is_escalated: false, - // `resolution` is REQUIRED when status is 'closed' (object validation - // `resolution_required_for_closed`) — without it the seed row is rejected. - resolution: 'Raised the production rate-limit tier and added client-side backoff; usage now within limits.', - // closed−created delta, as case.hook computes it. - resolution_time_hours: 24.0, - created_date: cel`daysAgo(7)`, - closed_date: cel`daysAgo(6)`, - sla_due_date: cel`daysAgo(6)`, - }, - { - subject: 'PDF reports not rendering charts correctly', - description: 'Charts appear blank when exporting dashboard to PDF.', - crm_account: 'Stark Medical', - crm_contact: 'emily.d@starkmed.example.com', - status: 'new', - priority: 'medium', - priority_rank: 2, - type: 'bug', - origin: 'email', - is_closed: false, - is_sla_violated: false, - is_escalated: false, - created_date: cel`daysAgo(1)`, - sla_due_date: cel`daysFromNow(2)`, - }, - { - subject: 'Billing discrepancy on last invoice', - description: `Customer (Lisa Kim, Procurement) flagged that the May invoice shows 15 active seats but Acme is only using 12. Two of the seats were de-provisioned in early April when two engineers left the company. - -**Root cause:** the de-provisioning happened in our admin console but the seat-count metric in billing only refreshes monthly, so the May invoice picked up the pre-change count. - -**Resolution path:** issue a $1,200 credit memo and switch Acme to the new real-time seat-billing pipeline so this can't recur. Waiting on Lisa to confirm she's good with the credit-memo treatment vs a refund.`, - crm_account: 'Acme Corporation', - crm_contact: 'john.smith@acme.example.com', - status: 'waiting_customer', - priority: 'low', - priority_rank: 1, - type: 'problem', - origin: 'email', - is_closed: false, - is_sla_violated: false, - is_escalated: false, - created_date: cel`daysAgo(4)`, - sla_due_date: cel`daysFromNow(3)`, - }, - { - subject: 'Mobile app crashes on iOS 17', - description: 'App crashes on launch for users running iOS 17.2+.', - crm_account: 'Globex Industries', - crm_contact: 'sarah.j@globex.example.com', - status: 'in_progress', - priority: 'critical', - priority_rank: 4, - type: 'bug', - origin: 'web', - is_closed: false, - is_sla_violated: true, - is_escalated: true, - escalation_reason: 'Affects 30% of mobile users', - created_date: cel`daysAgo(3)`, - sla_due_date: cel`daysAgo(1)`, - }, - { - subject: 'Request: bulk import via CSV', - description: 'Customer requesting ability to import records via CSV upload.', - crm_account: 'Wayne Enterprises', - crm_contact: 'rwilson@wayne.example.com', - status: 'closed', - priority: 'low', - priority_rank: 1, - type: 'feature_request', - origin: 'web', - is_closed: true, - is_sla_violated: false, - is_escalated: false, - // Required for closed cases (resolution_required_for_closed). - resolution: 'Delivered CSV bulk-import in the 9.4 release; shared the docs link with the customer.', - // closed−created delta, as case.hook computes it. - resolution_time_hours: 48.0, - created_date: cel`daysAgo(10)`, - closed_date: cel`daysAgo(8)`, - sla_due_date: cel`daysAgo(8)`, - }, - // ─── Generated demo cases — 30 cases over the last 30 days, mixed across - // priorities. Powers `CasesOpenedByDayPriorityReport` (daily bucketing - // matrix) and the service dashboard's daily-volume area chart. - ...((): readonly Record[] => { - const priorities = ['low', 'medium', 'high', 'critical'] as const; - // Mirror of case.hook's priority rank map — hooks don't run over seeds. - const rankByPriority: Record = { low: 1, medium: 2, high: 3, critical: 4 }; - const types = ['question', 'bug', 'problem', 'feature_request'] as const; - const origins = ['email', 'phone', 'web', 'chat'] as const; - const statuses = ['new', 'in_progress', 'resolved', 'closed', 'escalated'] as const; - const accountsList = ['Acme Corporation', 'Globex Industries', 'Wayne Enterprises', 'Initech Solutions', 'Stark Medical'] as const; - const out: Record[] = []; - for (let i = 0; i < 30; i++) { - const priority = priorities[i % priorities.length]; - const status = statuses[i % statuses.length]; - const settled = status === 'resolved' || status === 'closed'; - const ageDays = 1 + (i % 30); - // Settled cases get a resolution delay of 1–3 days (capped at the - // case's age); resolution_time_hours is exactly the closed−created - // delta case.hook would compute (daysAgo() is day-granular → 24h steps). - const resolutionDays = Math.min(ageDays, 1 + (i % 3)); - // SLA breaches only make sense on OPEN cases with a due date already in - // the past (the case_sla_monitor flow's definition). The old generator - // flagged rows as violated while giving every row a FUTURE due date. - const slaViolated = !settled && priority === 'critical' && i % 3 === 0; - out.push({ - subject: `Demo case ${String(i + 1).padStart(2, '0')} — ${priority} ${types[i % types.length]}`, - description: `Auto-generated demo case for ${priority} priority on day -${ageDays}.`, - crm_account: accountsList[i % accountsList.length], - status, - priority, - priority_rank: rankByPriority[priority], - type: types[i % types.length], - origin: origins[i % origins.length], - // is_closed strictly mirrors case.hook: true ONLY for status - // 'closed' — a resolved case is NOT closed yet. - is_closed: status === 'closed', - is_sla_violated: slaViolated, - is_escalated: status === 'escalated', - ...(settled ? { resolution_time_hours: resolutionDays * 24 } : {}), - // Object validations require these when closed/escalated — without - // them the generated rows are rejected (resolution_required_for_closed - // / escalation_reason_required). - ...(status === 'closed' ? { resolution: 'Resolved per standard runbook; root cause documented and customer confirmed.' } : {}), - ...(status === 'escalated' ? { escalation_reason: 'Escalated to tier-2 engineering for SLA-risk review.' } : {}), - created_date: celDaysAgo(ageDays), - // Resolved cases also carry closed_date: case.hook stamps it as the - // resolved-date proxy while keeping is_closed=false. - ...(settled ? { closed_date: celDaysAgo(ageDays - resolutionDays) } : {}), - sla_due_date: slaViolated - ? celDaysAgo(1) - : celDaysFromNow(priority === 'critical' ? 1 : priority === 'high' ? 2 : 4), - }); - } - return out; - })(), - ], -}); - -// ─── Campaign membership ────────────────────────────────────────────── -/** - * Campaign members, keyed by campaign name. - * - * Membership is the ONLY path from a campaign to a lead — `crm_lead` has no - * campaign field — so with this table empty (as it was before #591) every - * campaign reported `num_sent` 0, `response_rate` 0% and no attributed leads - * at all, whatever the campaign records claimed. - * - * Status vocabulary is deliberately restricted to the lifecycle values a - * writer actually produces: `sent` (what the campaign_enrollment flow stamps), - * `responded` and `unsubscribed`. The `opened` / `clicked` / `bounced` states - * and the `first_opened_date` / `first_clicked_date` stamps have no writer - * anywhere in the app and are being trimmed under #597 — seeding them would - * manufacture data that nothing can maintain and that the trim would then - * invalidate. - * - * `converted` is likewise unused here: the hook counts converted leads from - * `crm_lead.is_converted`, and no seeded lead is converted (conversion is the - * lead_conversion flow's job at runtime), so a `converted` member would be a - * member state no lead record backs up. + * CRM Seed Data — the aggregating export. + * + * Demo records for all core CRM objects, authored per object family in the + * `*.seed.ts` modules below and collected here as the single `CrmSeedData` + * array `objectstack.config.ts` registers. Uses `defineSeed()` for type-safe + * field name checking at compile time. + * + * The families were one 100KB module until #635: it sat ~1.5KB under the + * `pnpm hygiene` byte cap, so the next demo record — the billing addresses of + * #638 — would have failed the build, and the cheapest way out would have been + * deleting the explanatory comments the cap exists to protect. Where to add a + * record now follows from the object: + * + * - `_shared.ts` seed doctrine + helpers used by more than one family + * - `catalog.seed.ts` products, and the catalogue price lookup + * - `sales.seed.ts` accounts, contacts, leads, opportunities, opp lines + * - `service.seed.ts` tasks, cases, knowledge articles + * - `marketing.seed.ts` campaigns, campaign members + * - `revenue.seed.ts` contracts, quotes, quote lines, forecasts */ -type CampaignMemberSpec = { - /** Lead email (the lead dataset's externalId) — mutually exclusive with `contact`. */ - readonly lead?: string; - /** Contact email (the contact dataset's externalId). */ - readonly contact?: string; - readonly status: 'sent' | 'responded' | 'unsubscribed'; - readonly addedDaysAgo: number; - /** Days ago the member responded. Required by `responded`, absent otherwise. */ - readonly respondedDaysAgo?: number; -}; - -const CAMPAIGN_MEMBERS: Record = { - 'Q3 Enterprise Email Nurture': [ - { lead: 'noah.patel@vertexanalytics.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 9 }, - { lead: 'maya.singh@bluepeaklogistics.example.com', status: 'sent', addedDaysAgo: 14 }, - { lead: 'owen.becker@northwindenergy.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 11 }, - { lead: 'sara.lopez@heliossolar.example.com', status: 'sent', addedDaysAgo: 14 }, - { lead: 'leo.vance@cleancart.example.com', status: 'unsubscribed', addedDaysAgo: 14 }, - { lead: 'iris.okafor@pulsehealth.example.com', status: 'sent', addedDaysAgo: 13 }, - { lead: 'ravi.mehta@foundryrobotics.example.com', status: 'sent', addedDaysAgo: 13 }, - { lead: 'tess.brown@latticeeducation.example.com', status: 'responded', addedDaysAgo: 13, respondedDaysAgo: 8 }, - { contact: 'john.smith@acme.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 10 }, - { contact: 'ethan.brooks@vertex.example.com', status: 'sent', addedDaysAgo: 14 }, - { contact: 'priya.shah@lattice.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 7 }, - { contact: 'rwilson@wayne.example.com', status: 'sent', addedDaysAgo: 13 }, - ], - 'Operations Platform Launch': [ - { lead: 'jonas.holt@polarcargo.example.com', status: 'responded', addedDaysAgo: 11, respondedDaysAgo: 6 }, - { lead: 'anya.volkov@redoakrealty.example.com', status: 'sent', addedDaysAgo: 11 }, - { lead: 'theo.park@skylinemedia.example.com', status: 'sent', addedDaysAgo: 11 }, - { lead: 'wren.garcia@maplebakerygroup.example.com', status: 'responded', addedDaysAgo: 10, respondedDaysAgo: 5 }, - { lead: 'hugo.dubois@nimbusaerospace.example.com', status: 'sent', addedDaysAgo: 10 }, - { contact: 'rwilson@wayne.example.com', status: 'responded', addedDaysAgo: 11, respondedDaysAgo: 7 }, - { contact: 'marcus.reed@apexlogistics.example.com', status: 'sent', addedDaysAgo: 11 }, - { contact: 'olivia.chen@northwind.example.com', status: 'sent', addedDaysAgo: 10 }, - { contact: 'sarah.j@globex.example.com', status: 'sent', addedDaysAgo: 10 }, - ], - 'Developer Content Marketing Push': [ - { lead: 'lena.fischer@graniteinsurance.example.com', status: 'responded', addedDaysAgo: 33, respondedDaysAgo: 27 }, - { lead: 'kai.watanabe@coralreefhotels.example.com', status: 'sent', addedDaysAgo: 33 }, - { lead: 'mira.costa@atlasconstruction.example.com', status: 'unsubscribed', addedDaysAgo: 33 }, - { lead: 'pia.anand@citrinefinance.example.com', status: 'responded', addedDaysAgo: 32, respondedDaysAgo: 24 }, - { lead: 'marco.ricci@auroratravel.example.com', status: 'sent', addedDaysAgo: 32 }, - { lead: 'lisa.t@cloudfirst.example.com', status: 'responded', addedDaysAgo: 30, respondedDaysAgo: 21 }, - { contact: 'ethan.brooks@vertex.example.com', status: 'responded', addedDaysAgo: 33, respondedDaysAgo: 19 }, - { contact: 'mchen@initech.example.com', status: 'sent', addedDaysAgo: 33 }, - { contact: 'john.smith@acme.example.com', status: 'sent', addedDaysAgo: 32 }, - { contact: 'emily.d@starkmed.example.com', status: 'sent', addedDaysAgo: 32 }, - { contact: 'sarah.j@globex.example.com', status: 'sent', addedDaysAgo: 30 }, - ], - // Campaigns still in `planning` carry their invitation / registration list: - // the enrollment flow runs for `planning` and `in_progress` campaigns alike - // and stamps `sent` on enrollment, so a pre-launch target list looks exactly - // like this. - 'Cloud Migration Webinar': [ - { lead: 'dkim@edutechlabs.example.com', status: 'sent', addedDaysAgo: 4 }, - { lead: 'alice@nextgenretail.example.com', status: 'sent', addedDaysAgo: 4 }, - { lead: 'jonas.holt@polarcargo.example.com', status: 'sent', addedDaysAgo: 3 }, - { lead: 'anya.volkov@redoakrealty.example.com', status: 'sent', addedDaysAgo: 3 }, - { contact: 'mchen@initech.example.com', status: 'sent', addedDaysAgo: 4 }, - { contact: 'marcus.reed@apexlogistics.example.com', status: 'sent', addedDaysAgo: 3 }, - ], - 'Executive AI Governance Roundtable': [ - { contact: 'john.smith@acme.example.com', status: 'sent', addedDaysAgo: 6 }, - { contact: 'rwilson@wayne.example.com', status: 'sent', addedDaysAgo: 6 }, - { contact: 'ethan.brooks@vertex.example.com', status: 'sent', addedDaysAgo: 5 }, - { contact: 'priya.shah@lattice.example.com', status: 'sent', addedDaysAgo: 5 }, - { contact: 'emily.d@starkmed.example.com', status: 'sent', addedDaysAgo: 5 }, - ], - 'Partner Operations Roadshow': [ - { lead: 'maya.singh@bluepeaklogistics.example.com', status: 'sent', addedDaysAgo: 7 }, - { lead: 'ravi.mehta@foundryrobotics.example.com', status: 'sent', addedDaysAgo: 7 }, - { contact: 'marcus.reed@apexlogistics.example.com', status: 'sent', addedDaysAgo: 6 }, - { contact: 'olivia.chen@northwind.example.com', status: 'sent', addedDaysAgo: 6 }, - ], - 'SaaSCon 2026 Trade Show': [ - { lead: 'theo.park@skylinemedia.example.com', status: 'sent', addedDaysAgo: 8 }, - { lead: 'wren.garcia@maplebakerygroup.example.com', status: 'sent', addedDaysAgo: 8 }, - { lead: 'hugo.dubois@nimbusaerospace.example.com', status: 'sent', addedDaysAgo: 7 }, - { lead: 'marco.ricci@auroratravel.example.com', status: 'sent', addedDaysAgo: 7 }, - ], -}; - -/** - * The metric block `campaign_snapshot_metrics` writes when a campaign moves to - * `completed`, computed here from the very same inputs the hook reads: the - * membership table above and the opportunities attributed via `crm_campaign`. - * - * Seeding these by hand is what made them wrong before. The snapshot fires on - * the transition INTO `completed` and nowhere else, so a hand-typed - * `actual_revenue` survives untouched right up until someone completes the - * campaign — at which point the hook replaces it with the truth and the number - * "mysteriously" changes. Deriving it here makes that snapshot a no-op instead: - * same inputs, same arithmetic, same answer. - */ -const campaignMetrics = (campaignName: string) => { - const members = CAMPAIGN_MEMBERS[campaignName] ?? []; - const leadKeys = new Set(members.map((m) => m.lead).filter((e): e is string => Boolean(e))); - const convertedLeads = leads.records.filter( - (l) => typeof l.email === 'string' && leadKeys.has(l.email) && l.is_converted === true, - ).length; - const attributed = opportunities.records.filter((o) => o.crm_campaign === campaignName); - const won = attributed.filter((o) => o.stage === 'closed_won'); - return { - // "Total members enrolled" — campaign.hook.ts's single definition of num_sent. - num_sent: members.length, - // Only the exact `responded` status counts, as in the hook. - num_responses: members.filter((m) => m.status === 'responded').length, - num_leads: leadKeys.size, - num_converted_leads: convertedLeads, - num_opportunities: attributed.length, - num_won_opportunities: won.length, - actual_revenue: won.reduce((sum, o) => sum + (typeof o.amount === 'number' ? o.amount : 0), 0), - }; -}; - -// ─── Campaigns ──────────────────────────────────────────────────────── -const campaigns = defineSeed(Campaign, { - mode: 'upsert', - externalId: 'name', - records: [ - { - name: 'Q3 Enterprise Email Nurture', - description: 'Multi-touch nurture for enterprise IT decision makers evaluating AI governance and analytics.', - type: 'email', - channel: 'email', - status: 'in_progress', - start_date: cel`daysAgo(15)`, - end_date: cel`daysFromNow(21)`, - budgeted_cost: 28000, - actual_cost: 14000, - expected_revenue: 560000, - target_size: 5000, - ...campaignMetrics('Q3 Enterprise Email Nurture'), - landing_page_url: 'https://acme.example.com/lp/enterprise-q3', - is_active: true, - }, - { - name: 'Executive AI Governance Roundtable', - description: 'A live executive roundtable on operating AI agents with governance, auditability and trust.', - type: 'conference', - channel: 'events', - status: 'planning', - start_date: cel`daysFromNow(2)`, - end_date: cel`daysFromNow(3)`, - budgeted_cost: 22000, - expected_revenue: 320000, - target_size: 120, - ...campaignMetrics('Executive AI Governance Roundtable'), - is_active: true, - }, - { - name: 'Cloud Migration Webinar', - description: 'A technical webinar on modernizing customer operations and data workflows in the cloud.', - type: 'webinar', - channel: 'digital', - status: 'planning', - start_date: cel`daysFromNow(6)`, - end_date: cel`daysFromNow(8)`, - budgeted_cost: 18000, - expected_revenue: 360000, - target_size: 1500, - ...campaignMetrics('Cloud Migration Webinar'), - landing_page_url: 'https://acme.example.com/webinars/cloud-migration', - is_active: true, - }, - { - name: 'Operations Platform Launch', - description: 'Launch campaign for the operations platform, pairing product content with customer proof points.', - type: 'content', - channel: 'digital', - status: 'in_progress', - // Was `daysFromNow(12)` — an `in_progress` campaign that had not started - // yet, which no attribution can be consistent with (#591). - start_date: cel`daysAgo(12)`, - end_date: cel`daysFromNow(14)`, - budgeted_cost: 36000, - actual_cost: 12000, - expected_revenue: 480000, - target_size: 8000, - ...campaignMetrics('Operations Platform Launch'), - landing_page_url: 'https://acme.example.com/launch/operations-platform', - is_active: true, - }, - { - name: 'Partner Operations Roadshow', - description: 'Joint regional sessions with strategic partners for operations and IT leaders.', - type: 'partner', - channel: 'partner', - status: 'planning', - start_date: cel`daysFromNow(19)`, - end_date: cel`daysFromNow(22)`, - budgeted_cost: 48000, - expected_revenue: 720000, - target_size: 600, - ...campaignMetrics('Partner Operations Roadshow'), - is_active: true, - }, - { - name: 'SaaSCon 2026 Trade Show', - description: 'A focused booth, executive dinner and customer-story program at the SaaSCon industry conference.', - type: 'trade_show', - channel: 'events', - status: 'planning', - start_date: cel`daysFromNow(27)`, - end_date: cel`daysFromNow(30)`, - budgeted_cost: 75000, - expected_revenue: 1200000, - target_size: 3000, - ...campaignMetrics('SaaSCon 2026 Trade Show'), - is_active: true, - }, - { - name: 'Developer Content Marketing Push', - description: 'Technical blog posts, video tutorials and developer community engagement completed last month.', - type: 'content', - channel: 'digital', - status: 'completed', - start_date: cel`daysAgo(35)`, - end_date: cel`daysAgo(4)`, - budgeted_cost: 40000, - actual_cost: 25000, - expected_revenue: 250000, - target_size: 10000, - ...campaignMetrics('Developer Content Marketing Push'), - landing_page_url: 'https://acme.example.com/developers', - is_active: true, - }, - ] -}); - -/** - * Campaign members are seeded as TWO datasets over one object, split by which - * side of the junction is populated. - * - * Not a stylistic choice: the object has no single natural key (its only text - * identity is a runtime-owned autonumber), so identity has to be the composite - * of campaign + member. The loader builds a composite key by joining the - * per-field values and returns an EMPTY key — no dedupe, so a replay boot - * re-inserts every row — as soon as ANY component is null. `crm_lead` and - * `crm_contact` are mutually exclusive by design, so a single - * `['crm_campaign', 'crm_lead', 'crm_contact']` key would be blank on every - * row. Splitting the rows by member type gives each dataset a key whose parts - * are all present. - */ -const memberRecords = ( - kind: 'lead' | 'contact', -): Array> => - Object.entries(CAMPAIGN_MEMBERS).flatMap(([campaign, members]) => - members - .filter((m) => (kind === 'lead' ? m.lead : m.contact)) - .map((m) => ({ - crm_campaign: campaign, - ...(kind === 'lead' ? { crm_lead: m.lead } : { crm_contact: m.contact }), - status: m.status, - added_date: celDaysAgo(m.addedDaysAgo), - // `has_responded` / `response_date` track the `responded` status - // exactly — a responded member with no response date is a shape the - // response-tracking surfaces cannot render. - has_responded: m.status === 'responded', - ...(m.respondedDaysAgo !== undefined ? { response_date: celDaysAgo(m.respondedDaysAgo) } : {}), - })), - ); - -const campaignMembersFromLeads = defineSeed(CampaignMember, { - mode: 'upsert', - externalId: ['crm_campaign', 'crm_lead'], - records: memberRecords('lead'), -}); - -const campaignMembersFromContacts = defineSeed(CampaignMember, { - mode: 'upsert', - externalId: ['crm_campaign', 'crm_contact'], - records: memberRecords('contact'), -}); - -// ─── Contracts ──────────────────────────────────────────────────────── -// `contract_number` is a runtime-owned autonumber and is NOT seeded (#490). -// Contract has no natural-name field, so the (unique, stable) description -// doubles as the upsert identity for these fixtures. -const contracts = defineSeed(Contract, { - mode: 'upsert', - externalId: 'description', - records: [ - { - crm_account: 'Acme Corporation', - crm_contact: 'john.smith@acme.example.com', - crm_opportunity: 'Acme Platform Upgrade', - status: 'activated', - contract_term_months: 12, - start_date: cel`daysAgo(30)`, - end_date: cel`daysFromNow(335)`, - contract_value: 150000, - billing_frequency: 'annually', - payment_terms: 'net_30', - auto_renewal: true, - renewal_notice_days: 60, - contract_type: 'subscription', - signed_date: cel`daysAgo(32)`, - signed_by: 'John Smith', - description: 'Annual platform subscription with premium support tier.', - }, - { - crm_account: 'Wayne Enterprises', - crm_contact: 'rwilson@wayne.example.com', - crm_opportunity: 'Wayne Enterprise License', - status: 'in_approval', - contract_term_months: 36, - start_date: cel`daysFromNow(14)`, - end_date: cel`daysFromNow(1109)`, - contract_value: 1200000, - billing_frequency: 'annually', - payment_terms: 'net_60', - auto_renewal: false, - renewal_notice_days: 90, - contract_type: 'license', - description: 'Multi-year enterprise license with custom SLA.', - }, - { - crm_account: 'Initech Solutions', - crm_contact: 'mchen@initech.example.com', - status: 'expired', - contract_term_months: 12, - start_date: cel`daysAgo(400)`, - end_date: cel`daysAgo(35)`, - contract_value: 60000, - billing_frequency: 'quarterly', - payment_terms: 'net_30', - auto_renewal: false, - renewal_notice_days: 30, - contract_type: 'service', - signed_date: cel`daysAgo(405)`, - signed_by: 'Michael Chen', - description: 'Initial service agreement, pending renewal discussion.', - }, - { - crm_account: 'Stark Medical', - crm_contact: 'emily.d@starkmed.example.com', - status: 'draft', - contract_term_months: 24, - start_date: cel`daysFromNow(30)`, - end_date: cel`daysFromNow(760)`, - contract_value: 350000, - billing_frequency: 'monthly', - payment_terms: 'net_30', - auto_renewal: true, - renewal_notice_days: 60, - contract_type: 'partnership', - description: 'Healthcare partnership agreement, currently under legal review.', - }, - ] -}); - -// ─── Quotes ─────────────────────────────────────────────────────────── -// `quote_number` is a runtime-owned autonumber and is NOT seeded (#490); -// the (unique) quote name is the upsert identity instead. -/** - * Quote line items, keyed by quote name. The four quotes generated from a deal - * carry that deal's configuration verbatim — which is exactly what the - * `quote_generation` flow does when it clones opportunity lines onto a quote. - */ -const QUOTE_LINES: Record = { - 'Acme Platform Upgrade Quote': OPPORTUNITY_LINES['Acme Platform Upgrade'], - 'Globex Manufacturing Suite Proposal': OPPORTUNITY_LINES['Globex Manufacturing Suite'], - 'Wayne Enterprise License Quote': OPPORTUNITY_LINES['Wayne Enterprise License'], - 'Initech Cloud Migration Estimate': OPPORTUNITY_LINES['Initech Cloud Migration'], - // Quoted standalone, before any opportunity existed — and then rejected. - 'Stark Medical Pilot Quote': [ - { product: 'Admin Training Workshop', quantity: 5, unit_price: 6000, description: 'Five workshop days for the pilot clinical teams.' }, - { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support for the pilot term.' }, - { product: 'AI Agent Seat (Annual)', quantity: 6, unit_price: 1000, description: 'Agent seats for the pilot coordinators.' }, - ], -}; - -/** - * `subtotal` / `discount_amount` / `total_price` for a quote, derived from its - * lines with `quote_total_rollup`'s own model: - * - * subtotal = Σ line (quantity × unit_price × (1 − line_discount/100)) - * discount_amount = subtotal × quote.discount% - * total_price = subtotal − discount_amount + tax + shipping_handling - * - * Quote-level `tax` and `shipping_handling` stay manual inputs — the rollup - * does not derive them either. Everything the rollup DOES own is computed here - * so that the first edit to a seeded line item recomputes the same numbers - * instead of visibly correcting them. - */ -const quoteTotals = ( - quoteName: string, - opts: { discount: number; tax: number; shipping_handling: number }, -) => { - const lines = QUOTE_LINES[quoteName]; - if (!lines) throw new Error(`Seed error: no line items authored for quote "${quoteName}"`); - const subtotal = linesTotal(lines); - const discount_amount = Math.round(subtotal * (opts.discount / 100) * 100) / 100; - const total_price = - Math.round((subtotal - discount_amount + opts.tax + opts.shipping_handling) * 100) / 100; - return { - subtotal, - discount: opts.discount, - discount_amount, - tax: opts.tax, - shipping_handling: opts.shipping_handling, - total_price, - }; -}; - -const quotes = defineSeed(Quote, { - mode: 'upsert', - externalId: 'name', - records: [ - { - name: 'Acme Platform Upgrade Quote', - crm_account: 'Acme Corporation', - crm_contact: 'john.smith@acme.example.com', - crm_opportunity: 'Acme Platform Upgrade', - status: 'accepted', - quote_date: cel`daysAgo(45)`, - expiration_date: cel`daysAgo(15)`, - ...quoteTotals('Acme Platform Upgrade Quote', { discount: 10, tax: 11475, shipping_handling: 0 }), - payment_terms: 'net_30', - description: 'Platform upgrade with 10% loyalty discount applied.', - }, - { - name: 'Globex Manufacturing Suite Proposal', - crm_account: 'Globex Industries', - crm_contact: 'sarah.j@globex.example.com', - crm_opportunity: 'Globex Manufacturing Suite', - status: 'presented', - quote_date: cel`daysAgo(7)`, - expiration_date: cel`daysFromNow(23)`, - ...quoteTotals('Globex Manufacturing Suite Proposal', { discount: 5, tax: 38000, shipping_handling: 2500 }), - payment_terms: 'net_60', - description: 'Manufacturing suite licensing with implementation services.', - }, - { - name: 'Wayne Enterprise License Quote', - crm_account: 'Wayne Enterprises', - crm_contact: 'rwilson@wayne.example.com', - crm_opportunity: 'Wayne Enterprise License', - status: 'in_review', - quote_date: cel`daysAgo(3)`, - expiration_date: cel`daysFromNow(27)`, - ...quoteTotals('Wayne Enterprise License Quote', { discount: 15, tax: 81600, shipping_handling: 0 }), - payment_terms: 'net_60', - description: 'Multi-year enterprise license with volume discount.', - }, - { - name: 'Initech Cloud Migration Estimate', - crm_account: 'Initech Solutions', - crm_contact: 'mchen@initech.example.com', - crm_opportunity: 'Initech Cloud Migration', - status: 'draft', - quote_date: cel`daysAgo(1)`, - expiration_date: cel`daysFromNow(29)`, - ...quoteTotals('Initech Cloud Migration Estimate', { discount: 0, tax: 6400, shipping_handling: 0 }), - payment_terms: 'net_30', - description: 'Cloud migration services, awaiting internal review.', - }, - { - name: 'Stark Medical Pilot Quote', - crm_account: 'Stark Medical', - crm_contact: 'emily.d@starkmed.example.com', - status: 'rejected', - quote_date: cel`daysAgo(60)`, - expiration_date: cel`daysAgo(30)`, - ...quoteTotals('Stark Medical Pilot Quote', { discount: 0, tax: 3600, shipping_handling: 0 }), - payment_terms: 'net_30', - description: 'Pilot project quote, rejected due to budget constraints.', - internal_notes: 'Customer requested re-quote with smaller scope.', - }, - ] -}); - -// ─── Quote line items ───────────────────────────────────────────────── -// Same composite-key reasoning as the opportunity lines above: (quote, -// product) is the only natural key this junction-shaped object has. -const quoteLineItems = defineSeed(QuoteLineItem, { - mode: 'upsert', - externalId: ['crm_quote', 'crm_product'], - // `tax_rate` is deliberately left at its 0 default: in this app tax is a - // QUOTE-level figure (`crm_quote.tax`, applied after the quote-level - // discount) and `quote_total_rollup` ignores the per-line rate entirely. - // Seeding a rate here would put a second, contradictory tax number on the - // record. - records: lineItemRecords('crm_quote', QUOTE_LINES), -}); - -// ─── Forecasts ──────────────────────────────────────────────────────── -// `owner` is left unset: seed inserts bypass field-level defaults and run -// before any human user exists, so ownership is backfilled to the active user -// at runtime (same as every other CRM object). -// -// Periods are REAL calendar periods, labelled exactly the way -// forecast.hook.ts derives them ('Q3 2026' / 'Aug 2026') — hooks don't run -// over seeds, and the hook only fills a BLANK period_label, so seeded rows -// must speak the same dialect as runtime snapshots or list views end up -// mixing 'This Quarter' with 'Q3 2026' (#490). Calendar-true period_start -// values also make the `this_quarter_forecasts` view's -// `{this_quarter_start}` filter actually match the seeded row. -// -// Computed in plain TS (UTC, mirroring the hook's helpers): this module is -// evaluated when the app bundle loads, the same moment the cel`...` seeds -// are resolved. -const FORECAST_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; -const forecastIsoDate = (d: Date) => - `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; -const forecastQuarterLabel = (d: Date) => `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`; -const forecastMonthLabel = (d: Date) => `${FORECAST_MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; -const forecastNow = new Date(); -const forecastYear = forecastNow.getUTCFullYear(); -const forecastMonth = forecastNow.getUTCMonth(); -const forecastQuarterMonth = Math.floor(forecastMonth / 3) * 3; -const thisQuarterStart = new Date(Date.UTC(forecastYear, forecastQuarterMonth, 1)); -const thisQuarterEnd = new Date(Date.UTC(forecastYear, forecastQuarterMonth + 3, 0)); -const thisMonthStart = new Date(Date.UTC(forecastYear, forecastMonth, 1)); -const thisMonthEnd = new Date(Date.UTC(forecastYear, forecastMonth + 1, 0)); -const lastQuarterStart = new Date(Date.UTC(forecastYear, forecastQuarterMonth - 3, 1)); -const lastQuarterEnd = new Date(Date.UTC(forecastYear, forecastQuarterMonth, 0)); -// Deeper history: quota-attainment and coverage trends need more than a single -// prior period to plot (#591). These are all SETTLED periods — deliberately so: -// the forecast snapshot sweep upserts on (owner, period) for the CURRENT -// period, so historical rows can never collide with what it writes. -const quarterStartAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastQuarterMonth - 3 * n, 1)); -const quarterEndAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastQuarterMonth - 3 * (n - 1), 0)); -const monthStartAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastMonth - n, 1)); -const monthEndAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastMonth - n + 1, 0)); - -/** A settled (past) period snapshot: pipeline is gone, only the closed number remains. */ -const closedPeriod = ( - seed_key: string, - period: 'month' | 'quarter', - start: Date, - end: Date, - quota: number, - closed: number, - notes: string, -) => ({ - seed_key, - period, - period_label: period === 'quarter' ? forecastQuarterLabel(start) : forecastMonthLabel(start), - period_start: forecastIsoDate(start), - period_end: forecastIsoDate(end), - snapshot_date: forecastIsoDate(end), - quota, - pipeline_amount: 0, - best_case_amount: 0, - commit_amount: 0, - closed_amount: closed, - source: 'scheduled' as const, - notes, -}); - -// Upsert identity is the synthetic `seed_key`, NOT `period_label` (#613). -// `period_label` names a SET of rows once the forecast_snapshot sweep (#590) -// writes one per active owner per quarter — all reading 'Q3 2026' — and the -// loader matches against the whole table, so a re-seed could overwrite a real -// rep's snapshot with the demo numbers. Why a synthetic key and not the true -// (owner, period, period_start) identity or an insert-once mode: see the -// `seed_key` declaration in `src/objects/forecast.object.ts`. -// -// The keys are POSITIONAL ('current quarter', 'two quarters back'), not -// calendar values, because the records are positional — recomputed against -// `new Date()` on every import. A positional key keeps the demo at exactly -// these eight rows as the calendar rolls forward, re-pointing each at its new -// period; a calendar-derived key would strand last quarter's demo row and add -// one row per re-seed. -const forecasts = defineSeed(Forecast, { - mode: 'upsert', - externalId: 'seed_key', - records: [ - { - seed_key: 'demo_quarter_current', - period: 'quarter', - period_label: forecastQuarterLabel(thisQuarterStart), - period_start: forecastIsoDate(thisQuarterStart), - period_end: forecastIsoDate(thisQuarterEnd), - snapshot_date: cel`today()`, - quota: 1500000, - pipeline_amount: 2400000, - best_case_amount: 1800000, - commit_amount: 1100000, - closed_amount: 820000, - source: 'scheduled', - notes: 'On track — commit + closed covers 64% of quota with the quarter still open.', - }, - { - seed_key: 'demo_month_current', - period: 'month', - period_label: forecastMonthLabel(thisMonthStart), - period_start: forecastIsoDate(thisMonthStart), - period_end: forecastIsoDate(thisMonthEnd), - snapshot_date: cel`today()`, - quota: 500000, - pipeline_amount: 760000, - best_case_amount: 540000, - commit_amount: 360000, - closed_amount: 295000, - source: 'scheduled', - notes: 'Healthy coverage; two commit deals expected to close this week.', - }, - { - seed_key: 'demo_quarter_minus_1', - period: 'quarter', - period_label: forecastQuarterLabel(lastQuarterStart), - period_start: forecastIsoDate(lastQuarterStart), - period_end: forecastIsoDate(lastQuarterEnd), - snapshot_date: forecastIsoDate(lastQuarterEnd), - quota: 1400000, - pipeline_amount: 0, - best_case_amount: 0, - commit_amount: 0, - closed_amount: 1485000, - source: 'scheduled', - notes: 'Closed at 106% of quota.', - }, - closedPeriod('demo_quarter_minus_2', 'quarter', quarterStartAgo(2), quarterEndAgo(2), 1300000, 1196000, - 'Closed at 92% of quota — two enterprise deals slipped into the next quarter.'), - closedPeriod('demo_quarter_minus_3', 'quarter', quarterStartAgo(3), quarterEndAgo(3), 1200000, 1308000, - 'Closed at 109% of quota, carried by the enterprise renewal cohort.'), - closedPeriod('demo_quarter_minus_4', 'quarter', quarterStartAgo(4), quarterEndAgo(4), 1100000, 1045000, - 'Closed at 95% of quota in the first quarter on the new territory model.'), - closedPeriod('demo_month_minus_1', 'month', monthStartAgo(1), monthEndAgo(1), 480000, 505000, - 'Closed at 105% of quota; the expansion motion covered a soft new-business month.'), - closedPeriod('demo_month_minus_2', 'month', monthStartAgo(2), monthEndAgo(2), 460000, 414000, - 'Closed at 90% of quota — summer slowdown across the mid-market segment.'), - ] -}); - -// ─── Knowledge Articles ─────────────────────────────────────────────── -const knowledgeArticles = defineSeed(KnowledgeArticle, { - mode: 'upsert', - externalId: 'title', - records: [ - { - title: 'Getting Started with HotCRM', - summary: 'A five-minute tour of accounts, contacts, leads and the sales pipeline.', - category: 'getting_started', - status: 'published', - audience: 'public', - language: 'en', - body: `# Getting Started with HotCRM - -Welcome! This guide walks you through the core objects: - -1. **Accounts** — the companies you sell to and serve. -2. **Contacts** — the people at those accounts. -3. **Leads** — unqualified prospects in the top of the funnel. -4. **Opportunities** — qualified deals moving through your pipeline. - -Open the **Sales Pipeline** kanban to drag deals between stages, and use the -**Executive Overview** dashboard to track revenue at a glance.`, - published_at: cel`daysAgo(40)`, - last_reviewed_at: cel`daysAgo(20)`, - view_count: 412, - helpful_count: 38, - not_helpful_count: 2, - }, - { - title: 'Resetting Your Password', - summary: 'How end users reset a forgotten password from the login screen.', - category: 'how_to', - status: 'published', - audience: 'public', - language: 'en', - body: `# Resetting Your Password - -1. On the login screen, click **Forgot password?** -2. Enter the email associated with your account. -3. Check your inbox for a reset link (valid for 30 minutes). -4. Choose a new password of at least 12 characters. - -If the email does not arrive, check spam or contact your administrator.`, - published_at: cel`daysAgo(25)`, - last_reviewed_at: cel`daysAgo(10)`, - view_count: 1280, - helpful_count: 96, - not_helpful_count: 7, - }, - { - title: 'API Rate Limits', - summary: 'Per-token request quotas and recommended back-off strategy.', - category: 'api', - status: 'draft', - audience: 'internal', - language: 'en', - body: `# API Rate Limits (DRAFT) - -Default quota is 600 requests/minute per token. On HTTP 429, back off -exponentially starting at 1s. Numbers pending final review with platform team.`, - }, - { - title: 'Legacy SSO Setup', - summary: 'SAML configuration for the pre-2025 identity stack.', - category: 'troubleshooting', - status: 'published', - audience: 'internal', - language: 'en', - body: `# Legacy SSO Setup - -This covers the deprecated SAML 1.1 flow. New tenants should use the OIDC -connector instead. Retained for customers still on the legacy stack.`, - published_at: cel`daysAgo(240)`, - last_reviewed_at: cel`daysAgo(220)`, - view_count: 64, - helpful_count: 5, - not_helpful_count: 9, - }, - ] -}); +import { products } from './catalog.seed'; +import { accounts, contacts, leads, opportunities, opportunityLineItems } from './sales.seed'; +import { tasks, cases, knowledgeArticles } from './service.seed'; +import { campaigns, campaignMembersFromLeads, campaignMembersFromContacts } from './marketing.seed'; +import { contracts, quotes, quoteLineItems, forecasts } from './revenue.seed'; /** * Ownership and CRM positions are NOT seeded here — they can't be. diff --git a/src/data/marketing.seed.ts b/src/data/marketing.seed.ts new file mode 100644 index 00000000..7602fdb1 --- /dev/null +++ b/src/data/marketing.seed.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Marketing seeds — campaigns and their membership. + * + * Reads the sales seeds (`leads`, `opportunities`) because the campaign metric + * block is DERIVED from them rather than typed in — see `campaignMetrics`. + * + * Split out of the former monolithic `src/data/index.ts` (#635). Seed doctrine + * lives in `./_shared.ts`. + */ +import { defineSeed } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; +import { Campaign } from '../objects/campaign.object'; +import { CampaignMember } from '../objects/campaign_member.object'; +import { celDaysAgo } from './_shared'; +import { leads, opportunities } from './sales.seed'; + +// ─── Campaign membership ────────────────────────────────────────────── +/** + * Campaign members, keyed by campaign name. + * + * Membership is the ONLY path from a campaign to a lead — `crm_lead` has no + * campaign field — so with this table empty (as it was before #591) every + * campaign reported `num_sent` 0, `response_rate` 0% and no attributed leads + * at all, whatever the campaign records claimed. + * + * Status vocabulary is deliberately restricted to the lifecycle values a + * writer actually produces: `sent` (what the campaign_enrollment flow stamps), + * `responded` and `unsubscribed`. The `opened` / `clicked` / `bounced` states + * and the `first_opened_date` / `first_clicked_date` stamps have no writer + * anywhere in the app and are being trimmed under #597 — seeding them would + * manufacture data that nothing can maintain and that the trim would then + * invalidate. + * + * `converted` is likewise unused here: the hook counts converted leads from + * `crm_lead.is_converted`, and no seeded lead is converted (conversion is the + * lead_conversion flow's job at runtime), so a `converted` member would be a + * member state no lead record backs up. + */ +type CampaignMemberSpec = { + /** Lead email (the lead dataset's externalId) — mutually exclusive with `contact`. */ + readonly lead?: string; + /** Contact email (the contact dataset's externalId). */ + readonly contact?: string; + readonly status: 'sent' | 'responded' | 'unsubscribed'; + readonly addedDaysAgo: number; + /** Days ago the member responded. Required by `responded`, absent otherwise. */ + readonly respondedDaysAgo?: number; +}; + +const CAMPAIGN_MEMBERS: Record = { + 'Q3 Enterprise Email Nurture': [ + { lead: 'noah.patel@vertexanalytics.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 9 }, + { lead: 'maya.singh@bluepeaklogistics.example.com', status: 'sent', addedDaysAgo: 14 }, + { lead: 'owen.becker@northwindenergy.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 11 }, + { lead: 'sara.lopez@heliossolar.example.com', status: 'sent', addedDaysAgo: 14 }, + { lead: 'leo.vance@cleancart.example.com', status: 'unsubscribed', addedDaysAgo: 14 }, + { lead: 'iris.okafor@pulsehealth.example.com', status: 'sent', addedDaysAgo: 13 }, + { lead: 'ravi.mehta@foundryrobotics.example.com', status: 'sent', addedDaysAgo: 13 }, + { lead: 'tess.brown@latticeeducation.example.com', status: 'responded', addedDaysAgo: 13, respondedDaysAgo: 8 }, + { contact: 'john.smith@acme.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 10 }, + { contact: 'ethan.brooks@vertex.example.com', status: 'sent', addedDaysAgo: 14 }, + { contact: 'priya.shah@lattice.example.com', status: 'responded', addedDaysAgo: 14, respondedDaysAgo: 7 }, + { contact: 'rwilson@wayne.example.com', status: 'sent', addedDaysAgo: 13 }, + ], + 'Operations Platform Launch': [ + { lead: 'jonas.holt@polarcargo.example.com', status: 'responded', addedDaysAgo: 11, respondedDaysAgo: 6 }, + { lead: 'anya.volkov@redoakrealty.example.com', status: 'sent', addedDaysAgo: 11 }, + { lead: 'theo.park@skylinemedia.example.com', status: 'sent', addedDaysAgo: 11 }, + { lead: 'wren.garcia@maplebakerygroup.example.com', status: 'responded', addedDaysAgo: 10, respondedDaysAgo: 5 }, + { lead: 'hugo.dubois@nimbusaerospace.example.com', status: 'sent', addedDaysAgo: 10 }, + { contact: 'rwilson@wayne.example.com', status: 'responded', addedDaysAgo: 11, respondedDaysAgo: 7 }, + { contact: 'marcus.reed@apexlogistics.example.com', status: 'sent', addedDaysAgo: 11 }, + { contact: 'olivia.chen@northwind.example.com', status: 'sent', addedDaysAgo: 10 }, + { contact: 'sarah.j@globex.example.com', status: 'sent', addedDaysAgo: 10 }, + ], + 'Developer Content Marketing Push': [ + { lead: 'lena.fischer@graniteinsurance.example.com', status: 'responded', addedDaysAgo: 33, respondedDaysAgo: 27 }, + { lead: 'kai.watanabe@coralreefhotels.example.com', status: 'sent', addedDaysAgo: 33 }, + { lead: 'mira.costa@atlasconstruction.example.com', status: 'unsubscribed', addedDaysAgo: 33 }, + { lead: 'pia.anand@citrinefinance.example.com', status: 'responded', addedDaysAgo: 32, respondedDaysAgo: 24 }, + { lead: 'marco.ricci@auroratravel.example.com', status: 'sent', addedDaysAgo: 32 }, + { lead: 'lisa.t@cloudfirst.example.com', status: 'responded', addedDaysAgo: 30, respondedDaysAgo: 21 }, + { contact: 'ethan.brooks@vertex.example.com', status: 'responded', addedDaysAgo: 33, respondedDaysAgo: 19 }, + { contact: 'mchen@initech.example.com', status: 'sent', addedDaysAgo: 33 }, + { contact: 'john.smith@acme.example.com', status: 'sent', addedDaysAgo: 32 }, + { contact: 'emily.d@starkmed.example.com', status: 'sent', addedDaysAgo: 32 }, + { contact: 'sarah.j@globex.example.com', status: 'sent', addedDaysAgo: 30 }, + ], + // Campaigns still in `planning` carry their invitation / registration list: + // the enrollment flow runs for `planning` and `in_progress` campaigns alike + // and stamps `sent` on enrollment, so a pre-launch target list looks exactly + // like this. + 'Cloud Migration Webinar': [ + { lead: 'dkim@edutechlabs.example.com', status: 'sent', addedDaysAgo: 4 }, + { lead: 'alice@nextgenretail.example.com', status: 'sent', addedDaysAgo: 4 }, + { lead: 'jonas.holt@polarcargo.example.com', status: 'sent', addedDaysAgo: 3 }, + { lead: 'anya.volkov@redoakrealty.example.com', status: 'sent', addedDaysAgo: 3 }, + { contact: 'mchen@initech.example.com', status: 'sent', addedDaysAgo: 4 }, + { contact: 'marcus.reed@apexlogistics.example.com', status: 'sent', addedDaysAgo: 3 }, + ], + 'Executive AI Governance Roundtable': [ + { contact: 'john.smith@acme.example.com', status: 'sent', addedDaysAgo: 6 }, + { contact: 'rwilson@wayne.example.com', status: 'sent', addedDaysAgo: 6 }, + { contact: 'ethan.brooks@vertex.example.com', status: 'sent', addedDaysAgo: 5 }, + { contact: 'priya.shah@lattice.example.com', status: 'sent', addedDaysAgo: 5 }, + { contact: 'emily.d@starkmed.example.com', status: 'sent', addedDaysAgo: 5 }, + ], + 'Partner Operations Roadshow': [ + { lead: 'maya.singh@bluepeaklogistics.example.com', status: 'sent', addedDaysAgo: 7 }, + { lead: 'ravi.mehta@foundryrobotics.example.com', status: 'sent', addedDaysAgo: 7 }, + { contact: 'marcus.reed@apexlogistics.example.com', status: 'sent', addedDaysAgo: 6 }, + { contact: 'olivia.chen@northwind.example.com', status: 'sent', addedDaysAgo: 6 }, + ], + 'SaaSCon 2026 Trade Show': [ + { lead: 'theo.park@skylinemedia.example.com', status: 'sent', addedDaysAgo: 8 }, + { lead: 'wren.garcia@maplebakerygroup.example.com', status: 'sent', addedDaysAgo: 8 }, + { lead: 'hugo.dubois@nimbusaerospace.example.com', status: 'sent', addedDaysAgo: 7 }, + { lead: 'marco.ricci@auroratravel.example.com', status: 'sent', addedDaysAgo: 7 }, + ], +}; + +/** + * The metric block `campaign_snapshot_metrics` writes when a campaign moves to + * `completed`, computed here from the very same inputs the hook reads: the + * membership table above and the opportunities attributed via `crm_campaign`. + * + * Seeding these by hand is what made them wrong before. The snapshot fires on + * the transition INTO `completed` and nowhere else, so a hand-typed + * `actual_revenue` survives untouched right up until someone completes the + * campaign — at which point the hook replaces it with the truth and the number + * "mysteriously" changes. Deriving it here makes that snapshot a no-op instead: + * same inputs, same arithmetic, same answer. + */ +const campaignMetrics = (campaignName: string) => { + const members = CAMPAIGN_MEMBERS[campaignName] ?? []; + const leadKeys = new Set(members.map((m) => m.lead).filter((e): e is string => Boolean(e))); + const convertedLeads = leads.records.filter( + (l) => typeof l.email === 'string' && leadKeys.has(l.email) && l.is_converted === true, + ).length; + const attributed = opportunities.records.filter((o) => o.crm_campaign === campaignName); + const won = attributed.filter((o) => o.stage === 'closed_won'); + return { + // "Total members enrolled" — campaign.hook.ts's single definition of num_sent. + num_sent: members.length, + // Only the exact `responded` status counts, as in the hook. + num_responses: members.filter((m) => m.status === 'responded').length, + num_leads: leadKeys.size, + num_converted_leads: convertedLeads, + num_opportunities: attributed.length, + num_won_opportunities: won.length, + actual_revenue: won.reduce((sum, o) => sum + (typeof o.amount === 'number' ? o.amount : 0), 0), + }; +}; + +// ─── Campaigns ──────────────────────────────────────────────────────── +export const campaigns = defineSeed(Campaign, { + mode: 'upsert', + externalId: 'name', + records: [ + { + name: 'Q3 Enterprise Email Nurture', + description: 'Multi-touch nurture for enterprise IT decision makers evaluating AI governance and analytics.', + type: 'email', + channel: 'email', + status: 'in_progress', + start_date: cel`daysAgo(15)`, + end_date: cel`daysFromNow(21)`, + budgeted_cost: 28000, + actual_cost: 14000, + expected_revenue: 560000, + target_size: 5000, + ...campaignMetrics('Q3 Enterprise Email Nurture'), + landing_page_url: 'https://acme.example.com/lp/enterprise-q3', + is_active: true, + }, + { + name: 'Executive AI Governance Roundtable', + description: 'A live executive roundtable on operating AI agents with governance, auditability and trust.', + type: 'conference', + channel: 'events', + status: 'planning', + start_date: cel`daysFromNow(2)`, + end_date: cel`daysFromNow(3)`, + budgeted_cost: 22000, + expected_revenue: 320000, + target_size: 120, + ...campaignMetrics('Executive AI Governance Roundtable'), + is_active: true, + }, + { + name: 'Cloud Migration Webinar', + description: 'A technical webinar on modernizing customer operations and data workflows in the cloud.', + type: 'webinar', + channel: 'digital', + status: 'planning', + start_date: cel`daysFromNow(6)`, + end_date: cel`daysFromNow(8)`, + budgeted_cost: 18000, + expected_revenue: 360000, + target_size: 1500, + ...campaignMetrics('Cloud Migration Webinar'), + landing_page_url: 'https://acme.example.com/webinars/cloud-migration', + is_active: true, + }, + { + name: 'Operations Platform Launch', + description: 'Launch campaign for the operations platform, pairing product content with customer proof points.', + type: 'content', + channel: 'digital', + status: 'in_progress', + // Was `daysFromNow(12)` — an `in_progress` campaign that had not started + // yet, which no attribution can be consistent with (#591). + start_date: cel`daysAgo(12)`, + end_date: cel`daysFromNow(14)`, + budgeted_cost: 36000, + actual_cost: 12000, + expected_revenue: 480000, + target_size: 8000, + ...campaignMetrics('Operations Platform Launch'), + landing_page_url: 'https://acme.example.com/launch/operations-platform', + is_active: true, + }, + { + name: 'Partner Operations Roadshow', + description: 'Joint regional sessions with strategic partners for operations and IT leaders.', + type: 'partner', + channel: 'partner', + status: 'planning', + start_date: cel`daysFromNow(19)`, + end_date: cel`daysFromNow(22)`, + budgeted_cost: 48000, + expected_revenue: 720000, + target_size: 600, + ...campaignMetrics('Partner Operations Roadshow'), + is_active: true, + }, + { + name: 'SaaSCon 2026 Trade Show', + description: 'A focused booth, executive dinner and customer-story program at the SaaSCon industry conference.', + type: 'trade_show', + channel: 'events', + status: 'planning', + start_date: cel`daysFromNow(27)`, + end_date: cel`daysFromNow(30)`, + budgeted_cost: 75000, + expected_revenue: 1200000, + target_size: 3000, + ...campaignMetrics('SaaSCon 2026 Trade Show'), + is_active: true, + }, + { + name: 'Developer Content Marketing Push', + description: 'Technical blog posts, video tutorials and developer community engagement completed last month.', + type: 'content', + channel: 'digital', + status: 'completed', + start_date: cel`daysAgo(35)`, + end_date: cel`daysAgo(4)`, + budgeted_cost: 40000, + actual_cost: 25000, + expected_revenue: 250000, + target_size: 10000, + ...campaignMetrics('Developer Content Marketing Push'), + landing_page_url: 'https://acme.example.com/developers', + is_active: true, + }, + ] +}); + +/** + * Campaign members are seeded as TWO datasets over one object, split by which + * side of the junction is populated. + * + * Not a stylistic choice: the object has no single natural key (its only text + * identity is a runtime-owned autonumber), so identity has to be the composite + * of campaign + member. The loader builds a composite key by joining the + * per-field values and returns an EMPTY key — no dedupe, so a replay boot + * re-inserts every row — as soon as ANY component is null. `crm_lead` and + * `crm_contact` are mutually exclusive by design, so a single + * `['crm_campaign', 'crm_lead', 'crm_contact']` key would be blank on every + * row. Splitting the rows by member type gives each dataset a key whose parts + * are all present. + */ +const memberRecords = ( + kind: 'lead' | 'contact', +): Array> => + Object.entries(CAMPAIGN_MEMBERS).flatMap(([campaign, members]) => + members + .filter((m) => (kind === 'lead' ? m.lead : m.contact)) + .map((m) => ({ + crm_campaign: campaign, + ...(kind === 'lead' ? { crm_lead: m.lead } : { crm_contact: m.contact }), + status: m.status, + added_date: celDaysAgo(m.addedDaysAgo), + // `has_responded` / `response_date` track the `responded` status + // exactly — a responded member with no response date is a shape the + // response-tracking surfaces cannot render. + has_responded: m.status === 'responded', + ...(m.respondedDaysAgo !== undefined ? { response_date: celDaysAgo(m.respondedDaysAgo) } : {}), + })), + ); + +export const campaignMembersFromLeads = defineSeed(CampaignMember, { + mode: 'upsert', + externalId: ['crm_campaign', 'crm_lead'], + records: memberRecords('lead'), +}); + +export const campaignMembersFromContacts = defineSeed(CampaignMember, { + mode: 'upsert', + externalId: ['crm_campaign', 'crm_contact'], + records: memberRecords('contact'), +}); diff --git a/src/data/revenue.seed.ts b/src/data/revenue.seed.ts new file mode 100644 index 00000000..b56eb9f6 --- /dev/null +++ b/src/data/revenue.seed.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Revenue seeds — contracts, quotes (and their lines) and forecast snapshots. + * + * Split out of the former monolithic `src/data/index.ts` (#635). Seed doctrine + * lives in `./_shared.ts`. + */ +import { defineSeed } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; +import { Contract } from '../objects/contract.object'; +import { Quote } from '../objects/quote.object'; +import { QuoteLineItem } from '../objects/quote_line_item.object'; +import { Forecast } from '../objects/forecast.object'; +import { linesTotal, type LineSpec } from './_shared'; +import { lineItemRecords } from './catalog.seed'; +import { OPPORTUNITY_LINES } from './sales.seed'; + +// ─── Contracts ──────────────────────────────────────────────────────── +// `contract_number` is a runtime-owned autonumber and is NOT seeded (#490). +// Contract has no natural-name field, so the (unique, stable) description +// doubles as the upsert identity for these fixtures. +export const contracts = defineSeed(Contract, { + mode: 'upsert', + externalId: 'description', + records: [ + { + crm_account: 'Acme Corporation', + crm_contact: 'john.smith@acme.example.com', + crm_opportunity: 'Acme Platform Upgrade', + status: 'activated', + contract_term_months: 12, + start_date: cel`daysAgo(30)`, + end_date: cel`daysFromNow(335)`, + contract_value: 150000, + billing_frequency: 'annually', + payment_terms: 'net_30', + auto_renewal: true, + renewal_notice_days: 60, + contract_type: 'subscription', + signed_date: cel`daysAgo(32)`, + signed_by: 'John Smith', + description: 'Annual platform subscription with premium support tier.', + }, + { + crm_account: 'Wayne Enterprises', + crm_contact: 'rwilson@wayne.example.com', + crm_opportunity: 'Wayne Enterprise License', + status: 'in_approval', + contract_term_months: 36, + start_date: cel`daysFromNow(14)`, + end_date: cel`daysFromNow(1109)`, + contract_value: 1200000, + billing_frequency: 'annually', + payment_terms: 'net_60', + auto_renewal: false, + renewal_notice_days: 90, + contract_type: 'license', + description: 'Multi-year enterprise license with custom SLA.', + }, + { + crm_account: 'Initech Solutions', + crm_contact: 'mchen@initech.example.com', + status: 'expired', + contract_term_months: 12, + start_date: cel`daysAgo(400)`, + end_date: cel`daysAgo(35)`, + contract_value: 60000, + billing_frequency: 'quarterly', + payment_terms: 'net_30', + auto_renewal: false, + renewal_notice_days: 30, + contract_type: 'service', + signed_date: cel`daysAgo(405)`, + signed_by: 'Michael Chen', + description: 'Initial service agreement, pending renewal discussion.', + }, + { + crm_account: 'Stark Medical', + crm_contact: 'emily.d@starkmed.example.com', + status: 'draft', + contract_term_months: 24, + start_date: cel`daysFromNow(30)`, + end_date: cel`daysFromNow(760)`, + contract_value: 350000, + billing_frequency: 'monthly', + payment_terms: 'net_30', + auto_renewal: true, + renewal_notice_days: 60, + contract_type: 'partnership', + description: 'Healthcare partnership agreement, currently under legal review.', + }, + ] +}); + +// ─── Quotes ─────────────────────────────────────────────────────────── +// `quote_number` is a runtime-owned autonumber and is NOT seeded (#490); +// the (unique) quote name is the upsert identity instead. +/** + * Quote line items, keyed by quote name. The four quotes generated from a deal + * carry that deal's configuration verbatim — which is exactly what the + * `quote_generation` flow does when it clones opportunity lines onto a quote. + */ +const QUOTE_LINES: Record = { + 'Acme Platform Upgrade Quote': OPPORTUNITY_LINES['Acme Platform Upgrade'], + 'Globex Manufacturing Suite Proposal': OPPORTUNITY_LINES['Globex Manufacturing Suite'], + 'Wayne Enterprise License Quote': OPPORTUNITY_LINES['Wayne Enterprise License'], + 'Initech Cloud Migration Estimate': OPPORTUNITY_LINES['Initech Cloud Migration'], + // Quoted standalone, before any opportunity existed — and then rejected. + 'Stark Medical Pilot Quote': [ + { product: 'Admin Training Workshop', quantity: 5, unit_price: 6000, description: 'Five workshop days for the pilot clinical teams.' }, + { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support for the pilot term.' }, + { product: 'AI Agent Seat (Annual)', quantity: 6, unit_price: 1000, description: 'Agent seats for the pilot coordinators.' }, + ], +}; + +/** + * `subtotal` / `discount_amount` / `total_price` for a quote, derived from its + * lines with `quote_total_rollup`'s own model: + * + * subtotal = Σ line (quantity × unit_price × (1 − line_discount/100)) + * discount_amount = subtotal × quote.discount% + * total_price = subtotal − discount_amount + tax + shipping_handling + * + * Quote-level `tax` and `shipping_handling` stay manual inputs — the rollup + * does not derive them either. Everything the rollup DOES own is computed here + * so that the first edit to a seeded line item recomputes the same numbers + * instead of visibly correcting them. + */ +const quoteTotals = ( + quoteName: string, + opts: { discount: number; tax: number; shipping_handling: number }, +) => { + const lines = QUOTE_LINES[quoteName]; + if (!lines) throw new Error(`Seed error: no line items authored for quote "${quoteName}"`); + const subtotal = linesTotal(lines); + const discount_amount = Math.round(subtotal * (opts.discount / 100) * 100) / 100; + const total_price = + Math.round((subtotal - discount_amount + opts.tax + opts.shipping_handling) * 100) / 100; + return { + subtotal, + discount: opts.discount, + discount_amount, + tax: opts.tax, + shipping_handling: opts.shipping_handling, + total_price, + }; +}; + +export const quotes = defineSeed(Quote, { + mode: 'upsert', + externalId: 'name', + records: [ + { + name: 'Acme Platform Upgrade Quote', + crm_account: 'Acme Corporation', + crm_contact: 'john.smith@acme.example.com', + crm_opportunity: 'Acme Platform Upgrade', + status: 'accepted', + quote_date: cel`daysAgo(45)`, + expiration_date: cel`daysAgo(15)`, + ...quoteTotals('Acme Platform Upgrade Quote', { discount: 10, tax: 11475, shipping_handling: 0 }), + payment_terms: 'net_30', + description: 'Platform upgrade with 10% loyalty discount applied.', + }, + { + name: 'Globex Manufacturing Suite Proposal', + crm_account: 'Globex Industries', + crm_contact: 'sarah.j@globex.example.com', + crm_opportunity: 'Globex Manufacturing Suite', + status: 'presented', + quote_date: cel`daysAgo(7)`, + expiration_date: cel`daysFromNow(23)`, + ...quoteTotals('Globex Manufacturing Suite Proposal', { discount: 5, tax: 38000, shipping_handling: 2500 }), + payment_terms: 'net_60', + description: 'Manufacturing suite licensing with implementation services.', + }, + { + name: 'Wayne Enterprise License Quote', + crm_account: 'Wayne Enterprises', + crm_contact: 'rwilson@wayne.example.com', + crm_opportunity: 'Wayne Enterprise License', + status: 'in_review', + quote_date: cel`daysAgo(3)`, + expiration_date: cel`daysFromNow(27)`, + ...quoteTotals('Wayne Enterprise License Quote', { discount: 15, tax: 81600, shipping_handling: 0 }), + payment_terms: 'net_60', + description: 'Multi-year enterprise license with volume discount.', + }, + { + name: 'Initech Cloud Migration Estimate', + crm_account: 'Initech Solutions', + crm_contact: 'mchen@initech.example.com', + crm_opportunity: 'Initech Cloud Migration', + status: 'draft', + quote_date: cel`daysAgo(1)`, + expiration_date: cel`daysFromNow(29)`, + ...quoteTotals('Initech Cloud Migration Estimate', { discount: 0, tax: 6400, shipping_handling: 0 }), + payment_terms: 'net_30', + description: 'Cloud migration services, awaiting internal review.', + }, + { + name: 'Stark Medical Pilot Quote', + crm_account: 'Stark Medical', + crm_contact: 'emily.d@starkmed.example.com', + status: 'rejected', + quote_date: cel`daysAgo(60)`, + expiration_date: cel`daysAgo(30)`, + ...quoteTotals('Stark Medical Pilot Quote', { discount: 0, tax: 3600, shipping_handling: 0 }), + payment_terms: 'net_30', + description: 'Pilot project quote, rejected due to budget constraints.', + internal_notes: 'Customer requested re-quote with smaller scope.', + }, + ] +}); + +// ─── Quote line items ───────────────────────────────────────────────── +// Same composite-key reasoning as the opportunity lines above: (quote, +// product) is the only natural key this junction-shaped object has. +export const quoteLineItems = defineSeed(QuoteLineItem, { + mode: 'upsert', + externalId: ['crm_quote', 'crm_product'], + // `tax_rate` is deliberately left at its 0 default: in this app tax is a + // QUOTE-level figure (`crm_quote.tax`, applied after the quote-level + // discount) and `quote_total_rollup` ignores the per-line rate entirely. + // Seeding a rate here would put a second, contradictory tax number on the + // record. + records: lineItemRecords('crm_quote', QUOTE_LINES), +}); + +// ─── Forecasts ──────────────────────────────────────────────────────── +// `owner` is left unset: seed inserts bypass field-level defaults and run +// before any human user exists, so ownership is backfilled to the active user +// at runtime (same as every other CRM object). +// +// Periods are REAL calendar periods, labelled exactly the way +// forecast.hook.ts derives them ('Q3 2026' / 'Aug 2026') — hooks don't run +// over seeds, and the hook only fills a BLANK period_label, so seeded rows +// must speak the same dialect as runtime snapshots or list views end up +// mixing 'This Quarter' with 'Q3 2026' (#490). Calendar-true period_start +// values also make the `this_quarter_forecasts` view's +// `{this_quarter_start}` filter actually match the seeded row. +// +// Computed in plain TS (UTC, mirroring the hook's helpers): this module is +// evaluated when the app bundle loads, the same moment the cel`...` seeds +// are resolved. +const FORECAST_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const forecastIsoDate = (d: Date) => + `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; +const forecastQuarterLabel = (d: Date) => `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`; +const forecastMonthLabel = (d: Date) => `${FORECAST_MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; +const forecastNow = new Date(); +const forecastYear = forecastNow.getUTCFullYear(); +const forecastMonth = forecastNow.getUTCMonth(); +const forecastQuarterMonth = Math.floor(forecastMonth / 3) * 3; +const thisQuarterStart = new Date(Date.UTC(forecastYear, forecastQuarterMonth, 1)); +const thisQuarterEnd = new Date(Date.UTC(forecastYear, forecastQuarterMonth + 3, 0)); +const thisMonthStart = new Date(Date.UTC(forecastYear, forecastMonth, 1)); +const thisMonthEnd = new Date(Date.UTC(forecastYear, forecastMonth + 1, 0)); +const lastQuarterStart = new Date(Date.UTC(forecastYear, forecastQuarterMonth - 3, 1)); +const lastQuarterEnd = new Date(Date.UTC(forecastYear, forecastQuarterMonth, 0)); +// Deeper history: quota-attainment and coverage trends need more than a single +// prior period to plot (#591). These are all SETTLED periods — deliberately so: +// the forecast snapshot sweep upserts on (owner, period) for the CURRENT +// period, so historical rows can never collide with what it writes. +const quarterStartAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastQuarterMonth - 3 * n, 1)); +const quarterEndAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastQuarterMonth - 3 * (n - 1), 0)); +const monthStartAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastMonth - n, 1)); +const monthEndAgo = (n: number) => new Date(Date.UTC(forecastYear, forecastMonth - n + 1, 0)); + +/** A settled (past) period snapshot: pipeline is gone, only the closed number remains. */ +const closedPeriod = ( + seed_key: string, + period: 'month' | 'quarter', + start: Date, + end: Date, + quota: number, + closed: number, + notes: string, +) => ({ + seed_key, + period, + period_label: period === 'quarter' ? forecastQuarterLabel(start) : forecastMonthLabel(start), + period_start: forecastIsoDate(start), + period_end: forecastIsoDate(end), + snapshot_date: forecastIsoDate(end), + quota, + pipeline_amount: 0, + best_case_amount: 0, + commit_amount: 0, + closed_amount: closed, + source: 'scheduled' as const, + notes, +}); + +// Upsert identity is the synthetic `seed_key`, NOT `period_label` (#613). +// `period_label` names a SET of rows once the forecast_snapshot sweep (#590) +// writes one per active owner per quarter — all reading 'Q3 2026' — and the +// loader matches against the whole table, so a re-seed could overwrite a real +// rep's snapshot with the demo numbers. Why a synthetic key and not the true +// (owner, period, period_start) identity or an insert-once mode: see the +// `seed_key` declaration in `src/objects/forecast.object.ts`. +// +// The keys are POSITIONAL ('current quarter', 'two quarters back'), not +// calendar values, because the records are positional — recomputed against +// `new Date()` on every import. A positional key keeps the demo at exactly +// these eight rows as the calendar rolls forward, re-pointing each at its new +// period; a calendar-derived key would strand last quarter's demo row and add +// one row per re-seed. +export const forecasts = defineSeed(Forecast, { + mode: 'upsert', + externalId: 'seed_key', + records: [ + { + seed_key: 'demo_quarter_current', + period: 'quarter', + period_label: forecastQuarterLabel(thisQuarterStart), + period_start: forecastIsoDate(thisQuarterStart), + period_end: forecastIsoDate(thisQuarterEnd), + snapshot_date: cel`today()`, + quota: 1500000, + pipeline_amount: 2400000, + best_case_amount: 1800000, + commit_amount: 1100000, + closed_amount: 820000, + source: 'scheduled', + notes: 'On track — commit + closed covers 64% of quota with the quarter still open.', + }, + { + seed_key: 'demo_month_current', + period: 'month', + period_label: forecastMonthLabel(thisMonthStart), + period_start: forecastIsoDate(thisMonthStart), + period_end: forecastIsoDate(thisMonthEnd), + snapshot_date: cel`today()`, + quota: 500000, + pipeline_amount: 760000, + best_case_amount: 540000, + commit_amount: 360000, + closed_amount: 295000, + source: 'scheduled', + notes: 'Healthy coverage; two commit deals expected to close this week.', + }, + { + seed_key: 'demo_quarter_minus_1', + period: 'quarter', + period_label: forecastQuarterLabel(lastQuarterStart), + period_start: forecastIsoDate(lastQuarterStart), + period_end: forecastIsoDate(lastQuarterEnd), + snapshot_date: forecastIsoDate(lastQuarterEnd), + quota: 1400000, + pipeline_amount: 0, + best_case_amount: 0, + commit_amount: 0, + closed_amount: 1485000, + source: 'scheduled', + notes: 'Closed at 106% of quota.', + }, + closedPeriod('demo_quarter_minus_2', 'quarter', quarterStartAgo(2), quarterEndAgo(2), 1300000, 1196000, + 'Closed at 92% of quota — two enterprise deals slipped into the next quarter.'), + closedPeriod('demo_quarter_minus_3', 'quarter', quarterStartAgo(3), quarterEndAgo(3), 1200000, 1308000, + 'Closed at 109% of quota, carried by the enterprise renewal cohort.'), + closedPeriod('demo_quarter_minus_4', 'quarter', quarterStartAgo(4), quarterEndAgo(4), 1100000, 1045000, + 'Closed at 95% of quota in the first quarter on the new territory model.'), + closedPeriod('demo_month_minus_1', 'month', monthStartAgo(1), monthEndAgo(1), 480000, 505000, + 'Closed at 105% of quota; the expansion motion covered a soft new-business month.'), + closedPeriod('demo_month_minus_2', 'month', monthStartAgo(2), monthEndAgo(2), 460000, 414000, + 'Closed at 90% of quota — summer slowdown across the mid-market segment.'), + ] +}); diff --git a/src/data/sales.seed.ts b/src/data/sales.seed.ts new file mode 100644 index 00000000..e834a5f0 --- /dev/null +++ b/src/data/sales.seed.ts @@ -0,0 +1,926 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Sales seeds — accounts, contacts, leads, opportunities and their line items. + * + * Split out of the former monolithic `src/data/index.ts` (#635). Seed doctrine + * lives in `./_shared.ts`. + */ +import { defineSeed } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; +import { Account } from '../objects/account.object'; +import { Contact } from '../objects/contact.object'; +import { Lead } from '../objects/lead.object'; +import { Opportunity } from '../objects/opportunity.object'; +import { OpportunityLineItem } from '../objects/opportunity_line_item.object'; +import { celDaysAgo, linesTotal, type LineSpec } from './_shared'; +import { lineItemRecords } from './catalog.seed'; + +// ─── Accounts ───────────────────────────────────────────────────────── +// +// `billing_address` is load-bearing demo data, not decoration (#638). The two +// territory sharing rules filter on `crm_account.billing_country` — the flat +// projection `account_protection` derives from `billing_address.country` — so +// an account with no address belongs to no territory. Until now not one seeded +// account carried an address, which meant #621 shipped two territory rules +// that installed correctly and then matched zero records: Setup listed two +// territories with nothing behind them. +// +// The nine accounts partition deliberately across all THREE outcomes the rules +// can produce, so a demo shows the split instead of asserting it: +// +// north_america_territory ["US","CA","MX"] → 6 (5 × US, 1 × CA) +// europe_territory ["UK","DE","FR","IT","ES"] → 2 (DE, UK) +// neither territory → 1 (SG) +// +// `country` carries the 2-LETTER CODE the rules match on. The projection trims +// and upper-cases; it does not translate, so 'United States' would silently +// land an account outside every territory. `UK` rather than the ISO `GB` for +// the same reason — that is the code the europe rule is authored against (see +// the `billing_country` note in `src/objects/account.object.ts`). +// +// `billing_country` itself is deliberately NOT authored here. Hooks DO run +// over seed writes (#617, corrected in `./_shared.ts`), so the projection is +// computed at seed time; the field is `readonly` and hook-owned, and a +// hand-copied value would be a second source of truth for something +// `account_protection` already maintains — the exact drift the doctrine block +// exists to prevent. `test/territory-seed-coverage.test.ts` pins the dependency +// from the other side: every seeded address must carry a country the +// projection can turn into the territory bucket claimed above. +// +// Phone numbers moved with the addresses: a Munich account answering a Denver +// number is the kind of incoherence an evaluator notices before the feature. +export const accounts = defineSeed(Account, { + mode: 'upsert', + externalId: 'name', + records: [ + { + name: 'Acme Corporation', + type: 'customer', + industry: 'technology', + annual_revenue: 5000000, + number_of_employees: 250, + phone: '+1-415-555-0100', + billing_address: { street: '500 Howard Street, Suite 400', city: 'San Francisco', state: 'CA', postalCode: '94105', country: 'US' }, + website: 'https://acme.example.com', + tier: 'enterprise', + segment: 'growth', + health_score: 'healthy', + next_renewal_date: cel`daysFromNow(45)`, + last_activity_date: cel`daysAgo(3)`, + description: `**Strategic Customer · Enterprise Tier** + +Acme Corporation is a Series-C robotics & industrial automation +company that switched from a competitor 18 months ago. They run +HotCRM as their system of record for sales + service across +three regional teams (NA, EMEA, APAC). + +**Relationship** +- Primary economic buyer: Jordan Park (CTO) — values our open + architecture and AI roadmap. +- Day-to-day champion: John Smith (VP Engineering) — owns the + technical evaluation and integration questions. +- Procurement: handled by Lisa Kim — prefers annual contracts, + net-30 terms. + +**Current state** +- ARR: $220K (signed Q1 2025 renewal). Up 22% YoY. +- 1 open enterprise opportunity ($150K platform upgrade) in proposal + stage, 1 service ticket open (login issues), 1 billing dispute + awaiting customer response. +- Renewal due in 45 days — they've already verbally committed but + want a workshop on AI agent governance before signing. + +**Red flags** +- Slipped one opportunity ($75K add-on) in the last quarter due + to slow procurement cycle on their side. +- Login issues ticket is approaching its SLA — needs eyes today.`, + }, + { + name: 'Globex Industries', + type: 'prospect', + industry: 'manufacturing', + annual_revenue: 12000000, + number_of_employees: 800, + phone: '+1-312-555-0200', + billing_address: { street: '233 South Wacker Drive', city: 'Chicago', state: 'IL', postalCode: '60606', country: 'US' }, + website: 'https://globex.example.com', + tier: 'enterprise', + segment: 'net_new', + last_activity_date: cel`daysAgo(8)`, + }, + { + name: 'Initech Solutions', + type: 'customer', + industry: 'finance', + annual_revenue: 3500000, + number_of_employees: 150, + phone: '+1-212-555-0300', + billing_address: { street: '1 Liberty Plaza, 23rd Floor', city: 'New York', state: 'NY', postalCode: '10006', country: 'US' }, + website: 'https://initech.example.com', + tier: 'mid_market', + segment: 'at_risk', + health_score: 'at_risk', + next_renewal_date: cel`daysFromNow(75)`, + last_activity_date: cel`daysAgo(21)`, + }, + { + name: 'Stark Medical', + type: 'partner', + industry: 'healthcare', + annual_revenue: 8000000, + number_of_employees: 400, + // The one Canadian account: still inside north_america_territory, and the + // reason that rule's `$in` list is not a single-country list in disguise. + phone: '+1-416-555-0400', + billing_address: { street: '181 Bay Street, Suite 2200', city: 'Toronto', state: 'ON', postalCode: 'M5J 2T3', country: 'CA' }, + website: 'https://starkmed.example.com', + tier: 'mid_market', + segment: 'stable', + last_activity_date: cel`daysAgo(5)`, + }, + { + name: 'Wayne Enterprises', + type: 'customer', + industry: 'technology', + annual_revenue: 25000000, + number_of_employees: 2000, + phone: '+1-650-555-0500', + billing_address: { street: '3000 Hanover Street', city: 'Palo Alto', state: 'CA', postalCode: '94304', country: 'US' }, + website: 'https://wayne.example.com', + tier: 'strategic', + segment: 'growth', + health_score: 'healthy', + next_renewal_date: cel`daysFromNow(28)`, + last_activity_date: cel`daysAgo(1)`, + }, + { + name: 'Northwind Energy', + type: 'prospect', + industry: 'energy', + annual_revenue: 18000000, + number_of_employees: 1100, + // europe_territory, half 1: DE. + phone: '+49-89-5550-0600', + billing_address: { street: 'Leopoldstrasse 21', city: 'Munich', state: 'BY', postalCode: '80802', country: 'DE' }, + website: 'https://northwind.example.com', + tier: 'enterprise', + segment: 'net_new', + last_activity_date: cel`daysAgo(2)`, + description: 'A regional energy provider evaluating a unified operations and customer-data platform.', + }, + { + name: 'Vertex Analytics', + type: 'customer', + industry: 'software', + annual_revenue: 9500000, + number_of_employees: 420, + phone: '+1-512-555-0700', + billing_address: { street: '600 Congress Avenue, Floor 14', city: 'Austin', state: 'TX', postalCode: '78701', country: 'US' }, + website: 'https://vertex.example.com', + tier: 'enterprise', + segment: 'growth', + health_score: 'healthy', + next_renewal_date: cel`daysFromNow(62)`, + last_activity_date: cel`daysAgo(4)`, + description: 'An analytics SaaS customer expanding usage from the data team to its revenue organization.', + }, + { + name: 'Lattice Education', + type: 'customer', + industry: 'education', + annual_revenue: 6800000, + number_of_employees: 320, + // europe_territory, half 2: UK — the code the rule names, not ISO `GB`. + // No `state`: an address without a subdivision is a normal shape, and the + // projection only ever reads `country`. + phone: '+44-20-7946-0800', + billing_address: { street: '20 Fenchurch Street', city: 'London', postalCode: 'EC3M 3BY', country: 'UK' }, + website: 'https://lattice.example.com', + tier: 'mid_market', + segment: 'stable', + health_score: 'healthy', + next_renewal_date: cel`daysFromNow(36)`, + last_activity_date: cel`daysAgo(6)`, + description: 'A higher-education technology company consolidating admissions, student-success and alumni workflows.', + }, + { + name: 'Apex Logistics', + type: 'prospect', + industry: 'logistics', + annual_revenue: 22000000, + number_of_employees: 1400, + // Outside BOTH territories on purpose: without a record that matches + // neither rule, "the rules partition the accounts" is unfalsifiable — + // a match-all regression would look identical to a working one. + phone: '+65-6555-0900', + billing_address: { street: '10 Marina Boulevard, #30-01', city: 'Singapore', postalCode: '018983', country: 'SG' }, + website: 'https://apexlogistics.example.com', + tier: 'enterprise', + segment: 'net_new', + last_activity_date: cel`daysAgo(9)`, + description: 'A fast-growing logistics provider assessing a modern data hub for its operations and commercial teams.', + }, + ] +}); + +// ─── Contacts ───────────────────────────────────────────────────────── +export const contacts = defineSeed(Contact, { + mode: 'upsert', + externalId: 'email', + records: [ + { + salutation: 'mr', + first_name: 'John', + last_name: 'Smith', + email: 'john.smith@acme.example.com', + phone: '+1-415-555-0101', + title: 'VP of Engineering', + department: 'engineering', + crm_account: 'Acme Corporation', + is_primary: true, + }, + { + salutation: 'ms', + first_name: 'Sarah', + last_name: 'Johnson', + email: 'sarah.j@globex.example.com', + phone: '+1-312-555-0201', + title: 'Chief Procurement Officer', + department: 'executive', + crm_account: 'Globex Industries', + is_primary: true, + }, + { + salutation: 'dr', + first_name: 'Michael', + last_name: 'Chen', + email: 'mchen@initech.example.com', + phone: '+1-212-555-0301', + title: 'Director of Operations', + department: 'operations', + crm_account: 'Initech Solutions', + is_primary: true, + }, + { + salutation: 'ms', + first_name: 'Emily', + last_name: 'Davis', + email: 'emily.d@starkmed.example.com', + phone: '+1-617-555-0401', + title: 'Head of Partnerships', + department: 'sales', + crm_account: 'Stark Medical', + is_primary: true, + }, + { + salutation: 'mr', + first_name: 'Robert', + last_name: 'Wilson', + email: 'rwilson@wayne.example.com', + phone: '+1-650-555-0501', + title: 'CTO', + department: 'engineering', + crm_account: 'Wayne Enterprises', + is_primary: true, + }, + { + salutation: 'ms', + first_name: 'Olivia', + last_name: 'Chen', + email: 'olivia.chen@northwind.example.com', + phone: '+1-303-555-0601', + title: 'VP of Digital Operations', + department: 'operations', + crm_account: 'Northwind Energy', + is_primary: true, + }, + { + salutation: 'mr', + first_name: 'Ethan', + last_name: 'Brooks', + email: 'ethan.brooks@vertex.example.com', + phone: '+1-512-555-0701', + title: 'Chief Revenue Officer', + department: 'executive', + crm_account: 'Vertex Analytics', + is_primary: true, + }, + { + salutation: 'ms', + first_name: 'Priya', + last_name: 'Shah', + email: 'priya.shah@lattice.example.com', + phone: '+1-617-555-0801', + title: 'VP of Student Success', + department: 'operations', + crm_account: 'Lattice Education', + is_primary: true, + }, + { + salutation: 'mr', + first_name: 'Marcus', + last_name: 'Reed', + email: 'marcus.reed@apexlogistics.example.com', + phone: '+1-404-555-0901', + title: 'Director of Commercial Systems', + department: 'operations', + crm_account: 'Apex Logistics', + is_primary: true, + }, + ] +}); + +// ─── Leads ──────────────────────────────────────────────────────────── +/** + * Disqualification reasons for the generated `unqualified` demo leads, paired + * with the note that explains them. + * + * `crm_lead.disqualification_reason_required` rejects an unqualified lead that + * carries no reason, so every seeded unqualified row must supply one — the rows + * used to carry a budget-flavoured note and no reason at all. Rotated over four + * values so the demo's disqualification breakdown has more than one bar, and + * the note always matches the reason it sits next to. + */ +const DISQUALIFICATION_REASONS = [ + { reason: 'no_budget', note: 'No budget approved for this fiscal year — revisit next planning cycle.' }, + { reason: 'not_a_fit', note: 'Requirements sit outside what the product covers today.' }, + { reason: 'wrong_persona', note: 'Contact has no say in the buying decision and no path to an owner.' }, + { reason: 'unreachable', note: 'Six touches across email and phone over four weeks, no response.' }, +] as const; + +export const leads = defineSeed(Lead, { + mode: 'upsert', + externalId: 'email', + records: [ + { + first_name: 'Alice', + last_name: 'Martinez', + company: 'NextGen Retail', + email: 'alice@nextgenretail.example.com', + phone: '+1-503-555-0600', + status: 'new', + lead_source: 'web', + industry: 'retail', + rating: 3, + next_followup_date: cel`daysFromNow(2)`, + }, + { + first_name: 'David', + last_name: 'Kim', + company: 'EduTech Labs', + email: 'dkim@edutechlabs.example.com', + phone: '+1-408-555-0700', + status: 'contacted', + lead_source: 'referral', + industry: 'education', + rating: 4, + next_followup_date: cel`daysFromNow(1)`, + last_contacted_date: cel`daysAgo(3)`, + }, + { + first_name: 'Lisa', + last_name: 'Thompson', + company: 'CloudFirst Inc', + email: 'lisa.t@cloudfirst.example.com', + phone: '+1-206-555-0800', + status: 'qualified', + lead_source: 'event', + industry: 'technology', + // WHOLE stars only. `lead.hook.ts` rounds its computed score to an + // integer because "half values rendered inconsistently in the star + // widget", and the hook leaves an explicitly seeded number alone — so a + // seeded 4.5 was the one rating in the system the contract could never + // produce, and it rendered as the broken half-star it was meant to + // prevent (#591). + rating: 5, + next_followup_date: cel`daysFromNow(0)`, + last_contacted_date: cel`daysAgo(1)`, + }, + // ─── Generated demo leads — spread across 6 months for monthly-bucket reports + // (`LeadInflowByMonthSourceReport`). Each `last_contacted_date` lives in a + // distinct month / source pair so the matrix has multiple cells populated. + ...[ + { fn: 'Noah', ln: 'Patel', co: 'Vertex Analytics', src: 'web', ind: 'technology', age: 7 }, + { fn: 'Maya', ln: 'Singh', co: 'BluePeak Logistics', src: 'referral', ind: 'logistics', age: 14 }, + { fn: 'Owen', ln: 'Becker', co: 'Northwind Energy', src: 'event', ind: 'energy', age: 21 }, + { fn: 'Sara', ln: 'Lopez', co: 'Helios Solar', src: 'partner', ind: 'energy', age: 28 }, + { fn: 'Leo', ln: 'Vance', co: 'CleanCart', src: 'web', ind: 'retail', age: 38 }, + { fn: 'Iris', ln: 'Okafor', co: 'PulseHealth', src: 'cold_call', ind: 'healthcare', age: 45 }, + { fn: 'Ravi', ln: 'Mehta', co: 'Foundry Robotics', src: 'event', ind: 'manufacturing',age: 52 }, + { fn: 'Tess', ln: 'Brown', co: 'Lattice Education', src: 'referral', ind: 'education', age: 67 }, + { fn: 'Marco', ln: 'Ricci', co: 'Aurora Travel', src: 'advertisement', ind: 'hospitality', age: 74 }, + { fn: 'Pia', ln: 'Anand', co: 'Citrine Finance', src: 'partner', ind: 'finance', age: 81 }, + { fn: 'Jonas', ln: 'Holt', co: 'Polar Cargo', src: 'web', ind: 'logistics', age: 95 }, + { fn: 'Anya', ln: 'Volkov', co: 'RedOak Realty', src: 'cold_call', ind: 'real_estate', age: 102 }, + { fn: 'Theo', ln: 'Park', co: 'Skyline Media', src: 'advertisement', ind: 'media', age: 116 }, + { fn: 'Wren', ln: 'Garcia', co: 'Maple Bakery Group', src: 'event', ind: 'retail', age: 123 }, + { fn: 'Hugo', ln: 'Dubois', co: 'Nimbus Aerospace', src: 'referral', ind: 'manufacturing',age: 138 }, + { fn: 'Lena', ln: 'Fischer', co: 'Granite Insurance', src: 'partner', ind: 'finance', age: 145 }, + { fn: 'Kai', ln: 'Watanabe', co: 'Coral Reef Hotels', src: 'web', ind: 'hospitality', age: 162 }, + { fn: 'Mira', ln: 'Costa', co: 'Atlas Construction', src: 'cold_call', ind: 'other', age: 175 }, + ].map((l, i) => { + const domain = `${l.co.toLowerCase().replace(/\s+/g, '')}.example.com`; + const status = (['new', 'contacted', 'qualified', 'unqualified'] as const)[i % 4]; + return { + first_name: l.fn, + last_name: l.ln, + company: l.co, + email: `${l.fn.toLowerCase()}.${l.ln.toLowerCase()}@${domain}`, + phone: `+1-555-01${String(i).padStart(2, '0')}-${String(1000 + i * 7)}`, + status, + lead_source: l.src, + industry: l.ind, + rating: 1 + ((i * 7) % 5), + last_contacted_date: celDaysAgo(l.age), + // The qualification fields were left blank on every generated lead, so + // the detail page's Contact / Lead Detail / Description sections had + // nothing to render (they also drop whatever the highlights strip + // already shows, so all four collapsed to nothing) and the demo read + // like a half-finished import. + title: (['VP Operations', 'Head of IT', 'Director of Sales', 'COO', 'Procurement Lead'] as const)[i % 5], + mobile: `+1-555-02${String(i).padStart(2, '0')}-${String(2000 + i * 3)}`, + website: `https://${domain}`, + annual_revenue: 2_000_000 + ((i * 3_700_000) % 90_000_000), + number_of_employees: 25 + ((i * 137) % 4_800), + description: + `Inbound via ${l.src.replace(/_/g, ' ')}. Evaluating a CRM to replace spreadsheets ` + + `across their ${l.ind.replace(/_/g, ' ')} operation.`, + ...(status === 'unqualified' + ? { + disqualification_reason: DISQUALIFICATION_REASONS[(i >> 2) % DISQUALIFICATION_REASONS.length].reason, + notes: DISQUALIFICATION_REASONS[(i >> 2) % DISQUALIFICATION_REASONS.length].note, + } + : {}), + }; + }), + ] +}); + +// ─── Line items: the one source of truth for deal value ─────────────── +/** + * Opportunity line items, keyed by the opportunity's name (its seed + * externalId). Every seeded deal is itemised — before #591 not one was, so the + * Products related list was empty on every opportunity in the demo and the CPQ + * story had nothing to show. + * + * These sums are AUTHORITATIVE: each opportunity's `amount` below is computed + * from its lines by `dealValue()` rather than typed in, which is the same + * answer `opportunity_amount_rollup` computes when a rep touches a line. There + * is therefore no seeded parent total that a first line-item edit can silently + * rewrite — the acceptance condition of #591 holds by construction rather than + * by anyone re-checking the arithmetic. + * + * Deriving rather than delegating is deliberate. Letting the rollup own the + * number would make every seeded `amount` depend on whether hooks fire over + * seed writes — which the doctrine block above says they do not and a boot log + * says they do (#617) — and on that hook surviving its own write call (#616). + * A derived literal is correct under every one of those combinations: if the + * rollup runs it recomputes the same figure, and if it never runs the figure + * was already right. + */ +export const OPPORTUNITY_LINES: Record = { + 'Acme Platform Upgrade': [ + { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, discount: 10, description: 'Enterprise edition — NA + EMEA production tenants (multi-year discount).' }, + { product: 'Premium Support', quantity: 1, unit_price: 25000, description: '24×7 premium support with a named TAM.' }, + { product: 'AI Agent Seat (Annual)', quantity: 35, unit_price: 1000, description: 'Agent seats for the Ops organization.' }, + ], + 'Globex Manufacturing Suite': [ + { product: 'ObjectStack Platform', quantity: 6, unit_price: 50000, description: 'Enterprise edition across six manufacturing sites.' }, + { product: 'Implementation Services', quantity: 2, unit_price: 75000, description: 'Two-phase implementation: plant operations, then commercial.' }, + { product: 'Premium Support', quantity: 2, unit_price: 25000, description: 'Premium support for both production regions.' }, + ], + 'Wayne Enterprise License': [ + { product: 'ObjectStack Platform', quantity: 20, unit_price: 50000, discount: 15, description: 'Enterprise-wide license, volume discount at 20 tenants.' }, + { product: 'Premium Support', quantity: 4, unit_price: 25000, description: 'Premium support across four business units.' }, + { product: 'AI Agent Seat (Annual)', quantity: 250, unit_price: 1000, description: 'Agent seats for the global revenue organization.' }, + ], + 'Initech Cloud Migration': [ + { product: 'Data Migration Services', quantity: 1, unit_price: 35000, description: 'Migration off the legacy on-premise CRM.' }, + { product: 'Cloud Hosting (Annual)', quantity: 2, unit_price: 12000, description: 'Managed hosting, primary + DR region.' }, + { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Business-hours support for the first year.' }, + { product: 'AI Agent Seat (Annual)', quantity: 12, unit_price: 1000, description: 'Agent seats for the service desk.' }, + ], + 'Acme Annual Renewal 2025': [ + { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Renewal of the three production tenants.' }, + { product: 'Premium Support', quantity: 1, unit_price: 25000, description: 'Premium support renewal.' }, + { product: 'AI Agent Seat (Annual)', quantity: 45, unit_price: 1000, description: 'Seat expansion driven by the new EMEA team.' }, + ], + 'Stark Medical Pilot': [ + { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Pilot tenants for two clinical service lines.' }, + { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support during the pilot.' }, + { product: 'Admin Training Workshop', quantity: 6, unit_price: 6000, description: 'Six workshop days for the clinical operations admins.' }, + ], + 'Wayne Q1 Expansion': [ + { product: 'ObjectStack Platform', quantity: 6, unit_price: 50000, description: 'Expansion into six additional subsidiaries.' }, + { product: 'Premium Support', quantity: 2, unit_price: 25000, description: 'Premium support for the expanded footprint.' }, + { product: 'AI Agent Seat (Annual)', quantity: 30, unit_price: 1000, description: 'Agent seats for the expansion teams.' }, + ], + 'Globex Training Package': [ + { product: 'Admin Training Workshop', quantity: 10, unit_price: 6000, description: 'Ten workshop days across the plant admin community.' }, + { product: 'AI Agent Seat (Annual)', quantity: 5, unit_price: 1000, description: 'Agent seats for the training sandbox.' }, + ], + 'Initech Phase 1': [ + { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Phase 1 implementation: accounts, contacts, pipeline.' }, + { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support for phase 1.' }, + { product: 'AI Agent Seat (Annual)', quantity: 6, unit_price: 1000, description: 'Agent seats for the phase-1 pilot group.' }, + ], + 'Acme Add-on (Lost)': [ + { product: 'Analytics Add-on', quantity: 2, unit_price: 22000, description: 'Marketing analytics for two business units.' }, + { product: 'Integration Connector Pack', quantity: 1, unit_price: 16000, description: 'Marketing-automation connector pack.' }, + { product: 'Sandbox Environment (Annual)', quantity: 2, unit_price: 7500, description: 'Two sandboxes for the marketing build-out.' }, + ], + 'Stark Expansion (Lost)': [ + { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Two additional clinical tenants.' }, + { product: 'Field Service Mobile', quantity: 1, unit_price: 14000, description: 'Field service mobile for the device-servicing team.' }, + { product: 'AI Agent Seat (Annual)', quantity: 6, unit_price: 1000, description: 'Agent seats for the service coordinators.' }, + ], + 'Northwind Grid Modernization': [ + { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Enterprise edition for grid, field and customer operations.' }, + { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Operational-data assessment and implementation.' }, + { product: 'AI Agent Seat (Annual)', quantity: 15, unit_price: 1000, description: 'Agent seats for the control-room pilot.' }, + ], + 'Lattice Student Success Platform': [ + { product: 'ObjectStack Platform (SMB Edition)', quantity: 5, unit_price: 18000, description: 'Student-success tenants for five colleges.' }, + { product: 'Admin Training Workshop', quantity: 5, unit_price: 6000, description: 'Five workshop days for the student-success advisers.' }, + { product: 'AI Agent Seat (Annual)', quantity: 5, unit_price: 1000, description: 'Agent seats for the advising team.' }, + ], + 'Vertex Analytics Expansion': [ + { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Two tenants for the revenue organization.' }, + { product: 'Analytics Add-on', quantity: 5, unit_price: 22000, description: 'Analytics for sales, marketing, service, finance and ops.' }, + { product: 'Premium Support', quantity: 2, unit_price: 25000, description: 'Premium support for the expanded footprint.' }, + { product: 'AI Agent Seat (Annual)', quantity: 60, unit_price: 1000, description: 'Agent seats for the full revenue organization.' }, + ], + 'Apex Logistics Data Hub': [ + { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Data-hub tenants for commercial, operations and finance.' }, + { product: 'Data Migration Services', quantity: 1, unit_price: 35000, description: 'Consolidation of four fragmented commercial systems.' }, + { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Architecture workshop through go-live.' }, + { product: 'AI Agent Seat (Annual)', quantity: 15, unit_price: 1000, description: 'Agent seats for the commercial operations team.' }, + ], + 'Lattice Education Renewal': [ + { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Renewal of the three institution tenants.' }, + { product: 'Analytics Add-on', quantity: 1, unit_price: 22000, description: 'Expanded analytics package for the renewal term.' }, + { product: 'Admin Training Workshop', quantity: 3, unit_price: 6000, description: 'Three workshop days for the new admissions admins.' }, + { product: 'AI Agent Seat (Annual)', quantity: 20, unit_price: 1000, description: 'Student-success automation seats.' }, + ], + 'Vertex Enterprise Rollout': [ + { product: 'ObjectStack Platform', quantity: 10, unit_price: 50000, discount: 20, description: 'Phased enterprise rollout, ten tenants (volume discount).' }, + { product: 'Implementation Services', quantity: 1, unit_price: 75000, description: 'Phased deployment with data-residency controls.' }, + { product: 'Premium Support', quantity: 4, unit_price: 25000, description: 'Premium support across four regions.' }, + { product: 'AI Agent Seat (Annual)', quantity: 100, unit_price: 1000, description: 'Agent seats for the first rollout wave.' }, + ], + // Campaign-attributed wins — see the campaign metrics note below. + 'Lattice Analytics Expansion': [ + { product: 'Analytics Add-on', quantity: 2, unit_price: 22000, description: 'Analytics for admissions and alumni engagement.' }, + { product: 'Admin Training Workshop', quantity: 1, unit_price: 6000, description: 'Analytics enablement day for the institutional-research team.' }, + { product: 'AI Agent Seat (Annual)', quantity: 20, unit_price: 1000, description: 'Agent seats for the admissions team.' }, + ], + 'Wayne Operations Module Rollout': [ + { product: 'ObjectStack Platform', quantity: 2, unit_price: 50000, description: 'Operations tenants for two manufacturing divisions.' }, + { product: 'Standard Support', quantity: 1, unit_price: 9000, description: 'Standard support for the operations rollout.' }, + { product: 'AI Agent Seat (Annual)', quantity: 11, unit_price: 1000, description: 'Agent seats for the operations planners.' }, + ], + 'Vertex Developer Platform Adoption': [ + { product: 'ObjectStack Platform', quantity: 3, unit_price: 50000, description: 'Developer platform tenants for three product teams.' }, + { product: 'Integration Connector Pack', quantity: 1, unit_price: 16000, description: 'Connector pack for the internal data warehouse.' }, + { product: 'Analytics Add-on', quantity: 1, unit_price: 22000, description: 'Product-usage analytics for the platform team.' }, + { product: 'AI Agent Seat (Annual)', quantity: 12, unit_price: 1000, description: 'Agent seats for the developer-experience team.' }, + ], +}; + +/** + * `amount` + `expected_revenue` for a seeded deal, both derived from its line + * items. `expected_revenue` uses opportunity.hook.ts's own expression + * (`Math.round(amount * probability) / 100`) so the two agree to the cent. + */ +const dealValue = (opportunityName: string, probability: number) => { + const lines = OPPORTUNITY_LINES[opportunityName]; + if (!lines) throw new Error(`Seed error: no line items authored for opportunity "${opportunityName}"`); + const amount = linesTotal(lines); + return { amount, expected_revenue: Math.round(amount * probability) / 100 }; +}; + +// ─── Opportunities ──────────────────────────────────────────────────── +// `probability`, `forecast_category` and `expected_revenue` are derived from +// `stage` by opportunity.hook.ts (STAGE_PROBABILITY / STAGE_FORECAST). Hooks +// don't run over seeds, so every row below carries the exact values the hook +// would compute — anything else gets silently rewritten on the first user +// edit (#490). +// +// `stage_entry_date` follows the same rule: the hook stamps it on every insert +// and stage change, so a seeded deal without one is a shape real data never +// has — and it is what the stagnation sweep filters on and what the +// `days_in_stage` FORMULA counts from, so it replaces the old hardcoded +// `days_in_stage` numbers (#489). Open deals carry an age that spans the +// 14-day stale threshold in both directions; settled deals entered their +// closed stage on their close date. +export const opportunities = defineSeed(Opportunity, { + mode: 'upsert', + externalId: 'name', + records: [ + { + name: 'Acme Platform Upgrade', + crm_account: 'Acme Corporation', + ...dealValue('Acme Platform Upgrade', 60), + stage: 'proposal', + probability: 60, + close_date: cel`daysFromNow(30)`, + type: 'existing_upgrade', + forecast_category: 'commit', + lead_source: 'web', + stage_entry_date: celDaysAgo(12), + description: `Upgrade from Standard to Enterprise edition for the +NA + EMEA teams. Drivers: (1) AI agent governance becomes a hard +requirement after their internal compliance review, (2) advanced +analytics seats for the Ops org, (3) priority support SLA.`, + next_step: `Send the revised proposal (Enterprise edition, 18-month term, 12% multi-year discount) to Jordan Park by EOW. Schedule the AI governance workshop for the week of close_date - 14d.`, + }, + { + name: 'Globex Manufacturing Suite', + crm_account: 'Globex Industries', + ...dealValue('Globex Manufacturing Suite', 25), + stage: 'qualification', + probability: 25, + close_date: cel`daysFromNow(60)`, + type: 'new_business', + forecast_category: 'pipeline', + lead_source: 'referral', + stage_entry_date: celDaysAgo(45), + }, + { + name: 'Wayne Enterprise License', + crm_account: 'Wayne Enterprises', + ...dealValue('Wayne Enterprise License', 80), + stage: 'negotiation', + probability: 80, + close_date: cel`daysFromNow(14)`, + type: 'new_business', + forecast_category: 'commit', + lead_source: 'partner', + stage_entry_date: celDaysAgo(7), + }, + { + name: 'Initech Cloud Migration', + crm_account: 'Initech Solutions', + ...dealValue('Initech Cloud Migration', 40), + stage: 'needs_analysis', + probability: 40, + close_date: cel`daysFromNow(45)`, + type: 'existing_upgrade', + forecast_category: 'best_case', + lead_source: 'event', + stage_entry_date: celDaysAgo(38), + }, + // ─── Closed Won deals (powers KPIs & revenue trends) ──────────────── + { + name: 'Acme Annual Renewal 2025', + crm_account: 'Acme Corporation', + ...dealValue('Acme Annual Renewal 2025', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(15)`, + stage_entry_date: cel`daysAgo(15)`, + type: 'existing_renewal', + forecast_category: 'closed', + lead_source: 'partner', + description: `Annual renewal of the Acme Standard subscription (40 seats), signed two weeks ahead of the renewal date. 22% YoY uplift driven by seat expansion in the new EMEA team. Multi-year option declined this round — they want to see how the platform upgrade lands first.`, + }, + { + name: 'Stark Medical Pilot', + crm_account: 'Stark Medical', + ...dealValue('Stark Medical Pilot', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(50)`, + stage_entry_date: cel`daysAgo(50)`, + type: 'new_business', + forecast_category: 'closed', + lead_source: 'event', + }, + { + name: 'Wayne Q1 Expansion', + crm_account: 'Wayne Enterprises', + ...dealValue('Wayne Q1 Expansion', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(95)`, + stage_entry_date: cel`daysAgo(95)`, + type: 'existing_upgrade', + forecast_category: 'closed', + lead_source: 'web', + }, + { + name: 'Globex Training Package', + crm_account: 'Globex Industries', + ...dealValue('Globex Training Package', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(140)`, + stage_entry_date: cel`daysAgo(140)`, + type: 'new_business', + forecast_category: 'closed', + lead_source: 'referral', + }, + { + name: 'Initech Phase 1', + crm_account: 'Initech Solutions', + ...dealValue('Initech Phase 1', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(200)`, + stage_entry_date: cel`daysAgo(200)`, + type: 'new_business', + forecast_category: 'closed', + lead_source: 'web', + }, + // ─── Campaign-attributed wins ─────────────────────────────────────── + // `crm_campaign` is what `campaign_snapshot_metrics` counts when a campaign + // completes (`num_opportunities` / `num_won_opportunities` / the summed + // `actual_revenue`). Before #591 not one opportunity carried it, so those + // three metrics were structurally zero and every campaign's seeded + // `actual_revenue` was a number the hook would have erased on completion. + // These three wins are what make the marketing ROI numbers below TRUE. + { + name: 'Lattice Analytics Expansion', + crm_account: 'Lattice Education', + primary_contact: 'priya.shah@lattice.example.com', + crm_campaign: 'Q3 Enterprise Email Nurture', + ...dealValue('Lattice Analytics Expansion', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(6)`, + stage_entry_date: cel`daysAgo(6)`, + type: 'existing_expansion', + forecast_category: 'closed', + lead_source: 'email_campaign', + description: 'Analytics package for admissions and alumni engagement, sourced from the enterprise nurture track.', + }, + { + name: 'Wayne Operations Module Rollout', + crm_account: 'Wayne Enterprises', + primary_contact: 'rwilson@wayne.example.com', + crm_campaign: 'Operations Platform Launch', + ...dealValue('Wayne Operations Module Rollout', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(4)`, + stage_entry_date: cel`daysAgo(4)`, + type: 'existing_upgrade', + forecast_category: 'closed', + lead_source: 'content', + description: 'Operations module for two manufacturing divisions, closed off the operations-platform launch program.', + }, + { + name: 'Vertex Developer Platform Adoption', + crm_account: 'Vertex Analytics', + primary_contact: 'ethan.brooks@vertex.example.com', + crm_campaign: 'Developer Content Marketing Push', + ...dealValue('Vertex Developer Platform Adoption', 100), + stage: 'closed_won', + probability: 100, + close_date: cel`daysAgo(6)`, + stage_entry_date: cel`daysAgo(6)`, + type: 'existing_expansion', + forecast_category: 'closed', + lead_source: 'content', + description: 'Three product teams adopted the developer platform after the technical content series.', + }, + // Closed Lost deals (powers win-rate analytics) + { + name: 'Acme Add-on (Lost)', + crm_account: 'Acme Corporation', + ...dealValue('Acme Add-on (Lost)', 0), + stage: 'closed_lost', + probability: 0, + close_date: cel`daysAgo(25)`, + stage_entry_date: cel`daysAgo(25)`, + type: 'existing_upgrade', + forecast_category: 'omitted', + lead_source: 'cold_call', + description: `Tried to bolt on the Marketing Cloud module via cold outbound. Lost because Acme's marketing org is already on a 2-year HubSpot contract. Revisit in Q3 when that contract is up for renewal.`, + }, + { + name: 'Stark Expansion (Lost)', + crm_account: 'Stark Medical', + ...dealValue('Stark Expansion (Lost)', 0), + stage: 'closed_lost', + probability: 0, + close_date: cel`daysAgo(60)`, + stage_entry_date: cel`daysAgo(60)`, + type: 'new_business', + forecast_category: 'omitted', + lead_source: 'advertisement', + }, + // ─── Curated active pipeline ───────────────────────────────────── + // Two cards in each active stage make the kanban immediately legible in + // a customer demo. They also tell a coherent sales story instead of + // overwhelming the board with anonymous generated rows. + { + name: 'Northwind Grid Modernization', + crm_account: 'Northwind Energy', + primary_contact: 'olivia.chen@northwind.example.com', + ...dealValue('Northwind Grid Modernization', 10), + stage: 'prospecting', + probability: 10, + close_date: cel`daysFromNow(72)`, + type: 'new_business', + forecast_category: 'pipeline', + lead_source: 'webinar', + stage_entry_date: celDaysAgo(4), + description: 'Northwind is assessing a unified grid-modernization program after an executive webinar.', + next_step: 'Confirm discovery participants and agree the operational-data assessment scope.', + }, + { + name: 'Lattice Student Success Platform', + crm_account: 'Lattice Education', + primary_contact: 'priya.shah@lattice.example.com', + crm_campaign: 'Developer Content Marketing Push', + ...dealValue('Lattice Student Success Platform', 10), + stage: 'prospecting', + probability: 10, + close_date: cel`daysFromNow(96)`, + type: 'new_business', + forecast_category: 'pipeline', + lead_source: 'content', + stage_entry_date: celDaysAgo(11), + description: 'A new student-success initiative sourced from the higher-education content series.', + next_step: 'Invite the operations sponsor to the discovery workshop.', + }, + { + name: 'Vertex Analytics Expansion', + crm_account: 'Vertex Analytics', + primary_contact: 'ethan.brooks@vertex.example.com', + ...dealValue('Vertex Analytics Expansion', 25), + stage: 'qualification', + probability: 25, + close_date: cel`daysFromNow(52)`, + type: 'existing_expansion', + forecast_category: 'pipeline', + lead_source: 'partner', + stage_entry_date: celDaysAgo(8), + description: 'Vertex wants to extend the platform from its data team to the full revenue organization.', + next_step: 'Validate the buying committee, budget owner and expansion timeline.', + }, + { + name: 'Apex Logistics Data Hub', + crm_account: 'Apex Logistics', + primary_contact: 'marcus.reed@apexlogistics.example.com', + ...dealValue('Apex Logistics Data Hub', 40), + stage: 'needs_analysis', + probability: 40, + close_date: cel`daysFromNow(38)`, + type: 'new_business', + forecast_category: 'best_case', + lead_source: 'partner', + stage_entry_date: celDaysAgo(18), + description: 'Apex is mapping its fragmented commercial systems before committing to a data-hub program.', + next_step: 'Complete the architecture workshop and quantify the integration backlog.', + }, + { + name: 'Lattice Education Renewal', + crm_account: 'Lattice Education', + primary_contact: 'priya.shah@lattice.example.com', + crm_campaign: 'Q3 Enterprise Email Nurture', + ...dealValue('Lattice Education Renewal', 60), + stage: 'proposal', + probability: 60, + close_date: cel`daysFromNow(24)`, + type: 'existing_renewal', + forecast_category: 'commit', + lead_source: 'email_campaign', + stage_entry_date: celDaysAgo(6), + description: 'Renewal proposal adds student-success automation and an expanded analytics package.', + next_step: 'Review the two-year option with procurement before the executive sponsor meeting.', + }, + { + name: 'Vertex Enterprise Rollout', + crm_account: 'Vertex Analytics', + primary_contact: 'ethan.brooks@vertex.example.com', + ...dealValue('Vertex Enterprise Rollout', 80), + stage: 'negotiation', + probability: 80, + close_date: cel`daysFromNow(11)`, + type: 'existing_expansion', + forecast_category: 'commit', + lead_source: 'referral', + stage_entry_date: celDaysAgo(3), + description: 'An enterprise rollout negotiated around phased deployment and data-residency requirements.', + next_step: 'Resolve the final security addendum and confirm the procurement signature date.', + }, + ] +}); + +// ─── Opportunity line items ─────────────────────────────────────────── +// Identity is the COMPOSITE natural key (opportunity, product): the object has +// no single natural key — no name, and `line_number` is only unique within a +// parent — and a junction-shaped dataset without one can only run +// `mode: 'insert'`, which re-inserts every row on each replay boot and +// duplicates the table (framework#3434). The loader matches composite key +// fields by their RESOLVED ids, so this dedupes correctly across restarts. +// The practical constraint it imposes: a product appears at most once per +// deal, which is how these lines are authored anyway. +export const opportunityLineItems = defineSeed(OpportunityLineItem, { + mode: 'upsert', + externalId: ['crm_opportunity', 'crm_product'], + records: lineItemRecords('crm_opportunity', OPPORTUNITY_LINES), +}); diff --git a/src/data/service.seed.ts b/src/data/service.seed.ts new file mode 100644 index 00000000..dd73686a --- /dev/null +++ b/src/data/service.seed.ts @@ -0,0 +1,403 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Service seeds — the task list, the case backlog and the knowledge base. + * + * Tasks live here rather than with the sales pipeline because they are the + * activity layer over every object (accounts, opportunities AND cases), and the + * case seeds are the records they most often hang off. + * + * Split out of the former monolithic `src/data/index.ts` (#635). Seed doctrine + * lives in `./_shared.ts`. + */ +import { defineSeed } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; +import { Task } from '../objects/task.object'; +import { Case } from '../objects/case.object'; +import { KnowledgeArticle } from '../objects/knowledge_article.object'; +import { celDaysAgo, celDaysFromNow } from './_shared'; + +// ─── Tasks ──────────────────────────────────────────────────────────── +// Polymorphic parents need BOTH halves: `related_to_type` names the parent +// object and the matching `related_to_*` lookup carries the record. The +// Related tab and task.hook's activity bubble key off `related_to_type`, so a +// lookup without it is invisible to both (#490). +export const tasks = defineSeed(Task, { + mode: 'upsert', + externalId: 'subject', + records: [ + { + subject: 'Follow up with Acme on proposal', + description: 'Send Jordan Park the revised Enterprise proposal and a 30-min calendar slot to walk through the AI governance section.', + status: 'not_started', + priority: 'high', + priority_rank: 3, + due_date: cel`daysFromNow(2)`, + related_to_type: 'crm_opportunity', + related_to_account: 'Acme Corporation', + related_to_opportunity: 'Acme Platform Upgrade', + }, + { + subject: 'Acme — schedule AI governance workshop', + description: 'Block a 90-min joint workshop with Acme’s compliance team to walk through how HotCRM agents handle data scoping, RBAC, audit trails, and human-in-the-loop. Pre-read: ADR-0007 + the governance demo deck.', + status: 'not_started', + priority: 'high', + priority_rank: 3, + due_date: cel`daysFromNow(7)`, + related_to_type: 'crm_opportunity', + related_to_account: 'Acme Corporation', + related_to_opportunity: 'Acme Platform Upgrade', + }, + { + subject: 'Acme — close out login-issues ticket before SLA', + description: 'Confirm engineering has the EMEA SSO clock-skew patch ready, deploy to Acme’s tenant, and send Lisa Kim a customer-facing post-mortem.', + status: 'in_progress', + priority: 'urgent', + priority_rank: 4, + due_date: cel`daysFromNow(1)`, + related_to_type: 'crm_case', + related_to_account: 'Acme Corporation', + related_to_case: 'Login issues after platform upgrade', + }, + { + subject: 'Schedule demo for Globex team', + status: 'in_progress', + priority: 'normal', + priority_rank: 2, + due_date: cel`daysFromNow(5)`, + related_to_type: 'crm_account', + related_to_account: 'Globex Industries', + }, + { + subject: 'Prepare contract for Wayne Enterprises', + status: 'not_started', + priority: 'urgent', + priority_rank: 4, + due_date: cel`daysFromNow(1)`, + related_to_type: 'crm_account', + related_to_account: 'Wayne Enterprises', + }, + { + subject: 'Send welcome package to Stark Medical', + status: 'completed', + priority: 'low', + priority_rank: 1, + completed_date: cel`daysAgo(2)`, + // Hooks don't run over seeds — mirror what task_completion would stamp. + is_completed: true, + progress_percent: 100, + related_to_type: 'crm_account', + related_to_account: 'Stark Medical', + }, + { + // Internal housekeeping task — deliberately unparented (the + // related_to_required rule is a warning, not an error). + subject: 'Update CRM pipeline report', + status: 'not_started', + priority: 'normal', + priority_rank: 2, + due_date: cel`daysFromNow(7)`, + }, + ] +}); + +// ─── Cases ──────────────────────────────────────────────────────────── +export const cases = defineSeed(Case, { + mode: 'upsert', + externalId: 'subject', + records: [ + { + subject: 'Login issues after platform upgrade', + description: `Users in the EMEA office report intermittent 401 errors when logging in after the v4.2 upgrade rolled out Wednesday night. Pattern: only affects users authenticating via SAML through Okta, only between 09:00–10:30 UTC. NA and APAC users are unaffected. + +**Customer impact:** ~40 users blocked at peak, costing ~3 productive hours per affected user. + +**Initial triage:** Suspect a clock-skew issue on the EMEA SSO relay added during the upgrade window. Engineering is reproducing in staging.`, + crm_account: 'Acme Corporation', + crm_contact: 'john.smith@acme.example.com', + status: 'in_progress', + priority: 'high', + priority_rank: 3, + type: 'problem', + origin: 'email', + is_closed: false, + is_sla_violated: false, + is_escalated: false, + created_date: cel`daysAgo(2)`, + sla_due_date: cel`daysFromNow(1)`, + }, + { + subject: 'Data export timing out for large datasets', + description: 'CSV export fails for datasets over 10k rows.', + crm_account: 'Globex Industries', + crm_contact: 'sarah.j@globex.example.com', + status: 'escalated', + priority: 'critical', + priority_rank: 4, + type: 'bug', + origin: 'phone', + is_closed: false, + is_sla_violated: true, + is_escalated: true, + escalation_reason: 'Customer threatening churn', + created_date: cel`daysAgo(5)`, + sla_due_date: cel`daysAgo(2)`, + }, + { + subject: 'How to configure SSO with Okta?', + description: 'Customer needs guidance on SSO setup with Okta.', + crm_account: 'Initech Solutions', + crm_contact: 'mchen@initech.example.com', + status: 'resolved', + priority: 'medium', + priority_rank: 2, + type: 'question', + origin: 'web', + // Resolved-but-not-closed: case.hook keeps is_closed=false and stamps + // closed_date as the resolved-date proxy; resolution_time_hours is the + // closed−created delta the hook would compute (daysAgo() is day-granular, + // so deltas come in 24h steps). + is_closed: false, + is_sla_violated: false, + is_escalated: false, + resolution_time_hours: 24.0, + created_date: cel`daysAgo(3)`, + closed_date: cel`daysAgo(2)`, + sla_due_date: cel`daysFromNow(2)`, + }, + { + subject: 'API rate limit exceeded on production', + description: 'Production environment hitting rate limits during peak hours.', + crm_account: 'Wayne Enterprises', + crm_contact: 'rwilson@wayne.example.com', + status: 'closed', + priority: 'high', + priority_rank: 3, + type: 'problem', + origin: 'chat', + is_closed: true, + is_sla_violated: false, + is_escalated: false, + // `resolution` is REQUIRED when status is 'closed' (object validation + // `resolution_required_for_closed`) — without it the seed row is rejected. + resolution: 'Raised the production rate-limit tier and added client-side backoff; usage now within limits.', + // closed−created delta, as case.hook computes it. + resolution_time_hours: 24.0, + created_date: cel`daysAgo(7)`, + closed_date: cel`daysAgo(6)`, + sla_due_date: cel`daysAgo(6)`, + }, + { + subject: 'PDF reports not rendering charts correctly', + description: 'Charts appear blank when exporting dashboard to PDF.', + crm_account: 'Stark Medical', + crm_contact: 'emily.d@starkmed.example.com', + status: 'new', + priority: 'medium', + priority_rank: 2, + type: 'bug', + origin: 'email', + is_closed: false, + is_sla_violated: false, + is_escalated: false, + created_date: cel`daysAgo(1)`, + sla_due_date: cel`daysFromNow(2)`, + }, + { + subject: 'Billing discrepancy on last invoice', + description: `Customer (Lisa Kim, Procurement) flagged that the May invoice shows 15 active seats but Acme is only using 12. Two of the seats were de-provisioned in early April when two engineers left the company. + +**Root cause:** the de-provisioning happened in our admin console but the seat-count metric in billing only refreshes monthly, so the May invoice picked up the pre-change count. + +**Resolution path:** issue a $1,200 credit memo and switch Acme to the new real-time seat-billing pipeline so this can't recur. Waiting on Lisa to confirm she's good with the credit-memo treatment vs a refund.`, + crm_account: 'Acme Corporation', + crm_contact: 'john.smith@acme.example.com', + status: 'waiting_customer', + priority: 'low', + priority_rank: 1, + type: 'problem', + origin: 'email', + is_closed: false, + is_sla_violated: false, + is_escalated: false, + created_date: cel`daysAgo(4)`, + sla_due_date: cel`daysFromNow(3)`, + }, + { + subject: 'Mobile app crashes on iOS 17', + description: 'App crashes on launch for users running iOS 17.2+.', + crm_account: 'Globex Industries', + crm_contact: 'sarah.j@globex.example.com', + status: 'in_progress', + priority: 'critical', + priority_rank: 4, + type: 'bug', + origin: 'web', + is_closed: false, + is_sla_violated: true, + is_escalated: true, + escalation_reason: 'Affects 30% of mobile users', + created_date: cel`daysAgo(3)`, + sla_due_date: cel`daysAgo(1)`, + }, + { + subject: 'Request: bulk import via CSV', + description: 'Customer requesting ability to import records via CSV upload.', + crm_account: 'Wayne Enterprises', + crm_contact: 'rwilson@wayne.example.com', + status: 'closed', + priority: 'low', + priority_rank: 1, + type: 'feature_request', + origin: 'web', + is_closed: true, + is_sla_violated: false, + is_escalated: false, + // Required for closed cases (resolution_required_for_closed). + resolution: 'Delivered CSV bulk-import in the 9.4 release; shared the docs link with the customer.', + // closed−created delta, as case.hook computes it. + resolution_time_hours: 48.0, + created_date: cel`daysAgo(10)`, + closed_date: cel`daysAgo(8)`, + sla_due_date: cel`daysAgo(8)`, + }, + // ─── Generated demo cases — 30 cases over the last 30 days, mixed across + // priorities. Powers `CasesOpenedByDayPriorityReport` (daily bucketing + // matrix) and the service dashboard's daily-volume area chart. + ...((): readonly Record[] => { + const priorities = ['low', 'medium', 'high', 'critical'] as const; + // Mirror of case.hook's priority rank map — hooks don't run over seeds. + const rankByPriority: Record = { low: 1, medium: 2, high: 3, critical: 4 }; + const types = ['question', 'bug', 'problem', 'feature_request'] as const; + const origins = ['email', 'phone', 'web', 'chat'] as const; + const statuses = ['new', 'in_progress', 'resolved', 'closed', 'escalated'] as const; + const accountsList = ['Acme Corporation', 'Globex Industries', 'Wayne Enterprises', 'Initech Solutions', 'Stark Medical'] as const; + const out: Record[] = []; + for (let i = 0; i < 30; i++) { + const priority = priorities[i % priorities.length]; + const status = statuses[i % statuses.length]; + const settled = status === 'resolved' || status === 'closed'; + const ageDays = 1 + (i % 30); + // Settled cases get a resolution delay of 1–3 days (capped at the + // case's age); resolution_time_hours is exactly the closed−created + // delta case.hook would compute (daysAgo() is day-granular → 24h steps). + const resolutionDays = Math.min(ageDays, 1 + (i % 3)); + // SLA breaches only make sense on OPEN cases with a due date already in + // the past (the case_sla_monitor flow's definition). The old generator + // flagged rows as violated while giving every row a FUTURE due date. + const slaViolated = !settled && priority === 'critical' && i % 3 === 0; + out.push({ + subject: `Demo case ${String(i + 1).padStart(2, '0')} — ${priority} ${types[i % types.length]}`, + description: `Auto-generated demo case for ${priority} priority on day -${ageDays}.`, + crm_account: accountsList[i % accountsList.length], + status, + priority, + priority_rank: rankByPriority[priority], + type: types[i % types.length], + origin: origins[i % origins.length], + // is_closed strictly mirrors case.hook: true ONLY for status + // 'closed' — a resolved case is NOT closed yet. + is_closed: status === 'closed', + is_sla_violated: slaViolated, + is_escalated: status === 'escalated', + ...(settled ? { resolution_time_hours: resolutionDays * 24 } : {}), + // Object validations require these when closed/escalated — without + // them the generated rows are rejected (resolution_required_for_closed + // / escalation_reason_required). + ...(status === 'closed' ? { resolution: 'Resolved per standard runbook; root cause documented and customer confirmed.' } : {}), + ...(status === 'escalated' ? { escalation_reason: 'Escalated to tier-2 engineering for SLA-risk review.' } : {}), + created_date: celDaysAgo(ageDays), + // Resolved cases also carry closed_date: case.hook stamps it as the + // resolved-date proxy while keeping is_closed=false. + ...(settled ? { closed_date: celDaysAgo(ageDays - resolutionDays) } : {}), + sla_due_date: slaViolated + ? celDaysAgo(1) + : celDaysFromNow(priority === 'critical' ? 1 : priority === 'high' ? 2 : 4), + }); + } + return out; + })(), + ], +}); + +// ─── Knowledge Articles ─────────────────────────────────────────────── +export const knowledgeArticles = defineSeed(KnowledgeArticle, { + mode: 'upsert', + externalId: 'title', + records: [ + { + title: 'Getting Started with HotCRM', + summary: 'A five-minute tour of accounts, contacts, leads and the sales pipeline.', + category: 'getting_started', + status: 'published', + audience: 'public', + language: 'en', + body: `# Getting Started with HotCRM + +Welcome! This guide walks you through the core objects: + +1. **Accounts** — the companies you sell to and serve. +2. **Contacts** — the people at those accounts. +3. **Leads** — unqualified prospects in the top of the funnel. +4. **Opportunities** — qualified deals moving through your pipeline. + +Open the **Sales Pipeline** kanban to drag deals between stages, and use the +**Executive Overview** dashboard to track revenue at a glance.`, + published_at: cel`daysAgo(40)`, + last_reviewed_at: cel`daysAgo(20)`, + view_count: 412, + helpful_count: 38, + not_helpful_count: 2, + }, + { + title: 'Resetting Your Password', + summary: 'How end users reset a forgotten password from the login screen.', + category: 'how_to', + status: 'published', + audience: 'public', + language: 'en', + body: `# Resetting Your Password + +1. On the login screen, click **Forgot password?** +2. Enter the email associated with your account. +3. Check your inbox for a reset link (valid for 30 minutes). +4. Choose a new password of at least 12 characters. + +If the email does not arrive, check spam or contact your administrator.`, + published_at: cel`daysAgo(25)`, + last_reviewed_at: cel`daysAgo(10)`, + view_count: 1280, + helpful_count: 96, + not_helpful_count: 7, + }, + { + title: 'API Rate Limits', + summary: 'Per-token request quotas and recommended back-off strategy.', + category: 'api', + status: 'draft', + audience: 'internal', + language: 'en', + body: `# API Rate Limits (DRAFT) + +Default quota is 600 requests/minute per token. On HTTP 429, back off +exponentially starting at 1s. Numbers pending final review with platform team.`, + }, + { + title: 'Legacy SSO Setup', + summary: 'SAML configuration for the pre-2025 identity stack.', + category: 'troubleshooting', + status: 'published', + audience: 'internal', + language: 'en', + body: `# Legacy SSO Setup + +This covers the deprecated SAML 1.1 flow. New tenants should use the OIDC +connector instead. Retained for customers still on the legacy stack.`, + published_at: cel`daysAgo(240)`, + last_reviewed_at: cel`daysAgo(220)`, + view_count: 64, + helpful_count: 5, + not_helpful_count: 9, + }, + ] +}); diff --git a/test/territory-seed-coverage.test.ts b/test/territory-seed-coverage.test.ts new file mode 100644 index 00000000..32fed11b --- /dev/null +++ b/test/territory-seed-coverage.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { compileCelToFilter } from '@objectstack/formula'; +import stack from '../objectstack.config'; +import accountHook from '../src/objects/account.hook'; +import { CrmSeedData } from '../src/data/index'; +import * as sharedSeed from '../src/data/_shared'; +import * as catalogSeed from '../src/data/catalog.seed'; +import * as salesSeed from '../src/data/sales.seed'; +import * as serviceSeed from '../src/data/service.seed'; +import * as marketingSeed from '../src/data/marketing.seed'; +import * as revenueSeed from '../src/data/revenue.seed'; + +/** + * The territory rules must match REAL seeded accounts (#638). + * + * ### The defect this file exists to catch + * + * #621 fixed the installation half: both territory rules now compile and seed + * (`test/sharing-seeding.test.ts` pins that). The data half was still empty — + * not one seeded account carried a `billing_address`, so both rules evaluated + * correctly against nine rows and matched **zero**. Nothing failed: Setup + * listed two territories, two positions, and nothing behind them. + * + * A "declared rule is seeded" assertion cannot see that, and neither can a + * "seed has an address" assertion on its own — the two only mean something + * joined. So this file walks the entire chain the demo depends on, using the + * platform's own pieces at every step rather than a re-implementation: + * + * seed record → `account_protection` (the real hook body) + * → `billing_country` + * → `compileCelToFilter(rule.condition)` (the seeder's own + * compiler, the same call `plugin-sharing` makes) + * → which accounts each territory actually covers. + * + * ### Hooks DO run over seed writes — measured, not assumed + * + * The chain above is only valid because lifecycle hooks fire on seed inserts, + * which is what makes `billing_country` exist at all on a seeded row (the seeds + * deliberately do not author it — it is `readonly` and hook-owned). #617 + * doubted this; measured on 17.0.0-rc.1 against a fresh install, all nine + * seeded accounts come out of `pnpm dev` carrying the projection: + * + * sqlite> SELECT name, billing_country FROM crm_account; + * Acme Corporation|US Stark Medical|CA Northwind Energy|DE + * Apex Logistics|SG Lattice Education|UK … + * + * `SeedLoaderService.SEED_OPTIONS` says the same thing in words: its + * `skipTriggers` suppresses record-change AUTOMATION (autolaunched flows) for + * seed writes, while "Lifecycle HOOKS (derived/default fields, validation) + * still run". `src/data/_shared.ts` now states the corrected premise. + * + * ### What is NOT covered here + * + * A matching account still materialises no `sys_record_share` row, because on a + * fresh install `sys_user_position` is empty — nobody holds `na_sales_team` or + * `eu_sales_team` (measured: 0 rows, 0 shares). That is a separate gap, filed + * separately; it is not something the seed data can fix, since a seed cannot + * name a user (see the note at the foot of `src/data/index.ts`). + */ + +type AnyRec = Record; +type Dataset = { object: string; records: AnyRec[] }; + +const datasets = CrmSeedData as unknown as Dataset[]; +const accountRecords = datasets.filter((d) => d.object === 'crm_account').flatMap((d) => d.records); + +const sharingRules: AnyRec[] = (stack as any).sharingRules ?? []; +const ruleByName = (name: string) => sharingRules.find((r) => r.name === name); +const TERRITORY_RULES = ['north_america_territory', 'europe_territory'] as const; + +/** + * The projection exactly as a seed insert produces it: the REAL + * `account_protection` body, invoked with the event the seed loader raises. + * Re-implementing `country → billing_country` here would let the two drift and + * still pass, which is the failure mode this whole file is about. + */ +async function project(record: AnyRec): Promise { + const input: AnyRec = { ...record }; + await (accountHook as AnyRec).handler({ event: 'beforeInsert', input }); + return input; +} + +const projectedAccounts = await Promise.all(accountRecords.map(project)); +const nameOf = (r: AnyRec) => String(r.name); + +/** + * Evaluate a compiled `FilterCondition` against one projected record. + * + * Deliberately narrow: it understands only the shapes these rules compile to + * today (`{field: {$in: [...]}}` and `{field: value}`) and THROWS on anything + * else. Returning `false` for an unrecognised shape would report "the + * territories are empty" — indistinguishable from the bug — the next time the + * compiler's output changes. + */ +function matches(filter: unknown, row: AnyRec): boolean { + if (!filter || typeof filter !== 'object' || Array.isArray(filter)) { + throw new Error(`unsupported compiled filter: ${JSON.stringify(filter)}`); + } + const entries = Object.entries(filter as AnyRec); + if (entries.length !== 1) { + throw new Error(`expected a single-field filter, got ${JSON.stringify(filter)}`); + } + const [field, condition] = entries[0]; + if (field.startsWith('$')) { + throw new Error(`expected a field-keyed filter, got operator "${field}"`); + } + if (condition && typeof condition === 'object' && !Array.isArray(condition)) { + const ops = Object.entries(condition as AnyRec); + if (ops.length === 1 && ops[0][0] === '$in' && Array.isArray(ops[0][1])) { + return (ops[0][1] as unknown[]).includes(row[field]); + } + throw new Error(`unsupported compiled operator: ${JSON.stringify(condition)}`); + } + return row[field] === condition; +} + +/** The accounts one declared rule covers, via the seeder's own compiler. */ +function covered(ruleName: string): string[] { + const rule = ruleByName(ruleName); + if (!rule) throw new Error(`no sharing rule named ${ruleName}`); + const compiled = compileCelToFilter(rule.condition ?? '', { variables: {} }); + if (!compiled.ok) { + throw new Error(`${ruleName}: condition does not compile (${compiled.reason}: ${compiled.detail})`); + } + return projectedAccounts.filter((row) => matches(compiled.filter, row)).map(nameOf); +} + +describe('the demo dataset can actually exercise the territory rules (#638)', () => { + it('finds the accounts and both rules at all', () => { + // Guard the guard: a seed or config refactor that stopped exposing either + // side would make every assertion below vacuously true. + expect(accountRecords.length, 'no crm_account seed records').toBeGreaterThanOrEqual(9); + for (const name of TERRITORY_RULES) { + expect(ruleByName(name), `sharing rule ${name} is not declared`).toBeTruthy(); + } + }); + + it('gives every seeded account a billing address with a 2-letter country code', () => { + const bad = accountRecords + .filter((r) => { + const country = (r.billing_address as AnyRec | undefined)?.country; + return typeof country !== 'string' || !/^[A-Z]{2}$/.test(country); + }) + .map((r) => `${nameOf(r)}: ${JSON.stringify((r.billing_address as AnyRec | undefined)?.country)}`); + expect( + bad, + 'These accounts carry no usable billing country, so they belong to no territory and the ' + + 'rules that filter on it match nothing. `billing_country` holds the address country ' + + 'verbatim (trimmed + upper-cased, never translated), so it must BE the code the rules ' + + 'name — "United States" or "Deutschland" silently matches nothing:\n ' + + bad.join('\n '), + ).toEqual([]); + }); + + it('never authors billing_country in a seed — the hook owns that column', () => { + // It is `readonly` and derived. A hand-copied value would be a second + // source of truth for something `account_protection` recomputes on every + // write that carries the address, i.e. drift with a delayed fuse. + const authored = accountRecords.filter((r) => 'billing_country' in r).map(nameOf); + expect(authored, `seed records authoring the derived billing_country: ${authored.join(', ')}`).toEqual([]); + }); + + it('projects a country onto every seeded account when the hook runs', async () => { + const missing = projectedAccounts.filter((r) => typeof r.billing_country !== 'string' || !r.billing_country); + expect(missing.map(nameOf), 'account_protection produced no billing_country for these seeds').toEqual([]); + }); + + it('partitions the seeded accounts across NA, EU and neither', () => { + const na = covered('north_america_territory'); + const eu = covered('europe_territory'); + const outside = projectedAccounts + .map(nameOf) + .filter((name) => !na.includes(name) && !eu.includes(name)); + + const summary = `NA=${na.length} [${na.join(', ')}] · EU=${eu.length} [${eu.join(', ')}] · ` + + `neither=${outside.length} [${outside.join(', ')}]`; + + // Each bucket non-empty is the actual deliverable of #638: a rule with no + // matching record is indistinguishable from a rule that was never seeded, + // and a set with nothing OUTSIDE the territories cannot tell a working + // filter apart from a match-all one. + expect(na.length, `north_america_territory covers no seeded account — ${summary}`).toBeGreaterThan(0); + expect(eu.length, `europe_territory covers no seeded account — ${summary}`).toBeGreaterThan(0); + expect( + outside.length, + `every seeded account falls in a territory, so a match-all regression would look ` + + `identical to a working rule — ${summary}`, + ).toBeGreaterThan(0); + + // The two territories are disjoint: an account in both would mean the + // country lists overlap, and `edit` granted twice hides which rule did it. + const both = na.filter((name) => eu.includes(name)); + expect(both, `accounts covered by BOTH territories: ${both.join(', ')}`).toEqual([]); + }); + + it('covers more than one country per territory where the rule claims several', () => { + // `$in ["US","CA","MX"]` degenerating to "US accounts only" would still + // pass every assertion above while leaving most of the rule untested. + const countries = new Set( + projectedAccounts + .filter((r) => covered('north_america_territory').includes(nameOf(r))) + .map((r) => String(r.billing_country)), + ); + expect( + [...countries].sort(), + 'north_america_territory is exercised by a single country — the rest of its $in list is unproven', + ).not.toEqual(['US']); + }); +}); + +/** + * The #635 split has one structural failure mode: a family module gains a + * dataset and nobody adds it to `CrmSeedData`, so it silently never seeds. + * Nothing else would notice — the module compiles, the records exist in source. + */ +describe('every family module dataset is wired into CrmSeedData (#635 split)', () => { + const FAMILIES: Record = { + '_shared': sharedSeed, + 'catalog.seed': catalogSeed, + 'sales.seed': salesSeed, + 'service.seed': serviceSeed, + 'marketing.seed': marketingSeed, + 'revenue.seed': revenueSeed, + }; + + const isDataset = (value: unknown): value is Dataset => + !!value && + typeof value === 'object' && + typeof (value as AnyRec).object === 'string' && + Array.isArray((value as AnyRec).records); + + const declared = Object.entries(FAMILIES).flatMap(([module, exports]) => + Object.entries(exports) + .filter(([, value]) => isDataset(value)) + .map(([name, value]) => ({ id: `${module}.${name}`, value })), + ); + + it('exports datasets from the family modules at all', () => { + expect(declared.length, 'no seed datasets found in src/data/*.seed.ts').toBeGreaterThanOrEqual(16); + }); + + it('aggregates all of them, and nothing else', () => { + const aggregated = CrmSeedData as unknown[]; + const missing = declared.filter((d) => !aggregated.includes(d.value)).map((d) => d.id); + expect( + missing, + `these datasets are authored but never seeded — add them to src/data/index.ts:\n ${missing.join('\n ')}`, + ).toEqual([]); + expect(aggregated.length, 'CrmSeedData holds an entry no family module exports').toBe(declared.length); + }); +});