From 714d8e24fd7da178c8b3863c6d79807f4b4817bc Mon Sep 17 00:00:00 2001 From: AINative Admin Date: Fri, 9 Oct 2026 02:44:40 -0700 Subject: [PATCH] fix(live): read each primitive's own provisioning flag for the Live/Planned badge (#1044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Business systems grid collapsed ZeroInvoice, ZeroCommerce and OpenCapStack onto a single shared boolean — "does this company have any ZeroDB project at all" (entry.zerodbProjectId) — so three different primitives rendered an identical Live/Planned badge no matter what had actually been provisioned. Confirmed live against production company "silo": GET /api/build/systems returned provisioned:true for ZeroPipeline, ZeroInvoice and ZeroCommerce alike. The per-primitive truth already existed and was already being fetched. The route called resolveApp(companyId), read only zerodbProjectId and pipelineProvisioned off the entry, and discarded the rest — even though setAppProvisioned writes zeroinvoiceProvisioned, commerceProvisioned, capstackProvisioned, serviceosProvisioned, livestreamingProvisioned and socialgraphProvisioned independently per primitive at provision time, each documented in the registry as its own real signal ("Absent/false = still simulated"). Changes: - business-systems.ts: replace the shared zdb/pipelineLive booleans with PROVISIONING_FLAG_BY_PRIMITIVE, mapping each system card to the registry field that is actually its provisioning truth. A primitive with no flag (Community, Context Graph, Search & Discovery, Content Workflow, Intent-Casting Marketplace, Browser Agent) stays honestly Planned instead of borrowing another primitive's state. Opts are now a named, documented SystemProvisioningFlags interface. - systems/route.ts: thread every per-primitive flag off the already-fetched registry entry, and surface them as a primitiveProvisioning block on the response so the real state is inspectable rather than inferred. Two collapse bugs beyond the three named in the issue are fixed by the same wiring: ZeroPipeline was ORed with the shared flag (a bare ZeroDB project made it Live without any pipeline), and ServiceOS / ZeroVoice / Live Streaming / Social Graph / ZeroDB were hardcoded to false despite having real flags. The shared `provisioned` response field keeps its existing meaning (the company has its own ZeroDB project) for Live.tsx's cloud-provisioning banner. Verified in a real browser against a local dev server: a company with only ZeroInvoice provisioned renders ZeroInvoice as "✓ LIVE" while ZeroCommerce, OpenCapStack and ZeroPipeline render "● PLANNED". --- ...ms-per-primitive-provisioning-1044.test.ts | 159 ++++++++++++++++ ...systems-per-primitive-provisioning.test.ts | 169 ++++++++++++++++++ app/api/build/systems/route.ts | 43 ++++- e2e/systems-per-primitive-badges-1044.spec.ts | 116 ++++++++++++ lib/build/business-systems.ts | 79 ++++++-- 5 files changed, 549 insertions(+), 17 deletions(-) create mode 100644 __tests__/api/systems-per-primitive-provisioning-1044.test.ts create mode 100644 __tests__/lib/business-systems-per-primitive-provisioning.test.ts create mode 100644 e2e/systems-per-primitive-badges-1044.spec.ts diff --git a/__tests__/api/systems-per-primitive-provisioning-1044.test.ts b/__tests__/api/systems-per-primitive-provisioning-1044.test.ts new file mode 100644 index 00000000..382cc8d2 --- /dev/null +++ b/__tests__/api/systems-per-primitive-provisioning-1044.test.ts @@ -0,0 +1,159 @@ +/** + * GET /api/build/systems — the Live dashboard's per-primitive provisioning + * status must come from each primitive's OWN registry flag (#1044). + * + * THE BUG: the route read `entry.zerodbProjectId` into one `provisioned` + * boolean and handed it to buildSystems, which reused it for ZeroInvoice, + * ZeroCommerce AND OpenCapStack alike. Confirmed live 2026-10-09 against the + * real production company "silo": all three came back `provisioned: true` + * identically, purely because the company had a ZeroDB project — not because + * each primitive had actually been provisioned. Everything the route needed was + * already on the `entry` it fetched (`zeroinvoiceProvisioned`, + * `commerceProvisioned`, `capstackProvisioned`, … each written independently by + * setAppProvisioned at provision time) and simply discarded. + * + * These tests drive the REAL route handler with a realistic partially- + * provisioned registry entry and assert the response distinguishes primitives. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const h = vi.hoisted(() => ({ + resolveApp: vi.fn<(...args: any[]) => Promise>(), + getAinativeApiKey: vi.fn(() => ''), +})) + +vi.mock('@/lib/build/app-registry', () => ({ resolveApp: h.resolveApp })) +vi.mock('@/lib/build/env-keys', () => ({ getAinativeApiKey: h.getAinativeApiKey })) + +const IDEA = + 'storefront selling specialty coffee beans with invoicing and billing, plus cap table and SAFE fundraising for investors' + +async function callRoute(companyId: string, idea = IDEA) { + const { GET } = await import('@/app/api/build/systems/route') + const url = `https://builder.ainative.studio/api/build/systems?companyId=${encodeURIComponent( + companyId, + )}&idea=${encodeURIComponent(idea)}` + const res = await GET(new Request(url) as any) + return res.json() as Promise +} + +function provisionedOf(body: any, name: string): boolean { + const card = body.systems.find((s: any) => s.primitive === name) + expect(card, `expected a system card for ${name}`).toBeDefined() + return card.provisioned +} + +beforeEach(() => { + vi.clearAllMocks() + // No service key → count reads short-circuit to {} without any network call. + h.getAinativeApiKey.mockReturnValue('') +}) + +describe('#1044 GET /api/build/systems per-primitive provisioning', () => { + it('distinguishes a real partial state: ZeroInvoice provisioned, ZeroCommerce + OpenCapStack not', async () => { + h.resolveApp.mockResolvedValue({ + chatId: 'chat_silo', + slug: 'silo', + // The company DOES have a ZeroDB project — the old shared flag was true. + zerodbProjectId: 'proj_real_silo', + // ...but only ZeroInvoice actually got provisioned. + zeroinvoiceProvisioned: true, + commerceProvisioned: false, + capstackProvisioned: false, + }) + + const body = await callRoute('silo') + + expect(provisionedOf(body, 'ZeroInvoice')).toBe(true) + expect(provisionedOf(body, 'ZeroCommerce')).toBe(false) + expect(provisionedOf(body, 'OpenCapStack')).toBe(false) + // And the response surfaces the real per-primitive truth explicitly. + expect(body.primitiveProvisioning).toMatchObject({ + zerodb: true, + zeroinvoice: true, + commerce: false, + capstack: false, + }) + }) + + it('no longer returns provisioned:true for all three off a bare ZeroDB project (the live "silo" bug)', async () => { + h.resolveApp.mockResolvedValue({ + chatId: 'chat_silo', + slug: 'silo', + zerodbProjectId: 'proj_real_silo', + // No individual primitive flag was ever written for this company. + }) + + const body = await callRoute('silo') + + expect(provisionedOf(body, 'ZeroInvoice')).toBe(false) + expect(provisionedOf(body, 'ZeroCommerce')).toBe(false) + expect(provisionedOf(body, 'OpenCapStack')).toBe(false) + }) + + it('shows all Live when every primitive is genuinely provisioned', async () => { + h.resolveApp.mockResolvedValue({ + chatId: 'chat_full', + slug: 'fullyprovisioned', + zerodbProjectId: 'proj_full', + pipelineProvisioned: true, + zeroinvoiceProvisioned: true, + commerceProvisioned: true, + capstackProvisioned: true, + serviceosProvisioned: true, + }) + + const body = await callRoute('fullyprovisioned') + + expect(provisionedOf(body, 'ZeroInvoice')).toBe(true) + expect(provisionedOf(body, 'ZeroCommerce')).toBe(true) + expect(provisionedOf(body, 'OpenCapStack')).toBe(true) + expect(body.primitiveProvisioning.pipeline).toBe(true) + expect(body.primitiveProvisioning.serviceos).toBe(true) + }) + + it('shows all Planned for a freshly-created, unprovisioned company', async () => { + h.resolveApp.mockResolvedValue({ chatId: 'chat_new', slug: 'brandnew' }) + + const body = await callRoute('brandnew') + + expect(body.provisioned).toBe(false) + for (const s of body.systems) { + expect(s.provisioned, `${s.primitive} should be Planned`).toBe(false) + } + for (const [k, v] of Object.entries(body.primitiveProvisioning as Record)) { + expect(v, `primitiveProvisioning.${k} should be false`).toBe(false) + } + }) + + it('keeps ZeroPipeline on its own flag (unregressed) and does not infer it from ZeroDB', async () => { + h.resolveApp.mockResolvedValue({ + chatId: 'chat_crm', + slug: 'crmco', + zerodbProjectId: 'proj_crm', + pipelineProvisioned: false, + }) + + const body = await callRoute('crmco', 'B2B sales CRM pipeline deals') + + expect(provisionedOf(body, 'ZeroPipeline')).toBe(false) + expect(body.pipelineProvisioned).toBe(false) + expect(body.provisioned).toBe(true) + }) + + it('still reports the shared `provisioned` field (ZeroDB project presence) for existing consumers', async () => { + h.resolveApp.mockResolvedValue({ + chatId: 'chat_x', + slug: 'x', + zerodbProjectId: 'proj_x', + pipelineProvisioned: true, + }) + + const body = await callRoute('x') + + // Live.tsx reads `provisioned` for the cloud-provisioning banner/button — + // it must keep meaning "this company has its own ZeroDB project". + expect(body.provisioned).toBe(true) + expect(body.pipelineProvisioned).toBe(true) + }) +}) diff --git a/__tests__/lib/business-systems-per-primitive-provisioning.test.ts b/__tests__/lib/business-systems-per-primitive-provisioning.test.ts new file mode 100644 index 00000000..71bdcf71 --- /dev/null +++ b/__tests__/lib/business-systems-per-primitive-provisioning.test.ts @@ -0,0 +1,169 @@ +/** + * #1044 — the Live dashboard's "Business systems" Live/Planned badge must reflect + * EACH primitive's own real provisioning state, not one shared + * "does this company have any ZeroDB project at all" boolean. + * + * Before this fix, buildSystems collapsed ZeroInvoice / ZeroCommerce / OpenCapStack + * onto `opts.provisioned` (derived from `entry.zerodbProjectId`), so all three showed + * an identical badge regardless of whether each one was actually provisioned. The + * per-primitive truth already exists on the registry entry (`zeroinvoiceProvisioned`, + * `commerceProvisioned`, `capstackProvisioned`, … written independently at provision + * time) — these tests pin that each primitive reads its OWN flag. + */ +import { describe, it, expect } from 'vitest' +import { buildSystems } from '@/lib/build/business-systems' + +/** + * An idea that deterministically surfaces ZeroInvoice, ZeroCommerce and + * OpenCapStack together so one call can assert all three diverge. + */ +const IDEA_INVOICE_COMMERCE_CAPSTACK = + 'storefront selling specialty coffee beans with invoicing and billing, plus cap table and SAFE fundraising for investors' + +function byName(systems: ReturnType, name: string) { + const s = systems.find((x) => x.primitive === name) + expect(s, `expected system card for ${name}`).toBeDefined() + return s! +} + +describe('#1044 per-primitive provisioning status (no shared collapse)', () => { + it('distinguishes a realistic PARTIAL provisioning state (ZeroInvoice live, ZeroCommerce + OpenCapStack not)', () => { + // Real shape: the company HAS a ZeroDB project (so the old shared `provisioned` + // boolean is true), but only ZeroInvoice actually got provisioned. + const systems = buildSystems( + IDEA_INVOICE_COMMERCE_CAPSTACK, + {}, + { + provisioned: true, + zeroinvoiceProvisioned: true, + commerceProvisioned: false, + capstackProvisioned: false, + }, + 8, + ) + + expect(byName(systems, 'ZeroInvoice').provisioned).toBe(true) + expect(byName(systems, 'ZeroCommerce').provisioned).toBe(false) + expect(byName(systems, 'OpenCapStack').provisioned).toBe(false) + }) + + it('distinguishes the inverse partial state (ZeroCommerce live, ZeroInvoice + OpenCapStack not)', () => { + const systems = buildSystems( + IDEA_INVOICE_COMMERCE_CAPSTACK, + {}, + { + provisioned: true, + zeroinvoiceProvisioned: false, + commerceProvisioned: true, + capstackProvisioned: false, + }, + 8, + ) + + expect(byName(systems, 'ZeroInvoice').provisioned).toBe(false) + expect(byName(systems, 'ZeroCommerce').provisioned).toBe(true) + expect(byName(systems, 'OpenCapStack').provisioned).toBe(false) + }) + + it('does not collapse three different primitives onto one identical value', () => { + const systems = buildSystems( + IDEA_INVOICE_COMMERCE_CAPSTACK, + {}, + { provisioned: true, capstackProvisioned: true }, + 8, + ) + const trio = ['ZeroInvoice', 'ZeroCommerce', 'OpenCapStack'].map( + (n) => byName(systems, n).provisioned, + ) + // Exactly one of the three is really provisioned — they must not all match. + expect(new Set(trio).size).toBeGreaterThan(1) + expect(trio).toEqual([false, false, true]) + }) + + it('shows every relevant primitive Live when ALL are genuinely provisioned', () => { + const systems = buildSystems( + IDEA_INVOICE_COMMERCE_CAPSTACK, + {}, + { + provisioned: true, + pipelineProvisioned: true, + zeroinvoiceProvisioned: true, + commerceProvisioned: true, + capstackProvisioned: true, + serviceosProvisioned: true, + socialgraphProvisioned: true, + livestreamingProvisioned: true, + zerovoiceProvisioned: true, + }, + 12, + ) + const flagged = new Set([ + 'ZeroPipeline', 'ZeroInvoice', 'ZeroCommerce', 'OpenCapStack', + 'ServiceOS', 'Social Graph', 'Live Streaming', 'ZeroVoice', + ]) + for (const s of systems) { + if (flagged.has(s.primitive)) { + expect(s.provisioned, `${s.primitive} should be Live`).toBe(true) + } + } + }) + + it('shows everything Planned for a freshly-created company with nothing provisioned', () => { + const systems = buildSystems(IDEA_INVOICE_COMMERCE_CAPSTACK, {}, {}, 12) + for (const s of systems) { + expect(s.provisioned, `${s.primitive} should be Planned`).toBe(false) + } + }) + + it('a ZeroDB project alone no longer implies ZeroInvoice/ZeroCommerce/OpenCapStack are Live', () => { + // The exact live-production state from #1044: entry.zerodbProjectId exists, so + // the shared flag is true, but no individual primitive flag was ever written. + const systems = buildSystems( + IDEA_INVOICE_COMMERCE_CAPSTACK, + {}, + { provisioned: true }, + 8, + ) + expect(byName(systems, 'ZeroInvoice').provisioned).toBe(false) + expect(byName(systems, 'ZeroCommerce').provisioned).toBe(false) + expect(byName(systems, 'OpenCapStack').provisioned).toBe(false) + }) + + describe('primitives that were NOT part of the bug stay correct', () => { + it('ZeroPipeline still reads its own pipelineProvisioned flag', () => { + const live = buildSystems('B2B sales CRM pipeline deals', {}, { pipelineProvisioned: true }) + expect(byName(live, 'ZeroPipeline').provisioned).toBe(true) + + const planned = buildSystems('B2B sales CRM pipeline deals', {}, { pipelineProvisioned: false }) + expect(byName(planned, 'ZeroPipeline').provisioned).toBe(false) + }) + + it('ZeroPipeline is NOT Live merely because a ZeroDB project exists', () => { + const s = buildSystems('B2B sales CRM pipeline deals', {}, { provisioned: true }) + expect(byName(s, 'ZeroPipeline').provisioned).toBe(false) + }) + + it('primitives with no registry provisioning flag stay honestly Planned', () => { + // Community / Context Graph / Search & Discovery / Content Workflow have no + // per-company provisioning signal in the registry at all — never claim Live. + const systems = buildSystems( + 'a social community app with groups, feeds, semantic search and a knowledge graph', + {}, + { provisioned: true, pipelineProvisioned: true }, + 12, + ) + const noFlag = new Set(['Community', 'Context Graph', 'Search & Discovery', 'Content Workflow']) + for (const s of systems) { + if (noFlag.has(s.primitive)) { + expect(s.provisioned, `${s.primitive} has no real flag; must be Planned`).toBe(false) + } + } + }) + + it('ZeroDB reads the real zerodbProjectId-backed flag (it IS the ZeroDB project)', () => { + const s = buildSystems('a note-taking app that stores posts and records', {}, { provisioned: true }, 12) + const zdb = s.find((x) => x.primitive === 'ZeroDB') + if (zdb) expect(zdb.provisioned).toBe(true) + }) + }) +}) diff --git a/app/api/build/systems/route.ts b/app/api/build/systems/route.ts index a7f4b128..1f38cfcc 100644 --- a/app/api/build/systems/route.ts +++ b/app/api/build/systems/route.ts @@ -11,7 +11,7 @@ */ import { NextRequest } from 'next/server' -import { buildSystems } from '@/lib/build/business-systems' +import { buildSystems, type SystemProvisioningFlags } from '@/lib/build/business-systems' import { resolveApp } from '@/lib/build/app-registry' import { getAinativeApiKey } from '@/lib/build/env-keys' @@ -27,15 +27,30 @@ export async function GET(request: NextRequest) { let counts: Counts = {} let provisioned = false - let pipelineProvisioned = false - let instanceUrls: Record = {} + // #1044: each primitive's OWN provisioning flag, read straight off the registry + // entry (written independently per primitive by setAppProvisioned at provision + // time). Previously only pipelineProvisioned was threaded through and the rest + // of the entry was discarded, so ZeroInvoice/ZeroCommerce/OpenCapStack shared + // the single `provisioned` boolean and showed identical Live/Planned badges. + let flags: SystemProvisioningFlags = {} + const instanceUrls: Record = {} if (companyId) { const entry = await resolveApp(companyId).catch(() => null) const projectId = entry?.zerodbProjectId - pipelineProvisioned = Boolean(entry?.pipelineProvisioned) + provisioned = Boolean(projectId) + flags = { + provisioned, + pipelineProvisioned: Boolean(entry?.pipelineProvisioned), + zeroinvoiceProvisioned: Boolean(entry?.zeroinvoiceProvisioned), + commerceProvisioned: Boolean(entry?.commerceProvisioned), + capstackProvisioned: Boolean(entry?.capstackProvisioned), + serviceosProvisioned: Boolean(entry?.serviceosProvisioned), + zerovoiceProvisioned: Boolean(entry?.zerovoiceProvisioned), + livestreamingProvisioned: Boolean(entry?.livestreamingProvisioned), + socialgraphProvisioned: Boolean(entry?.socialgraphProvisioned), + } if (projectId) { - provisioned = true try { counts = await readProvisionedCounts(projectId) } catch { @@ -50,14 +65,28 @@ export async function GET(request: NextRequest) { } } - const systems = buildSystems(idea, counts, { provisioned, pipelineProvisioned, instanceUrls }) + const systems = buildSystems(idea, counts, { ...flags, instanceUrls }) return Response.json({ systems, companyId, idea, provisioned, - pipelineProvisioned, + pipelineProvisioned: Boolean(flags.pipelineProvisioned), + // #1044: surface the real per-primitive provisioning state alongside the + // cards, so the dashboard (and QA) can see which primitives are genuinely + // live rather than inferring it from one shared boolean. + primitiveProvisioning: { + zerodb: Boolean(flags.provisioned), + pipeline: Boolean(flags.pipelineProvisioned), + zeroinvoice: Boolean(flags.zeroinvoiceProvisioned), + commerce: Boolean(flags.commerceProvisioned), + capstack: Boolean(flags.capstackProvisioned), + serviceos: Boolean(flags.serviceosProvisioned), + zerovoice: Boolean(flags.zerovoiceProvisioned), + livestreaming: Boolean(flags.livestreamingProvisioned), + socialgraph: Boolean(flags.socialgraphProvisioned), + }, zeroState: Object.keys(counts).length === 0, }) } diff --git a/e2e/systems-per-primitive-badges-1044.spec.ts b/e2e/systems-per-primitive-badges-1044.spec.ts new file mode 100644 index 00000000..130c52b4 --- /dev/null +++ b/e2e/systems-per-primitive-badges-1044.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test' + +/** + * #1044 — real browser verification that the Live dashboard's "Business systems" + * grid renders a GENUINELY DISTINCT Live/Planned badge per primitive. + * + * Before the fix, ZeroInvoice / ZeroCommerce / OpenCapStack all read one shared + * "does this company have a ZeroDB project" boolean, so a company with only + * ZeroInvoice really provisioned rendered three identical "✓ LIVE" badges + * (confirmed live against production company "silo" on 2026-10-09). + * + * This drives the REAL Live.tsx grid + real SystemStatusBadge in a real browser. + * Only next-auth's session endpoint and the systems API payload are stubbed — + * the rendering path under test is the actual production component tree. The + * payload used is exactly the shape the fixed route now returns for a realistic + * partially-provisioned company. + */ + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000' + +/** Realistic partial-provisioning state: only ZeroInvoice is genuinely live. */ +const PARTIAL_SYSTEMS = { + companyId: 'qa1044', + idea: 'coffee storefront with invoicing and a cap table', + provisioned: true, + pipelineProvisioned: false, + primitiveProvisioning: { + zerodb: true, + pipeline: false, + zeroinvoice: true, + commerce: false, + capstack: false, + serviceos: false, + zerovoice: false, + livestreaming: false, + socialgraph: false, + }, + zeroState: true, + systems: [ + { + key: 'zeroinvoice', name: 'ZeroInvoice', primitive: 'ZeroInvoice', + docUrl: 'https://docs.ainative.studio/docs/business-ops/zeroinvoice', + stat: 'Ready · $0 collected', count: 0, provisioned: true, + vsProvider: 'QuickBooks', savedMonthly: 30, + }, + { + key: 'zerocommerce', name: 'ZeroCommerce', primitive: 'ZeroCommerce', + docUrl: 'https://docs.ainative.studio/docs/business-ops/zerocommerce', + stat: 'Ready · shop live', count: 0, provisioned: false, + vsProvider: 'Shopify', savedMonthly: 29, + }, + { + key: 'opencapstack', name: 'OpenCapStack', primitive: 'OpenCapStack', + docUrl: 'https://docs.ainative.studio/docs/business-ops/opencapstack', + stat: 'Ready · cap table empty', count: 0, provisioned: false, + vsProvider: 'Carta', savedMonthly: 40, + }, + { + key: 'zeropipeline', name: 'ZeroPipeline', primitive: 'ZeroPipeline', + docUrl: 'https://docs.ainative.studio/docs/business-ops/zeropipeline', + stat: 'Ready · Scout sourcing', count: 0, provisioned: false, + vsProvider: 'Salesforce', savedMonthly: 25, + }, + ], +} + +async function stubSession(page: import('@playwright/test').Page) { + await page.route('**/api/auth/session', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + user: { id: 'qa-1044', email: 'qa1044@example.com', name: 'QA 1044', type: 'regular' }, + expires: '2099-01-01T00:00:00.000Z', + }), + }), + ) +} + +test.describe('#1044 Business systems badges are distinct per primitive', () => { + test('renders ZeroInvoice Live while ZeroCommerce / OpenCapStack stay Planned', async ({ page }) => { + await stubSession(page) + await page.route('**/api/build/systems**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(PARTIAL_SYSTEMS), + }), + ) + + await page.goto(`${BASE}/build?screen=live&company=qa1044`, { waitUntil: 'domcontentloaded' }) + + const grid = page.getByTestId('systems-grid') + await expect(grid).toBeVisible({ timeout: 60_000 }) + + // Read the real rendered badge next to each real primitive card. + const statusFor = async (primitive: string) => { + const card = grid.locator('.m-system', { hasText: primitive }).first() + await expect(card).toBeVisible() + return card.getByTestId('system-status-badge').first().getAttribute('data-status') + } + + const invoice = await statusFor('ZeroInvoice') + const commerce = await statusFor('ZeroCommerce') + const capstack = await statusFor('OpenCapStack') + + // The whole point of #1044: these must NOT all be the same value. + expect(new Set([invoice, commerce, capstack]).size).toBeGreaterThan(1) + expect(invoice).toBe('live') + expect(commerce).not.toBe('live') + expect(capstack).not.toBe('live') + + await grid.scrollIntoViewIfNeeded() + await grid.screenshot({ path: 'e2e/test-results/1044-partial-provisioning-badges.png' }) + }) +}) diff --git a/lib/build/business-systems.ts b/lib/build/business-systems.ts index be9bf39a..e1d2cbaf 100644 --- a/lib/build/business-systems.ts +++ b/lib/build/business-systems.ts @@ -215,6 +215,64 @@ const SYSTEM_CARD_NAMES = new Set([ 'Social Graph', 'Community', 'Context Graph', 'Search & Discovery', 'ZeroDB', ]) +/** + * Per-primitive provisioning flags, as actually stored on the company's registry + * entry (`AppEntry` in lib/build/app-registry.ts) and written independently per + * primitive at provision time (`setAppProvisioned`). Each is the REAL signal for + * that one primitive — absent/false means "still simulated", per the registry's + * own field docs. (#1044) + * + * `provisioned` here means only "the company has its own ZeroDB project" — it is + * the ZeroDB card's own flag and a data-source signal for counts. It is NOT a + * stand-in for any other primitive's state, which was the #1044 bug: ZeroInvoice, + * ZeroCommerce and OpenCapStack all read this one boolean, so their Live/Planned + * badges were identical no matter what had actually been provisioned. + */ +export interface SystemProvisioningFlags { + /** entry.zerodbProjectId exists — the ZeroDB card's own provisioning truth */ + provisioned?: boolean + /** entry.pipelineProvisioned — ZeroPipeline */ + pipelineProvisioned?: boolean + /** entry.zeroinvoiceProvisioned — ZeroInvoice */ + zeroinvoiceProvisioned?: boolean + /** entry.commerceProvisioned — ZeroCommerce */ + commerceProvisioned?: boolean + /** entry.capstackProvisioned — OpenCapStack */ + capstackProvisioned?: boolean + /** entry.serviceosProvisioned — ServiceOS */ + serviceosProvisioned?: boolean + /** entry.zerovoiceProvisioned — ZeroVoice (explicit founder action only) */ + zerovoiceProvisioned?: boolean + /** entry.livestreamingProvisioned — Live Streaming */ + livestreamingProvisioned?: boolean + /** entry.socialgraphProvisioned — Social Graph */ + socialgraphProvisioned?: boolean + /** Per-primitive instance URLs for the card link (#278) */ + instanceUrls?: Record +} + +/** + * Which `SystemProvisioningFlags` field is the real provisioning truth for each + * system card (#1044). A primitive absent from this map has NO per-company + * provisioning signal in the registry at all (Community, Context Graph, + * Search & Discovery, Content Workflow, Intent-Casting Marketplace, Browser + * Agent) — those stay honestly Planned rather than borrowing another + * primitive's flag. ZeroCRM/ZeroForms/AgentFlow/ZeroERP do have registry flags + * but are not in SYSTEM_CARD_NAMES, so they render no card today (out of scope + * per #1044); add them here if/when they become cards. + */ +const PROVISIONING_FLAG_BY_PRIMITIVE: Record = { + ZeroPipeline: 'pipelineProvisioned', + ZeroInvoice: 'zeroinvoiceProvisioned', + ZeroCommerce: 'commerceProvisioned', + OpenCapStack: 'capstackProvisioned', + ServiceOS: 'serviceosProvisioned', + ZeroVoice: 'zerovoiceProvisioned', + 'Live Streaming': 'livestreamingProvisioned', + 'Social Graph': 'socialgraphProvisioned', + ZeroDB: 'provisioned', +} + /** * Build the systems list for a company from its idea + real counts. * @@ -230,13 +288,13 @@ const SYSTEM_CARD_NAMES = new Set([ * * @param idea The founder's original idea string (drives primitive selection) * @param counts Real per-primitive counts from the company's ZeroDB project - * @param opts Provisioning state flags + * @param opts Real per-primitive provisioning flags off the registry entry (#1044) * @param maxCards Max number of system cards to show (default 4) */ export function buildSystems( idea: string = '', counts: Record = {}, - opts: { provisioned?: boolean; pipelineProvisioned?: boolean; instanceUrls?: Record } = {}, + opts: SystemProvisioningFlags = {}, maxCards = 4, ): BusinessSystem[] { // (#72) Rank ALL primitives (foundational included) by idea relevance, then @@ -264,8 +322,6 @@ export function buildSystems( candidates = candidates.slice(0, maxCards) - const zdb = Boolean(opts.provisioned) - const pipelineLive = Boolean(opts.pipelineProvisioned) || zdb const instanceUrls = opts.instanceUrls || {} return candidates.map((prim): BusinessSystem => { @@ -274,11 +330,14 @@ export function buildSystems( const rawCount = counts[countKey]?.count ?? 0 const rawValue = counts[countKey]?.value ?? 0 - // Per-primitive provisioning honesty (#243) - let isProvisioned = false - if (prim.name === 'ZeroPipeline') isProvisioned = pipelineLive - else if (['ZeroInvoice', 'ZeroCommerce', 'OpenCapStack'].includes(prim.name)) isProvisioned = zdb - // ServiceOS, ZeroVoice, Content Workflow etc. have no per-company data yet + // Per-primitive provisioning honesty (#243, fixed in #1044): read THIS + // primitive's own registry flag. Previously ZeroInvoice/ZeroCommerce/ + // OpenCapStack all shared `opts.provisioned` ("has any ZeroDB project"), so + // three different primitives showed one identical badge. A primitive with no + // flag in the map has no real per-company signal and stays Planned — we never + // borrow another primitive's state to claim Live. + const flagKey = PROVISIONING_FLAG_BY_PRIMITIVE[prim.name] + const isProvisioned = flagKey ? Boolean(opts[flagKey]) : false // Card URL (#278): own instance > no URL. Never marketing site. const url = instanceUrls[prim.name] || undefined @@ -314,7 +373,7 @@ export function buildSystems( */ export function buildSystemsLegacy( counts: Partial> = {}, - opts: { provisioned?: boolean; pipelineProvisioned?: boolean } = {}, + opts: SystemProvisioningFlags = {}, ): BusinessSystem[] { // Map old fixed keys → new generic counts shape const mapped: Record = {}