create-ailk 0.1.0 → 0.2.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 (620) hide show
  1. package/README.md +124 -3
  2. package/component-catalog.md +456 -0
  3. package/dist/cli.js +92 -16
  4. package/dist/component-catalog.d.ts +11 -0
  5. package/dist/component-catalog.js +39 -0
  6. package/dist/copy.d.ts +7 -0
  7. package/dist/copy.js +24 -0
  8. package/dist/derive-architecture.d.ts +13 -0
  9. package/dist/derive-architecture.js +238 -0
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.js +19 -2
  12. package/dist/lib/extract-module-bundle.d.ts +16 -0
  13. package/dist/lib/extract-module-bundle.js +184 -0
  14. package/dist/lib/fetch-module.d.ts +55 -0
  15. package/dist/lib/fetch-module.js +125 -0
  16. package/dist/lib/module-license-gate.d.ts +48 -0
  17. package/dist/lib/module-license-gate.js +67 -0
  18. package/dist/lib/module-tier.d.ts +35 -0
  19. package/dist/lib/module-tier.js +37 -0
  20. package/dist/module-architecture.d.ts +175 -0
  21. package/dist/module-architecture.js +663 -0
  22. package/dist/parse-args.d.ts +47 -1
  23. package/dist/parse-args.js +143 -2
  24. package/dist/programmatic.d.ts +160 -3
  25. package/dist/programmatic.js +196 -7
  26. package/dist/surfaces.d.ts +211 -0
  27. package/dist/surfaces.js +532 -0
  28. package/dist/sync-routes.d.ts +92 -0
  29. package/dist/sync-routes.js +350 -0
  30. package/package.json +12 -5
  31. package/templates/.claude/rules/architecture.md +6 -2
  32. package/templates/.claude/skills/README.md +9 -9
  33. package/templates/.claude/skills/add-schema/SKILL.md +14 -7
  34. package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
  35. package/templates/.claude/skills/provision-config/SKILL.md +1 -1
  36. package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
  37. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
  38. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
  39. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
  40. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
  41. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
  42. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
  43. package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
  44. package/templates/.env.example +126 -2
  45. package/templates/CLAUDE.md +11 -5
  46. package/templates/README.md +1 -1
  47. package/templates/apps/api/.env.example +38 -0
  48. package/templates/apps/api/CLAUDE.md +78 -1
  49. package/templates/apps/api/LICENSE +201 -0
  50. package/templates/apps/api/api/index.ts +6 -141
  51. package/templates/apps/api/jest.config.cjs +16 -0
  52. package/templates/apps/api/package.json +22 -6
  53. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
  54. package/templates/apps/api/src/__tests__/server.test.ts +46 -0
  55. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
  56. package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
  57. package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
  58. package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
  59. package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
  60. package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
  61. package/templates/apps/api/src/bin/seed-presets.ts +58 -0
  62. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
  63. package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
  64. package/templates/apps/api/src/cli.ts +5 -11
  65. package/templates/apps/api/src/config/index.ts +35 -0
  66. package/templates/apps/api/src/config/modules.ts +43 -0
  67. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
  68. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
  69. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
  70. package/templates/apps/api/src/lib/site.ts +16 -0
  71. package/templates/apps/api/src/lib/stripe.ts +22 -0
  72. package/templates/apps/api/src/lib/tenant-db.ts +225 -0
  73. package/templates/apps/api/src/lib/ws-token.ts +91 -0
  74. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
  75. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
  77. package/templates/apps/api/src/middleware/auth.ts +43 -8
  78. package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
  79. package/templates/apps/api/src/middleware/tenant.ts +99 -0
  80. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
  81. package/templates/apps/api/src/openapi/spec.ts +312 -3
  82. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
  83. package/templates/apps/api/src/routes/aeo/index.ts +107 -0
  84. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
  85. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
  86. package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
  87. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
  88. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
  89. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
  90. package/templates/apps/api/src/routes/billing/index.ts +36 -0
  91. package/templates/apps/api/src/routes/billing/portal.ts +182 -0
  92. package/templates/apps/api/src/routes/billing/read.ts +75 -0
  93. package/templates/apps/api/src/routes/billing/usage.ts +153 -0
  94. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
  95. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
  96. package/templates/apps/api/src/routes/content/create.ts +1 -0
  97. package/templates/apps/api/src/routes/content/delete.ts +1 -0
  98. package/templates/apps/api/src/routes/content/index.ts +42 -6
  99. package/templates/apps/api/src/routes/content/update.ts +1 -0
  100. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
  101. package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
  102. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
  103. package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
  104. package/templates/apps/api/src/routes/flows/README.md +147 -0
  105. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
  106. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
  107. package/templates/apps/api/src/routes/flows/index.ts +202 -0
  108. package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
  109. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
  110. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
  111. package/templates/apps/api/src/routes/leads/index.ts +43 -9
  112. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
  113. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
  114. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
  115. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  116. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
  120. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  121. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
  122. package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
  123. package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
  124. package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
  125. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  126. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
  127. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
  128. package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
  129. package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
  130. package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
  131. package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
  132. package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
  133. package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
  134. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
  135. package/templates/apps/api/src/routes/schedule/index.ts +249 -0
  136. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
  137. package/templates/apps/api/src/routes/slack/actions.ts +177 -0
  138. package/templates/apps/api/src/routes/slack/index.ts +38 -0
  139. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
  140. package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
  141. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
  142. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
  143. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
  144. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
  145. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
  146. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
  147. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
  148. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
  149. package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
  150. package/templates/apps/api/src/routes/webhooks/README.md +35 -12
  151. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
  152. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
  153. package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
  154. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
  155. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
  156. package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
  157. package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
  158. package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
  159. package/templates/apps/api/src/server.ts +307 -7
  160. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
  161. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
  162. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
  163. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  164. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  165. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
  166. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
  167. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
  168. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
  169. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
  170. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
  171. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
  172. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
  173. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  174. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
  175. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
  176. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
  177. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
  178. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
  179. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
  180. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
  181. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
  182. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
  183. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
  184. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
  185. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
  186. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
  187. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
  188. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
  189. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
  190. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
  191. package/templates/apps/api/src/services/abandon-email.ts +307 -0
  192. package/templates/apps/api/src/services/aeo-score.ts +288 -0
  193. package/templates/apps/api/src/services/answer-distributions.ts +138 -0
  194. package/templates/apps/api/src/services/consent.ts +236 -0
  195. package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
  196. package/templates/apps/api/src/services/entitlements.ts +102 -0
  197. package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
  198. package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
  199. package/templates/apps/api/src/services/flow-engine.ts +1296 -0
  200. package/templates/apps/api/src/services/lead-promotion.ts +176 -0
  201. package/templates/apps/api/src/services/lead-routing.ts +3 -3
  202. package/templates/apps/api/src/services/listing-config.ts +102 -0
  203. package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
  204. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  205. package/templates/apps/api/src/services/playbook-compile.ts +398 -0
  206. package/templates/apps/api/src/services/playbook-render.ts +263 -0
  207. package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
  208. package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
  209. package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
  210. package/templates/apps/api/src/services/project-listings.ts +1472 -0
  211. package/templates/apps/api/src/services/recommender-capture.ts +835 -0
  212. package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
  213. package/templates/apps/api/src/services/scheduling/index.ts +63 -0
  214. package/templates/apps/api/src/services/scheduling/types.ts +88 -0
  215. package/templates/apps/api/src/services/slack-notify.ts +240 -0
  216. package/templates/apps/api/src/services/tenant-context.ts +451 -0
  217. package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
  218. package/templates/apps/api/src/services/usage-credits.ts +340 -0
  219. package/templates/apps/api/src/services/usage-metering.ts +699 -0
  220. package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
  221. package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
  222. package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
  223. package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
  224. package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
  225. package/templates/apps/api/src/vercel-handler.ts +165 -0
  226. package/templates/apps/api/tsconfig.json +0 -12
  227. package/templates/apps/mcp/CLAUDE.md +19 -9
  228. package/templates/apps/mcp/LICENSE +201 -0
  229. package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
  230. package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
  231. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
  232. package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
  233. package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
  234. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
  235. package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
  236. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
  237. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
  238. package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
  239. package/templates/apps/mcp/package.json +6 -4
  240. package/templates/apps/mcp/src/config/modules.ts +37 -0
  241. package/templates/apps/mcp/src/server.ts +109 -18
  242. package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
  243. package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
  244. package/templates/apps/mcp/src/tools/create_page.ts +1 -0
  245. package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
  246. package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
  247. package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
  248. package/templates/apps/mcp/src/tools/index.ts +25 -0
  249. package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
  250. package/templates/apps/mcp/src/tools/purchase.ts +3 -0
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
  252. package/templates/apps/mcp/src/tools/update_page.ts +1 -0
  253. package/templates/apps/mcp/tsconfig.json +1 -9
  254. package/templates/apps/web/.env.example +44 -0
  255. package/templates/apps/web/CLAUDE.md +28 -0
  256. package/templates/apps/web/LICENSE +201 -0
  257. package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
  258. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
  259. package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
  260. package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
  261. package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
  262. package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
  263. package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
  264. package/templates/apps/web/__tests__/setup.ts +22 -32
  265. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
  266. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
  267. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
  268. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
  269. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
  270. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
  271. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
  272. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
  273. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
  274. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
  275. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
  276. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
  277. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
  278. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
  279. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
  280. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
  281. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
  282. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
  283. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
  284. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
  285. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
  286. package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
  287. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
  288. package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
  289. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
  290. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
  291. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  292. package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
  293. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
  294. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  295. package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
  296. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
  297. package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
  298. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
  299. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
  300. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
  301. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
  302. package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
  303. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
  304. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
  305. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
  306. package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
  307. package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
  308. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
  309. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  310. package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
  311. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
  312. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
  313. package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
  314. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
  315. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
  316. package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
  317. package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
  318. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
  319. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
  320. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
  321. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
  322. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
  323. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
  324. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
  325. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
  326. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
  327. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
  328. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
  329. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
  330. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
  331. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
  332. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
  333. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
  334. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  335. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
  336. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  337. package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
  338. package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
  339. package/templates/apps/web/app/[locale]/layout.tsx +127 -16
  340. package/templates/apps/web/app/[locale]/page.tsx +10 -1
  341. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
  342. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  343. package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
  344. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
  345. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  346. package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
  347. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
  348. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
  349. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
  350. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
  351. package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
  352. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
  353. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
  354. package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
  355. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
  356. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
  357. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
  358. package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
  359. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
  360. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  361. package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
  362. package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
  363. package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
  364. package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
  365. package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
  366. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
  367. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
  368. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  369. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
  370. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  371. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
  372. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  373. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
  374. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
  375. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
  376. package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
  377. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
  378. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  379. package/templates/apps/web/app/globals.css +35 -0
  380. package/templates/apps/web/app/llms.txt/route.ts +2 -1
  381. package/templates/apps/web/app/og/route.tsx +10 -2
  382. package/templates/apps/web/auth.ts +8 -0
  383. package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
  384. package/templates/apps/web/content/en/blog/README.txt +25 -0
  385. package/templates/apps/web/content/en/docs/index.mdx +28 -0
  386. package/templates/apps/web/jest.config.cjs +90 -27
  387. package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
  388. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  389. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
  390. package/templates/apps/web/lib/account-config.ts +13 -0
  391. package/templates/apps/web/lib/server-api.ts +85 -0
  392. package/templates/apps/web/lib/site-brand.tsx +121 -0
  393. package/templates/apps/web/lib/site-theme.ts +74 -0
  394. package/templates/apps/web/messages/en.json +275 -7
  395. package/templates/apps/web/middleware.ts +11 -3
  396. package/templates/apps/web/next.config.mjs +8 -2
  397. package/templates/apps/web/package.json +17 -7
  398. package/templates/apps/web/postcss.config.mjs +23 -0
  399. package/templates/apps/web/public/.well-known/security.txt +3 -3
  400. package/templates/apps/web/src/lib/routes.ts +15 -2
  401. package/templates/apps/web/tsconfig.json +1 -16
  402. package/templates/content/_site.mdx +36 -25
  403. package/templates/content/en/AboutPage/about.mdx +24 -14
  404. package/templates/content/en/ContactPage/contact.mdx +23 -14
  405. package/templates/content/en/FAQPage/faq.mdx +10 -392
  406. package/templates/content/en/HomePage/home.mdx +14 -285
  407. package/templates/database/CHANGELOG.md +443 -0
  408. package/templates/database/LICENSE +201 -0
  409. package/templates/database/README.md +3 -1
  410. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
  411. package/templates/database/__tests__/flows-presets.test.ts +173 -0
  412. package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
  413. package/templates/database/__tests__/seed.test.ts +88 -0
  414. package/templates/database/__tests__/user-cascade.test.ts +19 -4
  415. package/templates/database/auth/schema.prisma +521 -0
  416. package/templates/database/config/schema.prisma +234 -0
  417. package/templates/database/content/schema.prisma +263 -0
  418. package/templates/database/flows/listing-diagnostic.ts +182 -0
  419. package/templates/database/flows/presets.ts +315 -0
  420. package/templates/database/inbox/__tests__/schema.test.ts +4 -0
  421. package/templates/database/inbox/schema.prisma +574 -3
  422. package/templates/database/jest.config.cjs +8 -0
  423. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
  424. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
  425. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
  426. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
  427. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
  428. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
  429. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
  430. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
  431. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
  432. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
  433. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
  434. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
  435. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
  436. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
  437. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
  438. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
  439. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
  440. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
  441. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
  442. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
  443. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
  444. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
  445. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
  446. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
  447. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
  448. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
  449. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
  450. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
  451. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
  452. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
  453. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
  454. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
  455. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  456. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  457. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  458. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  459. package/templates/database/ops/schema.prisma +27 -0
  460. package/templates/database/package.json +42 -4
  461. package/templates/database/prisma.config.ts +1 -1
  462. package/templates/database/schema.prisma +19 -435
  463. package/templates/database/seed-test.ts +14 -4
  464. package/templates/database/seed.ts +148 -0
  465. package/templates/database/tenancy.ts +45 -0
  466. package/templates/database/tsconfig.flows.json +15 -0
  467. package/templates/database/tsconfig.json +7 -1
  468. package/templates/database/tsconfig.tenancy.json +14 -0
  469. package/templates/package.json +30 -55
  470. package/templates/pnpm-workspace.yaml +0 -3
  471. package/templates/project.yaml +1 -1
  472. package/templates/tsconfig.json +1 -37
  473. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
  474. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
  475. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
  476. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
  477. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
  478. package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
  479. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
  480. package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
  481. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
  482. package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
  483. package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
  484. package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
  485. package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
  486. package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
  487. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
  488. package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
  489. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
  490. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
  491. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
  492. package/templates/apps/web/__tests__/page.test.tsx +0 -230
  493. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
  494. package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
  495. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
  496. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
  497. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
  498. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
  499. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
  500. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
  501. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
  502. package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
  503. package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
  504. package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
  505. package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
  506. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
  507. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
  508. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
  509. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
  510. package/templates/apps/web/content/en/blog/index.mdx +0 -15
  511. package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
  512. package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
  513. package/templates/content/.gitkeep +0 -0
  514. package/templates/content/CLAUDE.md +0 -85
  515. package/templates/content/ar/HomePage/home.mdx +0 -123
  516. package/templates/content/ar/_site.mdx +0 -35
  517. package/templates/content/de/_site.mdx +0 -29
  518. package/templates/content/en/.gitkeep +0 -0
  519. package/templates/content/en/AboutPage/about.schema.json +0 -82
  520. package/templates/content/en/BookCall/book-call.mdx +0 -19
  521. package/templates/content/en/BookCall/book-call.schema.json +0 -121
  522. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
  523. package/templates/content/en/CareersList/careers.mdx +0 -24
  524. package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
  525. package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
  526. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
  527. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
  528. package/templates/content/en/ClientsList/clients.mdx +0 -38
  529. package/templates/content/en/ContactPage/contact.schema.json +0 -100
  530. package/templates/content/en/ContactPoint/primary.mdx +0 -14
  531. package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
  532. package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
  533. package/templates/content/en/CoursesList/courses.mdx +0 -22
  534. package/templates/content/en/DownloadsList/downloads.mdx +0 -22
  535. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
  536. package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
  537. package/templates/content/en/EventsList/events.mdx +0 -26
  538. package/templates/content/en/FAQPage/faq.schema.json +0 -231
  539. package/templates/content/en/GlossaryList/glossary.mdx +0 -19
  540. package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
  541. package/templates/content/en/HomePage/home.schema.json +0 -88
  542. package/templates/content/en/HomePage/launch-service.mdx +0 -475
  543. package/templates/content/en/HomePage/launch-service.schema.json +0 -138
  544. package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
  545. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
  546. package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
  547. package/templates/content/en/Integration/github-actions.mdx +0 -31
  548. package/templates/content/en/Integration/openai-api.mdx +0 -26
  549. package/templates/content/en/Integration/vercel.mdx +0 -32
  550. package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
  551. package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
  552. package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
  553. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
  554. package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
  555. package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
  556. package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
  557. package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
  558. package/templates/content/en/LandingPage/developers.mdx +0 -19
  559. package/templates/content/en/LandingPage/developers.schema.json +0 -109
  560. package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
  561. package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
  562. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
  563. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
  564. package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
  565. package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
  566. package/templates/content/en/LandingPage/platform.mdx +0 -369
  567. package/templates/content/en/LandingPage/platform.schema.json +0 -166
  568. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
  569. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
  570. package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
  571. package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
  572. package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
  573. package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
  574. package/templates/content/en/Organization/site-owner.mdx +0 -16
  575. package/templates/content/en/Partner/prisma.mdx +0 -31
  576. package/templates/content/en/Partner/supabase.mdx +0 -30
  577. package/templates/content/en/Partner/vercel.mdx +0 -28
  578. package/templates/content/en/PartnersList/partners.mdx +0 -22
  579. package/templates/content/en/Person/founder.mdx +0 -18
  580. package/templates/content/en/Pricing/pricing.mdx +0 -538
  581. package/templates/content/en/Pricing/pricing.schema.json +0 -209
  582. package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
  583. package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
  584. package/templates/content/en/Product/ailk.mdx +0 -19
  585. package/templates/content/en/Product/ailk.schema.json +0 -94
  586. package/templates/content/en/Product/api-launch-kit.mdx +0 -26
  587. package/templates/content/en/Product/launch-kit.mdx +0 -28
  588. package/templates/content/en/ProductsList/products.mdx +0 -26
  589. package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
  590. package/templates/content/en/ProjectsList/projects.mdx +0 -25
  591. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
  592. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
  593. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
  594. package/templates/content/en/ResourcesHub/resources.mdx +0 -22
  595. package/templates/content/en/Service/consulting.mdx +0 -22
  596. package/templates/content/en/Service/implementation.mdx +0 -30
  597. package/templates/content/en/Service/launch-service.mdx +0 -232
  598. package/templates/content/en/Service/launch.mdx +0 -228
  599. package/templates/content/en/ServicesList/services.mdx +0 -84
  600. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
  601. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
  602. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
  603. package/templates/content/en/TeamList/team.mdx +0 -63
  604. package/templates/content/en/TeamMember/founder.mdx +0 -72
  605. package/templates/content/en/TermsPage/terms.mdx +0 -19
  606. package/templates/content/en/TermsPage/terms.schema.json +0 -99
  607. package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
  608. package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
  609. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
  610. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
  611. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
  612. package/templates/content/en-XA/AboutPage/about.mdx +0 -8
  613. package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
  614. package/templates/content/en-XA/HomePage/home.mdx +0 -8
  615. package/templates/content/es/_site.mdx +0 -30
  616. package/templates/content/fr/_site.mdx +0 -30
  617. package/templates/content/ja/_site.mdx +0 -30
  618. package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
  619. package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
  620. package/templates/content/pt-BR/HomePage/home.mdx +0 -23
