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