create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,1278 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file flow-engine.ts
|
|
3
|
-
* @description The linear flow engine service (#3956 D3).
|
|
4
|
-
*
|
|
5
|
-
* `serveFlow` reads a validated FlowConfig; `submitStep` is the one mutation
|
|
6
|
-
* path — it persists every landed step server-side IMMEDIATELY (this write is
|
|
7
|
-
* the partial-state capture the feature exists for, not a batched or
|
|
8
|
-
* end-of-flow write), and composes the existing `routeLead` service on
|
|
9
|
-
* completion. Mirrors `lead-routing.ts`'s house shape (ADR 0009 D3
|
|
10
|
-
* service-layer pattern): type-result returns, mocked-dep tests, PII-safe
|
|
11
|
-
* logging.
|
|
12
|
-
*
|
|
13
|
-
* `submitStep` itself is a thin orchestrator (#3974 AC1) over four isolated
|
|
14
|
-
* concerns, each a named helper below: `resolveSession` (session
|
|
15
|
-
* resolution) → `locateStep` (find the submitted step in the snapshot) →
|
|
16
|
-
* `extractStepValues` (per-step value validation) → `persistStepUpdate`
|
|
17
|
-
* (the immediate partial-state write) → `completeSubmission` (completion
|
|
18
|
-
* orchestration: routeLead + deliverable fulfillment). No single function
|
|
19
|
-
* does more than one of these jobs.
|
|
20
|
-
*
|
|
21
|
-
* `cleanupAbandonedSessions` (#3974 AC3) is the FlowSession retention
|
|
22
|
-
* policy — see its own doc comment below for the full policy + its
|
|
23
|
-
* coordination with #3962's abandon-email sweep.
|
|
24
|
-
*
|
|
25
|
-
* PII boundary: only `{ siteId, slug, stepKey, sessionId, completed }` appear
|
|
26
|
-
* in logs — never `values` or the assembled `payload` (mirrors
|
|
27
|
-
* lead-routing.ts's boundary).
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import type { FlowDefinition } from "@working-theory/database";
|
|
31
|
-
import { Prisma } from "@working-theory/database";
|
|
32
|
-
import { logger } from "@working-theory/observability";
|
|
33
|
-
import {
|
|
34
|
-
aeoScoreResultSchema,
|
|
35
|
-
deriveFlags,
|
|
36
|
-
flowConfigSchema,
|
|
37
|
-
hiddenAnswers,
|
|
38
|
-
isStepVisible,
|
|
39
|
-
pruneHiddenState,
|
|
40
|
-
resolveVisibleSteps,
|
|
41
|
-
submitFlowStepInputSchema,
|
|
42
|
-
type AeoScoreResult,
|
|
43
|
-
type FlowConfig,
|
|
44
|
-
type FlowFlags,
|
|
45
|
-
type FlowState,
|
|
46
|
-
type FlowStep,
|
|
47
|
-
} from "@working-theory/validation";
|
|
48
|
-
import { z } from "zod";
|
|
49
|
-
|
|
50
|
-
import { prisma } from "../lib/prisma.js";
|
|
51
|
-
|
|
52
|
-
import { loadExampleScore, resolveScoreBudgetMs, scoreUrl } from "./aeo-score.js";
|
|
53
|
-
import { fulfillDeliverable } from "./deliverable-fulfillment.js";
|
|
54
|
-
import { routeLead, type SourceMeta } from "./lead-routing.js";
|
|
55
|
-
import { scoreCompletedSignup } from "./waitlist-scoring.js";
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The exact email-format + length check `leadSubmitSchema` (leads.ts:
|
|
59
|
-
* `email: z.string().email().max(254)`) will re-apply at completion —
|
|
60
|
-
* reused here VERBATIM (both the format check and the 254 cap) so an
|
|
61
|
-
* invalid or over-length email is rejected AT SUBMIT TIME (400
|
|
62
|
-
* invalid_input) rather than silently failing `routeLead` later.
|
|
63
|
-
*/
|
|
64
|
-
const EMAIL_SHAPE = z.string().email().max(254);
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* text/tel value cap — matches the short-free-text bound `mcp-tools.ts`
|
|
68
|
-
* already uses for comparable MCP inputs (`name`: max 200, `phone`: max 50);
|
|
69
|
-
* 256 covers either with headroom (security-review finding, Medium).
|
|
70
|
-
*/
|
|
71
|
-
const TEXT_VALUE_MAX = 256;
|
|
72
|
-
|
|
73
|
-
/** textarea value cap — matches `submitLeadInputSchema.message`'s max(2000). */
|
|
74
|
-
const TEXTAREA_VALUE_MAX = 2000;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* D5 (#4663) — the exact `url` shape check `flowConfigSchema`'s own field
|
|
78
|
-
* validation implies.
|
|
79
|
-
*
|
|
80
|
-
* Security-review finding (#4663, M3): under the installed zod 4.4.3,
|
|
81
|
-
* `z.string().url()` restricts NEITHER hostname shape NOR protocol — its
|
|
82
|
-
* internal `$ZodURL` applies those checks only when `def.hostname`/
|
|
83
|
-
* `def.protocol` are set, and bare `.url()` sets neither. A `javascript:`,
|
|
84
|
-
* `data:`, or `file:` value would therefore pass this check and reach
|
|
85
|
-
* persistence in the lead payload (SSRF itself stays blocked downstream,
|
|
86
|
-
* at `assertUrlSafe` — this is a separate, narrower gap: an unconstrained
|
|
87
|
-
* value boundary, not a network-reach one). `z.httpUrl()` is zod's own
|
|
88
|
-
* preset for exactly this — it fixes `protocol` to the http(s)-only regex
|
|
89
|
-
* and `hostname` to a domain-shaped pattern.
|
|
90
|
-
*/
|
|
91
|
-
const URL_SHAPE = z.httpUrl().max(2048);
|
|
92
|
-
|
|
93
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
export type FlowEngineErrorKind =
|
|
96
|
-
"invalid_input" | "flow_not_found" | "step_conflict" | "flows_not_configured";
|
|
97
|
-
|
|
98
|
-
export type FlowEngineError = { kind: FlowEngineErrorKind; message: string };
|
|
99
|
-
|
|
100
|
-
export type ServeFlowResult =
|
|
101
|
-
{ ok: true; config: FlowConfig } | { ok: false; error: FlowEngineError };
|
|
102
|
-
|
|
103
|
-
export type SubmitStepInput = {
|
|
104
|
-
siteId: string;
|
|
105
|
-
slug: string;
|
|
106
|
-
sessionId?: string;
|
|
107
|
-
stepKey: string;
|
|
108
|
-
values: Record<string, unknown>;
|
|
109
|
-
locale?: string;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export type SubmitStepResult =
|
|
113
|
-
| {
|
|
114
|
-
ok: true;
|
|
115
|
-
sessionId: string;
|
|
116
|
-
landed: string;
|
|
117
|
-
nextStepKey: string | null;
|
|
118
|
-
completed: boolean;
|
|
119
|
-
leadId?: string;
|
|
120
|
-
/**
|
|
121
|
-
* Internal-only signal — completion ran but `routeLead` returned
|
|
122
|
-
* `ok:false` (the session is still the durable record, per D9's
|
|
123
|
-
* fallback). The route handler reads this to log a PII-safe warning;
|
|
124
|
-
* it is NOT part of `submitFlowStepOutputSchema`'s public shape and
|
|
125
|
-
* must be stripped before the response is sent.
|
|
126
|
-
*/
|
|
127
|
-
leadRoutingFailed?: true;
|
|
128
|
-
/**
|
|
129
|
-
* Server-derived segment (#3959 D3/D6) — present from the
|
|
130
|
-
* segmentation-step response onward (derived this request, or
|
|
131
|
-
* previously persisted on the session).
|
|
132
|
-
*/
|
|
133
|
-
segment?: string;
|
|
134
|
-
/**
|
|
135
|
-
* D9 (#4663) — present on the `aeo_score` step's own response AND on
|
|
136
|
-
* the completion response when the flow declares one (the persisted
|
|
137
|
-
* value, when this submission isn't the one that computed it).
|
|
138
|
-
*/
|
|
139
|
-
aeoScore?: AeoScoreResult;
|
|
140
|
-
}
|
|
141
|
-
| { ok: false; error: FlowEngineError };
|
|
142
|
-
|
|
143
|
-
export type UpsertFlowDefinitionResult =
|
|
144
|
-
{ ok: true; id: string } | { ok: false; error: FlowEngineError };
|
|
145
|
-
|
|
146
|
-
// ─── Generated Prisma Client payload types (#3974 AC2) ─────────────────────
|
|
147
|
-
//
|
|
148
|
-
// Narrowed via `select` to exactly the columns this module reads/writes —
|
|
149
|
-
// replacing the former hand-written `as {...}` result casts (code-review
|
|
150
|
-
// finding on #3956's acceptance PR: a schema/select drift is now caught at
|
|
151
|
-
// compile time instead of silently passing through an untyped assertion).
|
|
152
|
-
//
|
|
153
|
-
// `configSnapshot` / `state` / `config` stay `unknown` (never the generated
|
|
154
|
-
// `Prisma.JsonValue`) — this module never trusts a JSON column's TS shape;
|
|
155
|
-
// every read runs through `flowConfigSchema.safeParse` or an explicit
|
|
156
|
-
// `typeof` guard before use, exactly as before this change.
|
|
157
|
-
|
|
158
|
-
const FLOW_SESSION_SELECT = {
|
|
159
|
-
id: true,
|
|
160
|
-
siteId: true,
|
|
161
|
-
flowId: true,
|
|
162
|
-
flowSlug: true,
|
|
163
|
-
configSnapshot: true,
|
|
164
|
-
state: true,
|
|
165
|
-
email: true,
|
|
166
|
-
segment: true,
|
|
167
|
-
// D2/D6 (#4663) — the derived flag set + the mid-flow aeo_score result.
|
|
168
|
-
flags: true,
|
|
169
|
-
aeoScore: true,
|
|
170
|
-
leadId: true,
|
|
171
|
-
completedAt: true,
|
|
172
|
-
} as const satisfies Prisma.FlowSessionSelect;
|
|
173
|
-
|
|
174
|
-
// Session row shapes read from / written to Prisma — narrowed to what this
|
|
175
|
-
// module touches (the JSON columns are widened back to `unknown` — see the
|
|
176
|
-
// section doc comment above).
|
|
177
|
-
type FlowSessionRow = Omit<
|
|
178
|
-
Prisma.FlowSessionGetPayload<{ select: typeof FLOW_SESSION_SELECT }>,
|
|
179
|
-
"configSnapshot" | "state" | "flags" | "aeoScore"
|
|
180
|
-
> & { configSnapshot: unknown; state: unknown; flags: unknown; aeoScore: unknown };
|
|
181
|
-
|
|
182
|
-
const FLOW_DEFINITION_ID_SELECT = {
|
|
183
|
-
id: true,
|
|
184
|
-
} as const satisfies Prisma.FlowDefinitionSelect;
|
|
185
|
-
|
|
186
|
-
type FlowDefinitionIdRow = Prisma.FlowDefinitionGetPayload<{
|
|
187
|
-
select: typeof FLOW_DEFINITION_ID_SELECT;
|
|
188
|
-
}>;
|
|
189
|
-
|
|
190
|
-
const FLOW_DEFINITION_FOR_SESSION_SELECT = {
|
|
191
|
-
id: true,
|
|
192
|
-
config: true,
|
|
193
|
-
// #5036 D6d — copied onto the session below, so a submission is attributable
|
|
194
|
-
// to one listing without re-deriving it from a slug that nothing enforces.
|
|
195
|
-
listingId: true,
|
|
196
|
-
} as const satisfies Prisma.FlowDefinitionSelect;
|
|
197
|
-
|
|
198
|
-
type FlowDefinitionForSessionRow = Omit<
|
|
199
|
-
Prisma.FlowDefinitionGetPayload<{
|
|
200
|
-
select: typeof FLOW_DEFINITION_FOR_SESSION_SELECT;
|
|
201
|
-
}>,
|
|
202
|
-
"config"
|
|
203
|
-
> & { config: unknown };
|
|
204
|
-
|
|
205
|
-
// `serveFlow`'s own lookup passes no `select` (its call args are asserted
|
|
206
|
-
// exactly by flow-engine.test.ts, so no `select` clause may be added) — typed
|
|
207
|
-
// against the full generated row instead, with `config` widened to
|
|
208
|
-
// `unknown` per this section's rule.
|
|
209
|
-
type FlowDefinitionRow = Omit<FlowDefinition, "config"> & { config: unknown };
|
|
210
|
-
|
|
211
|
-
// ─── DB-required guard (D5) ─────────────────────────────────────────────────────
|
|
212
|
-
|
|
213
|
-
const FLOWS_NOT_CONFIGURED_ERROR: FlowEngineError = {
|
|
214
|
-
kind: "flows_not_configured",
|
|
215
|
-
message: "Flows are not configured.",
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* A flow session is read-modify-write across requests — the file fallback
|
|
220
|
-
* `lead-routing.ts` uses for an append-only Lead write would race and
|
|
221
|
-
* silently lose partial state here. So flows require a configured DB; the
|
|
222
|
-
* zero-config OSS path keeps its existing static capture via `/v1/leads/*`.
|
|
223
|
-
*/
|
|
224
|
-
function requireDb(): FlowEngineError | null {
|
|
225
|
-
if (!process.env.DATABASE_URL) return FLOWS_NOT_CONFIGURED_ERROR;
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── serveFlow ────────────────────────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
export async function serveFlow(
|
|
232
|
-
siteId: string,
|
|
233
|
-
slug: string,
|
|
234
|
-
): Promise<ServeFlowResult> {
|
|
235
|
-
const dbError = requireDb();
|
|
236
|
-
if (dbError) return { ok: false, error: dbError };
|
|
237
|
-
|
|
238
|
-
const definition: FlowDefinitionRow | null =
|
|
239
|
-
await prisma.flowDefinition.findUnique({
|
|
240
|
-
where: { siteId_slug: { siteId, slug } },
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
if (!definition) {
|
|
244
|
-
return {
|
|
245
|
-
ok: false,
|
|
246
|
-
error: { kind: "flow_not_found", message: "Flow not found." },
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const parsed = flowConfigSchema.safeParse(definition.config);
|
|
251
|
-
if (!parsed.success) {
|
|
252
|
-
// Defensive — upsertFlowDefinition already validates before write; a
|
|
253
|
-
// parse failure here means the stored config drifted from the schema.
|
|
254
|
-
return {
|
|
255
|
-
ok: false,
|
|
256
|
-
error: {
|
|
257
|
-
kind: "invalid_input",
|
|
258
|
-
message: "Stored flow config failed validation.",
|
|
259
|
-
},
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return { ok: true, config: parsed.data };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ─── upsertFlowDefinition (D6 — service-level only, no authoring route) ────────
|
|
267
|
-
|
|
268
|
-
export async function upsertFlowDefinition(
|
|
269
|
-
siteId: string,
|
|
270
|
-
config: unknown,
|
|
271
|
-
/**
|
|
272
|
-
* The `ProjectListing` this definition is projected from (#5036 D6d), when
|
|
273
|
-
* it is one. Stamped on the row so every session started on this flow can
|
|
274
|
-
* carry the same reference, instead of the link being the coincidence that
|
|
275
|
-
* the flow's slug equals the listing's.
|
|
276
|
-
*/
|
|
277
|
-
listingId?: string,
|
|
278
|
-
): Promise<UpsertFlowDefinitionResult> {
|
|
279
|
-
const dbError = requireDb();
|
|
280
|
-
if (dbError) return { ok: false, error: dbError };
|
|
281
|
-
|
|
282
|
-
const parsed = flowConfigSchema.safeParse(config);
|
|
283
|
-
if (!parsed.success) {
|
|
284
|
-
return {
|
|
285
|
-
ok: false,
|
|
286
|
-
error: {
|
|
287
|
-
kind: "invalid_input",
|
|
288
|
-
message: parsed.error.issues[0]?.message ?? "invalid flow config",
|
|
289
|
-
},
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const row: FlowDefinitionIdRow = await prisma.flowDefinition.upsert({
|
|
294
|
-
where: { siteId_slug: { siteId, slug: parsed.data.slug } },
|
|
295
|
-
create: {
|
|
296
|
-
siteId,
|
|
297
|
-
slug: parsed.data.slug,
|
|
298
|
-
config: parsed.data as unknown as Prisma.InputJsonValue,
|
|
299
|
-
...(listingId !== undefined && { listingId }),
|
|
300
|
-
},
|
|
301
|
-
update: {
|
|
302
|
-
config: parsed.data as unknown as Prisma.InputJsonValue,
|
|
303
|
-
...(listingId !== undefined && { listingId }),
|
|
304
|
-
},
|
|
305
|
-
select: FLOW_DEFINITION_ID_SELECT,
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
return { ok: true, id: row.id };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ─── seedFlowPresets (#3958 §3.3 — reuse, do not duplicate) ────────────────────
|
|
312
|
-
|
|
313
|
-
export type SeedFlowPresetsResult =
|
|
314
|
-
| { ok: true; seeded: string[] }
|
|
315
|
-
| { ok: false; error: FlowEngineError; seeded: string[] };
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Write the given preset flow configs through the existing validated
|
|
319
|
-
* `upsertFlowDefinition` path, once per preset, for the given site.
|
|
320
|
-
*
|
|
321
|
-
* This is the ONLY write path into `flow_definitions` — reusing
|
|
322
|
-
* `upsertFlowDefinition` (rather than a second Prisma write here) is what
|
|
323
|
-
* keeps a malformed preset failing at seed time instead of becoming the
|
|
324
|
-
* engine's defensive "stored flow config failed validation" branch
|
|
325
|
-
* (`serveFlow` above) at a customer's first request.
|
|
326
|
-
*
|
|
327
|
-
* Stops and returns the first `invalid_input` (or `flows_not_configured`)
|
|
328
|
-
* failure rather than swallowing it, per §3.3 — a caller must not seed a
|
|
329
|
-
* partial, broken preset set. `seeded` names the slugs successfully written
|
|
330
|
-
* before any failure, for diagnostics.
|
|
331
|
-
*/
|
|
332
|
-
export async function seedFlowPresets(
|
|
333
|
-
siteId: string,
|
|
334
|
-
presets: unknown[],
|
|
335
|
-
): Promise<SeedFlowPresetsResult> {
|
|
336
|
-
const seeded: string[] = [];
|
|
337
|
-
for (const preset of presets) {
|
|
338
|
-
const result = await upsertFlowDefinition(siteId, preset);
|
|
339
|
-
if (!result.ok) {
|
|
340
|
-
return { ok: false, error: result.error, seeded };
|
|
341
|
-
}
|
|
342
|
-
const slug =
|
|
343
|
-
typeof preset === "object" && preset !== null && "slug" in preset
|
|
344
|
-
? String((preset as { slug: unknown }).slug)
|
|
345
|
-
: "(unknown)";
|
|
346
|
-
seeded.push(slug);
|
|
347
|
-
}
|
|
348
|
-
return { ok: true, seeded };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// ─── Per-step value validation (D3 — "required fields, select/segmentation
|
|
352
|
-
// values ∈ declared options") ───────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Validate + extract one step's submitted `values` against its snapshot
|
|
356
|
-
* definition. Returns only the declared field/question names present in
|
|
357
|
-
* `values` — an undeclared key is silently dropped, mirroring the leads
|
|
358
|
-
* route's `buildInput` explicit-destructure discipline (never let an
|
|
359
|
-
* unexpected key smuggle into persisted state).
|
|
360
|
-
*/
|
|
361
|
-
function extractStepValues(
|
|
362
|
-
step: FlowStep,
|
|
363
|
-
values: Record<string, unknown>,
|
|
364
|
-
):
|
|
365
|
-
{ ok: true; data: Record<string, unknown> } | { ok: false; message: string } {
|
|
366
|
-
if (step.type === "message" || step.type === "aeo_score" || step.type === "custom") {
|
|
367
|
-
// Interstitial (message) / server-computed (aeo_score, D6) — nothing to
|
|
368
|
-
// capture from the submission; any submission just lands the step.
|
|
369
|
-
//
|
|
370
|
-
// A `custom` step (#5048) lands the same way from the ENGINE's side: its
|
|
371
|
-
// values are shaped by a renderer this server has no definition for, so
|
|
372
|
-
// there is nothing here to validate them against. A served flow does not
|
|
373
|
-
// carry one today — the founder application is client-configured and
|
|
374
|
-
// never a `FlowSession` — and this branch is what keeps that true by
|
|
375
|
-
// construction rather than by a fallthrough into the segmentation reader.
|
|
376
|
-
return { ok: true, data: {} };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (step.type === "field") {
|
|
380
|
-
const data: Record<string, unknown> = {};
|
|
381
|
-
for (const field of step.fields) {
|
|
382
|
-
const raw = values[field.name];
|
|
383
|
-
const present = raw !== undefined && raw !== null && raw !== "";
|
|
384
|
-
if (field.required && !present) {
|
|
385
|
-
return {
|
|
386
|
-
ok: false,
|
|
387
|
-
message: `missing required field "${field.name}"`,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
if (!present) continue;
|
|
391
|
-
if (field.inputType === "select") {
|
|
392
|
-
const allowed = new Set(field.options?.map((o) => o.value) ?? []);
|
|
393
|
-
if (typeof raw !== "string" || !allowed.has(raw)) {
|
|
394
|
-
return {
|
|
395
|
-
ok: false,
|
|
396
|
-
message: `field "${field.name}" is not one of the declared options`,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
// A field that becomes session.email (mapsTo:'email') OR is declared
|
|
401
|
-
// inputType:'email' must actually validate as one — otherwise it
|
|
402
|
-
// denormalizes onto session.email and later fails routeLead's strict
|
|
403
|
-
// leadSubmitSchema at completion, where the failure is swallowed (a
|
|
404
|
-
// silent lost lead, code-review finding on #3956's acceptance verdict).
|
|
405
|
-
if (
|
|
406
|
-
(field.inputType === "email" || field.mapsTo === "email") &&
|
|
407
|
-
(typeof raw !== "string" || !EMAIL_SHAPE.safeParse(raw).success)
|
|
408
|
-
) {
|
|
409
|
-
return {
|
|
410
|
-
ok: false,
|
|
411
|
-
message: `field "${field.name}" must be a valid email address`,
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
// text/tel/textarea carry no format constraint, but `raw` is
|
|
415
|
-
// `unknown` (boundedStepValues), so left unchecked an anonymous
|
|
416
|
-
// POST could smuggle an arbitrarily large string — or a non-string
|
|
417
|
-
// (nested object/array) — into `flow_sessions.state` JSONB. Bound
|
|
418
|
-
// by type + length here (security-review finding, Medium: unbounded
|
|
419
|
-
// + untyped text-field values), mirroring the length caps
|
|
420
|
-
// ./mcp-tools.ts already uses for comparable free-text MCP inputs
|
|
421
|
-
// (`name`/`phone` at 200/50, `message` at 2000).
|
|
422
|
-
if (
|
|
423
|
-
(field.inputType === "text" || field.inputType === "tel") &&
|
|
424
|
-
(typeof raw !== "string" || raw.length > TEXT_VALUE_MAX)
|
|
425
|
-
) {
|
|
426
|
-
return {
|
|
427
|
-
ok: false,
|
|
428
|
-
message: `field "${field.name}" must be a string of at most ${TEXT_VALUE_MAX} characters`,
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
if (
|
|
432
|
-
field.inputType === "textarea" &&
|
|
433
|
-
(typeof raw !== "string" || raw.length > TEXTAREA_VALUE_MAX)
|
|
434
|
-
) {
|
|
435
|
-
return {
|
|
436
|
-
ok: false,
|
|
437
|
-
message: `field "${field.name}" must be a string of at most ${TEXTAREA_VALUE_MAX} characters`,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
// D5 (#4663) — a `url` field must satisfy the same shape check the
|
|
441
|
-
// config schema implies (max 2048); the client (StepFlow's
|
|
442
|
-
// HAS_SCHEME_RE idiom) prefixes a bare-domain value with `https://`
|
|
443
|
-
// before it ever reaches here, so a scheme-less value is rejected.
|
|
444
|
-
if (
|
|
445
|
-
field.inputType === "url" &&
|
|
446
|
-
(typeof raw !== "string" || !URL_SHAPE.safeParse(raw).success)
|
|
447
|
-
) {
|
|
448
|
-
return {
|
|
449
|
-
ok: false,
|
|
450
|
-
message: `field "${field.name}" must be a valid URL`,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
// D5 (#4663) — text/textarea minLength (declared on text/textarea only
|
|
454
|
-
// — flowConfigSchema's own cross-field check).
|
|
455
|
-
if (
|
|
456
|
-
field.minLength !== undefined &&
|
|
457
|
-
typeof raw === "string" &&
|
|
458
|
-
raw.length < field.minLength
|
|
459
|
-
) {
|
|
460
|
-
return {
|
|
461
|
-
ok: false,
|
|
462
|
-
message: `field "${field.name}" must be at least ${field.minLength} characters`,
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
data[field.name] = raw;
|
|
466
|
-
}
|
|
467
|
-
return { ok: true, data };
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// step.type === "segmentation"
|
|
471
|
-
const data: Record<string, unknown> = {};
|
|
472
|
-
for (const question of step.questions) {
|
|
473
|
-
const raw = values[question.name];
|
|
474
|
-
const allowed = new Set(question.options.map((o) => o.value));
|
|
475
|
-
|
|
476
|
-
if (question.multi) {
|
|
477
|
-
// D4 (#4663) — a non-empty array, ≤ options.length, every element ∈
|
|
478
|
-
// options, no duplicates, and an exclusive value only as the sole
|
|
479
|
-
// element (§ D4).
|
|
480
|
-
if (
|
|
481
|
-
!Array.isArray(raw) ||
|
|
482
|
-
raw.length === 0 ||
|
|
483
|
-
raw.length > question.options.length
|
|
484
|
-
) {
|
|
485
|
-
return {
|
|
486
|
-
ok: false,
|
|
487
|
-
message: `question "${question.name}" requires a non-empty array of declared options`,
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
const seen = new Set<string>();
|
|
491
|
-
for (const v of raw) {
|
|
492
|
-
if (typeof v !== "string" || !allowed.has(v)) {
|
|
493
|
-
return {
|
|
494
|
-
ok: false,
|
|
495
|
-
message: `question "${question.name}" contains an option value that is not declared`,
|
|
496
|
-
};
|
|
497
|
-
}
|
|
498
|
-
if (seen.has(v)) {
|
|
499
|
-
return {
|
|
500
|
-
ok: false,
|
|
501
|
-
message: `question "${question.name}" contains a duplicate option value`,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
seen.add(v);
|
|
505
|
-
}
|
|
506
|
-
const exclusive = question.exclusiveValues
|
|
507
|
-
? raw.filter((v) => question.exclusiveValues!.includes(v as string))
|
|
508
|
-
: [];
|
|
509
|
-
if (exclusive.length > 0 && raw.length > 1) {
|
|
510
|
-
return {
|
|
511
|
-
ok: false,
|
|
512
|
-
message: `question "${question.name}" — an exclusive value must be selected alone`,
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
data[question.name] = raw;
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// single — a deliberate, required single-select.
|
|
520
|
-
if (typeof raw !== "string" || !allowed.has(raw)) {
|
|
521
|
-
return {
|
|
522
|
-
ok: false,
|
|
523
|
-
message: `question "${question.name}" requires one of the declared options`,
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
data[question.name] = raw;
|
|
527
|
-
}
|
|
528
|
-
return { ok: true, data };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** The `mapsTo:'email'` field value landed in this step's data, if any. */
|
|
532
|
-
function extractMappedEmail(
|
|
533
|
-
step: FlowStep,
|
|
534
|
-
stepData: Record<string, unknown>,
|
|
535
|
-
): string | undefined {
|
|
536
|
-
if (step.type !== "field") return undefined;
|
|
537
|
-
for (const field of step.fields) {
|
|
538
|
-
if (field.mapsTo === "email") {
|
|
539
|
-
const value = stepData[field.name];
|
|
540
|
-
if (typeof value === "string" && value.length > 0) return value;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
return undefined;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Derive the segment for a just-landed segmentation step declaring a band
|
|
548
|
-
* map (#3959 D3). Returns undefined for any non-segmentation step, a
|
|
549
|
-
* segmentation step with no `segments` block (band maps are opt-in), or —
|
|
550
|
-
* only possible against a pre-D2 snapshot — a driving-question answer that
|
|
551
|
-
* doesn't hit any band; D2's totality check guarantees a miss can't happen
|
|
552
|
-
* against a schema-valid config, and a miss here leaves the session's
|
|
553
|
-
* segment untouched rather than failing the submission (the snapshot, not
|
|
554
|
-
* the visitor, is at fault).
|
|
555
|
-
*/
|
|
556
|
-
function deriveSegment(
|
|
557
|
-
step: FlowStep,
|
|
558
|
-
stepData: Record<string, unknown>,
|
|
559
|
-
): string | undefined {
|
|
560
|
-
if (step.type !== "segmentation" || !step.segments) return undefined;
|
|
561
|
-
const answer = stepData[step.segments.question];
|
|
562
|
-
if (typeof answer !== "string") return undefined;
|
|
563
|
-
const band = step.segments.bands.find((b) => b.values.includes(answer));
|
|
564
|
-
return band?.key;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// ─── Session resolution (#3974 AC1) ────────────────────────────────────────────
|
|
568
|
-
|
|
569
|
-
type ResolveSessionResult =
|
|
570
|
-
| { ok: true; session: FlowSessionRow; configSnapshot: FlowConfig }
|
|
571
|
-
| { ok: false; error: FlowEngineError };
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Resolve the session a step submission targets: reuse an in-progress one
|
|
575
|
-
* (by `sessionId`), or create the first session for a fresh walk. Isolated
|
|
576
|
-
* from the per-step value validation / persistence / completion-
|
|
577
|
-
* orchestration concerns `submitStep` composes below (#3974 AC1).
|
|
578
|
-
*/
|
|
579
|
-
async function resolveSession(
|
|
580
|
-
input: SubmitStepInput,
|
|
581
|
-
): Promise<ResolveSessionResult> {
|
|
582
|
-
if (input.sessionId) {
|
|
583
|
-
const existing: FlowSessionRow | null =
|
|
584
|
-
await prisma.flowSession.findUnique({
|
|
585
|
-
where: { id: input.sessionId },
|
|
586
|
-
select: FLOW_SESSION_SELECT,
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
!existing ||
|
|
591
|
-
existing.siteId !== input.siteId ||
|
|
592
|
-
existing.flowSlug !== input.slug
|
|
593
|
-
) {
|
|
594
|
-
// A sessionId minted under a different flow (flowSlug mismatch) is
|
|
595
|
-
// rejected here — accepting it would attribute the completing Lead's
|
|
596
|
-
// `source`/`flowSlug` to the WRONG flow (code-review finding on
|
|
597
|
-
// #3956's acceptance verdict).
|
|
598
|
-
return {
|
|
599
|
-
ok: false,
|
|
600
|
-
error: { kind: "step_conflict", message: "Unknown session." },
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
if (existing.completedAt) {
|
|
604
|
-
return {
|
|
605
|
-
ok: false,
|
|
606
|
-
error: { kind: "step_conflict", message: "Session already completed." },
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const parsedSnapshot = flowConfigSchema.safeParse(existing.configSnapshot);
|
|
611
|
-
if (!parsedSnapshot.success) {
|
|
612
|
-
return {
|
|
613
|
-
ok: false,
|
|
614
|
-
error: {
|
|
615
|
-
kind: "step_conflict",
|
|
616
|
-
message: "Session's config snapshot failed validation.",
|
|
617
|
-
},
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
return { ok: true, session: existing, configSnapshot: parsedSnapshot.data };
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const definition: FlowDefinitionForSessionRow | null =
|
|
624
|
-
await prisma.flowDefinition.findUnique({
|
|
625
|
-
where: { siteId_slug: { siteId: input.siteId, slug: input.slug } },
|
|
626
|
-
select: FLOW_DEFINITION_FOR_SESSION_SELECT,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
if (!definition) {
|
|
630
|
-
return {
|
|
631
|
-
ok: false,
|
|
632
|
-
error: { kind: "flow_not_found", message: "Flow not found." },
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const parsedConfig = flowConfigSchema.safeParse(definition.config);
|
|
637
|
-
if (!parsedConfig.success) {
|
|
638
|
-
return {
|
|
639
|
-
ok: false,
|
|
640
|
-
error: {
|
|
641
|
-
kind: "invalid_input",
|
|
642
|
-
message: "Stored flow config failed validation.",
|
|
643
|
-
},
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
const configSnapshot = parsedConfig.data;
|
|
647
|
-
|
|
648
|
-
const session: FlowSessionRow = await prisma.flowSession.create({
|
|
649
|
-
data: {
|
|
650
|
-
siteId: input.siteId,
|
|
651
|
-
flowId: definition.id,
|
|
652
|
-
flowSlug: input.slug,
|
|
653
|
-
configSnapshot: configSnapshot as unknown as Prisma.InputJsonValue,
|
|
654
|
-
state: {},
|
|
655
|
-
// #3962 D3: persist the locale of the FIRST landed step — an
|
|
656
|
-
// abandoned session (the only session this feature cares about)
|
|
657
|
-
// otherwise has no locale anywhere (submitStep only forwarded it
|
|
658
|
-
// into the completion payload). Set once at create; not updated on
|
|
659
|
-
// later steps (a mid-walk locale switch is an edge the completion
|
|
660
|
-
// payload already handles its own way).
|
|
661
|
-
locale: input.locale ?? configSnapshot.defaultLocale,
|
|
662
|
-
// #5036 D6d — carried down from the definition, so the session names the
|
|
663
|
-
// listing it belongs to rather than sharing its slug with it.
|
|
664
|
-
...(definition.listingId !== null && { listingId: definition.listingId }),
|
|
665
|
-
},
|
|
666
|
-
select: FLOW_SESSION_SELECT,
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
return { ok: true, session, configSnapshot };
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// ─── Step lookup ────────────────────────────────────────────────────────────────
|
|
673
|
-
|
|
674
|
-
type LocateStepResult =
|
|
675
|
-
| { ok: true; step: FlowStep; index: number }
|
|
676
|
-
| { ok: false; error: FlowEngineError };
|
|
677
|
-
|
|
678
|
-
/** Find the submitted `stepKey` in the session's pinned config snapshot. */
|
|
679
|
-
function locateStep(
|
|
680
|
-
configSnapshot: FlowConfig,
|
|
681
|
-
stepKey: string,
|
|
682
|
-
): LocateStepResult {
|
|
683
|
-
const index = configSnapshot.steps.findIndex((s) => s.key === stepKey);
|
|
684
|
-
if (index === -1) {
|
|
685
|
-
return {
|
|
686
|
-
ok: false,
|
|
687
|
-
error: {
|
|
688
|
-
kind: "step_conflict",
|
|
689
|
-
message: `Unknown stepKey "${stepKey}" for this flow.`,
|
|
690
|
-
},
|
|
691
|
-
};
|
|
692
|
-
}
|
|
693
|
-
return { ok: true, step: configSnapshot.steps[index]!, index };
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ─── Flow state helpers (D1/D2/D3, #4663) ───────────────────────────────────────
|
|
697
|
-
|
|
698
|
-
/** Normalize a session's `state` JSON column into a typed, defensive value. */
|
|
699
|
-
function normalizeFlowState(value: unknown): FlowState {
|
|
700
|
-
return value && typeof value === "object" ? (value as FlowState) : {};
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/** Merge every landed step's values into one flat name → value map (mirrors flow-visibility.ts's internal helper — engine-local, since that one isn't exported). */
|
|
704
|
-
function flattenFlowState(state: FlowState): Record<string, unknown> {
|
|
705
|
-
const flat: Record<string, unknown> = {};
|
|
706
|
-
for (const stepKey of Object.keys(state)) {
|
|
707
|
-
const stepData = state[stepKey];
|
|
708
|
-
if (stepData && typeof stepData === "object") Object.assign(flat, stepData);
|
|
709
|
-
}
|
|
710
|
-
return flat;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/** A schema-defensive read of a stored `aeoScore` JSON column value. */
|
|
714
|
-
function parseStoredAeoScore(value: unknown): AeoScoreResult | undefined {
|
|
715
|
-
const parsed = aeoScoreResultSchema.safeParse(value);
|
|
716
|
-
return parsed.success ? parsed.data : undefined;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// ─── Per-step persistence (#3974 AC1) ───────────────────────────────────────────
|
|
720
|
-
|
|
721
|
-
type PersistStepUpdateResult = {
|
|
722
|
-
updatedState: FlowState;
|
|
723
|
-
email: string | undefined;
|
|
724
|
-
segment: string | undefined;
|
|
725
|
-
flags: FlowFlags;
|
|
726
|
-
/** The resulting `aeoScore` JSON value AFTER this update (unchanged / reset-to-null / newly-computed). */
|
|
727
|
-
aeoScore: unknown;
|
|
728
|
-
};
|
|
729
|
-
|
|
730
|
-
/**
|
|
731
|
-
* Merge one landed step's extracted values into the session's accumulated
|
|
732
|
-
* state and persist immediately — the per-step partial-state capture this
|
|
733
|
-
* feature exists for. Isolated from session resolution / value validation /
|
|
734
|
-
* completion orchestration (#3974 AC1).
|
|
735
|
-
*
|
|
736
|
-
* D1/D2 (#4663): re-derives `flags` from the just-updated state and prunes
|
|
737
|
-
* every now-hidden step's stale state entry (D3) — in the SAME update. D6/D3:
|
|
738
|
-
* when this landing changes an EARLIER-landed `urlField` value, the
|
|
739
|
-
* downstream `aeo_score` step's own state entry is dropped and `aeoScore` is
|
|
740
|
-
* reset to `null`, so the resolver names it as the next step again.
|
|
741
|
-
* `aeoScoreResult`, when supplied, is this step's OWN aeo_score landing
|
|
742
|
-
* (D6) — persisted in this SAME update, taking priority over any reset.
|
|
743
|
-
*/
|
|
744
|
-
async function persistStepUpdate(
|
|
745
|
-
session: FlowSessionRow,
|
|
746
|
-
configSnapshot: FlowConfig,
|
|
747
|
-
step: FlowStep,
|
|
748
|
-
extractedData: Record<string, unknown>,
|
|
749
|
-
aeoScoreResult: AeoScoreResult | undefined,
|
|
750
|
-
): Promise<PersistStepUpdateResult> {
|
|
751
|
-
const priorState = normalizeFlowState(session.state);
|
|
752
|
-
let updatedState: FlowState = {
|
|
753
|
-
...priorState,
|
|
754
|
-
[step.key]: extractedData,
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
const mappedEmail = extractMappedEmail(step, extractedData);
|
|
758
|
-
const email = mappedEmail ?? session.email ?? undefined;
|
|
759
|
-
|
|
760
|
-
// #3959 D3: derive the segment (if this landing step declares a band
|
|
761
|
-
// map) and hold it in a REQUEST-LOCAL variable — never re-read off
|
|
762
|
-
// `session`, which is never reassigned after this update, so on the one
|
|
763
|
-
// submission that lands BOTH segmentation AND completion (segmentation
|
|
764
|
-
// plausibly last), reading `session.segment` below would see the stale
|
|
765
|
-
// pre-update null.
|
|
766
|
-
const derivedSegment = deriveSegment(step, extractedData);
|
|
767
|
-
const segment = derivedSegment ?? session.segment ?? undefined;
|
|
768
|
-
|
|
769
|
-
// D6/D3 (#4663) — a changed `urlField` value resets any downstream
|
|
770
|
-
// aeo_score result. Detected on the FIELD step landing, before pruning.
|
|
771
|
-
let aeoScoreReset = false;
|
|
772
|
-
if (step.type === "field") {
|
|
773
|
-
const priorStepData = priorState[step.key];
|
|
774
|
-
for (const field of step.fields) {
|
|
775
|
-
const scoreStep = configSnapshot.steps.find(
|
|
776
|
-
(s): s is Extract<FlowStep, { type: "aeo_score" }> =>
|
|
777
|
-
s.type === "aeo_score" && s.urlField === field.name,
|
|
778
|
-
);
|
|
779
|
-
if (!scoreStep) continue;
|
|
780
|
-
// code-review suggestion (#4663): a step landed for the FIRST time
|
|
781
|
-
// (no prior state at all) is never a "change" — skip the reset so a
|
|
782
|
-
// fresh session's first url landing doesn't issue a wasted
|
|
783
|
-
// Prisma.JsonNull write against a column already null by default.
|
|
784
|
-
if (priorStepData === undefined) continue;
|
|
785
|
-
const priorValue = priorStepData[field.name];
|
|
786
|
-
const newValue = extractedData[field.name];
|
|
787
|
-
if (priorValue !== newValue) {
|
|
788
|
-
aeoScoreReset = true;
|
|
789
|
-
if (scoreStep.key in updatedState) {
|
|
790
|
-
const rest = { ...updatedState };
|
|
791
|
-
delete rest[scoreStep.key];
|
|
792
|
-
updatedState = rest;
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// D1/D2 (#4663) — re-derive flags from the just-updated state, then prune
|
|
799
|
-
// any now-hidden step's stale state (persisted in the SAME update).
|
|
800
|
-
const flags = deriveFlags(configSnapshot, updatedState);
|
|
801
|
-
updatedState = pruneHiddenState(configSnapshot, updatedState, flags);
|
|
802
|
-
|
|
803
|
-
const aeoScore: unknown = aeoScoreResult ?? (aeoScoreReset ? null : undefined);
|
|
804
|
-
|
|
805
|
-
await prisma.flowSession.update({
|
|
806
|
-
where: { id: session.id },
|
|
807
|
-
data: {
|
|
808
|
-
state: updatedState as unknown as Prisma.InputJsonValue,
|
|
809
|
-
flags: flags as unknown as Prisma.InputJsonValue,
|
|
810
|
-
...(mappedEmail !== undefined && { email: mappedEmail }),
|
|
811
|
-
...(derivedSegment !== undefined && { segment: derivedSegment }),
|
|
812
|
-
// A JSON column's SQL NULL is written via the `Prisma.JsonNull`
|
|
813
|
-
// sentinel, never a bare `null` (which Prisma reserves for "leave the
|
|
814
|
-
// column untouched" on a nullable Json field).
|
|
815
|
-
...(aeoScore === null && { aeoScore: Prisma.JsonNull }),
|
|
816
|
-
...(aeoScoreResult !== undefined && {
|
|
817
|
-
aeoScore: aeoScoreResult as unknown as Prisma.InputJsonValue,
|
|
818
|
-
}),
|
|
819
|
-
},
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
return {
|
|
823
|
-
updatedState,
|
|
824
|
-
email,
|
|
825
|
-
segment,
|
|
826
|
-
flags,
|
|
827
|
-
aeoScore: aeoScore !== undefined ? aeoScore : session.aeoScore,
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// ─── Completion orchestration (#3974 AC1) ───────────────────────────────────────
|
|
832
|
-
|
|
833
|
-
type CompleteSubmissionParams = {
|
|
834
|
-
session: FlowSessionRow;
|
|
835
|
-
configSnapshot: FlowConfig;
|
|
836
|
-
siteId: string;
|
|
837
|
-
slug: string;
|
|
838
|
-
locale: string | undefined;
|
|
839
|
-
updatedState: FlowState;
|
|
840
|
-
email: string | undefined;
|
|
841
|
-
segment: string | undefined;
|
|
842
|
-
flags: FlowFlags;
|
|
843
|
-
aeoScore: AeoScoreResult | undefined;
|
|
844
|
-
sourceMeta: SourceMeta;
|
|
845
|
-
};
|
|
846
|
-
|
|
847
|
-
type CompleteSubmissionResult = {
|
|
848
|
-
leadId: string | undefined;
|
|
849
|
-
leadRoutingFailed: true | undefined;
|
|
850
|
-
};
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* Runs once every step in the snapshot has landed: assembles the completion
|
|
854
|
-
* payload, routes it through `routeLead`, stamps `completedAt` (+ `leadId`
|
|
855
|
-
* on success), and — on success, when the snapshot declares a deliverables
|
|
856
|
-
* block — fires deliverable fulfillment. Isolated from session resolution /
|
|
857
|
-
* value validation / per-step persistence (#3974 AC1).
|
|
858
|
-
*/
|
|
859
|
-
async function completeSubmission(
|
|
860
|
-
params: CompleteSubmissionParams,
|
|
861
|
-
): Promise<CompleteSubmissionResult> {
|
|
862
|
-
const {
|
|
863
|
-
session,
|
|
864
|
-
configSnapshot,
|
|
865
|
-
siteId,
|
|
866
|
-
slug,
|
|
867
|
-
locale,
|
|
868
|
-
updatedState,
|
|
869
|
-
email,
|
|
870
|
-
segment,
|
|
871
|
-
flags,
|
|
872
|
-
aeoScore,
|
|
873
|
-
sourceMeta,
|
|
874
|
-
} = params;
|
|
875
|
-
|
|
876
|
-
if (!email) {
|
|
877
|
-
// D9: a flow with no email-mapped field completes without a Lead row —
|
|
878
|
-
// the session is the record. Legal but unused by the #3958 presets.
|
|
879
|
-
await prisma.flowSession.update({
|
|
880
|
-
where: { id: session.id },
|
|
881
|
-
data: { completedAt: new Date() },
|
|
882
|
-
});
|
|
883
|
-
return { leadId: undefined, leadRoutingFailed: undefined };
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// Assemble the payload (D3 order, #4663): landed answers of VISIBLE steps
|
|
887
|
-
// only, config order → hiddenAnswers of currently-hidden steps →
|
|
888
|
-
// flowSlug → locale → segment → flags → aeoScore → experimentKey /
|
|
889
|
-
// waitlistKey (FINAL, per A1 D4 — unchanged). mapsTo-tagged values (incl.
|
|
890
|
-
// the email field itself) stay in payload too; email is ALSO promoted to
|
|
891
|
-
// routeLead's top-level required field, per D9 ("email top-level;
|
|
892
|
-
// everything else in payload").
|
|
893
|
-
const visibleSteps = resolveVisibleSteps(configSnapshot, updatedState, flags);
|
|
894
|
-
const payload: Record<string, unknown> = {};
|
|
895
|
-
for (const s of visibleSteps) {
|
|
896
|
-
const stepData = updatedState[s.key];
|
|
897
|
-
if (stepData && typeof stepData === "object") {
|
|
898
|
-
Object.assign(payload, stepData);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
Object.assign(payload, hiddenAnswers(configSnapshot, updatedState, flags));
|
|
902
|
-
|
|
903
|
-
const resolvedLocale = locale ?? configSnapshot.defaultLocale;
|
|
904
|
-
payload["flowSlug"] = slug;
|
|
905
|
-
payload["locale"] = resolvedLocale;
|
|
906
|
-
// #3959 D4: server-owned, written LAST — a smuggled `segment` key in
|
|
907
|
-
// any submitted values is already dropped by extractStepValues's
|
|
908
|
-
// declared-names-only extraction (and unrepresentable as a declared
|
|
909
|
-
// name per D2.5), so this is defense in depth, not the only guard.
|
|
910
|
-
if (segment !== undefined) {
|
|
911
|
-
payload["segment"] = segment;
|
|
912
|
-
}
|
|
913
|
-
// D2 (#4663) — the derived flag object, written when the snapshot
|
|
914
|
-
// declares flags (possibly `{}` — every flag evaluated false).
|
|
915
|
-
if (configSnapshot.flags) {
|
|
916
|
-
payload["flags"] = flags;
|
|
917
|
-
}
|
|
918
|
-
// D6 (#4663) — the persisted aeo_score result, written whenever the
|
|
919
|
-
// score step is part of the VISIBLE set (it always lands by completion
|
|
920
|
-
// time in that case — completed requires every visible step in state).
|
|
921
|
-
if (visibleSteps.some((s) => s.type === "aeo_score") && aeoScore !== undefined) {
|
|
922
|
-
payload["aeoScore"] = aeoScore;
|
|
923
|
-
}
|
|
924
|
-
// #4661 D4: server-owned, written LAST (after `segment`/`flags`/
|
|
925
|
-
// `aeoScore`) — a smuggled `experimentKey`/`waitlistKey` key in any
|
|
926
|
-
// submitted values is already dropped by extractStepValues's
|
|
927
|
-
// declared-names-only extraction (and unrepresentable as a declared name,
|
|
928
|
-
// per flowConfigSchema's D4 reserved-name check), so this is defense in
|
|
929
|
-
// depth, not the only guard. The Signup composes the existing Lead
|
|
930
|
-
// record — no second signup store.
|
|
931
|
-
if (configSnapshot.experiment) {
|
|
932
|
-
payload["experimentKey"] = configSnapshot.experiment.key;
|
|
933
|
-
payload["waitlistKey"] = configSnapshot.experiment.waitlist;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
const leadResult = await routeLead(
|
|
937
|
-
{
|
|
938
|
-
kind: "submit",
|
|
939
|
-
siteId,
|
|
940
|
-
source: `flow:${slug}`,
|
|
941
|
-
email,
|
|
942
|
-
payload,
|
|
943
|
-
},
|
|
944
|
-
sourceMeta,
|
|
945
|
-
);
|
|
946
|
-
|
|
947
|
-
if (!leadResult.ok) {
|
|
948
|
-
// routeLead's own DB→file fallback already exhausted itself before
|
|
949
|
-
// returning ok:false. The flow interaction is still complete from
|
|
950
|
-
// the visitor's perspective — the session itself remains the durable
|
|
951
|
-
// record (D9's "session is the record" fallback), so we do not fail
|
|
952
|
-
// this submission over a downstream lead-persistence hiccup.
|
|
953
|
-
// `leadRoutingFailed` surfaces the fact so the route handler can log
|
|
954
|
-
// it (PII-safe) — this function has no request-scoped logger of its
|
|
955
|
-
// own to log through directly.
|
|
956
|
-
await prisma.flowSession.update({
|
|
957
|
-
where: { id: session.id },
|
|
958
|
-
data: { completedAt: new Date() },
|
|
959
|
-
});
|
|
960
|
-
return { leadId: undefined, leadRoutingFailed: true };
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
const leadId = leadResult.id;
|
|
964
|
-
await prisma.flowSession.update({
|
|
965
|
-
where: { id: session.id },
|
|
966
|
-
data: { completedAt: new Date(), leadId },
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
// #4662 D6: fires ONLY when the snapshot declares an experiment binding
|
|
970
|
-
// (opt-in per flow — a flow with no `experiment` block behaves exactly as
|
|
971
|
-
// merged main, the AC4 regression floor: no read, no score row, unchanged
|
|
972
|
-
// payload). scoreCompletedSignup is the ONE write path into
|
|
973
|
-
// waitlist_scores; a scoring failure of ANY kind never fails this
|
|
974
|
-
// submission (fail-soft, the deliverables-hook posture below).
|
|
975
|
-
if (configSnapshot.experiment) {
|
|
976
|
-
try {
|
|
977
|
-
await scoreCompletedSignup({
|
|
978
|
-
siteId,
|
|
979
|
-
leadId,
|
|
980
|
-
experimentKey: configSnapshot.experiment.key,
|
|
981
|
-
waitlistKey: configSnapshot.experiment.waitlist,
|
|
982
|
-
payload,
|
|
983
|
-
flags: {}, // A3 wires the pre_launch session flag at completion
|
|
984
|
-
});
|
|
985
|
-
} catch (err) {
|
|
986
|
-
logger.error(
|
|
987
|
-
{
|
|
988
|
-
siteId,
|
|
989
|
-
flowSlug: slug,
|
|
990
|
-
sessionId: session.id,
|
|
991
|
-
errKind: err instanceof Error ? err.constructor.name : typeof err,
|
|
992
|
-
},
|
|
993
|
-
"[flow-engine] waitlist scoring failed — completion still succeeded",
|
|
994
|
-
);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
// #3961 D4: fulfillment fires ONLY on this success path, and only
|
|
999
|
-
// when the snapshot declares a deliverables block (opt-in per flow
|
|
1000
|
-
// — a flow with no block behaves exactly as merged main, the AC4
|
|
1001
|
-
// regression floor). fulfillDeliverable itself never throws on its
|
|
1002
|
-
// own business-logic misses (no entry, no asset row, no configured
|
|
1003
|
-
// sender — all internal log-and-skip); this try/catch is the
|
|
1004
|
-
// residual safety net for a genuine DB/network error escaping the
|
|
1005
|
-
// service, so a fulfillment failure of ANY kind never fails this
|
|
1006
|
-
// submission (the owner-notification posture).
|
|
1007
|
-
if (configSnapshot.deliverables) {
|
|
1008
|
-
try {
|
|
1009
|
-
await fulfillDeliverable({
|
|
1010
|
-
siteId,
|
|
1011
|
-
flowSlug: slug,
|
|
1012
|
-
configSnapshot,
|
|
1013
|
-
...(segment !== undefined && { segment }),
|
|
1014
|
-
leadId: leadResult.id,
|
|
1015
|
-
sessionId: session.id,
|
|
1016
|
-
email,
|
|
1017
|
-
locale: resolvedLocale,
|
|
1018
|
-
});
|
|
1019
|
-
} catch (err) {
|
|
1020
|
-
// Swallowed by design (D4) — the submission still succeeds
|
|
1021
|
-
// regardless. fulfillDeliverable logs its own PII-safe
|
|
1022
|
-
// outcomes on every path it controls; reaching HERE means an
|
|
1023
|
-
// exception escaped before it could log, so this is the
|
|
1024
|
-
// residual observability signal — the error's constructor
|
|
1025
|
-
// name only (never `.message`, which could carry an
|
|
1026
|
-
// interpolated PII value from a driver/library exception),
|
|
1027
|
-
// matching the `{siteId, flowSlug, sessionId}` shape the
|
|
1028
|
-
// service's own outcome logs already use (code-review finding
|
|
1029
|
-
// on this issue's own PR: a fully-silent swallow would make a
|
|
1030
|
-
// genuine regression here invisible in production).
|
|
1031
|
-
logger.error(
|
|
1032
|
-
{
|
|
1033
|
-
siteId,
|
|
1034
|
-
flowSlug: slug,
|
|
1035
|
-
sessionId: session.id,
|
|
1036
|
-
errKind:
|
|
1037
|
-
err instanceof Error ? err.constructor.name : typeof err,
|
|
1038
|
-
},
|
|
1039
|
-
"[flow-engine] deliverable fulfillment failed — completion still succeeded",
|
|
1040
|
-
);
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
return { leadId, leadRoutingFailed: undefined };
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// ─── submitStep — the orchestrator (#3974 AC1) ──────────────────────────────────
|
|
1048
|
-
|
|
1049
|
-
export async function submitStep(
|
|
1050
|
-
input: SubmitStepInput,
|
|
1051
|
-
sourceMeta: SourceMeta,
|
|
1052
|
-
): Promise<SubmitStepResult> {
|
|
1053
|
-
const dbError = requireDb();
|
|
1054
|
-
if (dbError) return { ok: false, error: dbError };
|
|
1055
|
-
|
|
1056
|
-
const parsedInput = submitFlowStepInputSchema.safeParse({
|
|
1057
|
-
sessionId: input.sessionId,
|
|
1058
|
-
stepKey: input.stepKey,
|
|
1059
|
-
values: input.values,
|
|
1060
|
-
locale: input.locale,
|
|
1061
|
-
});
|
|
1062
|
-
if (!parsedInput.success) {
|
|
1063
|
-
return {
|
|
1064
|
-
ok: false,
|
|
1065
|
-
error: {
|
|
1066
|
-
kind: "invalid_input",
|
|
1067
|
-
message: parsedInput.error.issues[0]?.message ?? "invalid input",
|
|
1068
|
-
},
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
const resolved = await resolveSession(input);
|
|
1073
|
-
if (!resolved.ok) return resolved;
|
|
1074
|
-
const { session, configSnapshot } = resolved;
|
|
1075
|
-
|
|
1076
|
-
const located = locateStep(configSnapshot, input.stepKey);
|
|
1077
|
-
if (!located.ok) return located;
|
|
1078
|
-
const { step, index: stepIndex } = located;
|
|
1079
|
-
|
|
1080
|
-
// D1 (#4663) — a submission for a step not reachable given the CURRENT
|
|
1081
|
-
// (pre-landing) state/flags is a conflict, not a value error.
|
|
1082
|
-
const priorState = normalizeFlowState(session.state);
|
|
1083
|
-
const priorFlags = deriveFlags(configSnapshot, priorState);
|
|
1084
|
-
if (!isStepVisible(configSnapshot, step.key, priorState, priorFlags)) {
|
|
1085
|
-
return {
|
|
1086
|
-
ok: false,
|
|
1087
|
-
error: {
|
|
1088
|
-
kind: "step_conflict",
|
|
1089
|
-
message: "Step is not reachable on this branch.",
|
|
1090
|
-
},
|
|
1091
|
-
};
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const extracted = extractStepValues(step, input.values);
|
|
1095
|
-
if (!extracted.ok) {
|
|
1096
|
-
return {
|
|
1097
|
-
ok: false,
|
|
1098
|
-
error: { kind: "invalid_input", message: extracted.message },
|
|
1099
|
-
};
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// D6 (#4663) — an aeo_score landing computes the score BEFORE persisting,
|
|
1103
|
-
// so it lands in the SAME write as the step's own state entry. The url
|
|
1104
|
-
// value was landed on an EARLIER step (the aeo_score step's own extracted
|
|
1105
|
-
// data is always `{}`).
|
|
1106
|
-
let aeoScoreResult: AeoScoreResult | undefined;
|
|
1107
|
-
if (step.type === "aeo_score") {
|
|
1108
|
-
const rawUrl = flattenFlowState(priorState)[step.urlField];
|
|
1109
|
-
const budgetMs = resolveScoreBudgetMs(process.env["AEO_SCORE_BUDGET_MS"], logger);
|
|
1110
|
-
try {
|
|
1111
|
-
aeoScoreResult =
|
|
1112
|
-
typeof rawUrl === "string" && rawUrl.length > 0
|
|
1113
|
-
? await scoreUrl(rawUrl, { budgetMs })
|
|
1114
|
-
: loadExampleScore();
|
|
1115
|
-
// Security-review finding (#4663, M1): scoreUrl RETURNS (never throws)
|
|
1116
|
-
// on an SSRF-guard rejection, with `message` built from the resolved
|
|
1117
|
-
// address ("Host H resolves to a blocked address: A") — the catch
|
|
1118
|
-
// block below only covers a THROWN exception, so this returned-value
|
|
1119
|
-
// path reached the anonymous caller verbatim and was persisted into
|
|
1120
|
-
// the lead record unsanitized. Scrub it here, in the one caller this
|
|
1121
|
-
// PR owns; the pre-existing POST /v1/aeo/score route (apps/api/src/
|
|
1122
|
-
// routes/aeo/index.ts, #4666, out of #4663's scope) shares the same
|
|
1123
|
-
// gap and is not touched by this fix.
|
|
1124
|
-
if (
|
|
1125
|
-
aeoScoreResult.status === "unavailable" &&
|
|
1126
|
-
aeoScoreResult.reason === "ssrf_blocked"
|
|
1127
|
-
) {
|
|
1128
|
-
aeoScoreResult = {
|
|
1129
|
-
status: "unavailable",
|
|
1130
|
-
reason: "ssrf_blocked",
|
|
1131
|
-
message: "This URL cannot be scored.",
|
|
1132
|
-
};
|
|
1133
|
-
}
|
|
1134
|
-
} catch (e) {
|
|
1135
|
-
// code-review finding (#4663): never forward a raw caught exception
|
|
1136
|
-
// message to the client — scoreUrl wraps an external-URL crawler, so
|
|
1137
|
-
// an unexpected internal error could leak infrastructure detail (DNS
|
|
1138
|
-
// failure text, timeout internals, an SSRF-guard rejection message).
|
|
1139
|
-
// Log PII-safe server-side (no url), return a generic client message.
|
|
1140
|
-
logger.error(
|
|
1141
|
-
{
|
|
1142
|
-
siteId: input.siteId,
|
|
1143
|
-
slug: input.slug,
|
|
1144
|
-
sessionId: session.id,
|
|
1145
|
-
errKind: e instanceof Error ? e.constructor.name : typeof e,
|
|
1146
|
-
},
|
|
1147
|
-
"[flow-engine] aeo_score crawl threw — landing as unavailable",
|
|
1148
|
-
);
|
|
1149
|
-
aeoScoreResult = {
|
|
1150
|
-
status: "unavailable",
|
|
1151
|
-
reason: "crawl_failed",
|
|
1152
|
-
message: "Could not score this URL.",
|
|
1153
|
-
};
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const { updatedState, email, segment, flags, aeoScore } = await persistStepUpdate(
|
|
1158
|
-
session,
|
|
1159
|
-
configSnapshot,
|
|
1160
|
-
step,
|
|
1161
|
-
extracted.data,
|
|
1162
|
-
aeoScoreResult,
|
|
1163
|
-
);
|
|
1164
|
-
|
|
1165
|
-
// D1 (#4663) — resolver-driven successor + completion, replacing the
|
|
1166
|
-
// former linear `steps[stepIndex + 1]` / "every step key in state".
|
|
1167
|
-
const visible = resolveVisibleSteps(configSnapshot, updatedState, flags);
|
|
1168
|
-
const nextVisible = visible.find(
|
|
1169
|
-
(s) => configSnapshot.steps.findIndex((cs) => cs.key === s.key) > stepIndex,
|
|
1170
|
-
);
|
|
1171
|
-
const nextStepKey = nextVisible?.key ?? null;
|
|
1172
|
-
const completed = visible.every((s) => s.key in updatedState);
|
|
1173
|
-
|
|
1174
|
-
let leadId: string | undefined;
|
|
1175
|
-
let leadRoutingFailed: true | undefined;
|
|
1176
|
-
|
|
1177
|
-
if (completed) {
|
|
1178
|
-
const completion = await completeSubmission({
|
|
1179
|
-
session,
|
|
1180
|
-
configSnapshot,
|
|
1181
|
-
siteId: input.siteId,
|
|
1182
|
-
slug: input.slug,
|
|
1183
|
-
locale: input.locale,
|
|
1184
|
-
updatedState,
|
|
1185
|
-
email,
|
|
1186
|
-
segment,
|
|
1187
|
-
flags,
|
|
1188
|
-
aeoScore: aeoScoreResult ?? parseStoredAeoScore(aeoScore),
|
|
1189
|
-
sourceMeta,
|
|
1190
|
-
});
|
|
1191
|
-
leadId = completion.leadId;
|
|
1192
|
-
leadRoutingFailed = completion.leadRoutingFailed;
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// D9 (#4663) — present on the aeo_score step's own response (just
|
|
1196
|
-
// computed), OR on the completion response (the persisted value, even
|
|
1197
|
-
// when this submission is a LATER step than the one that computed it).
|
|
1198
|
-
const responseAeoScore =
|
|
1199
|
-
aeoScoreResult ?? (completed ? parseStoredAeoScore(aeoScore) : undefined);
|
|
1200
|
-
|
|
1201
|
-
return {
|
|
1202
|
-
ok: true,
|
|
1203
|
-
sessionId: session.id,
|
|
1204
|
-
landed: step.key,
|
|
1205
|
-
nextStepKey,
|
|
1206
|
-
completed,
|
|
1207
|
-
...(leadId !== undefined && { leadId }),
|
|
1208
|
-
...(leadRoutingFailed !== undefined && { leadRoutingFailed }),
|
|
1209
|
-
...(segment !== undefined && { segment }),
|
|
1210
|
-
...(responseAeoScore !== undefined && { aeoScore: responseAeoScore }),
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// ─── Session retention / cleanup (#3974 AC3) ────────────────────────────────────
|
|
1215
|
-
|
|
1216
|
-
/**
|
|
1217
|
-
* FlowSession retention policy. An ABANDONED session (`completedAt: null` —
|
|
1218
|
-
* the visitor never finished the walk) otherwise accumulates indefinitely
|
|
1219
|
-
* with no TTL (security-review finding, Medium, non-blocking, on #3956's
|
|
1220
|
-
* acceptance PR) — `cleanupAbandonedSessions` below is the fix: delete an
|
|
1221
|
-
* abandoned row once its `updatedAt` is older than the retention window.
|
|
1222
|
-
*
|
|
1223
|
-
* A COMPLETED session (`completedAt` set) is NEVER a retention candidate —
|
|
1224
|
-
* it is the durable lead-of-record (D9) and outside this policy's scope;
|
|
1225
|
-
* the delete's `where` clause enforces this structurally.
|
|
1226
|
-
*
|
|
1227
|
-
* Coordinated with #3962's abandon-email sweep (`runAbandonEmailSweep` in
|
|
1228
|
-
* ./abandon-email.ts), which is exactly the natural consumer of these same
|
|
1229
|
-
* abandoned rows (this issue's own "Why"). The default window
|
|
1230
|
-
* (`RETENTION_STALE_DAYS`, 90 days) is chosen to sit far above any realistic
|
|
1231
|
-
* `abandonEmail.delayMinutes` (schema floor: 5 minutes — every shipped
|
|
1232
|
-
* preset and documented example uses a minutes-to-hours delay), so the
|
|
1233
|
-
* sweep always has its full window to run before a row it might still want
|
|
1234
|
-
* becomes retention-eligible — and for a flow with no abandon-email policy
|
|
1235
|
-
* configured at all, the row still gets cleaned up eventually rather than
|
|
1236
|
-
* accumulating forever, instead of waiting on a stamp that will never come.
|
|
1237
|
-
*
|
|
1238
|
-
* Operational wiring (a CLI entry + external scheduler, exactly
|
|
1239
|
-
* `abandon-sweep.ts`'s established shape) lives at
|
|
1240
|
-
* `src/bin/session-retention-sweep.ts`.
|
|
1241
|
-
*/
|
|
1242
|
-
const RETENTION_STALE_DAYS = 90;
|
|
1243
|
-
|
|
1244
|
-
export type CleanupAbandonedSessionsOptions = {
|
|
1245
|
-
/** Override the retention window, in days. Defaults to RETENTION_STALE_DAYS. */
|
|
1246
|
-
staleDays?: number;
|
|
1247
|
-
/** Override "now" — test seam. */
|
|
1248
|
-
now?: Date;
|
|
1249
|
-
};
|
|
1250
|
-
|
|
1251
|
-
export type CleanupAbandonedSessionsResult =
|
|
1252
|
-
| { ok: true; deleted: number }
|
|
1253
|
-
| { ok: false; error: FlowEngineError };
|
|
1254
|
-
|
|
1255
|
-
export async function cleanupAbandonedSessions(
|
|
1256
|
-
opts: CleanupAbandonedSessionsOptions = {},
|
|
1257
|
-
): Promise<CleanupAbandonedSessionsResult> {
|
|
1258
|
-
const dbError = requireDb();
|
|
1259
|
-
if (dbError) return { ok: false, error: dbError };
|
|
1260
|
-
|
|
1261
|
-
const staleDays = opts.staleDays ?? RETENTION_STALE_DAYS;
|
|
1262
|
-
const now = opts.now ?? new Date();
|
|
1263
|
-
const cutoff = new Date(now.getTime() - staleDays * 24 * 60 * 60_000);
|
|
1264
|
-
|
|
1265
|
-
const result = await prisma.flowSession.deleteMany({
|
|
1266
|
-
where: {
|
|
1267
|
-
completedAt: null,
|
|
1268
|
-
updatedAt: { lte: cutoff },
|
|
1269
|
-
},
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
logger.info(
|
|
1273
|
-
{ deleted: result.count, staleDays },
|
|
1274
|
-
"[flow-engine] abandoned-session cleanup swept",
|
|
1275
|
-
);
|
|
1276
|
-
|
|
1277
|
-
return { ok: true, deleted: result.count };
|
|
1278
|
-
}
|