create-ailk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (620) hide show
  1. package/README.md +124 -3
  2. package/component-catalog.md +456 -0
  3. package/dist/cli.js +92 -16
  4. package/dist/component-catalog.d.ts +11 -0
  5. package/dist/component-catalog.js +39 -0
  6. package/dist/copy.d.ts +7 -0
  7. package/dist/copy.js +24 -0
  8. package/dist/derive-architecture.d.ts +13 -0
  9. package/dist/derive-architecture.js +238 -0
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.js +19 -2
  12. package/dist/lib/extract-module-bundle.d.ts +16 -0
  13. package/dist/lib/extract-module-bundle.js +184 -0
  14. package/dist/lib/fetch-module.d.ts +55 -0
  15. package/dist/lib/fetch-module.js +125 -0
  16. package/dist/lib/module-license-gate.d.ts +48 -0
  17. package/dist/lib/module-license-gate.js +67 -0
  18. package/dist/lib/module-tier.d.ts +35 -0
  19. package/dist/lib/module-tier.js +37 -0
  20. package/dist/module-architecture.d.ts +175 -0
  21. package/dist/module-architecture.js +663 -0
  22. package/dist/parse-args.d.ts +47 -1
  23. package/dist/parse-args.js +143 -2
  24. package/dist/programmatic.d.ts +160 -3
  25. package/dist/programmatic.js +196 -7
  26. package/dist/surfaces.d.ts +211 -0
  27. package/dist/surfaces.js +532 -0
  28. package/dist/sync-routes.d.ts +92 -0
  29. package/dist/sync-routes.js +350 -0
  30. package/package.json +12 -5
  31. package/templates/.claude/rules/architecture.md +6 -2
  32. package/templates/.claude/skills/README.md +9 -9
  33. package/templates/.claude/skills/add-schema/SKILL.md +14 -7
  34. package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
  35. package/templates/.claude/skills/provision-config/SKILL.md +1 -1
  36. package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
  37. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
  38. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
  39. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
  40. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
  41. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
  42. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
  43. package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
  44. package/templates/.env.example +126 -2
  45. package/templates/CLAUDE.md +11 -5
  46. package/templates/README.md +1 -1
  47. package/templates/apps/api/.env.example +38 -0
  48. package/templates/apps/api/CLAUDE.md +78 -1
  49. package/templates/apps/api/LICENSE +201 -0
  50. package/templates/apps/api/api/index.ts +6 -141
  51. package/templates/apps/api/jest.config.cjs +16 -0
  52. package/templates/apps/api/package.json +22 -6
  53. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
  54. package/templates/apps/api/src/__tests__/server.test.ts +46 -0
  55. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
  56. package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
  57. package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
  58. package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
  59. package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
  60. package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
  61. package/templates/apps/api/src/bin/seed-presets.ts +58 -0
  62. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
  63. package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
  64. package/templates/apps/api/src/cli.ts +5 -11
  65. package/templates/apps/api/src/config/index.ts +35 -0
  66. package/templates/apps/api/src/config/modules.ts +43 -0
  67. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
  68. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
  69. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
  70. package/templates/apps/api/src/lib/site.ts +16 -0
  71. package/templates/apps/api/src/lib/stripe.ts +22 -0
  72. package/templates/apps/api/src/lib/tenant-db.ts +225 -0
  73. package/templates/apps/api/src/lib/ws-token.ts +91 -0
  74. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
  75. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
  77. package/templates/apps/api/src/middleware/auth.ts +43 -8
  78. package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
  79. package/templates/apps/api/src/middleware/tenant.ts +99 -0
  80. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
  81. package/templates/apps/api/src/openapi/spec.ts +312 -3
  82. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
  83. package/templates/apps/api/src/routes/aeo/index.ts +107 -0
  84. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
  85. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
  86. package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
  87. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
  88. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
  89. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
  90. package/templates/apps/api/src/routes/billing/index.ts +36 -0
  91. package/templates/apps/api/src/routes/billing/portal.ts +182 -0
  92. package/templates/apps/api/src/routes/billing/read.ts +75 -0
  93. package/templates/apps/api/src/routes/billing/usage.ts +153 -0
  94. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
  95. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
  96. package/templates/apps/api/src/routes/content/create.ts +1 -0
  97. package/templates/apps/api/src/routes/content/delete.ts +1 -0
  98. package/templates/apps/api/src/routes/content/index.ts +42 -6
  99. package/templates/apps/api/src/routes/content/update.ts +1 -0
  100. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
  101. package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
  102. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
  103. package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
  104. package/templates/apps/api/src/routes/flows/README.md +147 -0
  105. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
  106. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
  107. package/templates/apps/api/src/routes/flows/index.ts +202 -0
  108. package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
  109. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
  110. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
  111. package/templates/apps/api/src/routes/leads/index.ts +43 -9
  112. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
  113. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
  114. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
  115. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  116. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
  120. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  121. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
  122. package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
  123. package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
  124. package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
  125. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  126. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
  127. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
  128. package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
  129. package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
  130. package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
  131. package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
  132. package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
  133. package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
  134. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
  135. package/templates/apps/api/src/routes/schedule/index.ts +249 -0
  136. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
  137. package/templates/apps/api/src/routes/slack/actions.ts +177 -0
  138. package/templates/apps/api/src/routes/slack/index.ts +38 -0
  139. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
  140. package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
  141. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
  142. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
  143. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
  144. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
  145. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
  146. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
  147. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
  148. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
  149. package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
  150. package/templates/apps/api/src/routes/webhooks/README.md +35 -12
  151. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
  152. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
  153. package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
  154. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
  155. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
  156. package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
  157. package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
  158. package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
  159. package/templates/apps/api/src/server.ts +307 -7
  160. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
  161. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
  162. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
  163. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  164. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  165. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
  166. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
  167. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
  168. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
  169. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
  170. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
  171. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
  172. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
  173. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  174. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
  175. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
  176. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
  177. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
  178. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
  179. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
  180. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
  181. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
  182. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
  183. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
  184. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
  185. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
  186. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
  187. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
  188. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
  189. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
  190. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
  191. package/templates/apps/api/src/services/abandon-email.ts +307 -0
  192. package/templates/apps/api/src/services/aeo-score.ts +288 -0
  193. package/templates/apps/api/src/services/answer-distributions.ts +138 -0
  194. package/templates/apps/api/src/services/consent.ts +236 -0
  195. package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
  196. package/templates/apps/api/src/services/entitlements.ts +102 -0
  197. package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
  198. package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
  199. package/templates/apps/api/src/services/flow-engine.ts +1296 -0
  200. package/templates/apps/api/src/services/lead-promotion.ts +176 -0
  201. package/templates/apps/api/src/services/lead-routing.ts +3 -3
  202. package/templates/apps/api/src/services/listing-config.ts +102 -0
  203. package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
  204. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  205. package/templates/apps/api/src/services/playbook-compile.ts +398 -0
  206. package/templates/apps/api/src/services/playbook-render.ts +263 -0
  207. package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
  208. package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
  209. package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
  210. package/templates/apps/api/src/services/project-listings.ts +1472 -0
  211. package/templates/apps/api/src/services/recommender-capture.ts +835 -0
  212. package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
  213. package/templates/apps/api/src/services/scheduling/index.ts +63 -0
  214. package/templates/apps/api/src/services/scheduling/types.ts +88 -0
  215. package/templates/apps/api/src/services/slack-notify.ts +240 -0
  216. package/templates/apps/api/src/services/tenant-context.ts +451 -0
  217. package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
  218. package/templates/apps/api/src/services/usage-credits.ts +340 -0
  219. package/templates/apps/api/src/services/usage-metering.ts +699 -0
  220. package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
  221. package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
  222. package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
  223. package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
  224. package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
  225. package/templates/apps/api/src/vercel-handler.ts +165 -0
  226. package/templates/apps/api/tsconfig.json +0 -12
  227. package/templates/apps/mcp/CLAUDE.md +19 -9
  228. package/templates/apps/mcp/LICENSE +201 -0
  229. package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
  230. package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
  231. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
  232. package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
  233. package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
  234. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
  235. package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
  236. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
  237. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
  238. package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
  239. package/templates/apps/mcp/package.json +6 -4
  240. package/templates/apps/mcp/src/config/modules.ts +37 -0
  241. package/templates/apps/mcp/src/server.ts +109 -18
  242. package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
  243. package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
  244. package/templates/apps/mcp/src/tools/create_page.ts +1 -0
  245. package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
  246. package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
  247. package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
  248. package/templates/apps/mcp/src/tools/index.ts +25 -0
  249. package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
  250. package/templates/apps/mcp/src/tools/purchase.ts +3 -0
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
  252. package/templates/apps/mcp/src/tools/update_page.ts +1 -0
  253. package/templates/apps/mcp/tsconfig.json +1 -9
  254. package/templates/apps/web/.env.example +44 -0
  255. package/templates/apps/web/CLAUDE.md +28 -0
  256. package/templates/apps/web/LICENSE +201 -0
  257. package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
  258. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
  259. package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
  260. package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
  261. package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
  262. package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
  263. package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
  264. package/templates/apps/web/__tests__/setup.ts +22 -32
  265. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
  266. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
  267. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
  268. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
  269. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
  270. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
  271. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
  272. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
  273. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
  274. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
  275. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
  276. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
  277. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
  278. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
  279. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
  280. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
  281. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
  282. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
  283. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
  284. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
  285. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
  286. package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
  287. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
  288. package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
  289. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
  290. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
  291. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  292. package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
  293. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
  294. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  295. package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
  296. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
  297. package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
  298. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
  299. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
  300. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
  301. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
  302. package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
  303. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
  304. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
  305. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
  306. package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
  307. package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
  308. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
  309. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  310. package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
  311. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
  312. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
  313. package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
  314. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
  315. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
  316. package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
  317. package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
  318. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
  319. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
  320. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
  321. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
  322. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
  323. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
  324. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
  325. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
  326. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
  327. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
  328. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
  329. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
  330. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
  331. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
  332. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
  333. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
  334. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  335. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
  336. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  337. package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
  338. package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
  339. package/templates/apps/web/app/[locale]/layout.tsx +127 -16
  340. package/templates/apps/web/app/[locale]/page.tsx +10 -1
  341. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
  342. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  343. package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
  344. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
  345. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  346. package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
  347. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
  348. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
  349. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
  350. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
  351. package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
  352. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
  353. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
  354. package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
  355. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
  356. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
  357. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
  358. package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
  359. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
  360. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  361. package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
  362. package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
  363. package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
  364. package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
  365. package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
  366. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
  367. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
  368. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  369. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
  370. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  371. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
  372. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  373. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
  374. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
  375. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
  376. package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
  377. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
  378. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  379. package/templates/apps/web/app/globals.css +35 -0
  380. package/templates/apps/web/app/llms.txt/route.ts +2 -1
  381. package/templates/apps/web/app/og/route.tsx +10 -2
  382. package/templates/apps/web/auth.ts +8 -0
  383. package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
  384. package/templates/apps/web/content/en/blog/README.txt +25 -0
  385. package/templates/apps/web/content/en/docs/index.mdx +28 -0
  386. package/templates/apps/web/jest.config.cjs +90 -27
  387. package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
  388. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  389. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
  390. package/templates/apps/web/lib/account-config.ts +13 -0
  391. package/templates/apps/web/lib/server-api.ts +85 -0
  392. package/templates/apps/web/lib/site-brand.tsx +121 -0
  393. package/templates/apps/web/lib/site-theme.ts +74 -0
  394. package/templates/apps/web/messages/en.json +275 -7
  395. package/templates/apps/web/middleware.ts +11 -3
  396. package/templates/apps/web/next.config.mjs +8 -2
  397. package/templates/apps/web/package.json +17 -7
  398. package/templates/apps/web/postcss.config.mjs +23 -0
  399. package/templates/apps/web/public/.well-known/security.txt +3 -3
  400. package/templates/apps/web/src/lib/routes.ts +15 -2
  401. package/templates/apps/web/tsconfig.json +1 -16
  402. package/templates/content/_site.mdx +36 -25
  403. package/templates/content/en/AboutPage/about.mdx +24 -14
  404. package/templates/content/en/ContactPage/contact.mdx +23 -14
  405. package/templates/content/en/FAQPage/faq.mdx +10 -392
  406. package/templates/content/en/HomePage/home.mdx +14 -285
  407. package/templates/database/CHANGELOG.md +443 -0
  408. package/templates/database/LICENSE +201 -0
  409. package/templates/database/README.md +3 -1
  410. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
  411. package/templates/database/__tests__/flows-presets.test.ts +173 -0
  412. package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
  413. package/templates/database/__tests__/seed.test.ts +88 -0
  414. package/templates/database/__tests__/user-cascade.test.ts +19 -4
  415. package/templates/database/auth/schema.prisma +521 -0
  416. package/templates/database/config/schema.prisma +234 -0
  417. package/templates/database/content/schema.prisma +263 -0
  418. package/templates/database/flows/listing-diagnostic.ts +182 -0
  419. package/templates/database/flows/presets.ts +315 -0
  420. package/templates/database/inbox/__tests__/schema.test.ts +4 -0
  421. package/templates/database/inbox/schema.prisma +574 -3
  422. package/templates/database/jest.config.cjs +8 -0
  423. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
  424. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
  425. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
  426. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
  427. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
  428. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
  429. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
  430. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
  431. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
  432. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
  433. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
  434. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
  435. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
  436. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
  437. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
  438. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
  439. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
  440. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
  441. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
  442. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
  443. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
  444. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
  445. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
  446. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
  447. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
  448. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
  449. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
  450. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
  451. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
  452. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
  453. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
  454. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
  455. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  456. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  457. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  458. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  459. package/templates/database/ops/schema.prisma +27 -0
  460. package/templates/database/package.json +42 -4
  461. package/templates/database/prisma.config.ts +1 -1
  462. package/templates/database/schema.prisma +19 -435
  463. package/templates/database/seed-test.ts +14 -4
  464. package/templates/database/seed.ts +148 -0
  465. package/templates/database/tenancy.ts +45 -0
  466. package/templates/database/tsconfig.flows.json +15 -0
  467. package/templates/database/tsconfig.json +7 -1
  468. package/templates/database/tsconfig.tenancy.json +14 -0
  469. package/templates/package.json +30 -55
  470. package/templates/pnpm-workspace.yaml +0 -3
  471. package/templates/project.yaml +1 -1
  472. package/templates/tsconfig.json +1 -37
  473. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
  474. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
  475. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
  476. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
  477. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
  478. package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
  479. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
  480. package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
  481. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
  482. package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
  483. package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
  484. package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
  485. package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
  486. package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
  487. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
  488. package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
  489. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
  490. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
  491. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
  492. package/templates/apps/web/__tests__/page.test.tsx +0 -230
  493. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
  494. package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
  495. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
  496. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
  497. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
  498. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
  499. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
  500. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
  501. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
  502. package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
  503. package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
  504. package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
  505. package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
  506. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
  507. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
  508. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
  509. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
  510. package/templates/apps/web/content/en/blog/index.mdx +0 -15
  511. package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
  512. package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
  513. package/templates/content/.gitkeep +0 -0
  514. package/templates/content/CLAUDE.md +0 -85
  515. package/templates/content/ar/HomePage/home.mdx +0 -123
  516. package/templates/content/ar/_site.mdx +0 -35
  517. package/templates/content/de/_site.mdx +0 -29
  518. package/templates/content/en/.gitkeep +0 -0
  519. package/templates/content/en/AboutPage/about.schema.json +0 -82
  520. package/templates/content/en/BookCall/book-call.mdx +0 -19
  521. package/templates/content/en/BookCall/book-call.schema.json +0 -121
  522. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
  523. package/templates/content/en/CareersList/careers.mdx +0 -24
  524. package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
  525. package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
  526. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
  527. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
  528. package/templates/content/en/ClientsList/clients.mdx +0 -38
  529. package/templates/content/en/ContactPage/contact.schema.json +0 -100
  530. package/templates/content/en/ContactPoint/primary.mdx +0 -14
  531. package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
  532. package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
  533. package/templates/content/en/CoursesList/courses.mdx +0 -22
  534. package/templates/content/en/DownloadsList/downloads.mdx +0 -22
  535. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
  536. package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
  537. package/templates/content/en/EventsList/events.mdx +0 -26
  538. package/templates/content/en/FAQPage/faq.schema.json +0 -231
  539. package/templates/content/en/GlossaryList/glossary.mdx +0 -19
  540. package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
  541. package/templates/content/en/HomePage/home.schema.json +0 -88
  542. package/templates/content/en/HomePage/launch-service.mdx +0 -475
  543. package/templates/content/en/HomePage/launch-service.schema.json +0 -138
  544. package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
  545. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
  546. package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
  547. package/templates/content/en/Integration/github-actions.mdx +0 -31
  548. package/templates/content/en/Integration/openai-api.mdx +0 -26
  549. package/templates/content/en/Integration/vercel.mdx +0 -32
  550. package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
  551. package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
  552. package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
  553. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
  554. package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
  555. package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
  556. package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
  557. package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
  558. package/templates/content/en/LandingPage/developers.mdx +0 -19
  559. package/templates/content/en/LandingPage/developers.schema.json +0 -109
  560. package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
  561. package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
  562. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
  563. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
  564. package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
  565. package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
  566. package/templates/content/en/LandingPage/platform.mdx +0 -369
  567. package/templates/content/en/LandingPage/platform.schema.json +0 -166
  568. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
  569. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
  570. package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
  571. package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
  572. package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
  573. package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
  574. package/templates/content/en/Organization/site-owner.mdx +0 -16
  575. package/templates/content/en/Partner/prisma.mdx +0 -31
  576. package/templates/content/en/Partner/supabase.mdx +0 -30
  577. package/templates/content/en/Partner/vercel.mdx +0 -28
  578. package/templates/content/en/PartnersList/partners.mdx +0 -22
  579. package/templates/content/en/Person/founder.mdx +0 -18
  580. package/templates/content/en/Pricing/pricing.mdx +0 -538
  581. package/templates/content/en/Pricing/pricing.schema.json +0 -209
  582. package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
  583. package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
  584. package/templates/content/en/Product/ailk.mdx +0 -19
  585. package/templates/content/en/Product/ailk.schema.json +0 -94
  586. package/templates/content/en/Product/api-launch-kit.mdx +0 -26
  587. package/templates/content/en/Product/launch-kit.mdx +0 -28
  588. package/templates/content/en/ProductsList/products.mdx +0 -26
  589. package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
  590. package/templates/content/en/ProjectsList/projects.mdx +0 -25
  591. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
  592. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
  593. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
  594. package/templates/content/en/ResourcesHub/resources.mdx +0 -22
  595. package/templates/content/en/Service/consulting.mdx +0 -22
  596. package/templates/content/en/Service/implementation.mdx +0 -30
  597. package/templates/content/en/Service/launch-service.mdx +0 -232
  598. package/templates/content/en/Service/launch.mdx +0 -228
  599. package/templates/content/en/ServicesList/services.mdx +0 -84
  600. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
  601. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
  602. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
  603. package/templates/content/en/TeamList/team.mdx +0 -63
  604. package/templates/content/en/TeamMember/founder.mdx +0 -72
  605. package/templates/content/en/TermsPage/terms.mdx +0 -19
  606. package/templates/content/en/TermsPage/terms.schema.json +0 -99
  607. package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
  608. package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
  609. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
  610. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
  611. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
  612. package/templates/content/en-XA/AboutPage/about.mdx +0 -8
  613. package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
  614. package/templates/content/en-XA/HomePage/home.mdx +0 -8
  615. package/templates/content/es/_site.mdx +0 -30
  616. package/templates/content/fr/_site.mdx +0 -30
  617. package/templates/content/ja/_site.mdx +0 -30
  618. package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
  619. package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
  620. package/templates/content/pt-BR/HomePage/home.mdx +0 -23
@@ -0,0 +1,947 @@
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
+ }