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,947 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file waitlist-dashboard.ts
4
- * @description The operator-dashboard READ service (#4665 D2/D3/D4/D5, B0).
5
- *
6
- * Read-only (`findMany` / `findFirst` / `findUnique` only — no write verb is
7
- * reachable from this module, asserted by
8
- * `__tests__/waitlist-dashboard.test.ts` and spec §5.2 AC4's
9
- * `grep -cE "prisma\.[a-zA-Z]+\.(create|update|upsert|delete|deleteMany|updateMany|createMany)"`
10
- * probe). Every query is scoped: `{ siteId }` for `kind: "site"`, nothing
11
- * extra for `kind: "all"` (D3) — `scopeWhere` is the single place that
12
- * predicate is expressed, so a query missing it is a code-review-visible
13
- * omission rather than a silent gap.
14
- *
15
- * Experiments are addressed by `id` (`wlexp_…`) — never `key`, which is
16
- * unique only per `(siteId, key)` and collides under the `all` scope (D3). A
17
- * lead is loaded THROUGH its experiment — `findFirst({ id: leadId, siteId:
18
- * experiment.siteId, payload: { path: ["experimentKey"], equals:
19
- * experiment.key } })` — so a foreign or unrelated lead is `not_found`,
20
- * indistinguishable from a missing one (the `tenant.ts` no-enumeration
21
- * posture).
22
- *
23
- * Aggregation runs over ONE scoped `Lead` read (`select: { id, siteId,
24
- * email, source, payload, createdAt }`) and ONE scoped `WaitlistScore` read
25
- * per experiment (D4) — never a query per question or per source; `list`
26
- * runs the same pair ONCE across every returned experiment, grouped in
27
- * memory by `${siteId}:${experimentKey}`. For the recommending experiments
28
- * in the list it adds ONE scoped `FlowSession` read (completed sessions on
29
- * their flow slugs — `recommended` is recomputed from each session's state
30
- * with the pure `recommend()`, #4839 D1) and ONE scoped `FlowCheckout` read
31
- * (rows at `status: "completed"`, #4839 D3); `saved` comes from the Lead
32
- * read already in hand (#4839 D2). Neither read runs when no recommending
33
- * experiment is in scope.
34
- *
35
- * Lives in `apps/api/src/services/` (ADR 0009 D3) — it assembles across the
36
- * `auth` `TenantContext` (`tenant-context.ts`) and the `inbox` rows
37
- * (`WaitlistExperiment` / `WaitlistScore` / `Lead`).
38
- */
39
-
40
- import type { Prisma } from "@working-theory/database";
41
- import { isSentinel } from "@working-theory/ids";
42
- import { logger } from "@working-theory/observability";
43
- import {
44
- recommend,
45
- waitlistExperimentConfigSchema,
46
- type WaitlistAnswers,
47
- type WaitlistExperimentConfig,
48
- type WaitlistExperimentOfferCounts,
49
- type WaitlistDashboardList,
50
- type WaitlistExperimentComparison,
51
- type WaitlistExperimentSummary,
52
- type WaitlistMetricRow,
53
- type WaitlistSignupDetail,
54
- type WaitlistSignupList,
55
- } from "@working-theory/validation";
56
-
57
- import { prisma } from "../lib/prisma.js";
58
-
59
- import {
60
- computeAnswerDistributions,
61
- logAggregationCapHit,
62
- MAX_AGGREGATION_ROWS,
63
- readPayloadAnswer,
64
- } from "./answer-distributions.js";
65
- import type { MembershipRole, TenantContext, TenantResult } from "./tenant-context.js";
66
-
67
- // ─── Scope (D2) ──────────────────────────────────────────────────────────────
68
-
69
- export type WaitlistReadScope =
70
- | { kind: "site"; siteId: string }
71
- | { kind: "all" };
72
-
73
- /** Reads require `owner` or `admin` in the active organization (D2) — the dashboard exposes signup PII. */
74
- export const WAITLIST_DASHBOARD_ROLES: ReadonlySet<MembershipRole> = new Set<
75
- MembershipRole
76
- >(["owner", "admin"]);
77
-
78
- /**
79
- * The platform operator is an `owner`-role member of the sentinel default
80
- * Organization (D2) — exactly the identity `bindOperatorMembership` mints
81
- * for a single-tenant deployment, and, on a hosted multi-tenant deployment,
82
- * the platform's own org owners. Keyed on `isSentinel`, never a literal
83
- * (ADR-0031 rule 7).
84
- *
85
- * Gated by an explicit deployment pin (Sam-disposed 2026-09-02, PR #4687
86
- * security review — fail closed): `AILK_OPERATOR_EMAIL` must be SET before
87
- * this predicate can ever be true. `bindOperatorMembership`
88
- * (`tenant-context.ts`) TOFU-binds the sentinel-owner Membership to whichever
89
- * caller wins a first-login race when the pin is unset; before this read
90
- * surface existed, winning that race granted scope over only the winner's
91
- * own workspace. This surface turns it into cross-tenant signup-PII read
92
- * access, so a deployment must opt in explicitly rather than inherit an
93
- * unpinned race's outcome. With the pin unset, even a sentinel-org owner
94
- * reads at `kind:"site"` scope like any other tenant.
95
- */
96
- export function isPlatformOperator(tenant: TenantContext): boolean {
97
- const pinned = process.env.AILK_OPERATOR_EMAIL?.trim();
98
- if (!pinned) return false;
99
- return isSentinel(tenant.organizationId) && tenant.role === "owner";
100
- }
101
-
102
- /** Owner scope = `siteId == tenant.workspaceId`; platform operator = every site. */
103
- export function resolveWaitlistReadScope(tenant: TenantContext): WaitlistReadScope {
104
- return isPlatformOperator(tenant)
105
- ? { kind: "all" }
106
- : { kind: "site", siteId: tenant.workspaceId };
107
- }
108
-
109
- function scopeWhere(scope: WaitlistReadScope): { siteId?: string } {
110
- return scope.kind === "site" ? { siteId: scope.siteId } : {};
111
- }
112
-
113
- // ─── Row shapes ──────────────────────────────────────────────────────────────
114
-
115
- const EXPERIMENT_SELECT = {
116
- id: true,
117
- siteId: true,
118
- key: true,
119
- mode: true,
120
- status: true,
121
- config: true,
122
- createdAt: true,
123
- updatedAt: true,
124
- } as const satisfies Prisma.WaitlistExperimentSelect;
125
- type ExperimentRow = Prisma.WaitlistExperimentGetPayload<{
126
- select: typeof EXPERIMENT_SELECT;
127
- }>;
128
-
129
- const LEAD_SELECT = {
130
- id: true,
131
- siteId: true,
132
- email: true,
133
- source: true,
134
- payload: true,
135
- createdAt: true,
136
- } as const satisfies Prisma.LeadSelect;
137
- type LeadRow = Prisma.LeadGetPayload<{ select: typeof LEAD_SELECT }>;
138
-
139
- const SCORE_SELECT = {
140
- leadId: true,
141
- siteId: true,
142
- experimentKey: true,
143
- tier: true,
144
- score: true,
145
- categoryScores: true,
146
- ruleTrace: true,
147
- updatedAt: true,
148
- } as const satisfies Prisma.WaitlistScoreSelect;
149
- type ScoreRow = Prisma.WaitlistScoreGetPayload<{ select: typeof SCORE_SELECT }>;
150
-
151
- const FLOW_SESSION_SELECT = {
152
- siteId: true,
153
- flowSlug: true,
154
- state: true,
155
- } as const satisfies Prisma.FlowSessionSelect;
156
- type FlowSessionRow = Prisma.FlowSessionGetPayload<{ select: typeof FLOW_SESSION_SELECT }>;
157
-
158
- const FLOW_CHECKOUT_SELECT = {
159
- siteId: true,
160
- experimentKey: true,
161
- offerKey: true,
162
- } as const satisfies Prisma.FlowCheckoutSelect;
163
- type FlowCheckoutRow = Prisma.FlowCheckoutGetPayload<{ select: typeof FLOW_CHECKOUT_SELECT }>;
164
-
165
- // `MAX_AGGREGATION_ROWS` and `logAggregationCapHit` — the defensive upper
166
- // bound on an aggregation read (Medium finding, PR #4687 security review) —
167
- // moved to `./answer-distributions.js` (#4771 D4), which both this module
168
- // and `flow-aggregate.ts` now import.
169
-
170
- // ─── payload / config helpers ────────────────────────────────────────────────
171
-
172
- /**
173
- * Parse a `WaitlistExperiment.config` JSON column back into its validated
174
- * shape, or `undefined` on a parse failure. `config` is written ONLY
175
- * through `upsertWaitlistExperiment` (`waitlist-experiments.ts`), which
176
- * validates against this same schema before writing — so a parse failure
177
- * here signals corrupted row data, not a legitimate read-time possibility.
178
- * Mirrors `flow-engine.ts`'s `flowConfigSchema.safeParse` read-back
179
- * discipline.
180
- */
181
- function tryParseConfig(
182
- raw: unknown,
183
- ): { config: WaitlistExperimentConfig; message?: undefined } | { config?: undefined; message: string } {
184
- const parsed = waitlistExperimentConfigSchema.safeParse(raw);
185
- if (!parsed.success) {
186
- return { message: parsed.error.message };
187
- }
188
- return { config: parsed.data };
189
- }
190
-
191
- /**
192
- * Throwing form of `tryParseConfig`, for the single-experiment paths
193
- * (`getExperimentComparison` / `getSignup`) where a corrupt row genuinely is
194
- * an internal error for that one request — there is no cross-tenant
195
- * availability coupling to protect against (the request already named this
196
- * one experiment by id). `listExperiments`'s `kind:"all"` loop uses
197
- * `tryParseConfig` directly instead, so one tenant's corrupt row can never
198
- * abort the platform operator's whole list (Medium finding, PR #4687
199
- * security review).
200
- */
201
- function parseConfig(raw: unknown): WaitlistExperimentConfig {
202
- const result = tryParseConfig(raw);
203
- if (result.config === undefined) {
204
- throw new Error(`waitlist-dashboard: experiment config failed to parse: ${result.message}`);
205
- }
206
- return result.config;
207
- }
208
-
209
- /**
210
- * Flatten a completed session's step-keyed `state` into the answer map
211
- * `recommend()` takes, keeping only `string | string[]` values. Mirrors
212
- * `flow-aggregate.ts`'s module-local `flattenSessionState` (itself a mirror
213
- * of `flow-engine.ts`'s) — the "mirrors, not imported" convention those two
214
- * already record (#4839 D1).
215
- */
216
- function flattenSessionAnswers(state: unknown): WaitlistAnswers {
217
- const answers: WaitlistAnswers = {};
218
- if (!state || typeof state !== "object") return answers;
219
- for (const stepData of Object.values(state as Record<string, unknown>)) {
220
- if (!stepData || typeof stepData !== "object") continue;
221
- for (const [name, value] of Object.entries(stepData as Record<string, unknown>)) {
222
- if (typeof value === "string") answers[name] = value;
223
- else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
224
- answers[name] = value as string[];
225
- }
226
- }
227
- }
228
- return answers;
229
- }
230
-
231
- /**
232
- * The memo key for one completed session's answers under `config` (#4861 D1).
233
- *
234
- * Built from the flattened answers PROJECTED onto the register's declared
235
- * question ids, in declared order — never the raw flattened map, which also
236
- * carries a walk's field-step values (a visitor's email) and would make almost
237
- * every real session's key unique.
238
- *
239
- * The projection is sound because `recommend()` reads answers only through the
240
- * predicate contexts it builds per offer rule and pairing, and through
241
- * `evaluateWaitlistScoring` — both address answers by question id, and
242
- * `waitlistExperimentConfigSchema` rejects a config whose predicate leaf
243
- * (`waitlist.ts:449-452`) or scoring point (`:759-765`) names an undeclared
244
- * question. `computeOfferCounts` only ever runs on a config that passed that
245
- * parse, so an answer key outside the projection is unreadable by
246
- * `recommend()`, and two sessions sharing a signature share a recommendation.
247
- *
248
- * The key is the JSON encoding of the projected VALUES in declared-id order —
249
- * not a delimiter-joined string. Answer values for a `text` question are raw
250
- * visitor input under no charset constraint, so any separator character a
251
- * joined key used would be forgeable: one visitor typing `q_team=agency` into
252
- * a free-text box could otherwise collide with another session that really
253
- * answered it. JSON quoting also keeps `"a\u001fb"` distinct from
254
- * `["a", "b"]`, which `includesAny` evaluates differently, and an unanswered
255
- * question (`null`) distinct from an empty one.
256
- */
257
- function answerSignature(questionIds: readonly string[], answers: WaitlistAnswers): string {
258
- return JSON.stringify(questionIds.map((id) => answers[id] ?? null));
259
- }
260
-
261
- /** `payload.recommender` as #4837 D2 writes it — `{ choice, fits }` — or undefined. */
262
- function readPayloadRecommender(
263
- payload: unknown,
264
- ): { choice: string; fits: string[] } | undefined {
265
- if (typeof payload !== "object" || payload === null) return undefined;
266
- const value = (payload as Record<string, unknown>)["recommender"];
267
- if (typeof value !== "object" || value === null) return undefined;
268
- const { choice, fits } = value as Record<string, unknown>;
269
- if (typeof choice !== "string" || !Array.isArray(fits)) return undefined;
270
- return { choice, fits: fits.filter((f): f is string => typeof f === "string") };
271
- }
272
-
273
- /**
274
- * The per-offer funnel for one recommending experiment (#4839 FR2–FR4,
275
- * FR6): rows in register `offers[]` order, an all-zero offer still present.
276
- */
277
- function computeOfferCounts(
278
- config: WaitlistExperimentConfig,
279
- sessions: FlowSessionRow[],
280
- leads: LeadRow[],
281
- checkouts: FlowCheckoutRow[],
282
- ): WaitlistExperimentOfferCounts[] | undefined {
283
- if (config.mode !== "recommending" || !config.offers) return undefined;
284
- const counts = new Map<string, WaitlistExperimentOfferCounts>(
285
- config.offers.map((o) => [o.key, { offerKey: o.key, recommended: 0, saved: 0, checkedOut: 0 }]),
286
- );
287
- const slugs = new Set(config.waitlists.map((w) => w.flowSlug));
288
- // #4861 D1 — `recommend()` is pure in `(register, answers)` and this loop
289
- // always calls it with `flags` defaulted, so the walk costs the DISTINCT
290
- // answer sets, not the session rows: at the 20k cap a real register collects
291
- // a handful of them. The memo lives for this one call, so an operator's
292
- // register edit shows up on the next read with nothing to invalidate (D6).
293
- const questionIds = config.questionSet.questions.map((q) => q.id);
294
- const fitsBySignature = new Map<string, { fits: string[] } | { errName: string }>();
295
- // D5 — one warn per experiment per list call, never one per row: a register
296
- // rename that breaks a whole flow used to emit up to MAX_AGGREGATION_ROWS
297
- // warn lines from ONE request (PR #4856 round-2 acceptance review).
298
- let skipped = 0;
299
- let skippedErrName = "UnknownError";
300
- let skippedSiteId = "";
301
- for (const session of sessions) {
302
- if (!slugs.has(session.flowSlug)) continue;
303
- // Both helpers are total over a session row (they walk plain JSON and
304
- // return), so only `recommend()` needs the try below — the same arm HEAD
305
- // guarded.
306
- const answers = flattenSessionAnswers(session.state);
307
- const signature = answerSignature(questionIds, answers);
308
- let entry = fitsBySignature.get(signature);
309
- if (entry === undefined) {
310
- try {
311
- entry = { fits: recommend(config, answers).fits.map((f) => f.offerKey) };
312
- } catch (err) {
313
- // A session whose state cannot be recommended against the CURRENT
314
- // register (a question renamed since it completed) counts nowhere —
315
- // memoized like a fit so the throw costs the distinct answer sets too.
316
- entry = { errName: err instanceof Error ? err.name : "UnknownError" };
317
- }
318
- fitsBySignature.set(signature, entry);
319
- }
320
- if ("errName" in entry) {
321
- skipped += 1;
322
- skippedErrName = entry.errName;
323
- skippedSiteId = session.siteId;
324
- continue;
325
- }
326
- for (const key of entry.fits) {
327
- const row = counts.get(key);
328
- if (row) row.recommended += 1;
329
- }
330
- }
331
- if (skipped > 0) {
332
- // Counts and an error NAME only — never the answers (the logging
333
- // discipline the corrupt-config skip already follows).
334
- logger.warn(
335
- { siteId: skippedSiteId, experimentKey: config.key, skipped, errName: skippedErrName },
336
- "[waitlist-dashboard] skipped completed sessions recommend() could not score",
337
- );
338
- }
339
- for (const lead of leads) {
340
- const rec = readPayloadRecommender(lead.payload);
341
- if (!rec || rec.choice !== "save") continue;
342
- for (const key of rec.fits) {
343
- const row = counts.get(key);
344
- if (row) row.saved += 1;
345
- }
346
- }
347
- for (const checkout of checkouts) {
348
- const row = counts.get(checkout.offerKey);
349
- if (row) row.checkedOut += 1;
350
- }
351
- return [...counts.values()];
352
- }
353
-
354
- function readPayloadString(payload: unknown, key: string): string | undefined {
355
- if (typeof payload !== "object" || payload === null) return undefined;
356
- const value = (payload as Record<string, unknown>)[key];
357
- return typeof value === "string" && value.length > 0 ? value : undefined;
358
- }
359
-
360
- // `readPayloadAnswer` moved to `./answer-distributions.js` (#4771 D4),
361
- // imported above — every call site below is unchanged.
362
-
363
- function toSummaryWaitlists(
364
- config: WaitlistExperimentConfig,
365
- ): WaitlistExperimentSummary["waitlists"] {
366
- return config.waitlists.map((w) => ({
367
- key: w.key,
368
- flowSlug: w.flowSlug,
369
- ...(w.entryPageKey ? { entryPageKey: w.entryPageKey } : {}),
370
- }));
371
- }
372
-
373
- // ─── D4 — the metric row (one waitlist, or the total) ───────────────────────
374
-
375
- function computeMetricRow(
376
- key: WaitlistMetricRow["key"],
377
- meta: { campaign?: string | undefined; channels?: string[] | undefined },
378
- leads: LeadRow[],
379
- scoresByLeadId: Map<string, ScoreRow>,
380
- config: WaitlistExperimentConfig,
381
- ): WaitlistMetricRow {
382
- const scoring = config.scoring;
383
- const serviceableExcludes = new Set(scoring?.serviceableExcludes ?? []);
384
- const raw = leads.length;
385
-
386
- // byTier — grouped over the scoped WaitlistScore read (D4).
387
- const tierCounts = new Map<string, number>();
388
- for (const lead of leads) {
389
- const score = scoresByLeadId.get(lead.id);
390
- if (!score) continue;
391
- tierCounts.set(score.tier, (tierCounts.get(score.tier) ?? 0) + 1);
392
- }
393
- const byTier = [...tierCounts.entries()].map(([tier, count]) => ({ tier, count }));
394
-
395
- // qualified — null when the experiment carries no scoring register (D4).
396
- let qualified: number | null = null;
397
- if (scoring) {
398
- qualified = 0;
399
- for (const [tier, count] of tierCounts) {
400
- if (!serviceableExcludes.has(tier)) qualified += count;
401
- }
402
- }
403
- const qualificationRate = qualified === null || raw === 0 ? null : qualified / raw;
404
-
405
- // bySource — grouped by the tracked UTM param NAME (config.tracking.utm.source);
406
- // an absent/empty value groups under "(direct)" (D4, Clarifications).
407
- const utmParam = config.tracking.utm.source;
408
- const bySourceMap = new Map<string, { raw: number; qualified: number }>();
409
- for (const lead of leads) {
410
- const source = readPayloadString(lead.payload, utmParam) ?? "(direct)";
411
- const bucket = bySourceMap.get(source) ?? { raw: 0, qualified: 0 };
412
- bucket.raw += 1;
413
- const score = scoresByLeadId.get(lead.id);
414
- if (scoring && score && !serviceableExcludes.has(score.tier)) {
415
- bucket.qualified += 1;
416
- }
417
- bySourceMap.set(source, bucket);
418
- }
419
- const bySource = [...bySourceMap.entries()].map(([source, bucket]) => ({
420
- source,
421
- raw: bucket.raw,
422
- qualified: scoring ? bucket.qualified : null,
423
- qualificationRate: scoring && bucket.raw > 0 ? bucket.qualified / bucket.raw : null,
424
- }));
425
-
426
- // distributions — one per declared question (D4); a value outside the
427
- // question's declared options is counted under "(other)" (Clarifications).
428
- // Bucketing itself is the shared `computeAnswerDistributions` (#4771 D4) —
429
- // this call site only supplies the row → flattened-payload mapping.
430
- const distributions = computeAnswerDistributions(
431
- config.questionSet.questions,
432
- leads.map((lead) => lead.payload),
433
- );
434
-
435
- return {
436
- key,
437
- ...(meta.campaign ? { campaign: meta.campaign } : {}),
438
- ...(meta.channels ? { channels: meta.channels } : {}),
439
- raw,
440
- qualified,
441
- qualificationRate,
442
- byTier,
443
- bySource,
444
- distributions,
445
- };
446
- }
447
-
448
- function summaryFromRow(
449
- row: ExperimentRow,
450
- config: WaitlistExperimentConfig,
451
- counts: WaitlistExperimentSummary["counts"],
452
- ): WaitlistExperimentSummary {
453
- return {
454
- id: row.id,
455
- siteId: row.siteId,
456
- key: row.key,
457
- mode: row.mode as WaitlistExperimentSummary["mode"],
458
- status: row.status as WaitlistExperimentSummary["status"],
459
- createdAt: row.createdAt.toISOString(),
460
- updatedAt: row.updatedAt.toISOString(),
461
- waitlists: toSummaryWaitlists(config),
462
- counts,
463
- };
464
- }
465
-
466
- // ─── List + rollup (D4, AC1) ─────────────────────────────────────────────────
467
-
468
- export async function listExperiments(
469
- scope: WaitlistReadScope,
470
- ): Promise<WaitlistDashboardList> {
471
- const experiments = await prisma.waitlistExperiment.findMany({
472
- where: scopeWhere(scope),
473
- orderBy: { createdAt: "desc" },
474
- select: EXPERIMENT_SELECT,
475
- });
476
-
477
- const emptyRollup: WaitlistDashboardList["rollup"] = {
478
- experiments: { total: 0, live: 0, closed: 0, draft: 0 },
479
- raw: 0,
480
- qualified: null,
481
- qualificationRate: null,
482
- sites: 0,
483
- };
484
- if (experiments.length === 0) {
485
- return { scope: scope.kind, experiments: [], rollup: emptyRollup };
486
- }
487
-
488
- const keys = [...new Set(experiments.map((e) => e.key))];
489
- // ONE scoped Lead read + ONE scoped WaitlistScore read across every
490
- // returned experiment (D4) — an OR over experimentKey equality is one
491
- // query, not one per experiment.
492
- const [leads, scores] = await Promise.all([
493
- prisma.lead.findMany({
494
- where: {
495
- ...scopeWhere(scope),
496
- OR: keys.map((key) => ({
497
- payload: { path: ["experimentKey"], equals: key },
498
- })),
499
- },
500
- // Deterministic read order (#5107) — without an explicit orderBy,
501
- // Postgres does not guarantee row order, which rows survive
502
- // MAX_AGGREGATION_ROWS is then arbitrary. Mirrors the flowSession/
503
- // flowCheckout reads below.
504
- orderBy: { createdAt: "asc" },
505
- take: MAX_AGGREGATION_ROWS,
506
- select: LEAD_SELECT,
507
- }),
508
- prisma.waitlistScore.findMany({
509
- where: { ...scopeWhere(scope), experimentKey: { in: keys } },
510
- orderBy: { createdAt: "asc" },
511
- take: MAX_AGGREGATION_ROWS,
512
- select: SCORE_SELECT,
513
- }),
514
- ]);
515
- logAggregationCapHit("listExperiments.lead", leads.length);
516
- logAggregationCapHit("listExperiments.waitlistScore", scores.length);
517
-
518
- // #4839 D1/D3 — ONE FlowSession read + ONE FlowCheckout read across every
519
- // recommending experiment in the list; none when there is none. The
520
- // register is parsed once more here only to learn the slug set — the
521
- // per-row parse below is the authoritative one and skips a corrupt row.
522
- const recommending = experiments
523
- .map((row) => ({ row, config: tryParseConfig(row.config).config }))
524
- .filter((e): e is { row: ExperimentRow; config: WaitlistExperimentConfig } =>
525
- e.config?.mode === "recommending" && e.config.offers !== undefined,
526
- );
527
- let sessions: FlowSessionRow[] = [];
528
- let checkouts: FlowCheckoutRow[] = [];
529
- if (recommending.length > 0) {
530
- const slugs = [...new Set(recommending.flatMap((e) => e.config.waitlists.map((w) => w.flowSlug)))];
531
- const recommendingKeys = [...new Set(recommending.map((e) => e.row.key))];
532
- [sessions, checkouts] = await Promise.all([
533
- prisma.flowSession.findMany({
534
- where: { ...scopeWhere(scope), flowSlug: { in: slugs }, completedAt: { not: null } },
535
- orderBy: { completedAt: "asc" },
536
- take: MAX_AGGREGATION_ROWS,
537
- select: FLOW_SESSION_SELECT,
538
- }),
539
- prisma.flowCheckout.findMany({
540
- where: { ...scopeWhere(scope), experimentKey: { in: recommendingKeys }, status: "completed" },
541
- orderBy: { createdAt: "asc" },
542
- take: MAX_AGGREGATION_ROWS,
543
- select: FLOW_CHECKOUT_SELECT,
544
- }),
545
- ]);
546
- logAggregationCapHit("listExperiments.flowSession", sessions.length);
547
- logAggregationCapHit("listExperiments.flowCheckout", checkouts.length);
548
- }
549
- const sessionsBySite = new Map<string, FlowSessionRow[]>();
550
- for (const session of sessions) {
551
- const bucket = sessionsBySite.get(session.siteId) ?? [];
552
- bucket.push(session);
553
- sessionsBySite.set(session.siteId, bucket);
554
- }
555
- const checkoutsByExp = new Map<string, FlowCheckoutRow[]>();
556
- for (const checkout of checkouts) {
557
- const mapKey = `${checkout.siteId}:${checkout.experimentKey}`;
558
- const bucket = checkoutsByExp.get(mapKey) ?? [];
559
- bucket.push(checkout);
560
- checkoutsByExp.set(mapKey, bucket);
561
- }
562
-
563
- const leadsByExp = new Map<string, LeadRow[]>();
564
- for (const lead of leads) {
565
- const experimentKey = readPayloadString(lead.payload, "experimentKey");
566
- if (!experimentKey) continue;
567
- const mapKey = `${lead.siteId}:${experimentKey}`;
568
- const bucket = leadsByExp.get(mapKey) ?? [];
569
- bucket.push(lead);
570
- leadsByExp.set(mapKey, bucket);
571
- }
572
- const scoresByExp = new Map<string, ScoreRow[]>();
573
- for (const score of scores) {
574
- const mapKey = `${score.siteId}:${score.experimentKey}`;
575
- const bucket = scoresByExp.get(mapKey) ?? [];
576
- bucket.push(score);
577
- scoresByExp.set(mapKey, bucket);
578
- }
579
-
580
- const summaries: WaitlistExperimentSummary[] = [];
581
- const statusCounts = { live: 0, closed: 0, draft: 0 };
582
- const sitesSeen = new Set<string>();
583
- let validCount = 0;
584
- let rollupRaw = 0;
585
- let rollupQualified = 0;
586
- let anyQualifiedKnown = false;
587
-
588
- for (const row of experiments) {
589
- // A corrupt config on ONE experiment must never abort the platform
590
- // operator's WHOLE list under `kind:"all"` (Medium finding, PR #4687
591
- // security review) — skip and log rather than throw, BEFORE this row
592
- // contributes to any rollup count. Only `{id, siteId, key}` are logged,
593
- // never `config` (PII/register boundary, mirrors `waitlist-experiments.ts`'s
594
- // own logging discipline).
595
- const parsedConfig = tryParseConfig(row.config);
596
- if (parsedConfig.config === undefined) {
597
- logger.warn(
598
- { experimentId: row.id, siteId: row.siteId, key: row.key, reason: parsedConfig.message },
599
- "[waitlist-dashboard] skipping experiment — config failed to parse",
600
- );
601
- continue;
602
- }
603
- const config = parsedConfig.config;
604
-
605
- validCount += 1;
606
- sitesSeen.add(row.siteId);
607
- if (row.status === "live") statusCounts.live += 1;
608
- else if (row.status === "closed") statusCounts.closed += 1;
609
- else if (row.status === "draft") statusCounts.draft += 1;
610
-
611
- const mapKey = `${row.siteId}:${row.key}`;
612
- const expLeads = leadsByExp.get(mapKey) ?? [];
613
- const expScores = scoresByExp.get(mapKey) ?? [];
614
- const scoresByLeadId = new Map(expScores.map((s) => [s.leadId, s]));
615
- const metricRow = computeMetricRow("total", {}, expLeads, scoresByLeadId, config);
616
-
617
- rollupRaw += metricRow.raw;
618
- if (metricRow.qualified !== null) {
619
- rollupQualified += metricRow.qualified;
620
- anyQualifiedKnown = true;
621
- }
622
-
623
- const offers = computeOfferCounts(
624
- config,
625
- sessionsBySite.get(row.siteId) ?? [],
626
- expLeads,
627
- checkoutsByExp.get(mapKey) ?? [],
628
- );
629
- summaries.push(
630
- summaryFromRow(row, config, {
631
- raw: metricRow.raw,
632
- qualified: metricRow.qualified,
633
- qualificationRate: metricRow.qualificationRate,
634
- ...(offers !== undefined && { offers }),
635
- }),
636
- );
637
- }
638
-
639
- return {
640
- scope: scope.kind,
641
- experiments: summaries,
642
- rollup: {
643
- experiments: { total: validCount, ...statusCounts },
644
- raw: rollupRaw,
645
- qualified: anyQualifiedKnown ? rollupQualified : null,
646
- qualificationRate:
647
- anyQualifiedKnown && rollupRaw > 0 ? rollupQualified / rollupRaw : null,
648
- sites: sitesSeen.size,
649
- },
650
- };
651
- }
652
-
653
- // ─── Comparison (D4, AC2) ────────────────────────────────────────────────────
654
-
655
- export async function getExperimentComparison(
656
- scope: WaitlistReadScope,
657
- experimentId: string,
658
- ): Promise<TenantResult<WaitlistExperimentComparison>> {
659
- const experiment = await prisma.waitlistExperiment.findFirst({
660
- where: { id: experimentId, ...scopeWhere(scope) },
661
- select: EXPERIMENT_SELECT,
662
- });
663
- if (!experiment) {
664
- return { ok: false, error: { kind: "not_found", message: "Experiment not found" } };
665
- }
666
-
667
- const config = parseConfig(experiment.config);
668
-
669
- const [leads, scores] = await Promise.all([
670
- prisma.lead.findMany({
671
- where: {
672
- siteId: experiment.siteId,
673
- payload: { path: ["experimentKey"], equals: experiment.key },
674
- },
675
- // Deterministic read order (#5107) — see the matching comment on
676
- // listExperiments's identical read above.
677
- orderBy: { createdAt: "asc" },
678
- take: MAX_AGGREGATION_ROWS,
679
- select: LEAD_SELECT,
680
- }),
681
- prisma.waitlistScore.findMany({
682
- where: { siteId: experiment.siteId, experimentKey: experiment.key },
683
- orderBy: { createdAt: "asc" },
684
- take: MAX_AGGREGATION_ROWS,
685
- select: SCORE_SELECT,
686
- }),
687
- ]);
688
- logAggregationCapHit("getExperimentComparison.lead", leads.length);
689
- logAggregationCapHit("getExperimentComparison.waitlistScore", scores.length);
690
- const scoresByLeadId = new Map(scores.map((s) => [s.leadId, s]));
691
-
692
- const waitlistRows = config.waitlists.map((w) => {
693
- const waitlistLeads = leads.filter(
694
- (lead) => readPayloadString(lead.payload, "waitlistKey") === w.key,
695
- );
696
- const trackingMeta = config.tracking.waitlists[w.key];
697
- return computeMetricRow(
698
- w.key,
699
- { campaign: trackingMeta?.campaign, channels: trackingMeta?.channels },
700
- waitlistLeads,
701
- scoresByLeadId,
702
- config,
703
- );
704
- });
705
- const totalRow = computeMetricRow("total", {}, leads, scoresByLeadId, config);
706
-
707
- const questions = config.questionSet.questions.map((q) => ({
708
- id: q.id,
709
- prompt: q.prompt,
710
- answerType: q.answerType,
711
- ...(q.options
712
- ? { options: q.options.map((o) => ({ value: o.value, label: o.label })) }
713
- : {}),
714
- }));
715
-
716
- const summary = summaryFromRow(experiment, config, {
717
- raw: totalRow.raw,
718
- qualified: totalRow.qualified,
719
- qualificationRate: totalRow.qualificationRate,
720
- });
721
-
722
- return {
723
- ok: true,
724
- value: {
725
- experiment: summary,
726
- questions,
727
- serviceableExcludes: config.scoring?.serviceableExcludes ?? [],
728
- waitlists: waitlistRows,
729
- total: totalRow,
730
- },
731
- };
732
- }
733
-
734
- // ─── Signups — paginated list (D7, AC1) ─────────────────────────────────────
735
-
736
- /** `listSignups`'s own error kind — `TenantErrorKind` (`tenant-context.ts`)
737
- * has no 400-mapped member, and this file does not modify that shared type. */
738
- export type ListSignupsErrorKind = "not_found" | "invalid_cursor";
739
- export type ListSignupsResult =
740
- | { ok: true; value: WaitlistSignupList }
741
- | { ok: false; error: { kind: ListSignupsErrorKind; message: string } };
742
-
743
- export async function listSignups(
744
- scope: WaitlistReadScope,
745
- experimentId: string,
746
- params: { waitlist?: string | undefined; cursor?: string | undefined; limit: number },
747
- ): Promise<ListSignupsResult> {
748
- const experiment = await prisma.waitlistExperiment.findFirst({
749
- where: { id: experimentId, ...scopeWhere(scope) },
750
- select: EXPERIMENT_SELECT,
751
- });
752
- if (!experiment) {
753
- return { ok: false, error: { kind: "not_found", message: "Experiment not found" } };
754
- }
755
-
756
- let cursorLead: { createdAt: Date; id: string } | undefined;
757
- if (params.cursor) {
758
- // Scoped to THIS experiment (siteId + experimentKey), not just siteId —
759
- // a cursor belonging to a different experiment on the same site is
760
- // exactly as invalid as one that does not exist (Low finding, PR #4687
761
- // security review: a supplied-but-unresolvable cursor must 400, not
762
- // silently degrade to page 1).
763
- cursorLead =
764
- (await prisma.lead.findFirst({
765
- where: {
766
- id: params.cursor,
767
- siteId: experiment.siteId,
768
- payload: { path: ["experimentKey"], equals: experiment.key },
769
- },
770
- select: { id: true, createdAt: true },
771
- })) ?? undefined;
772
- if (!cursorLead) {
773
- return {
774
- ok: false,
775
- error: { kind: "invalid_cursor", message: "cursor does not resolve within this experiment" },
776
- };
777
- }
778
- }
779
-
780
- const pathFilters: Prisma.LeadWhereInput[] = [
781
- { payload: { path: ["experimentKey"], equals: experiment.key } },
782
- ];
783
- if (params.waitlist) {
784
- pathFilters.push({ payload: { path: ["waitlistKey"], equals: params.waitlist } });
785
- }
786
-
787
- const rows = await prisma.lead.findMany({
788
- where: {
789
- siteId: experiment.siteId,
790
- AND: [
791
- ...pathFilters,
792
- ...(cursorLead
793
- ? [
794
- {
795
- OR: [
796
- { createdAt: { lt: cursorLead.createdAt } },
797
- {
798
- createdAt: cursorLead.createdAt,
799
- id: { lt: cursorLead.id },
800
- },
801
- ],
802
- },
803
- ]
804
- : []),
805
- ],
806
- },
807
- orderBy: [{ createdAt: "desc" }, { id: "desc" }],
808
- take: params.limit + 1,
809
- select: LEAD_SELECT,
810
- });
811
-
812
- const hasMore = rows.length > params.limit;
813
- const page = hasMore ? rows.slice(0, params.limit) : rows;
814
- const leadIds = page.map((r) => r.id);
815
- const scores =
816
- leadIds.length > 0
817
- ? await prisma.waitlistScore.findMany({
818
- // `siteId` is included even though `leadIds` are already scope-derived
819
- // (D3: scope is a parameter of EVERY query — the invariant holds by
820
- // construction here too, matching `getSignup`'s defensive re-check).
821
- where: { leadId: { in: leadIds }, siteId: experiment.siteId },
822
- select: SCORE_SELECT,
823
- })
824
- : [];
825
- const scoreByLeadId = new Map(scores.map((s) => [s.leadId, s]));
826
-
827
- // A lead missing `waitlistKey` is corrupt/anomalous data (same posture as
828
- // the corrupt-config skip above) — skipped rather than emitted with an
829
- // out-of-contract empty string (Low finding, PR #4687 security review).
830
- // `nextCursor`/`hasMore` are computed from `page` BEFORE this filter (the
831
- // true pagination boundary), so a skip here never desyncs pagination.
832
- const items: WaitlistSignupList["items"] = [];
833
- for (const lead of page) {
834
- const waitlistKey = readPayloadString(lead.payload, "waitlistKey");
835
- if (!waitlistKey) {
836
- logger.warn(
837
- { experimentId: experiment.id, siteId: experiment.siteId, leadId: lead.id },
838
- "[waitlist-dashboard] skipping signup — missing waitlistKey",
839
- );
840
- continue;
841
- }
842
- const score = scoreByLeadId.get(lead.id);
843
- items.push({
844
- leadId: lead.id,
845
- waitlistKey,
846
- email: lead.email,
847
- createdAt: lead.createdAt.toISOString(),
848
- source: lead.source,
849
- tier: score?.tier ?? null,
850
- score: score?.score ?? null,
851
- });
852
- }
853
-
854
- return {
855
- ok: true,
856
- value: {
857
- experimentId: experiment.id,
858
- items,
859
- nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null,
860
- },
861
- };
862
- }
863
-
864
- // ─── Signup drill-down (D7, AC3) ─────────────────────────────────────────────
865
-
866
- export async function getSignup(
867
- scope: WaitlistReadScope,
868
- experimentId: string,
869
- leadId: string,
870
- ): Promise<TenantResult<WaitlistSignupDetail>> {
871
- const experiment = await prisma.waitlistExperiment.findFirst({
872
- where: { id: experimentId, ...scopeWhere(scope) },
873
- select: EXPERIMENT_SELECT,
874
- });
875
- if (!experiment) {
876
- return { ok: false, error: { kind: "not_found", message: "Experiment not found" } };
877
- }
878
-
879
- // Loaded THROUGH its experiment (D3) — a foreign or unrelated lead is
880
- // not_found, indistinguishable from a missing one.
881
- const lead = await prisma.lead.findFirst({
882
- where: {
883
- id: leadId,
884
- siteId: experiment.siteId,
885
- payload: { path: ["experimentKey"], equals: experiment.key },
886
- },
887
- select: LEAD_SELECT,
888
- });
889
- if (!lead) {
890
- return { ok: false, error: { kind: "not_found", message: "Signup not found" } };
891
- }
892
-
893
- const config = parseConfig(experiment.config);
894
- // `waitlistKey` is server-written LAST alongside `experimentKey`
895
- // (flow-engine.ts D4) — a Lead whose payload carries `experimentKey` but
896
- // no `waitlistKey` is corrupt/anomalous data, not a legitimate state.
897
- // Treated as not_found rather than emitting an out-of-contract empty
898
- // string (`waitlistKey` is a non-empty `flowIdentifierSchema` on the wire,
899
- // Low finding, PR #4687 security review) — the same posture the corrupt
900
- // WaitlistExperiment.config skip already applies one level up.
901
- const waitlistKey = readPayloadString(lead.payload, "waitlistKey");
902
- if (!waitlistKey) {
903
- logger.warn(
904
- { experimentId: experiment.id, siteId: experiment.siteId, leadId: lead.id },
905
- "[waitlist-dashboard] signup missing waitlistKey — treating as not_found",
906
- );
907
- return { ok: false, error: { kind: "not_found", message: "Signup not found" } };
908
- }
909
-
910
- const answers = config.questionSet.questions.map((q) => ({
911
- questionId: q.id,
912
- prompt: q.prompt,
913
- value: readPayloadAnswer(lead.payload, q.id) ?? null,
914
- }));
915
-
916
- // `leadId` is `@@unique`, so `findFirst` with the scope predicates ANDed in
917
- // still resolves at most one row — the scope is now a parameter of this
918
- // query (D3), not a post-filter (Low finding, PR #4687 security review).
919
- const scoreRow = await prisma.waitlistScore.findFirst({
920
- where: { leadId: lead.id, siteId: experiment.siteId, experimentKey: experiment.key },
921
- select: SCORE_SELECT,
922
- });
923
- const score = scoreRow
924
- ? {
925
- tier: scoreRow.tier,
926
- score: scoreRow.score,
927
- categoryScores: scoreRow.categoryScores as Record<string, number>,
928
- ruleTrace: scoreRow.ruleTrace,
929
- updatedAt: scoreRow.updatedAt.toISOString(),
930
- }
931
- : null;
932
-
933
- return {
934
- ok: true,
935
- value: {
936
- leadId: lead.id,
937
- experimentId: experiment.id,
938
- experimentKey: experiment.key,
939
- waitlistKey,
940
- email: lead.email,
941
- createdAt: lead.createdAt.toISOString(),
942
- source: lead.source,
943
- answers,
944
- score,
945
- },
946
- };
947
- }