@@ -0,0 +1,2840 @@
1
+ /**
2
+ * @file flow-engine.test.ts
3
+ * @description Unit tests for the flow-engine service (#3956 D3).
4
+ *
5
+ * External dependency boundaries mocked:
6
+ * - @working-theory/database (via moduleNameMapper → prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
7
+ * - @working-theory/email sendLeadNotification (transitively, via lead-routing.ts's routeLead)
8
+ * + sendDeliverableEmail (transitively, via ../deliverable-fulfillment.js's fulfillDeliverable, #3961)
9
+ *
10
+ * Internal modules run real:
11
+ * - ../lead-routing (routeLead) — real, exercising the actual completion composition (AC4)
12
+ * - ../deliverable-fulfillment (fulfillDeliverable) — real, exercising the actual D4 completion
13
+ * seam; its own resolution/grant/email logic is covered directly by
14
+ * deliverable-fulfillment.test.ts, so this file asserts only the CALL — that the
15
+ * completion branch reaches the real service, with the request-local segment, under
16
+ * the "no block → no call" gate, and that its failures never fail the submission
17
+ * - apps/api/src/services/enrichment-shim — real; @working-theory/license catches + returns lead unchanged
18
+ * - apps/api/src/lib/file-fallback — real; unused here since prisma.lead.create is mocked to succeed
19
+ * - ../waitlist-scoring (scoreCompletedSignup, #4662 D6) — real, exercising the actual
20
+ * completion-hook composition; its own evaluator/glue logic is covered directly by
21
+ * packages/validation's + apps/api's own waitlist-scoring.test.ts, so this file
22
+ * asserts only the CALL — that a snapshot carrying `configSnapshot.experiment`
23
+ * reaches the real service (observed via the mocked prisma.waitlistExperiment /
24
+ * prisma.waitlistScore boundary it reads/writes), that a snapshot without it never
25
+ * touches that boundary, and that its failures never fail the submission
26
+ */
27
+
28
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
29
+ jest.mock("../../lib/prisma.js");
30
+
31
+ const mockSendDeliverableEmail = jest.fn().mockResolvedValue({
32
+ ok: true,
33
+ messageId: "msg-deliverable-test",
34
+ });
35
+
36
+ jest.mock("@working-theory/email", () => ({
37
+ sendLeadNotification: jest.fn().mockResolvedValue({
38
+ ok: true,
39
+ messageId: "msg-test",
40
+ }),
41
+ sendDeliverableEmail: (...args: unknown[]) =>
42
+ mockSendDeliverableEmail(...args),
43
+ }));
44
+
45
+ jest.mock("@working-theory/license", () => ({
46
+ checkLicense: jest.fn().mockResolvedValue({
47
+ tier: "free",
48
+ plan: null,
49
+ features: [],
50
+ expiresAt: null,
51
+ revoked: false,
52
+ }),
53
+ }));
54
+
55
+ // #4663 D6 — mock the A4 score service so AC6 exercises the engine's own
56
+ // trigger/persist orchestration without a real crawl. aeo-score.ts wraps
57
+ // the SSRF-guarded external-URL crawler (a network boundary), the same
58
+ // class of boundary the prisma.js mock above keeps this file off of.
59
+ const mockScoreUrl = jest.fn();
60
+ const mockLoadExampleScore = jest.fn();
61
+ // eslint-disable-next-line no-restricted-syntax -- aeo-score.ts is a network boundary (external crawl), same class as the DB mock above
62
+ jest.mock("../aeo-score.js", () => ({
63
+ scoreUrl: (...args: unknown[]) => mockScoreUrl(...args),
64
+ loadExampleScore: (...args: unknown[]) => mockLoadExampleScore(...args),
65
+ resolveScoreBudgetMs: (raw: string | undefined) => {
66
+ const parsed = Number(raw);
67
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 6000;
68
+ },
69
+ }));
70
+
71
+ import { Prisma } from "@working-theory/database";
72
+ import { FLOW_PRESETS } from "@working-theory/database/flows/presets";
73
+ import { flowConfigSchema } from "@working-theory/validation";
74
+
75
+ import { prisma } from "../../lib/prisma.js";
76
+ import {
77
+ serveFlow,
78
+ submitStep,
79
+ seedFlowPresets,
80
+ upsertFlowDefinition,
81
+ cleanupAbandonedSessions,
82
+ } from "../flow-engine";
83
+
84
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
85
+
86
+ const en = (s: string) => ({ en: s });
87
+
88
+ const sourceMeta = {
89
+ ip: "127.0.0.1",
90
+ userAgent: "jest",
91
+ receivedAt: new Date().toISOString(),
92
+ };
93
+
94
+ function contactFirstFlow() {
95
+ return {
96
+ slug: "get-a-quote",
97
+ defaultLocale: "en",
98
+ steps: [
99
+ {
100
+ key: "contact",
101
+ type: "field" as const,
102
+ copy: { title: en("Let's get started") },
103
+ fields: [
104
+ {
105
+ name: "email",
106
+ inputType: "email" as const,
107
+ required: true,
108
+ mapsTo: "email" as const,
109
+ copy: { label: en("Email") },
110
+ },
111
+ {
112
+ name: "company",
113
+ inputType: "text" as const,
114
+ copy: { label: en("Company") },
115
+ },
116
+ ],
117
+ },
118
+ {
119
+ key: "budget",
120
+ type: "segmentation" as const,
121
+ copy: { title: en("What's your budget?") },
122
+ questions: [
123
+ {
124
+ name: "budget",
125
+ options: [
126
+ { value: "small", label: en("Under $10k") },
127
+ { value: "large", label: en("$10k+") },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ {
133
+ key: "thanks",
134
+ type: "message" as const,
135
+ copy: { title: en("Thanks!") },
136
+ },
137
+ ],
138
+ };
139
+ }
140
+
141
+ // contactFirstFlow() extended with an #4661 D4 experiment binding — the A2
142
+ // (#4662 D6) scoring hook's opt-in gate.
143
+ function contactFirstFlowWithExperiment() {
144
+ const flow = contactFirstFlow();
145
+ return {
146
+ ...flow,
147
+ experiment: { key: "ailk-founder-diagnostic", waitlist: "founder_diagnostic" },
148
+ };
149
+ }
150
+
151
+ // The `WaitlistExperiment.config` row scoreCompletedSignup (#4662 D6) loads
152
+ // for the `ailk-founder-diagnostic` / `founder_diagnostic` binding above —
153
+ // its questionSet mirrors contactFirstFlow()'s "budget" question exactly, so
154
+ // the real evaluator scores the same payload the flow assembles. tier "hot"
155
+ // at budget_points ≥ 5 (large=10, small=1) is a real, minimal WaitlistScoring
156
+ // register — not a mock of the evaluator itself.
157
+ function waitlistExperimentConfigForFixture() {
158
+ return {
159
+ key: "ailk-founder-diagnostic",
160
+ mode: "qualifying" as const,
161
+ status: "draft" as const,
162
+ waitlists: [{ key: "founder_diagnostic", flowSlug: "get-a-quote" }],
163
+ questionSet: {
164
+ version: 1 as const,
165
+ questions: [
166
+ {
167
+ id: "email",
168
+ prompt: en("Your email"),
169
+ answerType: "email" as const,
170
+ mapsTo: "email" as const,
171
+ },
172
+ {
173
+ id: "budget",
174
+ prompt: en("What's your budget?"),
175
+ answerType: "single" as const,
176
+ options: [
177
+ { value: "small", label: en("Under $10k") },
178
+ { value: "large", label: en("$10k+") },
179
+ ],
180
+ },
181
+ ],
182
+ },
183
+ scoring: {
184
+ version: 1 as const,
185
+ points: [
186
+ {
187
+ name: "budget_points",
188
+ question: "budget",
189
+ kind: "optionWeights" as const,
190
+ weights: { small: 1, large: 10 },
191
+ },
192
+ ],
193
+ scores: { total: { sum: ["budget_points"] } },
194
+ tiers: [
195
+ { key: "hot", when: { score: "total", gte: 5 } },
196
+ // Never matches its own `when` — reachable only via the default
197
+ // fall-through, so "small" (total 1) is distinguishable from
198
+ // "large" (total 10) in the persisted tier.
199
+ { key: "cold", when: { score: "total", lte: -1 } },
200
+ ],
201
+ default: "cold",
202
+ },
203
+ tracking: {
204
+ version: 1 as const,
205
+ utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
206
+ waitlists: { founder_diagnostic: { campaign: "launch", channels: ["organic"] } },
207
+ },
208
+ stripeConfigRef: null,
209
+ };
210
+ }
211
+
212
+ // contactFirstFlow() extended with a segments band map on the "budget"
213
+ // question (#3959 D1) — small -> "starter", large -> "enterprise". A
214
+ // "thanks" message step still follows, so landing "budget" alone does not
215
+ // complete the session.
216
+ function contactFirstFlowWithSegments() {
217
+ const flow = contactFirstFlow();
218
+ const budgetStep = flow.steps.find((s) => s.key === "budget")!;
219
+ (budgetStep as typeof budgetStep & { segments?: unknown }).segments = {
220
+ question: "budget",
221
+ bands: [
222
+ { key: "starter", values: ["small"] },
223
+ { key: "enterprise", values: ["large"] },
224
+ ],
225
+ };
226
+ return flow;
227
+ }
228
+
229
+ // The #3959 D3 terminal-segmentation-step fixture: segmentation is the
230
+ // LAST step (no trailing message step), so ONE submission both lands the
231
+ // band map AND completes the session — the exact shape that would surface
232
+ // a stale-`session.segment` bug if completion read the row instead of the
233
+ // request-local derived value.
234
+ function contactThenBudgetOnlyFlowWithSegments() {
235
+ return {
236
+ slug: "get-a-quote",
237
+ defaultLocale: "en",
238
+ steps: [
239
+ {
240
+ key: "contact",
241
+ type: "field" as const,
242
+ copy: { title: en("Let's get started") },
243
+ fields: [
244
+ {
245
+ name: "email",
246
+ inputType: "email" as const,
247
+ required: true,
248
+ mapsTo: "email" as const,
249
+ copy: { label: en("Email") },
250
+ },
251
+ ],
252
+ },
253
+ {
254
+ key: "budget",
255
+ type: "segmentation" as const,
256
+ copy: { title: en("What's your budget?") },
257
+ questions: [
258
+ {
259
+ name: "budget",
260
+ options: [
261
+ { value: "small", label: en("Under $10k") },
262
+ { value: "large", label: en("$10k+") },
263
+ ],
264
+ },
265
+ ],
266
+ segments: {
267
+ question: "budget",
268
+ bands: [
269
+ { key: "starter", values: ["small"] },
270
+ { key: "enterprise", values: ["large"] },
271
+ ],
272
+ },
273
+ },
274
+ ],
275
+ };
276
+ }
277
+
278
+ // #3961 D4 — contactThenBudgetOnlyFlowWithSegments() extended with a
279
+ // deliverables asset map keyed on the same "starter"/"enterprise" bands —
280
+ // the segmentation-last single-submission fixture, so landing "budget"
281
+ // both derives the segment AND fires fulfillment in the same request.
282
+ function contactThenBudgetOnlyFlowWithDeliverables() {
283
+ const flow = contactThenBudgetOnlyFlowWithSegments();
284
+ return {
285
+ ...flow,
286
+ deliverables: {
287
+ assets: [
288
+ { segment: "starter", assetKey: "guide-starter" },
289
+ { segment: "enterprise", assetKey: "guide-enterprise" },
290
+ ],
291
+ copy: {
292
+ subject: en("Here's your guide"),
293
+ body: en("Thanks — your download is attached."),
294
+ },
295
+ },
296
+ };
297
+ }
298
+
299
+ // #4769 AC4 — a SERVED `mode: "recommending"` flow: one segmentation step
300
+ // (no `mapsTo` field, mirroring `noEmailFlow()`'s D9 shape below) plus the
301
+ // register's `offers`/`mode`/`recommendation` marker, exactly what
302
+ // `compileWaitlistFlow` (packages/validation) carries verbatim under the
303
+ // `mode === "recommending"` gate. Exercises the SERVED shape directly since
304
+ // `flow-engine.ts` needs no production change (D1/D1a) — `locateStep`
305
+ // already refuses an unknown `stepKey`, and `completeSubmission` already
306
+ // completes without a Lead row when no field maps to email.
307
+ function recommendingServedFlow() {
308
+ return {
309
+ slug: "ailk-recommender",
310
+ defaultLocale: "en",
311
+ steps: [
312
+ {
313
+ key: "q_team",
314
+ type: "segmentation" as const,
315
+ copy: { title: en("What best describes your team?") },
316
+ questions: [
317
+ {
318
+ name: "q_team",
319
+ options: [
320
+ { value: "solo", label: en("Just me") },
321
+ { value: "small_team", label: en("A small team") },
322
+ ],
323
+ },
324
+ ],
325
+ resultsReveal: true as const,
326
+ },
327
+ ],
328
+ mode: "recommending" as const,
329
+ offers: [
330
+ {
331
+ key: "free_oss",
332
+ copy: { name: en("Free (OSS)") },
333
+ price: { kind: "label" as const, label: en("Free — self-hosted") },
334
+ rules: [
335
+ {
336
+ when: { answer: "q_team", eq: "solo" },
337
+ reason: en("Solo builders get the substrate free."),
338
+ },
339
+ ],
340
+ },
341
+ ],
342
+ recommendation: {
343
+ key: "recommendation",
344
+ copy: { title: en("Your recommendation") },
345
+ },
346
+ };
347
+ }
348
+
349
+ // A flow with no mapsTo:'email' field (D9's legal-but-unused path).
350
+ function noEmailFlow() {
351
+ return {
352
+ slug: "feedback",
353
+ defaultLocale: "en",
354
+ steps: [
355
+ {
356
+ key: "rating",
357
+ type: "segmentation" as const,
358
+ copy: { title: en("How was it?") },
359
+ questions: [
360
+ {
361
+ name: "rating",
362
+ options: [
363
+ { value: "good", label: en("Good") },
364
+ { value: "bad", label: en("Bad") },
365
+ ],
366
+ },
367
+ ],
368
+ },
369
+ ],
370
+ };
371
+ }
372
+
373
+ const SITE_ID = "site-abc";
374
+ const SESSION_ID = "flsess_00000000-0000-7000-8000-0000000000c1";
375
+ const DEFINITION_ID = "flow_00000000-0000-7000-8000-0000000000d1";
376
+
377
+ describe("flow-engine", () => {
378
+ beforeEach(() => {
379
+ jest.clearAllMocks();
380
+ process.env.DATABASE_URL = "postgres://stub";
381
+ });
382
+
383
+ afterEach(() => {
384
+ delete process.env.DATABASE_URL;
385
+ });
386
+
387
+ // ── D5 — DB required ────────────────────────────────────────────────────
388
+
389
+ describe("flows_not_configured (D5)", () => {
390
+ it("serveFlow returns flows_not_configured when DATABASE_URL is unset", async () => {
391
+ delete process.env.DATABASE_URL;
392
+ const result = await serveFlow(SITE_ID, "get-a-quote");
393
+ expect(result).toEqual({
394
+ ok: false,
395
+ error: { kind: "flows_not_configured", message: expect.any(String) },
396
+ });
397
+ expect(prisma.flowDefinition.findUnique).not.toHaveBeenCalled();
398
+ });
399
+
400
+ it("submitStep returns flows_not_configured when DATABASE_URL is unset", async () => {
401
+ delete process.env.DATABASE_URL;
402
+ const result = await submitStep(
403
+ {
404
+ siteId: SITE_ID,
405
+ slug: "get-a-quote",
406
+ stepKey: "contact",
407
+ values: {},
408
+ },
409
+ sourceMeta,
410
+ );
411
+ expect(result).toEqual({
412
+ ok: false,
413
+ error: { kind: "flows_not_configured", message: expect.any(String) },
414
+ });
415
+ });
416
+ });
417
+
418
+ // ── serveFlow ────────────────────────────────────────────────────────────
419
+
420
+ describe("serveFlow", () => {
421
+ it("returns the validated FlowConfig for a stored definition", async () => {
422
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
423
+ config: contactFirstFlow(),
424
+ });
425
+
426
+ const result = await serveFlow(SITE_ID, "get-a-quote");
427
+
428
+ expect(result.ok).toBe(true);
429
+ if (result.ok) {
430
+ expect(result.config.slug).toBe("get-a-quote");
431
+ expect(result.config.steps.map((s) => s.type)).toEqual([
432
+ "field",
433
+ "segmentation",
434
+ "message",
435
+ ]);
436
+ }
437
+ expect(prisma.flowDefinition.findUnique).toHaveBeenCalledWith({
438
+ where: { siteId_slug: { siteId: SITE_ID, slug: "get-a-quote" } },
439
+ });
440
+ });
441
+
442
+ it("returns flow_not_found when no definition exists for the slug", async () => {
443
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue(null);
444
+
445
+ const result = await serveFlow(SITE_ID, "missing");
446
+
447
+ expect(result).toEqual({
448
+ ok: false,
449
+ error: { kind: "flow_not_found", message: expect.any(String) },
450
+ });
451
+ });
452
+ });
453
+
454
+ // ── upsertFlowDefinition (D6) ────────────────────────────────────────────
455
+
456
+ describe("upsertFlowDefinition", () => {
457
+ it("validates + upserts a well-formed config", async () => {
458
+ (prisma.flowDefinition.upsert as jest.Mock).mockResolvedValue({
459
+ id: DEFINITION_ID,
460
+ });
461
+
462
+ const result = await upsertFlowDefinition(SITE_ID, contactFirstFlow());
463
+
464
+ expect(result).toEqual({ ok: true, id: DEFINITION_ID });
465
+ expect(prisma.flowDefinition.upsert).toHaveBeenCalledWith(
466
+ expect.objectContaining({
467
+ where: { siteId_slug: { siteId: SITE_ID, slug: "get-a-quote" } },
468
+ }),
469
+ );
470
+ });
471
+
472
+ it("rejects an invalid config without touching prisma", async () => {
473
+ const result = await upsertFlowDefinition(SITE_ID, { not: "a flow" });
474
+
475
+ expect(result.ok).toBe(false);
476
+ expect(prisma.flowDefinition.upsert).not.toHaveBeenCalled();
477
+ });
478
+ });
479
+
480
+ // ── seedFlowPresets (#3958 §3.3 — reuse, do not duplicate) ──────────────
481
+
482
+ describe("seedFlowPresets", () => {
483
+ it("upserts each preset once, in order, through upsertFlowDefinition", async () => {
484
+ (prisma.flowDefinition.upsert as jest.Mock)
485
+ .mockResolvedValueOnce({ id: "flow_a" })
486
+ .mockResolvedValueOnce({ id: "flow_b" });
487
+
488
+ const presetA = { ...contactFirstFlow(), slug: "form" };
489
+ const presetB = { ...contactFirstFlow(), slug: "waitlist" };
490
+
491
+ const result = await seedFlowPresets(SITE_ID, [presetA, presetB]);
492
+
493
+ expect(result).toEqual({ ok: true, seeded: ["form", "waitlist"] });
494
+ expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(2);
495
+ expect(prisma.flowDefinition.upsert).toHaveBeenNthCalledWith(
496
+ 1,
497
+ expect.objectContaining({
498
+ where: { siteId_slug: { siteId: SITE_ID, slug: "form" } },
499
+ }),
500
+ );
501
+ expect(prisma.flowDefinition.upsert).toHaveBeenNthCalledWith(
502
+ 2,
503
+ expect.objectContaining({
504
+ where: { siteId_slug: { siteId: SITE_ID, slug: "waitlist" } },
505
+ }),
506
+ );
507
+ });
508
+
509
+ it("stops at the first invalid preset and reports what seeded before it — never a silent partial success", async () => {
510
+ (prisma.flowDefinition.upsert as jest.Mock).mockResolvedValueOnce({
511
+ id: "flow_a",
512
+ });
513
+
514
+ const presetA = { ...contactFirstFlow(), slug: "form" };
515
+ const invalid = { not: "a flow" };
516
+
517
+ const result = await seedFlowPresets(SITE_ID, [presetA, invalid]);
518
+
519
+ expect(result.ok).toBe(false);
520
+ if (!result.ok) {
521
+ expect(result.seeded).toEqual(["form"]);
522
+ expect(result.error.kind).toBe("invalid_input");
523
+ }
524
+ // Only presetA's upsert fires — the invalid second preset stops the
525
+ // loop before a second (rejected) call is ever attempted.
526
+ expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(1);
527
+ });
528
+
529
+ it("is idempotent — re-running upserts the same slug again rather than duplicating", async () => {
530
+ (prisma.flowDefinition.upsert as jest.Mock)
531
+ .mockResolvedValueOnce({ id: "flow_a" })
532
+ .mockResolvedValueOnce({ id: "flow_a" });
533
+
534
+ const preset = { ...contactFirstFlow(), slug: "form" };
535
+
536
+ const first = await seedFlowPresets(SITE_ID, [preset]);
537
+ const second = await seedFlowPresets(SITE_ID, [preset]);
538
+
539
+ expect(first).toEqual({ ok: true, seeded: ["form"] });
540
+ expect(second).toEqual({ ok: true, seeded: ["form"] });
541
+ expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(2);
542
+ // Both calls target the SAME siteId_slug key — an upsert on re-run,
543
+ // never a second row for the same preset.
544
+ for (const call of (prisma.flowDefinition.upsert as jest.Mock).mock
545
+ .calls) {
546
+ expect(call[0]).toEqual(
547
+ expect.objectContaining({
548
+ where: { siteId_slug: { siteId: SITE_ID, slug: "form" } },
549
+ }),
550
+ );
551
+ }
552
+ });
553
+
554
+ it("the real FLOW_PRESETS array seeds cleanly end to end (every declared preset, whatever the current set is)", async () => {
555
+ // Derived from FLOW_PRESETS itself (DEF-007) — never a hardcoded
556
+ // enumeration of preset slugs, so adding a new preset (e.g. #3963's
557
+ // "roadmap") can't re-break this assertion the way a fixed
558
+ // ["form", "waitlist", "lead-magnet"] literal did (#4008 CI red).
559
+ (prisma.flowDefinition.upsert as jest.Mock).mockImplementation(
560
+ (args: { where: { siteId_slug: { slug: string } } }) =>
561
+ Promise.resolve({
562
+ id: `flow_${args.where.siteId_slug.slug.replace(/-/g, "_")}`,
563
+ }),
564
+ );
565
+
566
+ const result = await seedFlowPresets(SITE_ID, FLOW_PRESETS);
567
+
568
+ expect(result).toEqual({
569
+ ok: true,
570
+ seeded: FLOW_PRESETS.map((p) => p.slug),
571
+ });
572
+ expect(prisma.flowDefinition.upsert).toHaveBeenCalledTimes(
573
+ FLOW_PRESETS.length,
574
+ );
575
+ });
576
+ });
577
+
578
+ // ── submitStep — session creation + per-step persistence ────────────────
579
+
580
+ describe("submitStep — session lifecycle", () => {
581
+ it("creates a session on the first submission (no sessionId)", async () => {
582
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
583
+ id: DEFINITION_ID,
584
+ config: contactFirstFlow(),
585
+ });
586
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
587
+ id: SESSION_ID,
588
+ siteId: SITE_ID,
589
+ flowId: DEFINITION_ID,
590
+ flowSlug: "get-a-quote",
591
+ configSnapshot: contactFirstFlow(),
592
+ state: {},
593
+ email: null,
594
+ leadId: null,
595
+ completedAt: null,
596
+ });
597
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
598
+
599
+ const result = await submitStep(
600
+ {
601
+ siteId: SITE_ID,
602
+ slug: "get-a-quote",
603
+ stepKey: "contact",
604
+ values: { email: "lead@example.com" },
605
+ },
606
+ sourceMeta,
607
+ );
608
+
609
+ expect(result).toMatchObject({
610
+ ok: true,
611
+ sessionId: SESSION_ID,
612
+ landed: "contact",
613
+ nextStepKey: "budget",
614
+ completed: false,
615
+ });
616
+ expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
617
+ // The partial-state capture — persisted immediately, not batched.
618
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
619
+ expect.objectContaining({
620
+ where: { id: SESSION_ID },
621
+ data: expect.objectContaining({
622
+ state: { contact: { email: "lead@example.com" } },
623
+ email: "lead@example.com",
624
+ }),
625
+ }),
626
+ );
627
+ });
628
+
629
+ it("returns flow_not_found when no sessionId AND no definition exists for the slug", async () => {
630
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue(null);
631
+
632
+ const result = await submitStep(
633
+ { siteId: SITE_ID, slug: "missing", stepKey: "contact", values: {} },
634
+ sourceMeta,
635
+ );
636
+
637
+ expect(result).toEqual({
638
+ ok: false,
639
+ error: { kind: "flow_not_found", message: expect.any(String) },
640
+ });
641
+ expect(prisma.flowSession.create).not.toHaveBeenCalled();
642
+ });
643
+
644
+ it("reuses an in-progress session when sessionId is supplied", async () => {
645
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
646
+ id: SESSION_ID,
647
+ siteId: SITE_ID,
648
+ flowId: DEFINITION_ID,
649
+ flowSlug: "get-a-quote",
650
+ configSnapshot: contactFirstFlow(),
651
+ state: { contact: { email: "lead@example.com" } },
652
+ email: "lead@example.com",
653
+ leadId: null,
654
+ completedAt: null,
655
+ });
656
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
657
+
658
+ const result = await submitStep(
659
+ {
660
+ siteId: SITE_ID,
661
+ slug: "get-a-quote",
662
+ sessionId: SESSION_ID,
663
+ stepKey: "budget",
664
+ values: { budget: "small" },
665
+ },
666
+ sourceMeta,
667
+ );
668
+
669
+ expect(result).toMatchObject({
670
+ ok: true,
671
+ sessionId: SESSION_ID,
672
+ landed: "budget",
673
+ nextStepKey: "thanks",
674
+ completed: false,
675
+ });
676
+ expect(prisma.flowDefinition.findUnique).not.toHaveBeenCalled();
677
+ expect(prisma.flowSession.create).not.toHaveBeenCalled();
678
+ });
679
+
680
+ it("returns step_conflict for an unknown sessionId", async () => {
681
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(null);
682
+
683
+ const result = await submitStep(
684
+ {
685
+ siteId: SITE_ID,
686
+ slug: "get-a-quote",
687
+ sessionId: "flsess_does-not-exist",
688
+ stepKey: "budget",
689
+ values: {},
690
+ },
691
+ sourceMeta,
692
+ );
693
+
694
+ expect(result).toEqual({
695
+ ok: false,
696
+ error: { kind: "step_conflict", message: expect.any(String) },
697
+ });
698
+ });
699
+
700
+ it("returns step_conflict when sessionId belongs to a different flow (flowSlug mismatch)", async () => {
701
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
702
+ id: SESSION_ID,
703
+ siteId: SITE_ID,
704
+ flowId: DEFINITION_ID,
705
+ flowSlug: "feedback", // a DIFFERENT flow than the one being submitted to
706
+ configSnapshot: noEmailFlow(),
707
+ state: {},
708
+ email: null,
709
+ leadId: null,
710
+ completedAt: null,
711
+ });
712
+
713
+ const result = await submitStep(
714
+ {
715
+ siteId: SITE_ID,
716
+ slug: "get-a-quote",
717
+ sessionId: SESSION_ID,
718
+ stepKey: "budget",
719
+ values: {},
720
+ },
721
+ sourceMeta,
722
+ );
723
+
724
+ expect(result).toEqual({
725
+ ok: false,
726
+ error: { kind: "step_conflict", message: expect.any(String) },
727
+ });
728
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
729
+ });
730
+
731
+ it("returns step_conflict when resubmitting to an already-completed session", async () => {
732
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
733
+ id: SESSION_ID,
734
+ siteId: SITE_ID,
735
+ flowId: DEFINITION_ID,
736
+ flowSlug: "get-a-quote",
737
+ configSnapshot: contactFirstFlow(),
738
+ state: {},
739
+ email: "lead@example.com",
740
+ leadId: "lead_x",
741
+ completedAt: new Date(),
742
+ });
743
+
744
+ const result = await submitStep(
745
+ {
746
+ siteId: SITE_ID,
747
+ slug: "get-a-quote",
748
+ sessionId: SESSION_ID,
749
+ stepKey: "budget",
750
+ values: { budget: "small" },
751
+ },
752
+ sourceMeta,
753
+ );
754
+
755
+ expect(result).toEqual({
756
+ ok: false,
757
+ error: { kind: "step_conflict", message: expect.any(String) },
758
+ });
759
+ });
760
+
761
+ it("returns step_conflict for an unknown stepKey in the snapshot", async () => {
762
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
763
+ id: SESSION_ID,
764
+ siteId: SITE_ID,
765
+ flowId: DEFINITION_ID,
766
+ flowSlug: "get-a-quote",
767
+ configSnapshot: contactFirstFlow(),
768
+ state: {},
769
+ email: null,
770
+ leadId: null,
771
+ completedAt: null,
772
+ });
773
+
774
+ const result = await submitStep(
775
+ {
776
+ siteId: SITE_ID,
777
+ slug: "get-a-quote",
778
+ sessionId: SESSION_ID,
779
+ stepKey: "does_not_exist",
780
+ values: {},
781
+ },
782
+ sourceMeta,
783
+ );
784
+
785
+ expect(result).toEqual({
786
+ ok: false,
787
+ error: { kind: "step_conflict", message: expect.any(String) },
788
+ });
789
+ });
790
+ });
791
+
792
+ // ── submitStep — recommending mode: no-PII completion + unsubmittable
793
+ // recommendation marker (#4769 AC4) ──────────────────────────────────────
794
+
795
+ describe("submitStep — recommending mode (#4769 AC4)", () => {
796
+ it("completes through submitStep on question answers alone, with no field mapping to email/name/phone submitted and no Lead row created", async () => {
797
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
798
+ id: SESSION_ID,
799
+ siteId: SITE_ID,
800
+ flowSlug: "ailk-recommender",
801
+ configSnapshot: recommendingServedFlow(),
802
+ state: {},
803
+ email: null,
804
+ leadId: null,
805
+ completedAt: null,
806
+ });
807
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
808
+
809
+ const result = await submitStep(
810
+ {
811
+ siteId: SITE_ID,
812
+ slug: "ailk-recommender",
813
+ sessionId: SESSION_ID,
814
+ stepKey: "q_team",
815
+ values: { q_team: "solo" },
816
+ },
817
+ sourceMeta,
818
+ );
819
+
820
+ expect(result).toMatchObject({ ok: true, completed: true });
821
+ expect(result.ok && result.leadId).toBeUndefined();
822
+ expect(prisma.lead.create).not.toHaveBeenCalled();
823
+ expect(prisma.flowSession.update).toHaveBeenLastCalledWith(
824
+ expect.objectContaining({
825
+ where: { id: SESSION_ID },
826
+ data: { completedAt: expect.any(Date) },
827
+ }),
828
+ );
829
+ });
830
+
831
+ it("returns step_conflict, without throwing, when the recommendation marker's key is submitted", async () => {
832
+ const flow = recommendingServedFlow();
833
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
834
+ id: SESSION_ID,
835
+ siteId: SITE_ID,
836
+ flowSlug: "ailk-recommender",
837
+ configSnapshot: flow,
838
+ state: {},
839
+ email: null,
840
+ leadId: null,
841
+ completedAt: null,
842
+ });
843
+
844
+ const result = await submitStep(
845
+ {
846
+ siteId: SITE_ID,
847
+ slug: "ailk-recommender",
848
+ sessionId: SESSION_ID,
849
+ stepKey: flow.recommendation.key,
850
+ values: {},
851
+ },
852
+ sourceMeta,
853
+ );
854
+
855
+ expect(result).toEqual({
856
+ ok: false,
857
+ error: { kind: "step_conflict", message: expect.any(String) },
858
+ });
859
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
860
+ expect(prisma.lead.create).not.toHaveBeenCalled();
861
+ });
862
+ });
863
+
864
+ // ── submitStep — session locale persistence (#3962 D3) ──────────────────
865
+
866
+ describe("submitStep — session locale persistence (#3962 D3)", () => {
867
+ it("persists the explicit locale on session create", async () => {
868
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
869
+ id: DEFINITION_ID,
870
+ config: contactFirstFlow(),
871
+ });
872
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
873
+ id: SESSION_ID,
874
+ siteId: SITE_ID,
875
+ flowId: DEFINITION_ID,
876
+ flowSlug: "get-a-quote",
877
+ configSnapshot: contactFirstFlow(),
878
+ state: {},
879
+ email: null,
880
+ leadId: null,
881
+ completedAt: null,
882
+ });
883
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
884
+
885
+ await submitStep(
886
+ {
887
+ siteId: SITE_ID,
888
+ slug: "get-a-quote",
889
+ stepKey: "contact",
890
+ values: { email: "lead@example.com" },
891
+ locale: "pt-BR",
892
+ },
893
+ sourceMeta,
894
+ );
895
+
896
+ expect(prisma.flowSession.create).toHaveBeenCalledWith(
897
+ expect.objectContaining({
898
+ data: expect.objectContaining({ locale: "pt-BR" }),
899
+ }),
900
+ );
901
+ });
902
+
903
+ it("falls back to the config's defaultLocale when no locale is submitted", async () => {
904
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
905
+ id: DEFINITION_ID,
906
+ config: contactFirstFlow(), // defaultLocale: "en"
907
+ });
908
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
909
+ id: SESSION_ID,
910
+ siteId: SITE_ID,
911
+ flowId: DEFINITION_ID,
912
+ flowSlug: "get-a-quote",
913
+ configSnapshot: contactFirstFlow(),
914
+ state: {},
915
+ email: null,
916
+ leadId: null,
917
+ completedAt: null,
918
+ });
919
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
920
+
921
+ await submitStep(
922
+ {
923
+ siteId: SITE_ID,
924
+ slug: "get-a-quote",
925
+ stepKey: "contact",
926
+ values: { email: "lead@example.com" },
927
+ },
928
+ sourceMeta,
929
+ );
930
+
931
+ expect(prisma.flowSession.create).toHaveBeenCalledWith(
932
+ expect.objectContaining({
933
+ data: expect.objectContaining({ locale: "en" }),
934
+ }),
935
+ );
936
+ });
937
+
938
+ it("does not update locale when resuming an existing session (set once at create)", async () => {
939
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
940
+ id: SESSION_ID,
941
+ siteId: SITE_ID,
942
+ flowId: DEFINITION_ID,
943
+ flowSlug: "get-a-quote",
944
+ configSnapshot: contactFirstFlow(),
945
+ state: { contact: { email: "lead@example.com" } },
946
+ email: "lead@example.com",
947
+ leadId: null,
948
+ completedAt: null,
949
+ });
950
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
951
+
952
+ await submitStep(
953
+ {
954
+ siteId: SITE_ID,
955
+ slug: "get-a-quote",
956
+ sessionId: SESSION_ID,
957
+ stepKey: "budget",
958
+ values: { budget: "small" },
959
+ locale: "es",
960
+ },
961
+ sourceMeta,
962
+ );
963
+
964
+ expect(prisma.flowSession.create).not.toHaveBeenCalled();
965
+ for (const call of (prisma.flowSession.update as jest.Mock).mock.calls) {
966
+ expect(call[0].data).not.toHaveProperty("locale");
967
+ }
968
+ });
969
+ });
970
+
971
+ // ── submitStep — value validation ───────────────────────────────────────
972
+
973
+ describe("submitStep — required fields + select/segmentation membership (D3)", () => {
974
+ it("rejects a missing required field", async () => {
975
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
976
+ id: DEFINITION_ID,
977
+ config: contactFirstFlow(),
978
+ });
979
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
980
+ id: SESSION_ID,
981
+ siteId: SITE_ID,
982
+ state: {},
983
+ email: null,
984
+ completedAt: null,
985
+ });
986
+
987
+ const result = await submitStep(
988
+ {
989
+ siteId: SITE_ID,
990
+ slug: "get-a-quote",
991
+ stepKey: "contact",
992
+ values: {},
993
+ },
994
+ sourceMeta,
995
+ );
996
+
997
+ expect(result).toEqual({
998
+ ok: false,
999
+ error: { kind: "invalid_input", message: expect.any(String) },
1000
+ });
1001
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
1002
+ });
1003
+
1004
+ it("rejects a malformed value for an email-typed field (so it can never fail routeLead silently at completion)", async () => {
1005
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1006
+ id: DEFINITION_ID,
1007
+ config: contactFirstFlow(),
1008
+ });
1009
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1010
+ id: SESSION_ID,
1011
+ siteId: SITE_ID,
1012
+ state: {},
1013
+ email: null,
1014
+ completedAt: null,
1015
+ });
1016
+
1017
+ const result = await submitStep(
1018
+ {
1019
+ siteId: SITE_ID,
1020
+ slug: "get-a-quote",
1021
+ stepKey: "contact",
1022
+ values: { email: "not-an-email" },
1023
+ },
1024
+ sourceMeta,
1025
+ );
1026
+
1027
+ expect(result).toEqual({
1028
+ ok: false,
1029
+ error: { kind: "invalid_input", message: expect.any(String) },
1030
+ });
1031
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
1032
+ });
1033
+
1034
+ it("rejects an over-length email value (>254, leadSubmitSchema's own bound) rather than letting it fail routeLead silently at completion", async () => {
1035
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1036
+ id: DEFINITION_ID,
1037
+ config: contactFirstFlow(),
1038
+ });
1039
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1040
+ id: SESSION_ID,
1041
+ siteId: SITE_ID,
1042
+ state: {},
1043
+ email: null,
1044
+ completedAt: null,
1045
+ });
1046
+
1047
+ const overLengthEmail = `${"a".repeat(250)}@example.com`; // > 254 chars
1048
+ const result = await submitStep(
1049
+ {
1050
+ siteId: SITE_ID,
1051
+ slug: "get-a-quote",
1052
+ stepKey: "contact",
1053
+ values: { email: overLengthEmail },
1054
+ },
1055
+ sourceMeta,
1056
+ );
1057
+
1058
+ expect(result).toEqual({
1059
+ ok: false,
1060
+ error: { kind: "invalid_input", message: expect.any(String) },
1061
+ });
1062
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
1063
+ });
1064
+
1065
+ it("accepts a normal-length text-field value", async () => {
1066
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1067
+ id: DEFINITION_ID,
1068
+ config: contactFirstFlow(),
1069
+ });
1070
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1071
+ id: SESSION_ID,
1072
+ siteId: SITE_ID,
1073
+ state: {},
1074
+ email: null,
1075
+ completedAt: null,
1076
+ });
1077
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1078
+
1079
+ const result = await submitStep(
1080
+ {
1081
+ siteId: SITE_ID,
1082
+ slug: "get-a-quote",
1083
+ stepKey: "contact",
1084
+ values: { email: "lead@example.com", company: "Acme Inc." },
1085
+ },
1086
+ sourceMeta,
1087
+ );
1088
+
1089
+ expect(result).toMatchObject({ ok: true });
1090
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
1091
+ expect.objectContaining({
1092
+ data: expect.objectContaining({
1093
+ state: {
1094
+ contact: { email: "lead@example.com", company: "Acme Inc." },
1095
+ },
1096
+ }),
1097
+ }),
1098
+ );
1099
+ });
1100
+
1101
+ it("rejects an over-length text-field value (>256) — security-review finding: unbounded text values", async () => {
1102
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1103
+ id: DEFINITION_ID,
1104
+ config: contactFirstFlow(),
1105
+ });
1106
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1107
+ id: SESSION_ID,
1108
+ siteId: SITE_ID,
1109
+ state: {},
1110
+ email: null,
1111
+ completedAt: null,
1112
+ });
1113
+
1114
+ const result = await submitStep(
1115
+ {
1116
+ siteId: SITE_ID,
1117
+ slug: "get-a-quote",
1118
+ stepKey: "contact",
1119
+ values: {
1120
+ email: "lead@example.com",
1121
+ company: "a".repeat(257),
1122
+ },
1123
+ },
1124
+ sourceMeta,
1125
+ );
1126
+
1127
+ expect(result).toEqual({
1128
+ ok: false,
1129
+ error: { kind: "invalid_input", message: expect.any(String) },
1130
+ });
1131
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
1132
+ });
1133
+
1134
+ it("rejects a non-string value for a text field — security-review finding: unbounded/untyped values", async () => {
1135
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1136
+ id: DEFINITION_ID,
1137
+ config: contactFirstFlow(),
1138
+ });
1139
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1140
+ id: SESSION_ID,
1141
+ siteId: SITE_ID,
1142
+ state: {},
1143
+ email: null,
1144
+ completedAt: null,
1145
+ });
1146
+
1147
+ const result = await submitStep(
1148
+ {
1149
+ siteId: SITE_ID,
1150
+ slug: "get-a-quote",
1151
+ stepKey: "contact",
1152
+ values: { email: "lead@example.com", company: { nested: "object" } },
1153
+ },
1154
+ sourceMeta,
1155
+ );
1156
+
1157
+ expect(result).toEqual({
1158
+ ok: false,
1159
+ error: { kind: "invalid_input", message: expect.any(String) },
1160
+ });
1161
+ expect(prisma.flowSession.update).not.toHaveBeenCalled();
1162
+ });
1163
+
1164
+ it("rejects a segmentation answer that is not one of the declared options", async () => {
1165
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1166
+ id: SESSION_ID,
1167
+ siteId: SITE_ID,
1168
+ flowSlug: "get-a-quote",
1169
+ configSnapshot: contactFirstFlow(),
1170
+ state: { contact: { email: "lead@example.com" } },
1171
+ email: "lead@example.com",
1172
+ completedAt: null,
1173
+ });
1174
+
1175
+ const result = await submitStep(
1176
+ {
1177
+ siteId: SITE_ID,
1178
+ slug: "get-a-quote",
1179
+ sessionId: SESSION_ID,
1180
+ stepKey: "budget",
1181
+ values: { budget: "not-a-real-option" },
1182
+ },
1183
+ sourceMeta,
1184
+ );
1185
+
1186
+ expect(result).toEqual({
1187
+ ok: false,
1188
+ error: { kind: "invalid_input", message: expect.any(String) },
1189
+ });
1190
+ });
1191
+
1192
+ it("drops an undeclared key from values rather than persisting it", async () => {
1193
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1194
+ id: DEFINITION_ID,
1195
+ config: contactFirstFlow(),
1196
+ });
1197
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1198
+ id: SESSION_ID,
1199
+ siteId: SITE_ID,
1200
+ state: {},
1201
+ email: null,
1202
+ completedAt: null,
1203
+ });
1204
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1205
+
1206
+ await submitStep(
1207
+ {
1208
+ siteId: SITE_ID,
1209
+ slug: "get-a-quote",
1210
+ stepKey: "contact",
1211
+ values: { email: "lead@example.com", attackerField: "smuggled" },
1212
+ },
1213
+ sourceMeta,
1214
+ );
1215
+
1216
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
1217
+ expect.objectContaining({
1218
+ data: expect.objectContaining({
1219
+ state: { contact: { email: "lead@example.com" } },
1220
+ }),
1221
+ }),
1222
+ );
1223
+ });
1224
+ });
1225
+
1226
+ // ── AC2 — the abandon-after-email partial lead ──────────────────────────
1227
+
1228
+ describe("AC2 — abandon after the email step persists a partial lead", () => {
1229
+ it("landing the contact step denormalizes session.email and persists state; the session never completes on its own", async () => {
1230
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
1231
+ id: DEFINITION_ID,
1232
+ config: contactFirstFlow(),
1233
+ });
1234
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({
1235
+ id: SESSION_ID,
1236
+ siteId: SITE_ID,
1237
+ state: {},
1238
+ email: null,
1239
+ leadId: null,
1240
+ completedAt: null,
1241
+ });
1242
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1243
+
1244
+ const result = await submitStep(
1245
+ {
1246
+ siteId: SITE_ID,
1247
+ slug: "get-a-quote",
1248
+ stepKey: "contact",
1249
+ values: { email: "abandoner@example.com" },
1250
+ },
1251
+ sourceMeta,
1252
+ );
1253
+
1254
+ expect(result).toMatchObject({ ok: true, completed: false });
1255
+ // Exactly one persist for this one landed step — the partial-state
1256
+ // capture happens on landing, not batched at the (never-reached) end.
1257
+ expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
1258
+ expect(prisma.flowSession.update).toHaveBeenCalledWith({
1259
+ where: { id: SESSION_ID },
1260
+ data: {
1261
+ state: { contact: { email: "abandoner@example.com" } },
1262
+ // #4663 D1/D2 — flags is re-derived + persisted on EVERY landing;
1263
+ // a flow declaring no `flags` always re-derives `{}`.
1264
+ flags: {},
1265
+ email: "abandoner@example.com",
1266
+ },
1267
+ });
1268
+ // completedAt is never touched — routeLead/completion is never reached.
1269
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1270
+ });
1271
+ });
1272
+
1273
+ // ── AC4 — completion composes routeLead ─────────────────────────────────
1274
+
1275
+ describe("AC4 — completion composes routeLead", () => {
1276
+ beforeEach(() => {
1277
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1278
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1279
+ });
1280
+
1281
+ afterEach(() => {
1282
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
1283
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
1284
+ });
1285
+
1286
+ it("completing the final step creates a Lead via routeLead and stamps completedAt + leadId", async () => {
1287
+ const leadRowId = "lead_00000000-0000-7000-8000-0000000000e1";
1288
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1289
+ id: SESSION_ID,
1290
+ siteId: SITE_ID,
1291
+ flowSlug: "get-a-quote",
1292
+ configSnapshot: contactFirstFlow(),
1293
+ state: {
1294
+ contact: { email: "complete@example.com" },
1295
+ budget: { budget: "large" },
1296
+ },
1297
+ email: "complete@example.com",
1298
+ leadId: null,
1299
+ completedAt: null,
1300
+ });
1301
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1302
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: leadRowId });
1303
+
1304
+ const result = await submitStep(
1305
+ {
1306
+ siteId: SITE_ID,
1307
+ slug: "get-a-quote",
1308
+ sessionId: SESSION_ID,
1309
+ stepKey: "thanks",
1310
+ values: {},
1311
+ },
1312
+ sourceMeta,
1313
+ );
1314
+
1315
+ expect(result).toMatchObject({
1316
+ ok: true,
1317
+ landed: "thanks",
1318
+ nextStepKey: null,
1319
+ completed: true,
1320
+ leadId: leadRowId,
1321
+ });
1322
+
1323
+ // routeLead composition (source-code contract, not a mock — the real
1324
+ // service ran and persisted via prisma.lead.create).
1325
+ expect(prisma.lead.create).toHaveBeenCalledWith(
1326
+ expect.objectContaining({
1327
+ data: expect.objectContaining({
1328
+ siteId: SITE_ID,
1329
+ source: "flow:get-a-quote",
1330
+ email: "complete@example.com",
1331
+ payload: expect.objectContaining({
1332
+ email: "complete@example.com",
1333
+ budget: "large",
1334
+ flowSlug: "get-a-quote",
1335
+ locale: "en",
1336
+ }),
1337
+ }),
1338
+ }),
1339
+ );
1340
+
1341
+ // The completion stamp is a SECOND, distinct update from the per-step
1342
+ // state persist (the two writes D3 describes: land, then complete).
1343
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
1344
+ expect.objectContaining({
1345
+ where: { id: SESSION_ID },
1346
+ data: expect.objectContaining({
1347
+ completedAt: expect.any(Date),
1348
+ leadId: leadRowId,
1349
+ }),
1350
+ }),
1351
+ );
1352
+ });
1353
+
1354
+ it("D9 — a flow with no email-mapped field completes without a Lead row (the session is the record)", async () => {
1355
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1356
+ id: SESSION_ID,
1357
+ siteId: SITE_ID,
1358
+ flowSlug: "feedback",
1359
+ configSnapshot: noEmailFlow(),
1360
+ state: {},
1361
+ email: null,
1362
+ leadId: null,
1363
+ completedAt: null,
1364
+ });
1365
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1366
+
1367
+ const result = await submitStep(
1368
+ {
1369
+ siteId: SITE_ID,
1370
+ slug: "feedback",
1371
+ sessionId: SESSION_ID,
1372
+ stepKey: "rating",
1373
+ values: { rating: "good" },
1374
+ },
1375
+ sourceMeta,
1376
+ );
1377
+
1378
+ expect(result).toMatchObject({ ok: true, completed: true });
1379
+ expect(result.ok && result.leadId).toBeUndefined();
1380
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1381
+ expect(prisma.flowSession.update).toHaveBeenLastCalledWith(
1382
+ expect.objectContaining({
1383
+ where: { id: SESSION_ID },
1384
+ data: { completedAt: expect.any(Date) },
1385
+ }),
1386
+ );
1387
+ });
1388
+ });
1389
+
1390
+ // ── #3961 D4 — deliverable fulfillment call ──────────────────────────────
1391
+
1392
+ describe("submitStep — deliverable fulfillment call (#3961 D4)", () => {
1393
+ beforeEach(() => {
1394
+ process.env.AILK_DELIVERABLE_EMAIL_FROM = "deliverable@example.com";
1395
+ process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
1396
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
1397
+ id: "dasset_00000000-0000-7000-8000-0000000000a1",
1398
+ siteId: SITE_ID,
1399
+ key: "guide-starter",
1400
+ });
1401
+ (prisma.deliverableGrant.create as jest.Mock).mockResolvedValue({
1402
+ id: "dgrant_00000000-0000-7000-8000-0000000000g1",
1403
+ });
1404
+ });
1405
+
1406
+ afterEach(() => {
1407
+ delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
1408
+ delete process.env.AILK_PUBLIC_API_URL;
1409
+ });
1410
+
1411
+ it("calls the real fulfillDeliverable once with the request-local segment on the leadResult.ok completion path", async () => {
1412
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1413
+ id: SESSION_ID,
1414
+ siteId: SITE_ID,
1415
+ flowId: DEFINITION_ID,
1416
+ flowSlug: "get-a-quote",
1417
+ configSnapshot: contactThenBudgetOnlyFlowWithDeliverables(),
1418
+ state: { contact: { email: "lead@example.com" } },
1419
+ email: "lead@example.com",
1420
+ segment: null,
1421
+ leadId: null,
1422
+ completedAt: null,
1423
+ });
1424
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1425
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1426
+ id: "lead_00000000-0000-7000-8000-0000000000e2",
1427
+ });
1428
+
1429
+ const result = await submitStep(
1430
+ {
1431
+ siteId: SITE_ID,
1432
+ slug: "get-a-quote",
1433
+ sessionId: SESSION_ID,
1434
+ stepKey: "budget",
1435
+ values: { budget: "small" },
1436
+ },
1437
+ sourceMeta,
1438
+ );
1439
+
1440
+ expect(result).toMatchObject({
1441
+ ok: true,
1442
+ completed: true,
1443
+ segment: "starter",
1444
+ });
1445
+ // The real fulfillDeliverable resolved "starter"'s entry (guide-starter),
1446
+ // minted a grant for it, and sent via the real (email-package-mocked)
1447
+ // sendDeliverableEmail — the observable trail through its own real
1448
+ // boundaries (prisma, @working-theory/email), never a mock of the
1449
+ // service itself.
1450
+ expect(prisma.deliverableAsset.findUnique).toHaveBeenCalledWith(
1451
+ expect.objectContaining({
1452
+ where: {
1453
+ siteId_key: { siteId: SITE_ID, key: "guide-starter" },
1454
+ },
1455
+ }),
1456
+ );
1457
+ expect(prisma.deliverableGrant.create).toHaveBeenCalledWith(
1458
+ expect.objectContaining({
1459
+ data: expect.objectContaining({
1460
+ siteId: SITE_ID,
1461
+ assetId: "dasset_00000000-0000-7000-8000-0000000000a1",
1462
+ leadId: "lead_00000000-0000-7000-8000-0000000000e2",
1463
+ sessionId: SESSION_ID,
1464
+ }),
1465
+ }),
1466
+ );
1467
+ expect(mockSendDeliverableEmail).toHaveBeenCalledWith(
1468
+ expect.objectContaining({
1469
+ to: "lead@example.com",
1470
+ downloadLink:
1471
+ "https://api.example.com/v1/deliverables/dgrant_00000000-0000-7000-8000-0000000000g1",
1472
+ }),
1473
+ );
1474
+ });
1475
+
1476
+ it("no deliverables block → no call (AC4 regression floor)", async () => {
1477
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1478
+ id: SESSION_ID,
1479
+ siteId: SITE_ID,
1480
+ flowId: DEFINITION_ID,
1481
+ flowSlug: "get-a-quote",
1482
+ configSnapshot: contactFirstFlow(),
1483
+ state: {
1484
+ contact: { email: "complete@example.com" },
1485
+ budget: { budget: "large" },
1486
+ },
1487
+ email: "complete@example.com",
1488
+ leadId: null,
1489
+ completedAt: null,
1490
+ });
1491
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1492
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1493
+ id: "lead_00000000-0000-7000-8000-0000000000e3",
1494
+ });
1495
+
1496
+ const result = await submitStep(
1497
+ {
1498
+ siteId: SITE_ID,
1499
+ slug: "get-a-quote",
1500
+ sessionId: SESSION_ID,
1501
+ stepKey: "thanks",
1502
+ values: {},
1503
+ },
1504
+ sourceMeta,
1505
+ );
1506
+
1507
+ expect(result).toMatchObject({ ok: true, completed: true });
1508
+ expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
1509
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
1510
+ expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
1511
+ });
1512
+
1513
+ it("a genuine fulfillDeliverable failure (a DB boundary throw) does not fail the submission", async () => {
1514
+ // A real, unexpected exception escaping fulfillDeliverable's own
1515
+ // internal log-and-skip postures (which cover expected business
1516
+ // misses, not exceptions) — the residual case flow-engine.ts's
1517
+ // try/catch exists for (D4).
1518
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockRejectedValue(
1519
+ new Error("boom"),
1520
+ );
1521
+
1522
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1523
+ id: SESSION_ID,
1524
+ siteId: SITE_ID,
1525
+ flowId: DEFINITION_ID,
1526
+ flowSlug: "get-a-quote",
1527
+ configSnapshot: contactThenBudgetOnlyFlowWithDeliverables(),
1528
+ state: { contact: { email: "lead@example.com" } },
1529
+ email: "lead@example.com",
1530
+ segment: null,
1531
+ leadId: null,
1532
+ completedAt: null,
1533
+ });
1534
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1535
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1536
+ id: "lead_00000000-0000-7000-8000-0000000000e4",
1537
+ });
1538
+
1539
+ const result = await submitStep(
1540
+ {
1541
+ siteId: SITE_ID,
1542
+ slug: "get-a-quote",
1543
+ sessionId: SESSION_ID,
1544
+ stepKey: "budget",
1545
+ values: { budget: "small" },
1546
+ },
1547
+ sourceMeta,
1548
+ );
1549
+
1550
+ expect(result).toMatchObject({ ok: true, completed: true });
1551
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
1552
+ expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
1553
+ });
1554
+ });
1555
+
1556
+ // ── #4662 D6 — waitlist scoring hook call ────────────────────────────────
1557
+
1558
+ describe("submitStep — waitlist scoring hook call (#4662 D6)", () => {
1559
+ it("evaluates + persists exactly one waitlistScore row on a snapshot carrying `experiment`", async () => {
1560
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1561
+ id: SESSION_ID,
1562
+ siteId: SITE_ID,
1563
+ flowId: DEFINITION_ID,
1564
+ flowSlug: "get-a-quote",
1565
+ configSnapshot: contactFirstFlowWithExperiment(),
1566
+ state: {
1567
+ contact: { email: "lead@example.com" },
1568
+ budget: { budget: "large" },
1569
+ },
1570
+ email: "lead@example.com",
1571
+ leadId: null,
1572
+ completedAt: null,
1573
+ });
1574
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1575
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1576
+ id: "lead_00000000-0000-7000-8000-0000000000f1",
1577
+ });
1578
+ (prisma.waitlistExperiment.findUnique as jest.Mock).mockResolvedValue({
1579
+ config: waitlistExperimentConfigForFixture(),
1580
+ });
1581
+ (prisma.waitlistScore.upsert as jest.Mock).mockResolvedValue({ id: "wlscore_1" });
1582
+
1583
+ const result = await submitStep(
1584
+ {
1585
+ siteId: SITE_ID,
1586
+ slug: "get-a-quote",
1587
+ sessionId: SESSION_ID,
1588
+ stepKey: "thanks",
1589
+ values: {},
1590
+ },
1591
+ sourceMeta,
1592
+ );
1593
+
1594
+ expect(result).toMatchObject({ ok: true, completed: true });
1595
+ expect(prisma.waitlistExperiment.findUnique).toHaveBeenCalledWith(
1596
+ expect.objectContaining({
1597
+ where: { siteId_key: { siteId: SITE_ID, key: "ailk-founder-diagnostic" } },
1598
+ }),
1599
+ );
1600
+ expect(prisma.waitlistScore.upsert).toHaveBeenCalledTimes(1);
1601
+ expect(prisma.waitlistScore.upsert).toHaveBeenCalledWith(
1602
+ expect.objectContaining({
1603
+ // The composite key since #5036 D6d — one row per (lead, EXPERIMENT),
1604
+ // not per lead, so a visitor who joins two founders' waitlists keeps
1605
+ // both scores.
1606
+ where: {
1607
+ leadId_experimentKey: {
1608
+ leadId: "lead_00000000-0000-7000-8000-0000000000f1",
1609
+ experimentKey: "ailk-founder-diagnostic",
1610
+ },
1611
+ },
1612
+ create: expect.objectContaining({
1613
+ siteId: SITE_ID,
1614
+ leadId: "lead_00000000-0000-7000-8000-0000000000f1",
1615
+ experimentKey: "ailk-founder-diagnostic",
1616
+ waitlistKey: "founder_diagnostic",
1617
+ tier: "hot",
1618
+ score: 10,
1619
+ categoryScores: { total: 10 },
1620
+ }),
1621
+ }),
1622
+ );
1623
+ });
1624
+
1625
+ it("a thrown scoring error (a genuine DB boundary failure) still completes the submission with leadId", async () => {
1626
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1627
+ id: SESSION_ID,
1628
+ siteId: SITE_ID,
1629
+ flowId: DEFINITION_ID,
1630
+ flowSlug: "get-a-quote",
1631
+ configSnapshot: contactFirstFlowWithExperiment(),
1632
+ state: {
1633
+ contact: { email: "lead2@example.com" },
1634
+ budget: { budget: "small" },
1635
+ },
1636
+ email: "lead2@example.com",
1637
+ leadId: null,
1638
+ completedAt: null,
1639
+ });
1640
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1641
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1642
+ id: "lead_00000000-0000-7000-8000-0000000000f2",
1643
+ });
1644
+ (prisma.waitlistExperiment.findUnique as jest.Mock).mockRejectedValue(
1645
+ new Error("boom"),
1646
+ );
1647
+
1648
+ const result = await submitStep(
1649
+ {
1650
+ siteId: SITE_ID,
1651
+ slug: "get-a-quote",
1652
+ sessionId: SESSION_ID,
1653
+ stepKey: "thanks",
1654
+ values: {},
1655
+ },
1656
+ sourceMeta,
1657
+ );
1658
+
1659
+ expect(result).toMatchObject({
1660
+ ok: true,
1661
+ completed: true,
1662
+ leadId: "lead_00000000-0000-7000-8000-0000000000f2",
1663
+ });
1664
+ expect(prisma.waitlistScore.upsert).not.toHaveBeenCalled();
1665
+ });
1666
+
1667
+ it("a snapshot without `experiment` never touches waitlistExperiment/waitlistScore and yields a payload byte-identical to today (AC4 regression floor)", async () => {
1668
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1669
+ id: SESSION_ID,
1670
+ siteId: SITE_ID,
1671
+ flowId: DEFINITION_ID,
1672
+ flowSlug: "get-a-quote",
1673
+ configSnapshot: contactFirstFlow(),
1674
+ state: {
1675
+ contact: { email: "lead3@example.com" },
1676
+ budget: { budget: "large" },
1677
+ },
1678
+ email: "lead3@example.com",
1679
+ leadId: null,
1680
+ completedAt: null,
1681
+ });
1682
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1683
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1684
+ id: "lead_00000000-0000-7000-8000-0000000000f3",
1685
+ });
1686
+
1687
+ const result = await submitStep(
1688
+ {
1689
+ siteId: SITE_ID,
1690
+ slug: "get-a-quote",
1691
+ sessionId: SESSION_ID,
1692
+ stepKey: "thanks",
1693
+ values: {},
1694
+ },
1695
+ sourceMeta,
1696
+ );
1697
+
1698
+ expect(result).toMatchObject({ ok: true, completed: true });
1699
+ expect(prisma.waitlistExperiment.findUnique).not.toHaveBeenCalled();
1700
+ expect(prisma.waitlistScore.upsert).not.toHaveBeenCalled();
1701
+ });
1702
+
1703
+ it("a smuggled tier submitted on an earlier step never influences the persisted evaluation (declared-names-only extraction, defense in depth)", async () => {
1704
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1705
+ id: SESSION_ID,
1706
+ siteId: SITE_ID,
1707
+ flowId: DEFINITION_ID,
1708
+ flowSlug: "get-a-quote",
1709
+ configSnapshot: contactFirstFlowWithExperiment(),
1710
+ state: { contact: { email: "lead4@example.com" } },
1711
+ email: "lead4@example.com",
1712
+ leadId: null,
1713
+ completedAt: null,
1714
+ });
1715
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1716
+
1717
+ // "budget" is a segmentation step declaring only the "budget"
1718
+ // question — a smuggled "tier" key in the same submission is dropped
1719
+ // by extractStepValues's declared-names-only extraction before it
1720
+ // ever reaches session.state, and therefore before it can reach the
1721
+ // assembled `payload` the scorer receives.
1722
+ const budgetResult = await submitStep(
1723
+ {
1724
+ siteId: SITE_ID,
1725
+ slug: "get-a-quote",
1726
+ sessionId: SESSION_ID,
1727
+ stepKey: "budget",
1728
+ values: { budget: "large", tier: "hot" },
1729
+ },
1730
+ sourceMeta,
1731
+ );
1732
+ expect(budgetResult).toMatchObject({ ok: true, completed: false });
1733
+ const lastUpdateCall = (prisma.flowSession.update as jest.Mock).mock.calls.at(-1)![0] as {
1734
+ data: { state: Record<string, Record<string, unknown>> };
1735
+ };
1736
+ expect(lastUpdateCall.data.state["budget"]).toEqual({ budget: "large" });
1737
+ expect(lastUpdateCall.data.state["budget"]).not.toHaveProperty("tier");
1738
+
1739
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1740
+ id: SESSION_ID,
1741
+ siteId: SITE_ID,
1742
+ flowId: DEFINITION_ID,
1743
+ flowSlug: "get-a-quote",
1744
+ configSnapshot: contactFirstFlowWithExperiment(),
1745
+ state: {
1746
+ contact: { email: "lead4@example.com" },
1747
+ budget: { budget: "large" },
1748
+ },
1749
+ email: "lead4@example.com",
1750
+ leadId: null,
1751
+ completedAt: null,
1752
+ });
1753
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1754
+ id: "lead_00000000-0000-7000-8000-0000000000f4",
1755
+ });
1756
+ (prisma.waitlistExperiment.findUnique as jest.Mock).mockResolvedValue({
1757
+ config: waitlistExperimentConfigForFixture(),
1758
+ });
1759
+ (prisma.waitlistScore.upsert as jest.Mock).mockResolvedValue({ id: "wlscore_2" });
1760
+
1761
+ const result = await submitStep(
1762
+ {
1763
+ siteId: SITE_ID,
1764
+ slug: "get-a-quote",
1765
+ sessionId: SESSION_ID,
1766
+ stepKey: "thanks",
1767
+ values: {},
1768
+ },
1769
+ sourceMeta,
1770
+ );
1771
+
1772
+ expect(result).toMatchObject({ ok: true, completed: true });
1773
+ expect(prisma.waitlistScore.upsert).toHaveBeenCalledTimes(1);
1774
+ const [call] = (prisma.waitlistScore.upsert as jest.Mock).mock.calls;
1775
+ const created = (call![0] as { create: { tier: string } }).create;
1776
+ // "large" alone (no smuggled tier) evaluates to "hot" (total 10 ≥ 5) —
1777
+ // the smuggled "tier":"hot" answer value made no difference because
1778
+ // there is no mechanism that reads it: it is neither a declared field
1779
+ // (dropped before session.state) nor a question the scorer's
1780
+ // answersFromPayload maps to any answer.
1781
+ expect(created.tier).toBe("hot");
1782
+ });
1783
+ });
1784
+
1785
+ // ── #3959 D3/D4 — segment derivation ─────────────────────────────────────
1786
+
1787
+ describe("submitStep — segment derivation (#3959 D3)", () => {
1788
+ it("derives + persists the segment in the SAME write as state, on landing a segmentation step declaring a band map", async () => {
1789
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1790
+ id: SESSION_ID,
1791
+ siteId: SITE_ID,
1792
+ flowId: DEFINITION_ID,
1793
+ flowSlug: "get-a-quote",
1794
+ configSnapshot: contactFirstFlowWithSegments(),
1795
+ state: { contact: { email: "lead@example.com" } },
1796
+ email: "lead@example.com",
1797
+ segment: null,
1798
+ leadId: null,
1799
+ completedAt: null,
1800
+ });
1801
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1802
+
1803
+ const result = await submitStep(
1804
+ {
1805
+ siteId: SITE_ID,
1806
+ slug: "get-a-quote",
1807
+ sessionId: SESSION_ID,
1808
+ stepKey: "budget",
1809
+ values: { budget: "small" },
1810
+ },
1811
+ sourceMeta,
1812
+ );
1813
+
1814
+ expect(result).toMatchObject({
1815
+ ok: true,
1816
+ completed: false,
1817
+ segment: "starter",
1818
+ });
1819
+ // Exactly one write — state + segment persisted together, not two
1820
+ // separate updates.
1821
+ expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
1822
+ expect(prisma.flowSession.update).toHaveBeenCalledWith({
1823
+ where: { id: SESSION_ID },
1824
+ data: {
1825
+ state: {
1826
+ contact: { email: "lead@example.com" },
1827
+ budget: { budget: "small" },
1828
+ },
1829
+ flags: {},
1830
+ segment: "starter",
1831
+ },
1832
+ });
1833
+ });
1834
+
1835
+ it("an abandoned session (segmentation landed, no trailing step submitted) carries the segment with completedAt still null — only the per-step write ran", async () => {
1836
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1837
+ id: SESSION_ID,
1838
+ siteId: SITE_ID,
1839
+ flowId: DEFINITION_ID,
1840
+ flowSlug: "get-a-quote",
1841
+ configSnapshot: contactFirstFlowWithSegments(),
1842
+ state: { contact: { email: "lead@example.com" } },
1843
+ email: "lead@example.com",
1844
+ segment: null,
1845
+ leadId: null,
1846
+ completedAt: null,
1847
+ });
1848
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1849
+
1850
+ const result = await submitStep(
1851
+ {
1852
+ siteId: SITE_ID,
1853
+ slug: "get-a-quote",
1854
+ sessionId: SESSION_ID,
1855
+ stepKey: "budget",
1856
+ values: { budget: "large" },
1857
+ },
1858
+ sourceMeta,
1859
+ );
1860
+
1861
+ expect(result).toMatchObject({ ok: true, segment: "enterprise" });
1862
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1863
+ // Exactly one write — the per-step persist; no completion-stamping
1864
+ // second write ran (the session never completed).
1865
+ expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
1866
+ });
1867
+
1868
+ it("no band map on the segmentation step derives nothing and submits unchanged (existing #3956 behavior)", async () => {
1869
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1870
+ id: SESSION_ID,
1871
+ siteId: SITE_ID,
1872
+ flowId: DEFINITION_ID,
1873
+ flowSlug: "get-a-quote",
1874
+ configSnapshot: contactFirstFlow(), // no segments block
1875
+ state: { contact: { email: "lead@example.com" } },
1876
+ email: "lead@example.com",
1877
+ segment: null,
1878
+ leadId: null,
1879
+ completedAt: null,
1880
+ });
1881
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1882
+
1883
+ const result = await submitStep(
1884
+ {
1885
+ siteId: SITE_ID,
1886
+ slug: "get-a-quote",
1887
+ sessionId: SESSION_ID,
1888
+ stepKey: "budget",
1889
+ values: { budget: "small" },
1890
+ },
1891
+ sourceMeta,
1892
+ );
1893
+
1894
+ expect(result.ok && result.segment).toBeUndefined();
1895
+ expect(prisma.flowSession.update).toHaveBeenCalledWith({
1896
+ where: { id: SESSION_ID },
1897
+ data: {
1898
+ state: {
1899
+ contact: { email: "lead@example.com" },
1900
+ budget: { budget: "small" },
1901
+ },
1902
+ flags: {},
1903
+ },
1904
+ });
1905
+ });
1906
+
1907
+ describe("completion — payload.segment (#3959 D4)", () => {
1908
+ it("writes payload.segment LAST, from the request-local value; a smuggled `values.segment` never reaches state or payload", async () => {
1909
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1910
+ id: SESSION_ID,
1911
+ siteId: SITE_ID,
1912
+ flowId: DEFINITION_ID,
1913
+ flowSlug: "get-a-quote",
1914
+ configSnapshot: contactFirstFlowWithSegments(),
1915
+ state: {
1916
+ contact: { email: "complete@example.com" },
1917
+ budget: { budget: "large" },
1918
+ },
1919
+ email: "complete@example.com",
1920
+ segment: "enterprise",
1921
+ leadId: null,
1922
+ completedAt: null,
1923
+ });
1924
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1925
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1926
+ id: "lead_00000000-0000-7000-8000-0000000000e2",
1927
+ });
1928
+
1929
+ const result = await submitStep(
1930
+ {
1931
+ siteId: SITE_ID,
1932
+ slug: "get-a-quote",
1933
+ sessionId: SESSION_ID,
1934
+ stepKey: "thanks",
1935
+ // "segment" is not a declared field/question on the "thanks"
1936
+ // message step — extractStepValues drops it unconditionally.
1937
+ values: { segment: "attacker-supplied" },
1938
+ },
1939
+ sourceMeta,
1940
+ );
1941
+
1942
+ expect(result).toMatchObject({
1943
+ ok: true,
1944
+ completed: true,
1945
+ segment: "enterprise",
1946
+ });
1947
+ expect(prisma.lead.create).toHaveBeenCalledWith(
1948
+ expect.objectContaining({
1949
+ data: expect.objectContaining({
1950
+ payload: expect.objectContaining({ segment: "enterprise" }),
1951
+ }),
1952
+ }),
1953
+ );
1954
+ });
1955
+
1956
+ it("the terminal-segmentation-step case — ONE submission landing segmentation AND completion carries the request-local segment, never the stale pre-update session.segment", async () => {
1957
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
1958
+ id: SESSION_ID,
1959
+ siteId: SITE_ID,
1960
+ flowId: DEFINITION_ID,
1961
+ flowSlug: "get-a-quote",
1962
+ configSnapshot: contactThenBudgetOnlyFlowWithSegments(),
1963
+ state: { contact: { email: "terminal@example.com" } },
1964
+ email: "terminal@example.com",
1965
+ // The row's segment is still null — this request must derive it
1966
+ // FRESH and use the local value, not this stale read.
1967
+ segment: null,
1968
+ leadId: null,
1969
+ completedAt: null,
1970
+ });
1971
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
1972
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
1973
+ id: "lead_00000000-0000-7000-8000-0000000000e3",
1974
+ });
1975
+
1976
+ const result = await submitStep(
1977
+ {
1978
+ siteId: SITE_ID,
1979
+ slug: "get-a-quote",
1980
+ sessionId: SESSION_ID,
1981
+ stepKey: "budget",
1982
+ values: { budget: "large" },
1983
+ },
1984
+ sourceMeta,
1985
+ );
1986
+
1987
+ expect(result).toMatchObject({
1988
+ ok: true,
1989
+ completed: true,
1990
+ segment: "enterprise",
1991
+ });
1992
+ expect(prisma.lead.create).toHaveBeenCalledWith(
1993
+ expect.objectContaining({
1994
+ data: expect.objectContaining({
1995
+ payload: expect.objectContaining({ segment: "enterprise" }),
1996
+ }),
1997
+ }),
1998
+ );
1999
+ // The per-step persist wrote segment too — the same write that
2000
+ // landed state.
2001
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
2002
+ expect.objectContaining({
2003
+ where: { id: SESSION_ID },
2004
+ data: expect.objectContaining({ segment: "enterprise" }),
2005
+ }),
2006
+ );
2007
+ });
2008
+
2009
+ it("no band map anywhere in the flow — completion payload carries no segment key", async () => {
2010
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2011
+ id: SESSION_ID,
2012
+ siteId: SITE_ID,
2013
+ flowId: DEFINITION_ID,
2014
+ flowSlug: "get-a-quote",
2015
+ configSnapshot: contactFirstFlow(),
2016
+ state: {
2017
+ contact: { email: "plain@example.com" },
2018
+ budget: { budget: "large" },
2019
+ },
2020
+ email: "plain@example.com",
2021
+ segment: null,
2022
+ leadId: null,
2023
+ completedAt: null,
2024
+ });
2025
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2026
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
2027
+ id: "lead_00000000-0000-7000-8000-0000000000e4",
2028
+ });
2029
+
2030
+ const result = await submitStep(
2031
+ {
2032
+ siteId: SITE_ID,
2033
+ slug: "get-a-quote",
2034
+ sessionId: SESSION_ID,
2035
+ stepKey: "thanks",
2036
+ values: {},
2037
+ },
2038
+ sourceMeta,
2039
+ );
2040
+
2041
+ expect(result.ok && result.segment).toBeUndefined();
2042
+ const call = (prisma.lead.create as jest.Mock).mock.calls[0][0];
2043
+ expect(call.data.payload).not.toHaveProperty("segment");
2044
+ });
2045
+ });
2046
+
2047
+ // ── #4661 D4 — the experiment binding composes the Lead payload ────────
2048
+
2049
+ describe("completion — payload.experimentKey/waitlistKey (#4661 D4, a Signup composes the Lead)", () => {
2050
+ it("writes experimentKey + waitlistKey LAST (after segment), from the snapshot's experiment block — the same lead row /v1/leads/submit persists", async () => {
2051
+ const flowWithExperiment = {
2052
+ ...contactFirstFlowWithSegments(),
2053
+ experiment: { key: "my-experiment", waitlist: "enterprise_list" },
2054
+ };
2055
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2056
+ id: SESSION_ID,
2057
+ siteId: SITE_ID,
2058
+ flowId: DEFINITION_ID,
2059
+ flowSlug: "get-a-quote",
2060
+ configSnapshot: flowWithExperiment,
2061
+ state: {
2062
+ contact: { email: "signup@example.com" },
2063
+ budget: { budget: "large" },
2064
+ },
2065
+ email: "signup@example.com",
2066
+ segment: "enterprise",
2067
+ leadId: null,
2068
+ completedAt: null,
2069
+ });
2070
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2071
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
2072
+ id: "lead_00000000-0000-7000-8000-0000000000f1",
2073
+ });
2074
+
2075
+ const result = await submitStep(
2076
+ {
2077
+ siteId: SITE_ID,
2078
+ slug: "get-a-quote",
2079
+ sessionId: SESSION_ID,
2080
+ stepKey: "thanks",
2081
+ values: {},
2082
+ },
2083
+ sourceMeta,
2084
+ );
2085
+
2086
+ expect(result).toMatchObject({ ok: true, completed: true });
2087
+ // AC1 — no second signup store: this IS the same lead.create call
2088
+ // /v1/leads/submit's routeLead path uses, carrying every landed
2089
+ // answer plus experimentKey/waitlistKey inside the one payload.
2090
+ expect(prisma.lead.create).toHaveBeenCalledWith(
2091
+ expect.objectContaining({
2092
+ data: expect.objectContaining({
2093
+ email: "signup@example.com",
2094
+ payload: expect.objectContaining({
2095
+ email: "signup@example.com",
2096
+ budget: "large",
2097
+ segment: "enterprise",
2098
+ experimentKey: "my-experiment",
2099
+ waitlistKey: "enterprise_list",
2100
+ }),
2101
+ }),
2102
+ }),
2103
+ );
2104
+ // Key order — experimentKey/waitlistKey land AFTER segment (D4:
2105
+ // "after segment is written and as the FINAL assignments").
2106
+ const call = (prisma.lead.create as jest.Mock).mock.calls[0][0];
2107
+ const keys = Object.keys(call.data.payload);
2108
+ expect(keys.indexOf("experimentKey")).toBeGreaterThan(
2109
+ keys.indexOf("segment"),
2110
+ );
2111
+ expect(keys.indexOf("waitlistKey")).toBeGreaterThan(
2112
+ keys.indexOf("segment"),
2113
+ );
2114
+ });
2115
+
2116
+ it("a flow with no experiment block completes with a payload byte-identical to today (regression floor)", async () => {
2117
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2118
+ id: SESSION_ID,
2119
+ siteId: SITE_ID,
2120
+ flowId: DEFINITION_ID,
2121
+ flowSlug: "get-a-quote",
2122
+ configSnapshot: contactFirstFlow(), // no `experiment` block
2123
+ state: {
2124
+ contact: { email: "plain2@example.com", company: "Acme" },
2125
+ budget: { budget: "small" },
2126
+ },
2127
+ email: "plain2@example.com",
2128
+ segment: null,
2129
+ leadId: null,
2130
+ completedAt: null,
2131
+ });
2132
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2133
+ (prisma.lead.create as jest.Mock).mockResolvedValue({
2134
+ id: "lead_00000000-0000-7000-8000-0000000000f2",
2135
+ });
2136
+
2137
+ await submitStep(
2138
+ {
2139
+ siteId: SITE_ID,
2140
+ slug: "get-a-quote",
2141
+ sessionId: SESSION_ID,
2142
+ stepKey: "thanks",
2143
+ values: {},
2144
+ },
2145
+ sourceMeta,
2146
+ );
2147
+
2148
+ const call = (prisma.lead.create as jest.Mock).mock.calls[0][0];
2149
+ expect(call.data.payload).not.toHaveProperty("experimentKey");
2150
+ expect(call.data.payload).not.toHaveProperty("waitlistKey");
2151
+ expect(call.data.payload).toEqual({
2152
+ email: "plain2@example.com",
2153
+ company: "Acme",
2154
+ budget: "small",
2155
+ flowSlug: "get-a-quote",
2156
+ locale: "en",
2157
+ });
2158
+ });
2159
+ });
2160
+ });
2161
+
2162
+ // ── cleanupAbandonedSessions — retention/cleanup policy (#3974 AC3) ──────
2163
+
2164
+ describe("cleanupAbandonedSessions", () => {
2165
+ it("returns flows_not_configured when DATABASE_URL is unset", async () => {
2166
+ delete process.env.DATABASE_URL;
2167
+
2168
+ const result = await cleanupAbandonedSessions();
2169
+
2170
+ expect(result).toEqual({
2171
+ ok: false,
2172
+ error: { kind: "flows_not_configured", message: expect.any(String) },
2173
+ });
2174
+ expect(prisma.flowSession.deleteMany).not.toHaveBeenCalled();
2175
+ });
2176
+
2177
+ it("deletes abandoned sessions past the retention window and reports the count", async () => {
2178
+ (prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
2179
+ count: 3,
2180
+ });
2181
+
2182
+ const result = await cleanupAbandonedSessions();
2183
+
2184
+ expect(result).toEqual({ ok: true, deleted: 3 });
2185
+ expect(prisma.flowSession.deleteMany).toHaveBeenCalledTimes(1);
2186
+ });
2187
+
2188
+ it("only ever targets abandoned rows — completedAt: null is structural in the where clause", async () => {
2189
+ (prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
2190
+ count: 0,
2191
+ });
2192
+
2193
+ await cleanupAbandonedSessions();
2194
+
2195
+ expect(prisma.flowSession.deleteMany).toHaveBeenCalledWith(
2196
+ expect.objectContaining({
2197
+ where: expect.objectContaining({ completedAt: null }),
2198
+ }),
2199
+ );
2200
+ });
2201
+
2202
+ it("computes the cutoff from the default 90-day retention window", async () => {
2203
+ (prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
2204
+ count: 0,
2205
+ });
2206
+ const now = new Date("2026-08-11T00:00:00.000Z");
2207
+
2208
+ await cleanupAbandonedSessions({ now });
2209
+
2210
+ const expectedCutoff = new Date("2026-05-13T00:00:00.000Z"); // 90 days earlier
2211
+ expect(prisma.flowSession.deleteMany).toHaveBeenCalledWith({
2212
+ where: {
2213
+ completedAt: null,
2214
+ updatedAt: { lte: expectedCutoff },
2215
+ },
2216
+ });
2217
+ });
2218
+
2219
+ it("honors a caller-supplied staleDays override", async () => {
2220
+ (prisma.flowSession.deleteMany as jest.Mock).mockResolvedValue({
2221
+ count: 0,
2222
+ });
2223
+ const now = new Date("2026-08-11T00:00:00.000Z");
2224
+
2225
+ await cleanupAbandonedSessions({ now, staleDays: 30 });
2226
+
2227
+ const expectedCutoff = new Date("2026-07-12T00:00:00.000Z"); // 30 days earlier
2228
+ expect(prisma.flowSession.deleteMany).toHaveBeenCalledWith({
2229
+ where: {
2230
+ completedAt: null,
2231
+ updatedAt: { lte: expectedCutoff },
2232
+ },
2233
+ });
2234
+ });
2235
+ });
2236
+
2237
+ // ─── #4663 — flow extensions (D1–D6) ──────────────────────────────────────
2238
+
2239
+ // A minimal branching fixture exercising every extension in one flow:
2240
+ // contact (email) -> site (url, optional) -> site_score (aeo_score) ->
2241
+ // note (showIf not pre_launch) -> interests (multi) -> q7 (single) ->
2242
+ // q8 (showIf q7 = yes).
2243
+ function branchingFlow() {
2244
+ return {
2245
+ slug: "branching",
2246
+ defaultLocale: "en",
2247
+ flags: [{ name: "pre_launch", when: { unanswered: "url" } }],
2248
+ steps: [
2249
+ {
2250
+ key: "contact",
2251
+ type: "field" as const,
2252
+ copy: { title: en("Start") },
2253
+ fields: [
2254
+ {
2255
+ name: "email",
2256
+ inputType: "email" as const,
2257
+ required: true,
2258
+ mapsTo: "email" as const,
2259
+ copy: { label: en("Email") },
2260
+ },
2261
+ ],
2262
+ },
2263
+ {
2264
+ key: "site",
2265
+ type: "field" as const,
2266
+ copy: { title: en("Site") },
2267
+ fields: [
2268
+ {
2269
+ name: "url",
2270
+ inputType: "url" as const,
2271
+ required: false,
2272
+ copy: { label: en("URL") },
2273
+ },
2274
+ ],
2275
+ },
2276
+ {
2277
+ key: "site_score",
2278
+ type: "aeo_score" as const,
2279
+ copy: { title: en("Scoring...") },
2280
+ urlField: "url",
2281
+ },
2282
+ {
2283
+ key: "note",
2284
+ type: "field" as const,
2285
+ copy: { title: en("Tell us more") },
2286
+ showIf: { not: { flag: "pre_launch" } },
2287
+ fields: [
2288
+ {
2289
+ name: "note",
2290
+ inputType: "textarea" as const,
2291
+ required: true,
2292
+ minLength: 10,
2293
+ copy: { label: en("Note") },
2294
+ },
2295
+ ],
2296
+ },
2297
+ {
2298
+ key: "interests",
2299
+ type: "segmentation" as const,
2300
+ copy: { title: en("Interests") },
2301
+ questions: [
2302
+ {
2303
+ name: "interests",
2304
+ multi: true as const,
2305
+ exclusiveValues: ["none"],
2306
+ options: [
2307
+ { value: "a", label: en("A") },
2308
+ { value: "b", label: en("B") },
2309
+ { value: "none", label: en("None") },
2310
+ ],
2311
+ },
2312
+ ],
2313
+ },
2314
+ {
2315
+ key: "q7",
2316
+ type: "segmentation" as const,
2317
+ copy: { title: en("Interested?") },
2318
+ questions: [
2319
+ {
2320
+ name: "q7",
2321
+ options: [
2322
+ { value: "yes", label: en("Yes") },
2323
+ { value: "no", label: en("No") },
2324
+ ],
2325
+ },
2326
+ ],
2327
+ },
2328
+ {
2329
+ key: "q8",
2330
+ type: "segmentation" as const,
2331
+ copy: { title: en("How much?") },
2332
+ showIf: { answer: "q7", eq: "yes" },
2333
+ questions: [
2334
+ {
2335
+ name: "q8",
2336
+ options: [
2337
+ { value: "small", label: en("Small") },
2338
+ { value: "large", label: en("Large") },
2339
+ ],
2340
+ },
2341
+ ],
2342
+ },
2343
+ ],
2344
+ };
2345
+ }
2346
+
2347
+ beforeEach(() => {
2348
+ mockScoreUrl.mockReset();
2349
+ mockLoadExampleScore.mockReset();
2350
+ });
2351
+
2352
+ describe("AC1 — branch served server-side (D1)", () => {
2353
+ it("serves q8 next when q7=yes, and 409s a submission for the now-hidden q8 after q7=no", async () => {
2354
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2355
+ id: SESSION_ID,
2356
+ siteId: SITE_ID,
2357
+ flowSlug: "branching",
2358
+ configSnapshot: branchingFlow(),
2359
+ state: {
2360
+ contact: { email: "a@example.com" },
2361
+ site: { url: "https://example.com" },
2362
+ site_score: {},
2363
+ note: { note: "0123456789" },
2364
+ interests: { interests: ["a"] },
2365
+ },
2366
+ flags: {},
2367
+ email: "a@example.com",
2368
+ segment: null,
2369
+ leadId: null,
2370
+ completedAt: null,
2371
+ });
2372
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2373
+
2374
+ const yesResult = await submitStep(
2375
+ {
2376
+ siteId: SITE_ID,
2377
+ slug: "branching",
2378
+ sessionId: SESSION_ID,
2379
+ stepKey: "q7",
2380
+ values: { q7: "yes" },
2381
+ },
2382
+ sourceMeta,
2383
+ );
2384
+ expect(yesResult).toMatchObject({
2385
+ ok: true,
2386
+ nextStepKey: "q8",
2387
+ completed: false,
2388
+ });
2389
+
2390
+ // Now simulate the session having landed q7=no instead (q8 hidden).
2391
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2392
+ id: SESSION_ID,
2393
+ siteId: SITE_ID,
2394
+ flowSlug: "branching",
2395
+ configSnapshot: branchingFlow(),
2396
+ state: {
2397
+ contact: { email: "a@example.com" },
2398
+ site: { url: "https://example.com" },
2399
+ site_score: {},
2400
+ note: { note: "0123456789" },
2401
+ interests: { interests: ["a"] },
2402
+ q7: { q7: "no" },
2403
+ },
2404
+ flags: {},
2405
+ email: "a@example.com",
2406
+ segment: null,
2407
+ leadId: null,
2408
+ completedAt: null,
2409
+ });
2410
+
2411
+ const conflictResult = await submitStep(
2412
+ {
2413
+ siteId: SITE_ID,
2414
+ slug: "branching",
2415
+ sessionId: SESSION_ID,
2416
+ stepKey: "q8",
2417
+ values: { q8: "small" },
2418
+ },
2419
+ sourceMeta,
2420
+ );
2421
+ expect(conflictResult).toEqual({
2422
+ ok: false,
2423
+ error: { kind: "step_conflict", message: expect.any(String) },
2424
+ });
2425
+ });
2426
+ });
2427
+
2428
+ describe("AC2 — flag-driven skip, persisted with the signup (D2/D3)", () => {
2429
+ it("derives pre_launch when url is unanswered", async () => {
2430
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2431
+ id: SESSION_ID,
2432
+ siteId: SITE_ID,
2433
+ flowSlug: "branching",
2434
+ configSnapshot: branchingFlow(),
2435
+ state: { contact: { email: "a@example.com" } },
2436
+ flags: {},
2437
+ email: "a@example.com",
2438
+ segment: null,
2439
+ leadId: null,
2440
+ completedAt: null,
2441
+ });
2442
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2443
+
2444
+ // Land "site" with NO value — derives pre_launch.
2445
+ const siteResult = await submitStep(
2446
+ {
2447
+ siteId: SITE_ID,
2448
+ slug: "branching",
2449
+ sessionId: SESSION_ID,
2450
+ stepKey: "site",
2451
+ values: {},
2452
+ },
2453
+ sourceMeta,
2454
+ );
2455
+ expect(siteResult).toMatchObject({ ok: true, nextStepKey: "site_score" });
2456
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
2457
+ expect.objectContaining({
2458
+ data: expect.objectContaining({ flags: { pre_launch: true } }),
2459
+ }),
2460
+ );
2461
+ });
2462
+ });
2463
+
2464
+ describe("AC3 — open text persists verbatim; bounds enforced (D5)", () => {
2465
+ it("rejects a note shorter than minLength and a malformed url", async () => {
2466
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2467
+ id: SESSION_ID,
2468
+ siteId: SITE_ID,
2469
+ flowSlug: "branching",
2470
+ configSnapshot: branchingFlow(),
2471
+ state: {
2472
+ contact: { email: "a@example.com" },
2473
+ site: { url: "https://example.com" },
2474
+ site_score: {},
2475
+ },
2476
+ flags: {},
2477
+ email: "a@example.com",
2478
+ segment: null,
2479
+ leadId: null,
2480
+ completedAt: null,
2481
+ });
2482
+
2483
+ const tooShort = await submitStep(
2484
+ {
2485
+ siteId: SITE_ID,
2486
+ slug: "branching",
2487
+ sessionId: SESSION_ID,
2488
+ stepKey: "note",
2489
+ values: { note: "short" },
2490
+ },
2491
+ sourceMeta,
2492
+ );
2493
+ expect(tooShort).toEqual({
2494
+ ok: false,
2495
+ error: { kind: "invalid_input", message: expect.any(String) },
2496
+ });
2497
+
2498
+ const badUrl = await submitStep(
2499
+ {
2500
+ siteId: SITE_ID,
2501
+ slug: "branching",
2502
+ sessionId: SESSION_ID,
2503
+ stepKey: "site",
2504
+ values: { url: "not a url" },
2505
+ },
2506
+ sourceMeta,
2507
+ );
2508
+ expect(badUrl).toEqual({
2509
+ ok: false,
2510
+ error: { kind: "invalid_input", message: expect.any(String) },
2511
+ });
2512
+
2513
+ // Security-review finding (#4663, M3): under the installed zod 4.4.3,
2514
+ // z.string().url() restricts neither hostname shape nor protocol, so a
2515
+ // non-http(s) scheme would have passed this check and reached
2516
+ // persistence (SSRF itself stays blocked downstream at assertUrlSafe —
2517
+ // this asserts the separate, narrower value-boundary gap is closed).
2518
+ const jsScheme = await submitStep(
2519
+ {
2520
+ siteId: SITE_ID,
2521
+ slug: "branching",
2522
+ sessionId: SESSION_ID,
2523
+ stepKey: "site",
2524
+ values: { url: "javascript:alert(1)" },
2525
+ },
2526
+ sourceMeta,
2527
+ );
2528
+ expect(jsScheme).toEqual({
2529
+ ok: false,
2530
+ error: { kind: "invalid_input", message: expect.any(String) },
2531
+ });
2532
+ });
2533
+ });
2534
+
2535
+ describe("AC5 — multi-select (D4)", () => {
2536
+ it("accepts a non-exclusive multi-select and rejects none+other combined", async () => {
2537
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2538
+ id: SESSION_ID,
2539
+ siteId: SITE_ID,
2540
+ flowSlug: "branching",
2541
+ configSnapshot: branchingFlow(),
2542
+ state: {
2543
+ contact: { email: "a@example.com" },
2544
+ site: { url: "https://example.com" },
2545
+ site_score: {},
2546
+ note: { note: "0123456789" },
2547
+ },
2548
+ flags: {},
2549
+ email: "a@example.com",
2550
+ segment: null,
2551
+ leadId: null,
2552
+ completedAt: null,
2553
+ });
2554
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2555
+
2556
+ const ok = await submitStep(
2557
+ {
2558
+ siteId: SITE_ID,
2559
+ slug: "branching",
2560
+ sessionId: SESSION_ID,
2561
+ stepKey: "interests",
2562
+ values: { interests: ["a", "b"] },
2563
+ },
2564
+ sourceMeta,
2565
+ );
2566
+ expect(ok).toMatchObject({ ok: true });
2567
+
2568
+ const rejected = await submitStep(
2569
+ {
2570
+ siteId: SITE_ID,
2571
+ slug: "branching",
2572
+ sessionId: SESSION_ID,
2573
+ stepKey: "interests",
2574
+ values: { interests: ["a", "none"] },
2575
+ },
2576
+ sourceMeta,
2577
+ );
2578
+ expect(rejected).toEqual({
2579
+ ok: false,
2580
+ error: { kind: "invalid_input", message: expect.any(String) },
2581
+ });
2582
+ });
2583
+ });
2584
+
2585
+ describe("AC6 — mid-flow AEO score (D6)", () => {
2586
+ it("calls scoreUrl with the landed url, persists + returns aeoScore", async () => {
2587
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2588
+ id: SESSION_ID,
2589
+ siteId: SITE_ID,
2590
+ flowSlug: "branching",
2591
+ configSnapshot: branchingFlow(),
2592
+ state: {
2593
+ contact: { email: "a@example.com" },
2594
+ site: { url: "https://example.com" },
2595
+ },
2596
+ flags: {},
2597
+ email: "a@example.com",
2598
+ segment: null,
2599
+ leadId: null,
2600
+ completedAt: null,
2601
+ });
2602
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2603
+ const readyScore = {
2604
+ status: "ready" as const,
2605
+ score: 42,
2606
+ dimensions: [],
2607
+ lowestTwo: [],
2608
+ meta: {
2609
+ auditedAt: "2026-01-01T00:00:00.000Z",
2610
+ pagesAudited: 1,
2611
+ partial: false,
2612
+ skipped: [],
2613
+ schemaVersion: "1",
2614
+ registryVersion: "1",
2615
+ },
2616
+ };
2617
+ mockScoreUrl.mockResolvedValue(readyScore);
2618
+
2619
+ const result = await submitStep(
2620
+ {
2621
+ siteId: SITE_ID,
2622
+ slug: "branching",
2623
+ sessionId: SESSION_ID,
2624
+ stepKey: "site_score",
2625
+ values: {},
2626
+ },
2627
+ sourceMeta,
2628
+ );
2629
+
2630
+ expect(mockScoreUrl).toHaveBeenCalledWith("https://example.com", {
2631
+ budgetMs: 6000,
2632
+ });
2633
+ expect(result).toMatchObject({ ok: true, aeoScore: readyScore });
2634
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
2635
+ expect.objectContaining({
2636
+ data: expect.objectContaining({ aeoScore: readyScore }),
2637
+ }),
2638
+ );
2639
+ });
2640
+
2641
+ it("calls loadExampleScore when the url was never landed", async () => {
2642
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2643
+ id: SESSION_ID,
2644
+ siteId: SITE_ID,
2645
+ flowSlug: "branching",
2646
+ configSnapshot: branchingFlow(),
2647
+ state: { contact: { email: "a@example.com" }, site: {} },
2648
+ flags: { pre_launch: true },
2649
+ email: "a@example.com",
2650
+ segment: null,
2651
+ leadId: null,
2652
+ completedAt: null,
2653
+ });
2654
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2655
+ const exampleScore = {
2656
+ status: "unavailable" as const,
2657
+ reason: "no_pages_fetched" as const,
2658
+ message: "n/a",
2659
+ };
2660
+ mockLoadExampleScore.mockReturnValue(exampleScore);
2661
+
2662
+ const result = await submitStep(
2663
+ {
2664
+ siteId: SITE_ID,
2665
+ slug: "branching",
2666
+ sessionId: SESSION_ID,
2667
+ stepKey: "site_score",
2668
+ values: {},
2669
+ },
2670
+ sourceMeta,
2671
+ );
2672
+
2673
+ expect(mockScoreUrl).not.toHaveBeenCalled();
2674
+ expect(mockLoadExampleScore).toHaveBeenCalledTimes(1);
2675
+ expect(result).toMatchObject({ ok: true, aeoScore: exampleScore });
2676
+ });
2677
+
2678
+ it("a thrown scoreUrl error lands as unavailable with a GENERIC message — never the raw exception text (code-review finding)", async () => {
2679
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2680
+ id: SESSION_ID,
2681
+ siteId: SITE_ID,
2682
+ flowSlug: "branching",
2683
+ configSnapshot: branchingFlow(),
2684
+ state: {
2685
+ contact: { email: "a@example.com" },
2686
+ site: { url: "https://example.com" },
2687
+ },
2688
+ flags: {},
2689
+ email: "a@example.com",
2690
+ segment: null,
2691
+ leadId: null,
2692
+ completedAt: null,
2693
+ });
2694
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2695
+ mockScoreUrl.mockRejectedValue(
2696
+ new Error("connect ECONNREFUSED 10.0.0.5:443 (internal DNS detail)"),
2697
+ );
2698
+
2699
+ const result = await submitStep(
2700
+ {
2701
+ siteId: SITE_ID,
2702
+ slug: "branching",
2703
+ sessionId: SESSION_ID,
2704
+ stepKey: "site_score",
2705
+ values: {},
2706
+ },
2707
+ sourceMeta,
2708
+ );
2709
+
2710
+ expect(result.ok && result.aeoScore).toEqual({
2711
+ status: "unavailable",
2712
+ reason: "crawl_failed",
2713
+ message: "Could not score this URL.",
2714
+ });
2715
+ const responseText = JSON.stringify(result);
2716
+ expect(responseText).not.toContain("ECONNREFUSED");
2717
+ expect(responseText).not.toContain("10.0.0.5");
2718
+ });
2719
+
2720
+ // Security-review finding (#4663, M1): scoreUrl RETURNS (never throws) on
2721
+ // an SSRF-guard rejection, so the "never forward a raw message" fix above
2722
+ // — which only covers the catch/throw path — never fired for it. The
2723
+ // returned message ("Host H resolves to a blocked address: A") disclosed
2724
+ // a resolved private IP and was persisted into the lead record verbatim.
2725
+ it("an ssrf_blocked scoreUrl RESULT (not a throw) lands with a GENERIC message — never the resolved-address disclosure", async () => {
2726
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2727
+ id: SESSION_ID,
2728
+ siteId: SITE_ID,
2729
+ flowSlug: "branching",
2730
+ configSnapshot: branchingFlow(),
2731
+ state: {
2732
+ contact: { email: "a@example.com" },
2733
+ site: { url: "https://internal.example.com" },
2734
+ },
2735
+ flags: {},
2736
+ email: "a@example.com",
2737
+ segment: null,
2738
+ leadId: null,
2739
+ completedAt: null,
2740
+ });
2741
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2742
+ mockScoreUrl.mockResolvedValue({
2743
+ status: "unavailable",
2744
+ reason: "ssrf_blocked",
2745
+ message:
2746
+ "Host internal.example.com resolves to a blocked address: 169.254.169.254",
2747
+ });
2748
+
2749
+ const result = await submitStep(
2750
+ {
2751
+ siteId: SITE_ID,
2752
+ slug: "branching",
2753
+ sessionId: SESSION_ID,
2754
+ stepKey: "site_score",
2755
+ values: {},
2756
+ },
2757
+ sourceMeta,
2758
+ );
2759
+
2760
+ expect(result.ok && result.aeoScore).toEqual({
2761
+ status: "unavailable",
2762
+ reason: "ssrf_blocked",
2763
+ message: "This URL cannot be scored.",
2764
+ });
2765
+ const responseText = JSON.stringify(result);
2766
+ expect(responseText).not.toContain("169.254.169.254");
2767
+ expect(responseText).not.toContain("blocked address");
2768
+ });
2769
+
2770
+ it("re-landing the url step with a changed value resets aeoScore to null and drops the score step's state", async () => {
2771
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
2772
+ id: SESSION_ID,
2773
+ siteId: SITE_ID,
2774
+ flowSlug: "branching",
2775
+ configSnapshot: branchingFlow(),
2776
+ state: {
2777
+ contact: { email: "a@example.com" },
2778
+ site: { url: "https://old.example.com" },
2779
+ site_score: {},
2780
+ },
2781
+ flags: {},
2782
+ email: "a@example.com",
2783
+ segment: null,
2784
+ leadId: null,
2785
+ completedAt: null,
2786
+ aeoScore: { status: "ready", score: 1 },
2787
+ });
2788
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
2789
+
2790
+ const result = await submitStep(
2791
+ {
2792
+ siteId: SITE_ID,
2793
+ slug: "branching",
2794
+ sessionId: SESSION_ID,
2795
+ stepKey: "site",
2796
+ values: { url: "https://new.example.com" },
2797
+ },
2798
+ sourceMeta,
2799
+ );
2800
+
2801
+ expect(result).toMatchObject({ ok: true, nextStepKey: "site_score" });
2802
+ const call = (prisma.flowSession.update as jest.Mock).mock.calls[0]![0] as {
2803
+ data: { state: Record<string, unknown>; aeoScore: unknown };
2804
+ };
2805
+ expect(Object.keys(call.data.state)).not.toContain("site_score");
2806
+ expect(call.data.aeoScore).toBe(Prisma.JsonNull);
2807
+ });
2808
+ });
2809
+
2810
+ // ── AC8 — Regression floor: FLOW_PRESETS schema-parse stability ──────────
2811
+ //
2812
+ // Spec 4663-flow-extensions-spec.md AC8, clause 1. `database/flows/presets.ts`
2813
+ // is UNTOUCHED by #4663 (verified: `git diff origin/main..HEAD --
2814
+ // database/flows/presets.ts` is empty) — so the only way this diff could
2815
+ // regress an existing preset is if the EXTENDED flowConfigSchema silently
2816
+ // injects a new default/coerced field into a config that declares none of
2817
+ // the new grammar (showIf/flags/whenHidden/multi/exclusiveValues/url/
2818
+ // minLength/aeo_score). A JSON round-trip of the parsed result deep-equal
2819
+ // to a JSON round-trip of the raw preset proves no field was added, dropped,
2820
+ // or coerced by the schema extension — this IS "parse deep-equals the
2821
+ // pre-change parse" without needing a checked-out pre-change snapshot,
2822
+ // because the input is provably unchanged and Zod parse is pure.
2823
+ describe("AC8 — FLOW_PRESETS schema-parse stability (regression floor)", () => {
2824
+ it.each(FLOW_PRESETS.map((p) => [p.slug, p] as const))(
2825
+ "preset %s parses to a value deep-equal to its own raw JSON shape (no field added/dropped/coerced by the D1-D10 schema extension)",
2826
+ (_slug, preset) => {
2827
+ const parsed = flowConfigSchema.parse(preset);
2828
+ expect(JSON.parse(JSON.stringify(parsed))).toEqual(
2829
+ JSON.parse(JSON.stringify(preset)),
2830
+ );
2831
+ },
2832
+ );
2833
+
2834
+ it("covers all four declared presets — never a stale hardcoded list (DEF-007, mirrors the seedFlowPresets test above)", () => {
2835
+ expect(FLOW_PRESETS.map((p) => p.slug).sort()).toEqual(
2836
+ ["form", "lead-magnet", "roadmap", "waitlist"].sort(),
2837
+ );
2838
+ });
2839
+ });
2840
+ });