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.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. 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
- }