diff --git a/README.md b/README.md index 2efc16f..49fde63 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![CI](https://github.com/ColinLi98/promotion-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/ColinLi98/promotion-agent/actions/workflows/ci.yml) +Demo: [https://promotion-agent-demo.vercel.app](https://promotion-agent-demo.vercel.app) + Backend-first MVP scaffold for the PRD in [Promotion_Agent_PRD_v0.9.docx](./Promotion_Agent_PRD_v0.9.docx). ## What is implemented @@ -50,6 +52,25 @@ pnpm start Server starts on `http://localhost:3000`. +## Demo Mode + +For a stable stakeholder demo with virtual data and isolated in-memory state: + +Hosted demo: + +- https://promotion-agent-demo.vercel.app + +```bash +pnpm start:demo +``` + +Demo mode starts on `http://localhost:3001` and: + +- uses a richer synthetic product dataset +- bootstraps measurement, settlement, queue, audit, and risk activity automatically +- keeps CRM focused on demo data instead of real discovery output +- ignores PostgreSQL / Redis / billing adapter runtime state so the demo stays deterministic + By default the app uses in-memory persistence and in-memory hot state. If `DATABASE_URL` is set, startup switches to PostgreSQL automatically. If `REDIS_URL` is set, idempotency keys and opportunity cache switch to Redis. Hot-state keys are namespaced and versioned: diff --git a/api/[...path].ts b/api/[...path].ts new file mode 100644 index 0000000..5903948 --- /dev/null +++ b/api/[...path].ts @@ -0,0 +1 @@ +export { default } from "./_handler.js"; diff --git a/api/_handler.ts b/api/_handler.ts new file mode 100644 index 0000000..a1deded --- /dev/null +++ b/api/_handler.ts @@ -0,0 +1,38 @@ +import { createConfiguredStore } from "../src/factory.js"; +import { buildServer } from "../src/server.js"; + +type CachedApp = { + app: ReturnType; +}; + +declare global { + var __promotionAgentDemoApp__: Promise | undefined; +} + +const getCachedApp = async () => { + if (!globalThis.__promotionAgentDemoApp__) { + globalThis.__promotionAgentDemoApp__ = (async () => { + const { store, appMode } = await createConfiguredStore(); + const app = buildServer(store, { appMode }); + await app.ready(); + return { app }; + })(); + } + + return globalThis.__promotionAgentDemoApp__; +}; + +const stripApiPrefix = (url: string | undefined) => { + if (!url) { + return "/"; + } + + const normalized = url.replace(/^\/api(?=\/|$)/, ""); + return normalized === "" ? "/" : normalized; +}; + +export default async function handler(req: { url?: string }, res: unknown) { + const { app } = await getCachedApp(); + req.url = stripApiPrefix(req.url); + app.server.emit("request", req, res); +} diff --git a/api/agent-leads/[...path].ts b/api/agent-leads/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/agent-leads/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/agents/[...path].ts b/api/agents/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/agents/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/appeals/[...path].ts b/api/appeals/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/appeals/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/campaigns/[...path].ts b/api/campaigns/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/campaigns/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/discovery/[...path].ts b/api/discovery/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/discovery/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/evidence/[...path].ts b/api/evidence/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/evidence/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/measurements/[...path].ts b/api/measurements/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/measurements/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/risk/[...path].ts b/api/risk/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/risk/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/api/settlements/[...path].ts b/api/settlements/[...path].ts new file mode 100644 index 0000000..a6c2526 --- /dev/null +++ b/api/settlements/[...path].ts @@ -0,0 +1 @@ +export { default } from "../_handler.js"; diff --git a/package.json b/package.json index 5e5ffa4..6ff5ff1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", + "start:demo": "APP_MODE=demo PORT=3001 tsx src/index.ts", "typecheck": "tsc --noEmit", "db:init": "tsx scripts/db-init.ts", "db:embedded": "tsx scripts/start-embedded-postgres.ts", diff --git a/public/agent-detail.html b/public/agent-detail.html index 0920177..d9aa955 100644 --- a/public/agent-detail.html +++ b/public/agent-detail.html @@ -97,6 +97,7 @@

Verification 历史

+ diff --git a/public/agents.html b/public/agents.html index 924542e..890a547 100644 --- a/public/agents.html +++ b/public/agents.html @@ -189,6 +189,7 @@

Agent Leads

+ diff --git a/public/agents.js b/public/agents.js index dc5f915..2fffeb7 100644 --- a/public/agents.js +++ b/public/agents.js @@ -5,6 +5,8 @@ const leadDetailPanel = document.querySelector("#leadDetailPanel"); const sourceForm = document.querySelector("#sourceForm"); const leadFilters = document.querySelector("#leadFilters"); const sourceFeedback = document.querySelector("#sourceFeedback"); +const appMode = window.__PROMOTION_AGENT_CONFIG__?.mode ?? "default"; +const defaultDataOriginFilter = appMode === "demo" ? "" : "discovered"; const state = { selectedLeadId: null, @@ -208,7 +210,7 @@ const load = async () => { api.get(`/agent-leads?${query.toString()}`), ]); if (leads.length === 0) { - if ((query.get("dataOrigin") ?? "discovered") === "discovered") { + if ((query.get("dataOrigin") ?? defaultDataOriginFilter) === "discovered") { sourceFeedback.textContent = "当前还没有真实 discovered leads。先运行上方真实 source crawl,或把 Data Origin 切回 seed 查看历史样例。"; } else { sourceFeedback.textContent = "当前筛选条件下没有匹配结果。"; @@ -243,7 +245,7 @@ leadFilters.addEventListener("submit", async (event) => { await load(); }); -leadFilters.querySelector('[name="dataOrigin"]').value = "discovered"; +leadFilters.querySelector('[name="dataOrigin"]').value = defaultDataOriginFilter; document.addEventListener("click", async (event) => { const target = event.target; diff --git a/public/audit.html b/public/audit.html index c24fa18..67b6b5c 100644 --- a/public/audit.html +++ b/public/audit.html @@ -116,6 +116,7 @@

事件列表

+ diff --git a/public/dlq.html b/public/dlq.html index 85ac12b..6c2f8c6 100644 --- a/public/dlq.html +++ b/public/dlq.html @@ -113,6 +113,7 @@

DLQ 列表

+ diff --git a/public/evidence.html b/public/evidence.html index 8b80b34..4532d4f 100644 --- a/public/evidence.html +++ b/public/evidence.html @@ -64,6 +64,7 @@

证据资产

+ diff --git a/public/index.html b/public/index.html index ab26daf..d0ba735 100644 --- a/public/index.html +++ b/public/index.html @@ -322,6 +322,7 @@

Shortlist 模拟器

+ diff --git a/public/measurement.html b/public/measurement.html index 6e5bfb2..d82796d 100644 --- a/public/measurement.html +++ b/public/measurement.html @@ -110,6 +110,7 @@

账单草案

+ diff --git a/public/risk.html b/public/risk.html index 999f6da..b8c1ff8 100644 --- a/public/risk.html +++ b/public/risk.html @@ -99,6 +99,7 @@

申诉流

+ diff --git a/src/demo-scenario.ts b/src/demo-scenario.ts new file mode 100644 index 0000000..22c6b58 --- /dev/null +++ b/src/demo-scenario.ts @@ -0,0 +1,178 @@ +import type { EventReceipt } from "./domain.js"; +import type { PromotionAgentStore } from "./store.js"; + +const demoReceipt = (receipt: EventReceipt): EventReceipt => receipt; + +export const bootstrapDemoScenario = async (store: PromotionAgentStore) => { + const existingSettlements = await store.listSettlements(); + if (existingSettlements.length > 0) { + return; + } + + const receiptsToSettle = [ + demoReceipt({ + receiptId: "rcpt_demo_hubflow_shown", + intentId: "int_demo_hubflow_01", + offerId: "offer_demo_hubflow", + campaignId: "cmp_demo_hubflow", + partnerId: "partner_demo_northstar", + eventType: "shown", + occurredAt: "2026-03-11T09:00:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_hubflow_detail", + intentId: "int_demo_hubflow_01", + offerId: "offer_demo_hubflow", + campaignId: "cmp_demo_hubflow", + partnerId: "partner_demo_northstar", + eventType: "detail_view", + occurredAt: "2026-03-11T09:01:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_hubflow_handoff", + intentId: "int_demo_hubflow_01", + offerId: "offer_demo_hubflow", + campaignId: "cmp_demo_hubflow", + partnerId: "partner_demo_northstar", + eventType: "handoff", + occurredAt: "2026-03-11T09:02:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_hubflow_shortlisted", + intentId: "int_demo_hubflow_01", + offerId: "offer_demo_hubflow", + campaignId: "cmp_demo_hubflow", + partnerId: "partner_demo_northstar", + eventType: "shortlisted", + occurredAt: "2026-03-11T09:03:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_northstar_shown", + intentId: "int_demo_northstar_02", + offerId: "offer_demo_northstar", + campaignId: "cmp_demo_northstar", + partnerId: "partner_demo_vector", + eventType: "shown", + occurredAt: "2026-03-11T10:00:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_northstar_detail", + intentId: "int_demo_northstar_02", + offerId: "offer_demo_northstar", + campaignId: "cmp_demo_northstar", + partnerId: "partner_demo_vector", + eventType: "detail_view", + occurredAt: "2026-03-11T10:01:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_northstar_shortlisted", + intentId: "int_demo_northstar_02", + offerId: "offer_demo_northstar", + campaignId: "cmp_demo_northstar", + partnerId: "partner_demo_vector", + eventType: "shortlisted", + occurredAt: "2026-03-11T10:02:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_signalstack_shown", + intentId: "int_demo_signalstack_03", + offerId: "offer_demo_signalstack", + campaignId: "cmp_demo_signalstack", + partnerId: "partner_demo_summit", + eventType: "shown", + occurredAt: "2026-03-11T11:00:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_signalstack_detail", + intentId: "int_demo_signalstack_03", + offerId: "offer_demo_signalstack", + campaignId: "cmp_demo_signalstack", + partnerId: "partner_demo_summit", + eventType: "detail_view", + occurredAt: "2026-03-11T11:01:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_signalstack_handoff", + intentId: "int_demo_signalstack_03", + offerId: "offer_demo_signalstack", + campaignId: "cmp_demo_signalstack", + partnerId: "partner_demo_summit", + eventType: "handoff", + occurredAt: "2026-03-11T11:02:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_signalstack_conversion", + intentId: "int_demo_signalstack_03", + offerId: "offer_demo_signalstack", + campaignId: "cmp_demo_signalstack", + partnerId: "partner_demo_summit", + eventType: "conversion", + occurredAt: "2026-03-11T11:03:00.000Z", + signature: "sig_demo", + }), + demoReceipt({ + receiptId: "rcpt_demo_vector_shortlisted", + intentId: "int_demo_vector_04", + offerId: "offer_demo_vector", + campaignId: "cmp_demo_vector", + partnerId: "partner_demo_vector", + eventType: "shortlisted", + occurredAt: "2026-03-11T12:00:00.000Z", + signature: "sig_demo", + }), + ]; + + for (const receipt of receiptsToSettle) { + await store.recordReceipt(receipt); + } + + await store.processSettlementRetryQueue(20); + + await store.recordReceipt( + demoReceipt({ + receiptId: "rcpt_demo_queue_pending", + intentId: "int_demo_queue_05", + offerId: "offer_demo_hubflow", + campaignId: "cmp_demo_hubflow", + partnerId: "partner_demo_summit", + eventType: "shortlisted", + occurredAt: "2026-03-11T13:00:00.000Z", + signature: "sig_demo", + }), + ); + + const disputed = await store.recordReceipt( + demoReceipt({ + receiptId: "rcpt_demo_disputed", + intentId: "int_demo_dispute_06", + offerId: "offer_demo_northstar", + campaignId: "cmp_demo_northstar", + partnerId: "partner_demo_northstar", + eventType: "shortlisted", + occurredAt: "2026-03-11T14:00:00.000Z", + signature: "sig_demo", + }), + ); + + if (disputed.settlement) { + await store.markSettlementDisputed(disputed.settlement.settlementId); + await store.createRiskCase({ + entityType: "settlement", + entityId: disputed.settlement.settlementId, + reasonType: "policy_violation", + severity: "medium", + ownerId: "risk:irene", + note: "Disputed settlement created for demo queue handling.", + }); + } +}; diff --git a/src/demo-seed.ts b/src/demo-seed.ts new file mode 100644 index 0000000..79af6f8 --- /dev/null +++ b/src/demo-seed.ts @@ -0,0 +1,623 @@ +import type { SeedData } from "./seed.js"; + +export const buildDemoSeedData = (): SeedData => ({ + discoverySources: [ + { + sourceId: "src_demo_registry", + sourceType: "public_registry", + name: "Global Buyer Agent Registry", + baseUrl: "https://demo-registry.example.com", + seedUrls: ["https://demo-registry.example.com/agents"], + active: true, + crawlPolicy: { + rateLimit: 1, + maxDepth: 1, + }, + verticalHints: ["crm_software", "sales_ops", "saas_procurement"], + geoHints: ["US", "EU", "APAC"], + createdAt: "2026-03-01T08:00:00.000Z", + updatedAt: "2026-03-11T08:00:00.000Z", + }, + { + sourceId: "src_demo_directory", + sourceType: "partner_directory", + name: "Enterprise Partner Directory", + baseUrl: "https://demo-directory.example.com", + seedUrls: ["https://demo-directory.example.com/partners"], + active: true, + crawlPolicy: { + rateLimit: 1, + maxDepth: 1, + }, + verticalHints: ["crm_software", "revops", "workflow_automation"], + geoHints: ["US", "EU", "SG"], + createdAt: "2026-03-01T08:30:00.000Z", + updatedAt: "2026-03-11T08:30:00.000Z", + }, + ], + leads: [ + { + agentId: "lead_demo_northstar", + dataOrigin: "seed", + source: "demo_registry", + sourceType: "public_registry", + sourceRef: "demo:northstar", + providerOrg: "Northstar Procurement Desk", + cardUrl: "https://demo-registry.example.com/agents/northstar", + verticals: ["crm_software", "saas_procurement"], + skills: ["compare_and_shortlist", "vendor_discovery"], + geo: ["UK", "EU"], + authModes: ["oauth2", "api_key"], + acceptsSponsored: true, + supportsDisclosure: true, + trustSeed: 0.9, + leadScore: 0.91, + discoveredAt: "2026-03-02T09:00:00.000Z", + lastSeenAt: "2026-03-11T09:00:00.000Z", + endpointUrl: "https://demo-registry.example.com/agents/northstar/opportunities", + contactRef: "ops@northstar-demo.example.com", + missingFields: [], + reachProxy: 0.88, + monetizationReadiness: 0.9, + verificationStatus: "active", + assignedOwner: "ops:alice", + notes: "High-fit EU enterprise buying desk used in demos.", + dedupeKey: "northstar_procurement_desk_demo_registry", + scoreBreakdown: { + icpFit: 0.94, + protocolFit: 0.9, + reachFit: 0.88, + }, + }, + { + agentId: "lead_demo_summit", + dataOrigin: "seed", + source: "demo_directory", + sourceType: "partner_directory", + sourceRef: "demo:summit", + providerOrg: "Summit RevOps Agent", + cardUrl: "https://demo-directory.example.com/partners/summit", + verticals: ["crm_software", "revops"], + skills: ["compare_and_shortlist", "pricing_analysis"], + geo: ["US", "EU"], + authModes: ["oauth2"], + acceptsSponsored: true, + supportsDisclosure: true, + trustSeed: 0.84, + leadScore: 0.86, + discoveredAt: "2026-03-03T09:00:00.000Z", + lastSeenAt: "2026-03-11T09:00:00.000Z", + endpointUrl: "https://demo-directory.example.com/partners/summit/opportunities", + contactRef: "alliances@summit-demo.example.com", + missingFields: [], + reachProxy: 0.82, + monetizationReadiness: 0.84, + verificationStatus: "active", + assignedOwner: "ops:bob", + notes: "Balanced mid-market coverage and clean disclosure handling.", + dedupeKey: "summit_revops_agent_demo_directory", + scoreBreakdown: { + icpFit: 0.88, + protocolFit: 0.82, + reachFit: 0.82, + }, + }, + { + agentId: "lead_demo_vector", + dataOrigin: "seed", + source: "demo_registry", + sourceType: "public_registry", + sourceRef: "demo:vector", + providerOrg: "Vector Workspace Agent", + cardUrl: "https://demo-registry.example.com/agents/vector", + verticals: ["crm_software", "workflow_automation"], + skills: ["vendor_discovery", "pricing_analysis"], + geo: ["US", "CA"], + authModes: ["oauth2", "api_key"], + acceptsSponsored: true, + supportsDisclosure: true, + trustSeed: 0.83, + leadScore: 0.85, + discoveredAt: "2026-03-03T12:00:00.000Z", + lastSeenAt: "2026-03-11T09:30:00.000Z", + endpointUrl: "https://demo-registry.example.com/agents/vector/opportunities", + contactRef: "growth@vector-demo.example.com", + missingFields: [], + reachProxy: 0.8, + monetizationReadiness: 0.83, + verificationStatus: "active", + assignedOwner: "ops:alice", + notes: "Strong North America fit with clean API operations.", + dedupeKey: "vector_workspace_agent_demo_registry", + scoreBreakdown: { + icpFit: 0.86, + protocolFit: 0.84, + reachFit: 0.8, + }, + }, + { + agentId: "lead_demo_orbit", + dataOrigin: "seed", + source: "demo_directory", + sourceType: "partner_directory", + sourceRef: "demo:orbit", + providerOrg: "Orbit Spend Advisor", + cardUrl: "https://demo-directory.example.com/partners/orbit", + verticals: ["saas_procurement", "crm_software"], + skills: ["compare_and_shortlist"], + geo: ["SG", "AU"], + authModes: ["oauth2"], + acceptsSponsored: true, + supportsDisclosure: true, + trustSeed: 0.74, + leadScore: 0.76, + discoveredAt: "2026-03-04T11:00:00.000Z", + lastSeenAt: "2026-03-11T09:45:00.000Z", + endpointUrl: "https://demo-directory.example.com/partners/orbit/opportunities", + contactRef: "ops@orbit-demo.example.com", + missingFields: [], + reachProxy: 0.72, + monetizationReadiness: 0.75, + verificationStatus: "reviewing", + assignedOwner: "ops:carol", + notes: "In manual verification for APAC coverage demo.", + dedupeKey: "orbit_spend_advisor_demo_directory", + scoreBreakdown: { + icpFit: 0.8, + protocolFit: 0.72, + reachFit: 0.72, + }, + }, + { + agentId: "lead_demo_lighthouse", + dataOrigin: "seed", + source: "demo_registry", + sourceType: "public_registry", + sourceRef: "demo:lighthouse", + providerOrg: "Lighthouse Buying Copilot", + cardUrl: "https://demo-registry.example.com/agents/lighthouse", + verticals: ["crm_software", "sales_ops"], + skills: ["compare_and_shortlist", "vendor_discovery"], + geo: ["US", "EU"], + authModes: ["oauth2"], + acceptsSponsored: true, + supportsDisclosure: true, + trustSeed: 0.79, + leadScore: 0.8, + discoveredAt: "2026-03-05T10:00:00.000Z", + lastSeenAt: "2026-03-11T10:00:00.000Z", + endpointUrl: "https://demo-registry.example.com/agents/lighthouse/opportunities", + contactRef: "team@lighthouse-demo.example.com", + missingFields: [], + reachProxy: 0.78, + monetizationReadiness: 0.8, + verificationStatus: "verified", + assignedOwner: "ops:dana", + notes: "Verified and queued for partner activation demo.", + dedupeKey: "lighthouse_buying_copilot_demo_registry", + scoreBreakdown: { + icpFit: 0.82, + protocolFit: 0.78, + reachFit: 0.78, + }, + }, + ], + partners: [ + { + partnerId: "partner_demo_northstar", + agentLeadId: "lead_demo_northstar", + providerOrg: "Northstar Procurement Desk", + endpoint: "https://demo-registry.example.com/agents/northstar/opportunities", + status: "active", + supportedCategories: ["crm_software"], + acceptsSponsored: true, + supportsDisclosure: true, + trustScore: 0.9, + authModes: ["oauth2", "api_key"], + slaTier: "gold", + }, + { + partnerId: "partner_demo_summit", + agentLeadId: "lead_demo_summit", + providerOrg: "Summit RevOps Agent", + endpoint: "https://demo-directory.example.com/partners/summit/opportunities", + status: "active", + supportedCategories: ["crm_software"], + acceptsSponsored: true, + supportsDisclosure: true, + trustScore: 0.84, + authModes: ["oauth2"], + slaTier: "gold", + }, + { + partnerId: "partner_demo_vector", + agentLeadId: "lead_demo_vector", + providerOrg: "Vector Workspace Agent", + endpoint: "https://demo-registry.example.com/agents/vector/opportunities", + status: "active", + supportedCategories: ["crm_software", "workflow_automation"], + acceptsSponsored: true, + supportsDisclosure: true, + trustScore: 0.83, + authModes: ["oauth2", "api_key"], + slaTier: "silver", + }, + { + partnerId: "partner_demo_orbit", + agentLeadId: "lead_demo_orbit", + providerOrg: "Orbit Spend Advisor", + endpoint: "https://demo-directory.example.com/partners/orbit/opportunities", + status: "reviewing", + supportedCategories: ["crm_software"], + acceptsSponsored: true, + supportsDisclosure: true, + trustScore: 0.74, + authModes: ["oauth2"], + slaTier: "silver", + }, + ], + campaigns: [ + { + campaignId: "cmp_demo_hubflow", + advertiser: "HubFlow", + category: "crm_software", + regions: ["UK", "EU", "US"], + targetingPartnerIds: [], + billingModel: "CPQR", + payoutAmount: 140, + currency: "USD", + budget: 24000, + status: "active", + disclosureText: "Sponsored recommendation from HubFlow. Compensation may apply if shortlisted.", + policyPass: true, + minTrust: 0.65, + offer: { + offerId: "offer_demo_hubflow", + title: "HubFlow CRM for scaling B2B teams", + description: "CRM focused on pipeline visibility, guided onboarding, and regional controls.", + price: 499, + currency: "USD", + intendedFor: ["compare_and_shortlist", "vendor_discovery"], + constraints: { company_size: "50-500" }, + claims: ["SOC 2 Type II", "Regional hosting", "14 day onboarding"], + actionEndpoints: ["https://api.demo-hubflow.example.com/demo"], + narrativeVariants: { + rational: "Lower pipeline admin with guided sales workflows.", + premium: "Enterprise CRM polish for RevOps-heavy teams.", + simple: "A modern CRM with strong rollout support.", + }, + }, + proofBundle: { + proofBundleId: "proof_demo_hubflow", + references: [ + { label: "Security overview", type: "doc", url: "https://demo-hubflow.example.com/security" }, + { label: "Case study", type: "case_study", url: "https://demo-hubflow.example.com/case-study" }, + ], + updatedAt: "2026-03-03T00:00:00.000Z", + }, + }, + { + campaignId: "cmp_demo_signalstack", + advertiser: "SignalStack", + category: "crm_software", + regions: ["UK", "EU", "US"], + targetingPartnerIds: ["partner_demo_northstar", "partner_demo_summit"], + billingModel: "CPA", + payoutAmount: 980, + currency: "USD", + budget: 42000, + status: "active", + disclosureText: "Sponsored recommendation from SignalStack. Compensation may apply after a verified conversion.", + policyPass: true, + minTrust: 0.7, + offer: { + offerId: "offer_demo_signalstack", + title: "SignalStack for mid-market revenue teams", + description: "CRM suite with territory planning, forecasting, and workflow automation.", + price: 799, + currency: "USD", + intendedFor: ["compare_and_shortlist", "pricing_analysis"], + constraints: { company_size: "100-1000" }, + claims: ["SOC 2 Type II", "Native ERP sync", "Dedicated success manager"], + actionEndpoints: ["https://api.demo-signalstack.example.com/trial"], + narrativeVariants: { + rational: "High coverage for complex sales ops teams.", + premium: "Enterprise-grade workflows without CRM sprawl.", + simple: "A powerful CRM for teams that outgrew lightweight tools.", + }, + }, + proofBundle: { + proofBundleId: "proof_demo_signalstack", + references: [ + { label: "Security certificate", type: "certificate", url: "https://demo-signalstack.example.com/soc" }, + ], + updatedAt: "2026-03-04T00:00:00.000Z", + }, + }, + { + campaignId: "cmp_demo_northstar", + advertiser: "Northstar CRM", + category: "crm_software", + regions: ["US", "EU"], + targetingPartnerIds: ["partner_demo_vector", "partner_demo_northstar"], + billingModel: "CPQR", + payoutAmount: 180, + currency: "USD", + budget: 36000, + status: "active", + disclosureText: "Sponsored recommendation from Northstar CRM. Compensation may apply if shortlisted.", + policyPass: true, + minTrust: 0.68, + offer: { + offerId: "offer_demo_northstar", + title: "Northstar CRM for disciplined handoffs", + description: "Revenue CRM for multi-team handoffs and territory governance.", + price: 699, + currency: "USD", + intendedFor: ["compare_and_shortlist", "vendor_discovery"], + constraints: { company_size: "75-700" }, + claims: ["SOC 2 Type II", "Role-based approvals", "Guided migration"], + actionEndpoints: ["https://api.northstar-demo.example.com/demo"], + narrativeVariants: { + rational: "Handoff discipline and governance without operational bloat.", + premium: "A polished buyer journey for control-minded sales teams.", + simple: "CRM built for cleaner handoffs and planning.", + }, + }, + proofBundle: { + proofBundleId: "proof_demo_northstar", + references: [ + { label: "Migration guide", type: "doc", url: "https://northstar-demo.example.com/migration" }, + { label: "Customer interview", type: "case_study", url: "https://northstar-demo.example.com/customers" }, + ], + updatedAt: "2026-03-05T00:00:00.000Z", + }, + }, + { + campaignId: "cmp_demo_vector", + advertiser: "Vector Suite", + category: "crm_software", + regions: ["US", "CA"], + targetingPartnerIds: ["partner_demo_vector"], + billingModel: "CPQR", + payoutAmount: 110, + currency: "USD", + budget: 18000, + status: "active", + disclosureText: "Sponsored recommendation from Vector Suite. Compensation may apply if shortlisted.", + policyPass: true, + minTrust: 0.66, + offer: { + offerId: "offer_demo_vector", + title: "Vector Suite for buyer enablement teams", + description: "Workflow-heavy CRM with strong procurement collaboration features.", + price: 459, + currency: "USD", + intendedFor: ["compare_and_shortlist"], + constraints: { company_size: "25-250" }, + claims: ["Native approval trails", "Shared buying workspace"], + actionEndpoints: ["https://api.vector-demo.example.com/tour"], + narrativeVariants: { + rational: "Faster vendor comparisons with clean internal review trails.", + premium: "A collaborative buyer workspace for teams scaling formal procurement.", + simple: "CRM plus shared buying workflows.", + }, + }, + proofBundle: { + proofBundleId: "proof_demo_vector", + references: [ + { label: "Pricing overview", type: "doc", url: "https://vector-demo.example.com/pricing" }, + ], + updatedAt: "2026-03-02T00:00:00.000Z", + }, + }, + { + campaignId: "cmp_demo_orbit", + advertiser: "Orbit CRM", + category: "crm_software", + regions: ["SG", "AU"], + targetingPartnerIds: ["partner_demo_orbit"], + billingModel: "CPQR", + payoutAmount: 90, + currency: "USD", + budget: 12000, + status: "reviewing", + disclosureText: "Sponsored recommendation from Orbit CRM. Compensation may apply if shortlisted.", + policyPass: false, + minTrust: 0.72, + offer: { + offerId: "offer_demo_orbit", + title: "Orbit CRM for APAC growth teams", + description: "CRM with stronger reseller collaboration and lighter implementation overhead.", + price: 389, + currency: "USD", + intendedFor: ["compare_and_shortlist"], + constraints: { company_size: "20-200" }, + claims: ["Fast onboarding", "Regional support available"], + actionEndpoints: ["https://api.orbit-demo.example.com/demo"], + narrativeVariants: { + rational: "A practical fit for teams standardising CRM without a full enterprise rollout.", + premium: "A lighter APAC deployment with strong partner support.", + simple: "Fast CRM rollout for regional growth teams.", + }, + }, + proofBundle: { + proofBundleId: "proof_demo_orbit", + references: [ + { label: "Deployment guide", type: "doc", url: "https://orbit-demo.example.com/deploy" }, + ], + updatedAt: "2026-02-28T00:00:00.000Z", + }, + }, + { + campaignId: "cmp_demo_risky", + advertiser: "VelocityCRM", + category: "crm_software", + regions: ["US"], + targetingPartnerIds: [], + billingModel: "CPA", + payoutAmount: 2200, + currency: "USD", + budget: 22000, + status: "rejected", + disclosureText: "Sponsored recommendation from VelocityCRM.", + policyPass: false, + minTrust: 0.7, + offer: { + offerId: "offer_demo_risky", + title: "VelocityCRM hyper-growth promise", + description: "Aggressive performance-led CRM positioning for policy demo cases.", + price: 299, + currency: "USD", + intendedFor: ["compare_and_shortlist"], + constraints: { company_size: "10-150" }, + claims: ["100% guaranteed ROI in 7 days"], + actionEndpoints: ["https://api.velocity-demo.example.com/signup"], + narrativeVariants: { + rational: "Low-cost CRM positioned for short sales cycles.", + premium: "Fast growth positioning with minimal setup.", + simple: "CRM with aggressive performance messaging.", + }, + }, + proofBundle: { + proofBundleId: "proof_demo_risky", + references: [ + { label: "Brochure", type: "doc", url: "https://velocity-demo.example.com/brochure" }, + ], + updatedAt: "2026-03-01T00:00:00.000Z", + }, + }, + ], + verificationRecords: [ + { + recordId: "verif_demo_northstar_active", + leadId: "lead_demo_northstar", + previousStatus: "verified", + nextStatus: "active", + checklist: { identity: true, auth: true, disclosure: true, sla: true, rateLimit: true }, + actorId: "ops:alice", + comment: "Activated for enterprise procurement launch demo.", + occurredAt: "2026-03-05T11:00:00.000Z", + }, + { + recordId: "verif_demo_summit_active", + leadId: "lead_demo_summit", + previousStatus: "verified", + nextStatus: "active", + checklist: { identity: true, auth: true, disclosure: true, sla: true, rateLimit: true }, + actorId: "ops:bob", + comment: "Activated after revops review.", + occurredAt: "2026-03-06T11:00:00.000Z", + }, + { + recordId: "verif_demo_vector_active", + leadId: "lead_demo_vector", + previousStatus: "verified", + nextStatus: "active", + checklist: { identity: true, auth: true, disclosure: true, sla: true, rateLimit: true }, + actorId: "ops:alice", + comment: "Activated for North America workspace demo.", + occurredAt: "2026-03-06T13:00:00.000Z", + }, + { + recordId: "verif_demo_orbit_reviewing", + leadId: "lead_demo_orbit", + previousStatus: "new", + nextStatus: "reviewing", + checklist: { identity: true, auth: true, disclosure: true, sla: false, rateLimit: true }, + actorId: "ops:carol", + comment: "Waiting on SLA addendum before activation.", + occurredAt: "2026-03-07T10:00:00.000Z", + }, + ], + evidenceAssets: [ + { + assetId: "asset_demo_hubflow_pricing", + campaignId: "cmp_demo_hubflow", + type: "pricing", + label: "HubFlow pricing sheet", + url: "https://demo-hubflow.example.com/pricing", + updatedAt: "2026-03-03T10:00:00.000Z", + verifiedBy: "risk:irene", + verificationNote: "Pricing verified against campaign copy.", + }, + { + assetId: "asset_demo_signalstack_soc", + campaignId: "cmp_demo_signalstack", + type: "certificate", + label: "SignalStack SOC summary", + url: "https://demo-signalstack.example.com/soc", + updatedAt: "2026-03-02T10:00:00.000Z", + verifiedBy: "risk:irene", + verificationNote: "Security certificate confirmed.", + }, + { + assetId: "asset_demo_northstar_case", + campaignId: "cmp_demo_northstar", + type: "case_study", + label: "Northstar migration case study", + url: "https://northstar-demo.example.com/case-study", + updatedAt: "2026-03-05T10:00:00.000Z", + verifiedBy: "ops:alice", + verificationNote: "Approved for buyer enablement demo.", + }, + ], + riskCases: [ + { + caseId: "risk_demo_velocity_rejected", + entityType: "campaign", + entityId: "cmp_demo_risky", + reasonType: "policy_violation", + severity: "high", + status: "resolved", + openedAt: "2026-03-05T12:00:00.000Z", + resolvedAt: "2026-03-06T12:00:00.000Z", + ownerId: "risk:irene", + note: "Rejected after high-risk claim language was confirmed.", + }, + { + caseId: "risk_demo_orbit_sla", + entityType: "agent_lead", + entityId: "lead_demo_orbit", + reasonType: "policy_violation", + severity: "medium", + status: "open", + openedAt: "2026-03-07T12:00:00.000Z", + resolvedAt: null, + ownerId: "risk:irene", + note: "SLA evidence missing during APAC partner review.", + }, + ], + reputationRecords: [ + { + recordId: "rep_demo_northstar_01", + partnerId: "partner_demo_northstar", + delta: 6, + reasonType: "manual_adjustment", + evidenceRefs: ["asset_demo_northstar_case"], + disputeStatus: "none", + occurredAt: "2026-03-05T12:00:00.000Z", + }, + { + recordId: "rep_demo_summit_01", + partnerId: "partner_demo_summit", + delta: -2, + reasonType: "high_complaint", + evidenceRefs: ["risk_demo_orbit_sla"], + disputeStatus: "under_review", + occurredAt: "2026-03-08T12:00:00.000Z", + }, + ], + appeals: [ + { + appealId: "appeal_demo_summit_01", + partnerId: "partner_demo_summit", + targetRecordId: "rep_demo_summit_01", + status: "reviewing", + statement: "Requesting a manual review of complaint weighting.", + openedAt: "2026-03-09T15:00:00.000Z", + decidedAt: null, + decisionNote: null, + }, + ], +}); diff --git a/src/factory.ts b/src/factory.ts index 94b999b..a6e23df 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -1,3 +1,5 @@ +import { bootstrapDemoScenario } from "./demo-scenario.js"; +import { buildDemoSeedData } from "./demo-seed.js"; import { InMemoryHotStateStore } from "./hot-state.js"; import { HttpSettlementGateway } from "./http-settlement-gateway.js"; import { PostgresPromotionAgentRepository } from "./postgres-repository.js"; @@ -7,6 +9,7 @@ import { SimulatedSettlementGateway } from "./settlement-gateway.js"; import { createStore, PromotionAgentStore } from "./store.js"; export const createConfiguredStore = async () => { + const appMode: "default" | "demo" = process.env.APP_MODE === "demo" ? "demo" : "default"; const connectionString = process.env.DATABASE_URL; const redisUrl = process.env.REDIS_URL; const billingAdapterUrl = process.env.BILLING_ADAPTER_URL; @@ -18,6 +21,7 @@ export const createConfiguredStore = async () => { const billingAdapterTimestampHeader = process.env.BILLING_ADAPTER_TIMESTAMP_HEADER; const hotStateNamespace = process.env.HOT_STATE_NAMESPACE ?? "promotion-agent"; const hotStateVersion = process.env.HOT_STATE_VERSION ?? "v1"; + const seedData = appMode === "demo" ? buildDemoSeedData() : buildSeedData(); const settlementGateway = billingAdapterUrl ? new HttpSettlementGateway({ url: billingAdapterUrl, @@ -31,10 +35,30 @@ export const createConfiguredStore = async () => { : new SimulatedSettlementGateway(); const settlementGatewayMode = billingAdapterUrl ? ("http" as const) : ("simulated" as const); + if (appMode === "demo") { + const hotState = new InMemoryHotStateStore(hotStateNamespace, hotStateVersion); + const store = createStore({ + seedData, + hotState, + settlementGateway: new SimulatedSettlementGateway(), + }); + await bootstrapDemoScenario(store); + + return { + store, + hotState, + persistence: "memory" as const, + hotStatePersistence: "memory" as const, + settlementGatewayMode: "simulated" as const, + appMode, + }; + } + if (!connectionString) { const hotState = new InMemoryHotStateStore(hotStateNamespace, hotStateVersion); return { store: createStore({ + seedData, hotState, settlementGateway, }), @@ -42,18 +66,20 @@ export const createConfiguredStore = async () => { persistence: "memory" as const, hotStatePersistence: "memory" as const, settlementGatewayMode, + appMode, }; } const hotState = redisUrl ? await RedisHotStateStore.connect(redisUrl, hotStateNamespace, hotStateVersion) : new InMemoryHotStateStore(hotStateNamespace, hotStateVersion); - const repository = await PostgresPromotionAgentRepository.connect(connectionString, buildSeedData()); + const repository = await PostgresPromotionAgentRepository.connect(connectionString, seedData); return { store: new PromotionAgentStore(repository, hotState, settlementGateway), hotState, persistence: "postgres" as const, hotStatePersistence: redisUrl ? ("redis" as const) : ("memory" as const), settlementGatewayMode, + appMode, }; }; diff --git a/src/index.ts b/src/index.ts index 3f87cfb..7219b9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,8 @@ const port = Number(process.env.PORT ?? 3000); const host = process.env.HOST ?? "0.0.0.0"; const main = async () => { - const { store, persistence, hotStatePersistence, settlementGatewayMode } = await createConfiguredStore(); - const app = buildServer(store); + const { store, persistence, hotStatePersistence, settlementGatewayMode, appMode } = await createConfiguredStore(); + const app = buildServer(store, { appMode }); const shutdown = async () => { await app.close(); @@ -26,7 +26,7 @@ const main = async () => { host, }); - console.log(`promotion-agent listening on http://${host}:${port} using ${persistence} persistence, ${hotStatePersistence} hot-state, ${settlementGatewayMode} billing adapter`); + console.log(`promotion-agent listening on http://${host}:${port} using ${persistence} persistence, ${hotStatePersistence} hot-state, ${settlementGatewayMode} billing adapter, mode=${appMode}`); }; main().catch((error) => { diff --git a/src/server.ts b/src/server.ts index 655d944..0667814 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,10 +23,14 @@ const sendPublicAsset = async (reply: { type: (contentType: string) => { send: ( return reply.type(contentType).send(body); }; -export const buildServer = (store: PromotionAgentStore = createStore()) => { +export const buildServer = ( + store: PromotionAgentStore = createStore(), + options: { appMode?: "default" | "demo" } = {}, +) => { const app = Fastify({ logger: false, }); + const appMode = options.appMode ?? "default"; app.get("/", async (_request, reply) => sendPublicAsset(reply, "index.html", "text/html; charset=utf-8")); app.get("/agents", async (_request, reply) => sendPublicAsset(reply, "agents.html", "text/html; charset=utf-8")); @@ -37,6 +41,10 @@ export const buildServer = (store: PromotionAgentStore = createStore()) => { app.get("/audit.html", async (_request, reply) => sendPublicAsset(reply, "audit.html", "text/html; charset=utf-8")); app.get("/dlq.html", async (_request, reply) => sendPublicAsset(reply, "dlq.html", "text/html; charset=utf-8")); app.get("/styles.css", async (_request, reply) => sendPublicAsset(reply, "styles.css", "text/css; charset=utf-8")); + app.get("/app-config.js", async (_request, reply) => + reply + .type("application/javascript; charset=utf-8") + .send(`window.__PROMOTION_AGENT_CONFIG__ = ${JSON.stringify({ mode: appMode })};`)); app.get("/app.js", async (_request, reply) => sendPublicAsset(reply, "app.js", "application/javascript; charset=utf-8")); app.get("/agents.js", async (_request, reply) => sendPublicAsset(reply, "agents.js", "application/javascript; charset=utf-8")); app.get("/agent-detail.js", async (_request, reply) => sendPublicAsset(reply, "agent-detail.js", "application/javascript; charset=utf-8")); diff --git a/src/store.ts b/src/store.ts index 1ff62fe..240cd42 100644 --- a/src/store.ts +++ b/src/store.ts @@ -572,6 +572,7 @@ type CreateStoreOptions = Partial<{ repository: PromotionAgentRepository; hotState: HotStateStore; settlementGateway: SettlementGateway; + seedData: SeedData; }>; export class PromotionAgentStore { @@ -1916,7 +1917,7 @@ export class PromotionAgentStore { export const createStore = (options: CreateStoreOptions = {}) => new PromotionAgentStore( - options.repository ?? new InMemoryPromotionAgentRepository(buildSeedData()), + options.repository ?? new InMemoryPromotionAgentRepository(options.seedData ?? buildSeedData()), options.hotState ?? new InMemoryHotStateStore(), options.settlementGateway ?? new SimulatedSettlementGateway(), ); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..01e1ead --- /dev/null +++ b/vercel.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "functions": { + "api/**/*.ts": { + "includeFiles": "{public/**,src/db/schema.sql}" + } + }, + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/$1" + } + ] +}