create-ailk 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,354 +0,0 @@
1
- /**
2
- * @file waitlist-signups.test.ts
3
- *
4
- * The respondent read (#4731 D2/D3/D4/D5). Mocked Prisma with a
5
- * where-clause-honoring in-memory Lead table (the `waitlist-dashboard.test.ts`
6
- * discipline) so the case-insensitive email match, the site scope, the
7
- * experimentKey payload filter, and newest-wins ordering are exercised by
8
- * what the service actually passes to the DB — not by a static return.
9
- * Every fence is asserted: exact seven keys; unverified → NO Lead read;
10
- * no lead → signup_not_found; foreign site → signup_not_found; every read
11
- * scoped; no other payload key leaves the row.
12
- */
13
- import { readMySignup } from "../waitlist-signups";
14
-
15
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
16
- jest.mock("../../lib/prisma.js", () => ({
17
- prisma: {
18
- user: { findUnique: jest.fn() },
19
- lead: { findFirst: jest.fn() },
20
- waitlistScore: { findFirst: jest.fn() },
21
- flowSession: { findFirst: jest.fn() },
22
- },
23
- }));
24
-
25
- import { prisma } from "../../lib/prisma.js";
26
-
27
- // ── Fixtures ─────────────────────────────────────────────────────────────────
28
-
29
- const SITE = "wksp_00000000-0000-7000-8000-000000000001";
30
- const OTHER_SITE = "wksp_00000000-0000-7000-8000-0000000000bb";
31
- const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
32
- const EXPERIMENT = "ailk-founder-diagnostic";
33
-
34
- const LEAD_OLD = "lead_00000000-0000-7000-8000-0000000000a1";
35
- const LEAD_NEW = "lead_00000000-0000-7000-8000-0000000000a2";
36
- const LEAD_FOREIGN = "lead_00000000-0000-7000-8000-0000000000b1";
37
-
38
- type LeadRow = {
39
- id: string;
40
- siteId: string;
41
- email: string;
42
- payload: Record<string, unknown>;
43
- createdAt: Date;
44
- };
45
-
46
- const LEADS: LeadRow[] = [
47
- {
48
- id: LEAD_OLD,
49
- siteId: SITE,
50
- // Stored AS TYPED (mixed case) — the D3 normalization case.
51
- email: "Alice@Example.com",
52
- payload: {
53
- experimentKey: EXPERIMENT,
54
- waitlistKey: "early_bird",
55
- q3: "old answer",
56
- name: "Alice",
57
- url: "https://alice.example",
58
- email: "Alice@Example.com",
59
- },
60
- createdAt: new Date("2026-09-01T00:00:00.000Z"),
61
- },
62
- {
63
- id: LEAD_NEW,
64
- siteId: SITE,
65
- email: "alice@example.com",
66
- payload: {
67
- experimentKey: EXPERIMENT,
68
- waitlistKey: "general",
69
- q1: "solo_builder",
70
- q3: "Our site never ships.",
71
- name: "Alice",
72
- url: "https://alice.example",
73
- email: "alice@example.com",
74
- aeoScore: { status: "unavailable", reason: "crawl_failed", message: "x" },
75
- },
76
- createdAt: new Date("2026-09-03T00:00:00.000Z"),
77
- },
78
- {
79
- id: LEAD_FOREIGN,
80
- siteId: OTHER_SITE,
81
- email: "alice@example.com",
82
- payload: { experimentKey: EXPERIMENT, waitlistKey: "foreign", q3: "foreign" },
83
- createdAt: new Date("2026-09-04T00:00:00.000Z"),
84
- },
85
- ];
86
-
87
- const SCORES = [
88
- { leadId: LEAD_NEW, siteId: SITE, experimentKey: EXPERIMENT, tier: "hot", score: 12 },
89
- { leadId: LEAD_OLD, siteId: SITE, experimentKey: EXPERIMENT, tier: "warm", score: 7 },
90
- { leadId: LEAD_FOREIGN, siteId: OTHER_SITE, experimentKey: EXPERIMENT, tier: "hot", score: 99 },
91
- ];
92
-
93
- const AEO_UNAVAILABLE = { status: "unavailable", reason: "crawl_failed", message: "This URL cannot be scored." };
94
-
95
- const SESSIONS = [
96
- {
97
- leadId: LEAD_NEW,
98
- siteId: SITE,
99
- aeoScore: AEO_UNAVAILABLE,
100
- completedAt: new Date("2026-09-03T00:00:05.000Z"),
101
- },
102
- { leadId: LEAD_OLD, siteId: SITE, aeoScore: null, completedAt: new Date("2026-09-01T00:00:05.000Z") },
103
- ];
104
-
105
- // ── A where-clause-honoring in-memory matcher ────────────────────────────────
106
-
107
- type Where = Record<string, unknown>;
108
-
109
- function matchesLead(row: LeadRow, where: Where): boolean {
110
- for (const [key, cond] of Object.entries(where)) {
111
- if (key === "email") {
112
- const c = cond as { equals: string; mode?: string };
113
- const stored = c.mode === "insensitive" ? row.email.toLowerCase() : row.email;
114
- const wanted = c.mode === "insensitive" ? c.equals.toLowerCase() : c.equals;
115
- if (stored !== wanted) return false;
116
- continue;
117
- }
118
- if (key === "payload") {
119
- const { path, equals } = cond as { path: string[]; equals: unknown };
120
- if (row.payload[path[0] as string] !== equals) return false;
121
- continue;
122
- }
123
- if ((row as Record<string, unknown>)[key] !== cond) return false;
124
- }
125
- return true;
126
- }
127
-
128
- function matchesScalar(row: Record<string, unknown>, where: Where): boolean {
129
- for (const [key, cond] of Object.entries(where)) {
130
- if (cond !== null && typeof cond === "object" && "not" in (cond as object)) {
131
- if (row[key] === (cond as { not: unknown }).not) return false;
132
- continue;
133
- }
134
- if (row[key] !== cond) return false;
135
- }
136
- return true;
137
- }
138
-
139
- function installFixtureMocks(): void {
140
- (prisma.lead.findFirst as jest.Mock).mockImplementation(
141
- ({ where, orderBy }: { where: Where; orderBy?: { createdAt: "asc" | "desc" } }) => {
142
- const rows = LEADS.filter((r) => matchesLead(r, where));
143
- if (orderBy?.createdAt === "desc") rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
144
- else rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
145
- const hit = rows[0];
146
- return Promise.resolve(hit ? { id: hit.id, payload: hit.payload, createdAt: hit.createdAt } : null);
147
- },
148
- );
149
- (prisma.waitlistScore.findFirst as jest.Mock).mockImplementation(({ where }: { where: Where }) => {
150
- const hit = SCORES.find((r) => matchesScalar(r, where));
151
- return Promise.resolve(hit ? { tier: hit.tier, score: hit.score } : null);
152
- });
153
- (prisma.flowSession.findFirst as jest.Mock).mockImplementation(({ where }: { where: Where }) => {
154
- const hit = SESSIONS.find((r) => matchesScalar(r, where));
155
- return Promise.resolve(hit ? { aeoScore: hit.aeoScore, completedAt: hit.completedAt } : null);
156
- });
157
- }
158
-
159
- function user(overrides?: Partial<{ email: string; emailVerified: boolean }>) {
160
- return {
161
- id: USER_ID,
162
- email: overrides?.email ?? "alice@example.com",
163
- emailVerified: overrides?.emailVerified ?? true,
164
- };
165
- }
166
-
167
- beforeEach(() => {
168
- jest.clearAllMocks();
169
- process.env.AILK_SITE_ID = SITE;
170
- installFixtureMocks();
171
- });
172
-
173
- afterAll(() => {
174
- delete process.env.AILK_SITE_ID;
175
- });
176
-
177
- // ── Tests ────────────────────────────────────────────────────────────────────
178
-
179
- describe("readMySignup — the verified, found path (D5)", () => {
180
- it("returns EXACTLY the seven contract keys and only q3 under answers", async () => {
181
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
182
-
183
- const result = await readMySignup(USER_ID, EXPERIMENT);
184
-
185
- expect(result.ok).toBe(true);
186
- if (!result.ok) throw new Error("unreachable");
187
- expect(Object.keys(result.value).sort()).toEqual(
188
- ["answers", "aeoScore", "completedAt", "experimentKey", "score", "tier", "waitlistKey"].sort(),
189
- );
190
- expect(Object.keys(result.value.answers)).toEqual(["q3"]);
191
- expect(result.value).toEqual({
192
- experimentKey: EXPERIMENT,
193
- waitlistKey: "general",
194
- completedAt: "2026-09-03T00:00:05.000Z",
195
- tier: "hot",
196
- score: 12,
197
- aeoScore: AEO_UNAVAILABLE,
198
- answers: { q3: "Our site never ships." },
199
- });
200
- // Nothing else from the payload leaves the row.
201
- const serialized = JSON.stringify(result.value);
202
- for (const leaked of ["name", "url", "q1", "solo_builder", "alice.example", "@example.com"]) {
203
- expect(serialized).not.toContain(leaked);
204
- }
205
- });
206
-
207
- it("reads the User exactly as lead-promotion.ts does (id, email, emailVerified by id)", async () => {
208
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
209
-
210
- await readMySignup(USER_ID, EXPERIMENT);
211
-
212
- expect(prisma.user.findUnique).toHaveBeenCalledWith({
213
- where: { id: USER_ID },
214
- select: { id: true, email: true, emailVerified: true },
215
- });
216
- });
217
-
218
- it("newest createdAt wins when two leads match (AC-5)", async () => {
219
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
220
-
221
- const result = await readMySignup(USER_ID, EXPERIMENT);
222
-
223
- expect(result.ok && result.value.waitlistKey).toBe("general"); // LEAD_NEW, not LEAD_OLD
224
- expect(prisma.lead.findFirst).toHaveBeenCalledWith(
225
- expect.objectContaining({ orderBy: { createdAt: "desc" } }),
226
- );
227
- });
228
-
229
- it("matches a mixed-case stored Lead.email case-insensitively on the trimmed user email (D3)", async () => {
230
- // Only the OLD lead carries the mixed-case stored email; pick it by
231
- // restricting the fixture to that row.
232
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user({ email: " ALICE@example.COM " }));
233
- (prisma.lead.findFirst as jest.Mock).mockImplementation(({ where }: { where: Where }) => {
234
- const hit = LEADS.filter((r) => r.id === LEAD_OLD).find((r) => matchesLead(r, where));
235
- return Promise.resolve(hit ? { id: hit.id, payload: hit.payload, createdAt: hit.createdAt } : null);
236
- });
237
-
238
- const result = await readMySignup(USER_ID, EXPERIMENT);
239
-
240
- expect(result.ok && result.value.waitlistKey).toBe("early_bird");
241
- const call = (prisma.lead.findFirst as jest.Mock).mock.calls[0][0] as { where: Where };
242
- expect(call.where.email).toEqual({ equals: "alice@example.com", mode: "insensitive" });
243
- });
244
-
245
- it("scopes EVERY read to currentSiteId() and filters the Lead by payload.experimentKey (AC-7)", async () => {
246
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
247
-
248
- await readMySignup(USER_ID, EXPERIMENT);
249
-
250
- const leadWhere = (prisma.lead.findFirst as jest.Mock).mock.calls[0][0].where as Where;
251
- expect(leadWhere.siteId).toBe(SITE);
252
- expect(leadWhere.payload).toEqual({ path: ["experimentKey"], equals: EXPERIMENT });
253
- const scoreWhere = (prisma.waitlistScore.findFirst as jest.Mock).mock.calls[0][0].where as Where;
254
- expect(scoreWhere).toEqual({ leadId: LEAD_NEW, siteId: SITE, experimentKey: EXPERIMENT });
255
- const sessionWhere = (prisma.flowSession.findFirst as jest.Mock).mock.calls[0][0].where as Where;
256
- expect(sessionWhere).toEqual({ leadId: LEAD_NEW, siteId: SITE, completedAt: { not: null } });
257
- });
258
-
259
- it("a lead that exists only at another site is signup_not_found (never cross-site)", async () => {
260
- process.env.AILK_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000cc";
261
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
262
-
263
- const result = await readMySignup(USER_ID, EXPERIMENT);
264
-
265
- expect(result).toEqual({
266
- ok: false,
267
- error: { kind: "signup_not_found", message: "Signup not found" },
268
- });
269
- });
270
-
271
- it("returns null tier/score when the fail-soft score row is absent, and null aeoScore when unparsable", async () => {
272
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
273
- (prisma.waitlistScore.findFirst as jest.Mock).mockResolvedValue(null);
274
- (prisma.flowSession.findFirst as jest.Mock).mockResolvedValue({
275
- aeoScore: { status: "bogus" },
276
- completedAt: new Date("2026-09-03T00:00:05.000Z"),
277
- });
278
-
279
- const result = await readMySignup(USER_ID, EXPERIMENT);
280
-
281
- expect(result.ok && result.value.tier).toBeNull();
282
- expect(result.ok && result.value.score).toBeNull();
283
- expect(result.ok && result.value.aeoScore).toBeNull();
284
- });
285
-
286
- it("falls back to Lead.createdAt for completedAt when no completing session exists, and q3 null when absent", async () => {
287
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
288
- (prisma.flowSession.findFirst as jest.Mock).mockResolvedValue(null);
289
- (prisma.lead.findFirst as jest.Mock).mockResolvedValue({
290
- id: LEAD_NEW,
291
- payload: { experimentKey: EXPERIMENT, waitlistKey: "general", q3: 42 },
292
- createdAt: new Date("2026-09-03T00:00:00.000Z"),
293
- });
294
-
295
- const result = await readMySignup(USER_ID, EXPERIMENT);
296
-
297
- expect(result.ok && result.value.completedAt).toBe("2026-09-03T00:00:00.000Z");
298
- expect(result.ok && result.value.answers.q3).toBeNull();
299
- });
300
- });
301
-
302
- describe("readMySignup — the refusals (D2/D4)", () => {
303
- it("email_unverified: refuses BEFORE any Lead read (AC-3)", async () => {
304
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user({ emailVerified: false }));
305
-
306
- const result = await readMySignup(USER_ID, EXPERIMENT);
307
-
308
- expect(result.ok).toBe(false);
309
- expect(!result.ok && result.error.kind).toBe("email_unverified");
310
- expect(prisma.lead.findFirst).not.toHaveBeenCalled();
311
- expect(prisma.waitlistScore.findFirst).not.toHaveBeenCalled();
312
- expect(prisma.flowSession.findFirst).not.toHaveBeenCalled();
313
- });
314
-
315
- it("not_found_user: no User row → no Lead read", async () => {
316
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
317
-
318
- const result = await readMySignup(USER_ID, EXPERIMENT);
319
-
320
- expect(!result.ok && result.error.kind).toBe("not_found_user");
321
- expect(prisma.lead.findFirst).not.toHaveBeenCalled();
322
- });
323
-
324
- it("signup_not_found: a different email owns the leads (AC-4)", async () => {
325
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user({ email: "bob@example.com" }));
326
-
327
- const result = await readMySignup(USER_ID, EXPERIMENT);
328
-
329
- expect(!result.ok && result.error.kind).toBe("signup_not_found");
330
- expect(prisma.waitlistScore.findFirst).not.toHaveBeenCalled();
331
- });
332
-
333
- it("signup_not_found: no lead for this experimentKey (AC-4)", async () => {
334
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
335
-
336
- const result = await readMySignup(USER_ID, "some-other-experiment");
337
-
338
- expect(!result.ok && result.error.kind).toBe("signup_not_found");
339
- });
340
-
341
- it("signup_not_found: a corrupt lead missing waitlistKey is not-found, never an out-of-contract value", async () => {
342
- (prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
343
- (prisma.lead.findFirst as jest.Mock).mockResolvedValue({
344
- id: LEAD_NEW,
345
- payload: { experimentKey: EXPERIMENT, q3: "x" },
346
- createdAt: new Date("2026-09-03T00:00:00.000Z"),
347
- });
348
-
349
- const result = await readMySignup(USER_ID, EXPERIMENT);
350
-
351
- expect(!result.ok && result.error.kind).toBe("signup_not_found");
352
- expect(prisma.waitlistScore.findFirst).not.toHaveBeenCalled();
353
- });
354
- });
@@ -1,232 +0,0 @@
1
- /**
2
- * @file flow-aggregate.ts
3
- * @description The anonymous aggregate-response service (#4771 D1/D2/D3).
4
- *
5
- * Read-only (no write verb is reachable from this module — the same
6
- * `prisma\.[a-zA-Z]+\.(create|update|upsert|delete|deleteMany|updateMany|createMany)`
7
- * probe `waitlist-dashboard.ts` asserts of itself returns 0 here too).
8
- *
9
- * Three scoped reads, each carrying `siteId`:
10
- * 1. `flowDefinition.findUnique` — existence only, mirroring `serveFlow`'s
11
- * own lookup (`flow-engine.ts`). A slug with no `FlowDefinition` is
12
- * `flow_not_found` (FR6).
13
- * 2. `waitlistExperiment.findMany` — resolves the slug to its backing
14
- * register (D2), for question metadata + the `resultsReveal` flag. A
15
- * flow with no backing register is legal (D2) — the response then
16
- * carries an empty `questions[]`.
17
- * 3. `flowSession.findMany` — the respondent set: **completed** sessions
18
- * for `(siteId, flowSlug)`, `completedAt` non-null, regardless of
19
- * whether a `Lead` was ever created (D1 — a recommending-mode
20
- * respondent has no Lead until the email moment, which the reveal is
21
- * shown before).
22
- *
23
- * Per-question bucketing is the shared `computeAnswerDistributions`
24
- * (`./answer-distributions.js`, D4) — the same module `waitlist-dashboard.ts`
25
- * imports, fed `sessions.map((s) => flattenSessionState(s.state))` rather
26
- * than `leads.map((l) => l.payload)`.
27
- */
28
- import type { Prisma } from "@working-theory/database";
29
- import { logger } from "@working-theory/observability";
30
- import {
31
- waitlistExperimentConfigSchema,
32
- type FlowAggregate,
33
- type FlowAggregateAnswerType,
34
- type WaitlistExperimentConfig,
35
- } from "@working-theory/validation";
36
-
37
- import { prisma } from "../lib/prisma.js";
38
-
39
- import {
40
- computeAnswerDistributions,
41
- logAggregationCapHit,
42
- MAX_AGGREGATION_ROWS,
43
- } from "./answer-distributions.js";
44
- import type { FlowEngineError } from "./flow-engine.js";
45
-
46
- export type GetFlowAggregateResult =
47
- { ok: true; data: FlowAggregate } | { ok: false; error: FlowEngineError };
48
-
49
- // ─── DB-required guard ──────────────────────────────────────────────────────
50
- // Mirrors flow-engine.ts's own private `requireDb` (#3956 D5) — not exported
51
- // there, so re-declared here rather than widening that module's surface for
52
- // one boolean check (the same engine-local-helper convention `flow-engine.ts`
53
- // already documents for `flattenFlowState`, below).
54
-
55
- const FLOWS_NOT_CONFIGURED_ERROR: FlowEngineError = {
56
- kind: "flows_not_configured",
57
- message: "Flows are not configured.",
58
- };
59
-
60
- function requireDb(): FlowEngineError | null {
61
- if (!process.env.DATABASE_URL) return FLOWS_NOT_CONFIGURED_ERROR;
62
- return null;
63
- }
64
-
65
- // ─── flattenSessionState ────────────────────────────────────────────────────
66
- // Mirrors flow-engine.ts's private `flattenFlowState` (:682-689) — collapses
67
- // FlowSession.state's step-keyed two-level shape (`{ [stepKey]: { [field]:
68
- // value } }`) into the flat `{ [field]: value }` map `readPayloadAnswer`
69
- // expects. Not imported: `flattenFlowState` is engine-local and unexported,
70
- // the exact "mirrors ... engine-local, since that one isn't exported"
71
- // convention flow-engine.ts's own doc comment already states for its
72
- // sibling helper. Behaviour is byte-identical to the mirrored function.
73
-
74
- function flattenSessionState(state: unknown): Record<string, unknown> {
75
- const flat: Record<string, unknown> = {};
76
- if (!state || typeof state !== "object") return flat;
77
- for (const stepKey of Object.keys(state as Record<string, unknown>)) {
78
- const stepData = (state as Record<string, unknown>)[stepKey];
79
- if (stepData && typeof stepData === "object") Object.assign(flat, stepData);
80
- }
81
- return flat;
82
- }
83
-
84
- // ─── Row shapes ──────────────────────────────────────────────────────────────
85
-
86
- const FLOW_DEFINITION_EXISTS_SELECT = {
87
- id: true,
88
- } as const satisfies Prisma.FlowDefinitionSelect;
89
-
90
- const EXPERIMENT_CONFIG_SELECT = {
91
- id: true,
92
- config: true,
93
- } as const satisfies Prisma.WaitlistExperimentSelect;
94
- type ExperimentConfigRow = Omit<
95
- Prisma.WaitlistExperimentGetPayload<{
96
- select: typeof EXPERIMENT_CONFIG_SELECT;
97
- }>,
98
- "config"
99
- > & { config: unknown };
100
-
101
- const FLOW_SESSION_STATE_SELECT = {
102
- state: true,
103
- } as const satisfies Prisma.FlowSessionSelect;
104
- type FlowSessionStateRow = Omit<
105
- Prisma.FlowSessionGetPayload<{ select: typeof FLOW_SESSION_STATE_SELECT }>,
106
- "state"
107
- > & { state: unknown };
108
-
109
- // ─── getFlowAggregate ────────────────────────────────────────────────────────
110
-
111
- export async function getFlowAggregate(
112
- siteId: string,
113
- slug: string,
114
- ): Promise<GetFlowAggregateResult> {
115
- const dbError = requireDb();
116
- if (dbError) return { ok: false, error: dbError };
117
-
118
- // Existence check only — mirrors serveFlow's own FlowDefinition lookup
119
- // (flow-engine.ts), so "unknown slug" resolves identically for both
120
- // routes (FR6).
121
- const definition = await prisma.flowDefinition.findUnique({
122
- where: { siteId_slug: { siteId, slug } },
123
- select: FLOW_DEFINITION_EXISTS_SELECT,
124
- });
125
- if (!definition) {
126
- return {
127
- ok: false,
128
- error: { kind: "flow_not_found", message: "Flow not found." },
129
- };
130
- }
131
-
132
- // D2 — question metadata + the resultsReveal flag come from the register,
133
- // not the compiled flow. A flow with no backing register is legal (D2) —
134
- // `matches` is then empty and `flaggedQuestions` stays [].
135
- const experiments: ExperimentConfigRow[] =
136
- await prisma.waitlistExperiment.findMany({
137
- where: { siteId },
138
- select: EXPERIMENT_CONFIG_SELECT,
139
- // Deterministic read order (code-review finding, #4771) — without an
140
- // explicit orderBy, Postgres does not guarantee row order, which
141
- // would make the matches.length > 1 fallback below pick a genuinely
142
- // arbitrary row rather than a defined "oldest wins" policy.
143
- orderBy: { createdAt: "asc" },
144
- // Bounds this anonymous read the same way the sibling
145
- // flowSession.findMany read below is bounded (#5109).
146
- take: MAX_AGGREGATION_ROWS,
147
- });
148
-
149
- const matches: WaitlistExperimentConfig[] = [];
150
- for (const row of experiments) {
151
- const parsed = waitlistExperimentConfigSchema.safeParse(row.config);
152
- if (!parsed.success) {
153
- // A corrupted register row must not abort an anonymous read — the
154
- // same "skip, don't throw" posture waitlist-dashboard.ts's
155
- // `listExperiments` applies to a corrupt config under kind:"all".
156
- logger.warn(
157
- { siteId, experimentId: row.id },
158
- "[flow-aggregate] experiment config failed to parse — skipped",
159
- );
160
- continue;
161
- }
162
- if (parsed.data.waitlists.some((w) => w.flowSlug === slug)) {
163
- matches.push(parsed.data);
164
- }
165
- }
166
- if (matches.length > 1) {
167
- // Spec §5.3 escalation boundary — a duplicate flowSlug across
168
- // experiments for one site is a register-integrity question this
169
- // service does not resolve arbitrarily. Logged rather than thrown so
170
- // the anonymous read still degrades (first match, by the oldest
171
- // `createdAt` per the orderBy above) instead of 500ing a public
172
- // endpoint.
173
- logger.warn(
174
- { siteId, slug, matchCount: matches.length },
175
- "[flow-aggregate] slug resolves to more than one WaitlistExperiment — using the first",
176
- );
177
- }
178
- const config = matches[0];
179
- const flaggedQuestions = (config?.questionSet.questions ?? []).filter(
180
- (q) => q.resultsReveal === true,
181
- );
182
-
183
- // D1 — respondents are completed FlowSession rows, not Leads.
184
- const sessions: FlowSessionStateRow[] = await prisma.flowSession.findMany({
185
- where: { siteId, flowSlug: slug, completedAt: { not: null } },
186
- select: FLOW_SESSION_STATE_SELECT,
187
- // Deterministic read order (code-review finding, #4771) — earliest
188
- // completions first, so a flow that exceeds MAX_AGGREGATION_ROWS drops
189
- // its most recent respondents consistently rather than an arbitrary set.
190
- orderBy: { completedAt: "asc" },
191
- take: MAX_AGGREGATION_ROWS,
192
- });
193
- logAggregationCapHit(`flow-aggregate.${slug}`, sessions.length);
194
-
195
- const answerSets = sessions.map((session) =>
196
- flattenSessionState(session.state),
197
- );
198
- const distributions = computeAnswerDistributions(
199
- flaggedQuestions,
200
- answerSets,
201
- );
202
-
203
- const questions: FlowAggregate["questions"] = distributions.map((d, i) => {
204
- const question = flaggedQuestions[i]!;
205
- const labelsByValue = new Map(
206
- (question.options ?? []).map((o) => [o.value, o.label]),
207
- );
208
- return {
209
- id: d.questionId,
210
- // Safe: flaggedQuestions is filtered to resultsReveal === true, and
211
- // waitlist.ts's own superRefine only permits that flag on single/multi
212
- // questions (D3) — no other answerType is reachable here.
213
- answerType: d.answerType as FlowAggregateAnswerType,
214
- answered: d.answered,
215
- // Declared options only — an off-register "(other)" bucket has no
216
- // register label to ship, and this schema's `label` is required
217
- // (never fabricated). Still counted in `answered` above.
218
- options: d.options
219
- .filter((o) => labelsByValue.has(o.value))
220
- .map((o) => ({
221
- value: o.value,
222
- label: labelsByValue.get(o.value)!,
223
- count: o.count,
224
- })),
225
- };
226
- });
227
-
228
- return {
229
- ok: true,
230
- data: { slug, respondents: sessions.length, questions },
231
- };
232
- }