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,983 @@
1
+ /**
2
+ * @file recommender-capture.test.ts
3
+ * @description Unit tests for the recommender email-moment service (#4837).
4
+ *
5
+ * Mocks (external boundaries only — the repo's test rule; no internal module
6
+ * is mocked):
7
+ * - ../../lib/prisma (auto-mock via __mocks__/prisma.ts) — the DB
8
+ * - @working-theory/email (sendLeadNotification + sendDeliverableEmail) —
9
+ * reached transitively through the REAL routeLead / fulfillDeliverable,
10
+ * exactly as flow-engine.test.ts drives them
11
+ * - @working-theory/license (checkLicense) — the flow-engine.test.ts idiom
12
+ * - @working-theory/observability logger — spied for the AC-6 PII walk
13
+ *
14
+ * So AC-1 asserts the Lead on `prisma.lead.create` (routeLead's own write),
15
+ * AC-3 asserts the grant on `prisma.deliverableGrant.create`
16
+ * (fulfillDeliverable's own mint), and AC-6 drives routeLead to `ok: false`
17
+ * the way production can — the DB write rejects AND the file fallback
18
+ * cannot write (`AILK_INBOX_FALLBACK_DIR` pointed at an unwritable path, the
19
+ * lead-routing.test.ts idiom).
20
+ *
21
+ * The fixtures are a schema-valid recommending register + the compiled
22
+ * FlowConfig `serveFlow` parses; `recommend()` / `price()` are the REAL pure
23
+ * functions, so AC-1's `fits` and AC-4's `quote` are derived from the same
24
+ * engine the browser runs (spec D3/D6).
25
+ */
26
+
27
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
28
+ jest.mock("../../lib/prisma.js");
29
+
30
+ const mockSendDeliverableEmail = jest.fn();
31
+ jest.mock("@working-theory/email", () => ({
32
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
33
+ sendDeliverableEmail: (...args: unknown[]) => mockSendDeliverableEmail(...args),
34
+ }));
35
+
36
+ jest.mock("@working-theory/license", () => ({
37
+ checkLicense: jest.fn().mockResolvedValue({
38
+ tier: "free",
39
+ plan: null,
40
+ features: [],
41
+ expiresAt: null,
42
+ revoked: false,
43
+ }),
44
+ }));
45
+
46
+ const mockLoggerInfo = jest.fn();
47
+ const mockLoggerWarn = jest.fn();
48
+ const mockLoggerError = jest.fn();
49
+ jest.mock("@working-theory/observability", () => ({
50
+ logger: {
51
+ info: (...args: unknown[]) => mockLoggerInfo(...args),
52
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
53
+ error: (...args: unknown[]) => mockLoggerError(...args),
54
+ debug: jest.fn(),
55
+ },
56
+ }));
57
+
58
+ import { price, recommend } from "@working-theory/validation";
59
+
60
+ import { prisma } from "../../lib/prisma.js";
61
+ import { MAX_AGGREGATION_ROWS } from "../answer-distributions.js";
62
+ import {
63
+ attachStripeSession,
64
+ captureRecommenderChoice,
65
+ expireFlowCheckout,
66
+ findRegisterForSlug,
67
+ FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS,
68
+ recordCheckout,
69
+ } from "../recommender-capture.js";
70
+
71
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
72
+
73
+ const SITE = "site_1";
74
+ const SLUG = "ailk-reco";
75
+ const EMAIL = "buyer@example.com";
76
+ const en = (s: string) => ({ en: s });
77
+
78
+ const SOURCE_META = {
79
+ ip: "203.0.113.9",
80
+ userAgent: "jest",
81
+ receivedAt: "2026-09-05T20:00:00.000Z",
82
+ };
83
+
84
+ const OFFERS = [
85
+ {
86
+ key: "free_oss",
87
+ copy: { name: en("Free (OSS)") },
88
+ price: { kind: "label" as const, label: en("Free — self-hosted") },
89
+ rules: [{ when: { answer: "q_team", eq: "solo" }, reason: en("Solo builders get it free.") }],
90
+ },
91
+ {
92
+ key: "pro",
93
+ copy: { name: en("Pro") },
94
+ price: { kind: "exact" as const, amount: 249, period: "annual" as const },
95
+ rules: [
96
+ { when: { answer: "q_team", eq: "small_team" }, reason: en("Teams need seats.") },
97
+ ],
98
+ },
99
+ ];
100
+
101
+ function rateTable(currency?: string) {
102
+ return {
103
+ seatRates: [{ offer: "pro", amount: 249, period: "annual" as const }],
104
+ creditLadder: {
105
+ denomination: "monthly" as const,
106
+ tiers: [{ credits: 10, amount: 99 }],
107
+ custom: en("Contact us for larger packages"),
108
+ },
109
+ ...(currency !== undefined ? { currency } : {}),
110
+ };
111
+ }
112
+
113
+ const QUESTION_OPTIONS = [
114
+ { value: "solo", label: en("Solo") },
115
+ { value: "small_team", label: en("Small team") },
116
+ ];
117
+
118
+ function register(overrides: Record<string, unknown> = {}) {
119
+ return {
120
+ key: "reco-exp",
121
+ mode: "recommending" as const,
122
+ status: "live" as const,
123
+ waitlists: [{ key: "main", flowSlug: SLUG }],
124
+ questionSet: {
125
+ version: 1 as const,
126
+ questions: [
127
+ {
128
+ id: "q_team",
129
+ prompt: en("Team?"),
130
+ answerType: "single" as const,
131
+ options: QUESTION_OPTIONS,
132
+ },
133
+ ],
134
+ },
135
+ tracking: {
136
+ version: 1 as const,
137
+ utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
138
+ waitlists: { main: { campaign: "reco-campaign", channels: ["organic"] } },
139
+ },
140
+ stripeConfigRef: "stripe-main",
141
+ offers: OFFERS,
142
+ rateTable: rateTable("usd"),
143
+ ...overrides,
144
+ };
145
+ }
146
+
147
+ function flowConfig(overrides: Record<string, unknown> = {}) {
148
+ return {
149
+ slug: SLUG,
150
+ defaultLocale: "en",
151
+ mode: "recommending" as const,
152
+ steps: [
153
+ {
154
+ key: "team",
155
+ type: "segmentation" as const,
156
+ copy: { title: en("Tell us about your team") },
157
+ questions: [{ name: "q_team", options: QUESTION_OPTIONS }],
158
+ },
159
+ ],
160
+ offers: OFFERS,
161
+ rateTable: rateTable("usd"),
162
+ recommendation: { key: "recommendation", copy: { title: en("Your recommendation") } },
163
+ experiment: { key: "reco-exp", waitlist: "main" },
164
+ ...overrides,
165
+ };
166
+ }
167
+
168
+ const DELIVERABLES = {
169
+ assets: [{ assetKey: "plan-pdf" }],
170
+ copy: { subject: en("Your plan"), body: en("Here it is") },
171
+ };
172
+
173
+ const ANSWERS = { q_team: "small_team" };
174
+
175
+ function arm(opts: { config?: Record<string, unknown>; register?: Record<string, unknown> } = {}) {
176
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
177
+ id: "flow_1",
178
+ siteId: SITE,
179
+ slug: SLUG,
180
+ config: flowConfig(opts.config),
181
+ });
182
+ (prisma.waitlistExperiment.findMany as jest.Mock).mockResolvedValue([
183
+ { id: "wlexp_1", config: register(opts.register) },
184
+ ]);
185
+ (prisma.flowSession.create as jest.Mock).mockResolvedValue({ id: "flsess_new" });
186
+ // Resume lookups see no walk session; recordCheckout's owner check sees the
187
+ // freshly-created session with its lead stamped.
188
+ (prisma.flowSession.findUnique as jest.Mock).mockImplementation(async (args: { where: { id: string } }) =>
189
+ args.where.id === "flsess_new" ? { siteId: SITE, flowSlug: SLUG, leadId: "lead_1" } : null,
190
+ );
191
+ (prisma.flowSession.update as jest.Mock).mockResolvedValue({});
192
+ (prisma.flowCheckout.create as jest.Mock).mockResolvedValue({ id: "flchk_1" });
193
+ // #4912 — the keyed idempotency lookup sees no prior attempt by default.
194
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(null);
195
+ (prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
196
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_1" });
197
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue({
198
+ id: "dasset_1",
199
+ siteId: SITE,
200
+ key: "plan-pdf",
201
+ });
202
+ (prisma.deliverableGrant.create as jest.Mock).mockResolvedValue({ id: "dgrant_1" });
203
+ (prisma.deliverableGrant.update as jest.Mock).mockResolvedValue({});
204
+ mockSendDeliverableEmail.mockResolvedValue({ ok: true, messageId: "msg-deliverable" });
205
+ }
206
+
207
+ function saveInput(extra: Record<string, unknown> = {}) {
208
+ return {
209
+ siteId: SITE,
210
+ flowSlug: SLUG,
211
+ answers: ANSWERS,
212
+ email: EMAIL,
213
+ locale: "en",
214
+ choice: "save" as const,
215
+ sourceMeta: SOURCE_META,
216
+ ...extra,
217
+ };
218
+ }
219
+
220
+ beforeEach(() => {
221
+ jest.clearAllMocks();
222
+ process.env.DATABASE_URL = "postgres://test";
223
+ process.env.AILK_DELIVERABLE_EMAIL_FROM = "deliverable@example.com";
224
+ process.env.AILK_INBOX_FALLBACK_DIR = "/dev/null/ailk-fallback-unwritable";
225
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
226
+ });
227
+
228
+ // ─── AC-1 — cold-load save: one session + one lead with the full payload ────
229
+
230
+ describe("AC-1 — cold-load save creates one session + one lead with the full payload", () => {
231
+ it("creates exactly one completed session keyed by segmentation step, and one lead via routeLead", async () => {
232
+ arm();
233
+ const result = await captureRecommenderChoice(saveInput());
234
+ expect(result.ok).toBe(true);
235
+ if (!result.ok) return;
236
+
237
+ expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
238
+ const createArg = (prisma.flowSession.create as jest.Mock).mock.calls[0][0];
239
+ expect(createArg.data.siteId).toBe(SITE);
240
+ expect(createArg.data.flowSlug).toBe(SLUG);
241
+ expect(createArg.data.email).toBe(EMAIL);
242
+ expect(createArg.data.completedAt).toBeInstanceOf(Date);
243
+ expect(createArg.data.state).toEqual({ team: { q_team: "small_team" } });
244
+
245
+ // The REAL routeLead ran — its one DB write is the Lead assertion surface.
246
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
247
+ const leadData = (prisma.lead.create as jest.Mock).mock.calls[0][0].data;
248
+ expect(leadData.siteId).toBe(SITE);
249
+ expect(leadData.source).toBe(`flow:${SLUG}`);
250
+ expect(leadData.email).toBe(EMAIL);
251
+ const expectedFits = recommend(register() as never, ANSWERS).fits.map((f) => f.offerKey);
252
+ expect(expectedFits).toEqual(["pro"]);
253
+ expect(leadData.payload).toMatchObject({
254
+ q_team: "small_team",
255
+ flowSlug: SLUG,
256
+ locale: "en",
257
+ experimentKey: "reco-exp",
258
+ waitlistKey: "main",
259
+ recommender: { choice: "save", fits: expectedFits },
260
+ });
261
+ expect(result.sessionId).toBe("flsess_new");
262
+ expect(result.leadId).toBe("lead_1");
263
+ // leadId is stamped on the session after routeLead succeeds.
264
+ expect(prisma.flowSession.update).toHaveBeenCalledWith(
265
+ expect.objectContaining({
266
+ where: { id: "flsess_new" },
267
+ data: expect.objectContaining({ leadId: "lead_1" }),
268
+ }),
269
+ );
270
+ });
271
+
272
+ it("findRegisterForSlug resolves the register via one siteId-scoped findMany, undefined on no match", async () => {
273
+ arm();
274
+ const found = await findRegisterForSlug(SITE, SLUG);
275
+ expect(found?.stripeConfigRef).toBe("stripe-main");
276
+ expect(found?.rateTable?.currency).toBe("usd");
277
+ expect(prisma.waitlistExperiment.findMany).toHaveBeenCalledTimes(1);
278
+ expect((prisma.waitlistExperiment.findMany as jest.Mock).mock.calls[0][0].where).toEqual({
279
+ siteId: SITE,
280
+ });
281
+ expect(await findRegisterForSlug(SITE, "no-such-flow")).toBeUndefined();
282
+ });
283
+
284
+ it("findRegisterForSlug bounds its findMany with take: MAX_AGGREGATION_ROWS (#5147)", async () => {
285
+ arm();
286
+ await findRegisterForSlug(SITE, SLUG);
287
+ expect((prisma.waitlistExperiment.findMany as jest.Mock).mock.calls[0][0].take).toBe(
288
+ MAX_AGGREGATION_ROWS,
289
+ );
290
+ });
291
+ });
292
+
293
+ // ─── AC-2 — resume, never duplicate ─────────────────────────────────────────
294
+
295
+ describe("AC-2 — a walk session is resumed, never duplicated", () => {
296
+ it("updates the existing session in place and never creates", async () => {
297
+ arm();
298
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
299
+ id: "flsess_walk",
300
+ siteId: SITE,
301
+ flowSlug: SLUG,
302
+ // #4912 — SESSION_RESUME_SELECT now returns these two; an unowned walk
303
+ // session (no Lead stamped yet) is the case this test has always meant.
304
+ leadId: null,
305
+ email: null,
306
+ });
307
+ const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
308
+ expect(result.ok).toBe(true);
309
+ expect(prisma.flowSession.create).not.toHaveBeenCalled();
310
+ const updates = (prisma.flowSession.update as jest.Mock).mock.calls;
311
+ expect(updates.length).toBeGreaterThanOrEqual(1);
312
+ expect(updates[0][0].where).toEqual({ id: "flsess_walk" });
313
+ expect(updates[0][0].data).toMatchObject({
314
+ email: EMAIL,
315
+ state: { team: { q_team: "small_team" } },
316
+ });
317
+ expect(updates[0][0].data.completedAt).toBeInstanceOf(Date);
318
+ if (result.ok) expect(result.sessionId).toBe("flsess_walk");
319
+ });
320
+
321
+ it.each([
322
+ ["unknown id", null],
323
+ ["foreign-site row", { id: "flsess_x", siteId: "site_other", flowSlug: SLUG, leadId: null, email: null }],
324
+ ["other-slug row", { id: "flsess_x", siteId: SITE, flowSlug: "other-flow", leadId: null, email: null }],
325
+ ])("falls back to create on an %s", async (_label, row) => {
326
+ arm();
327
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(row);
328
+ const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_x" }));
329
+ expect(result.ok).toBe(true);
330
+ expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
331
+ if (result.ok) expect(result.sessionId).toBe("flsess_new");
332
+ });
333
+ });
334
+
335
+ // ─── #4912 AC-2 — resume ownership, and the Lead reused rather than doubled ──
336
+
337
+ describe("#4912 AC-2 — a session that carries a Lead is resumable only by its own email", () => {
338
+ const OWNED = {
339
+ id: "flsess_walk",
340
+ siteId: SITE,
341
+ flowSlug: SLUG,
342
+ leadId: "lead_owner",
343
+ email: EMAIL,
344
+ };
345
+
346
+ it("resumes for the same email and REUSES the Lead — no second routeLead write", async () => {
347
+ arm();
348
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
349
+
350
+ const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
351
+
352
+ expect(result.ok).toBe(true);
353
+ if (!result.ok) return;
354
+ expect(result.sessionId).toBe("flsess_walk");
355
+ expect(result.leadId).toBe("lead_owner");
356
+ // The whole point of #4860: routeLead's own write does not happen twice.
357
+ expect(prisma.lead.create).not.toHaveBeenCalled();
358
+ expect(prisma.flowSession.create).not.toHaveBeenCalled();
359
+ // The answers/locale/completion refresh still lands …
360
+ const updates = (prisma.flowSession.update as jest.Mock).mock.calls;
361
+ expect(updates).toHaveLength(1);
362
+ expect(updates[0][0].data).toMatchObject({
363
+ email: EMAIL,
364
+ state: { team: { q_team: "small_team" } },
365
+ });
366
+ // … and the leadId is NOT re-stamped, because it never changed.
367
+ expect(updates[0][0].data.leadId).toBeUndefined();
368
+ });
369
+
370
+ it.each([
371
+ ["a second, different email", "attacker@example.com"],
372
+ ["an email that merely looks similar", "buyer@example.com.evil.test"],
373
+ ])("never overwrites the row for %s — it falls back to create", async (_label, email) => {
374
+ arm();
375
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
376
+
377
+ const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk", email }));
378
+
379
+ expect(result.ok).toBe(true);
380
+ if (!result.ok) return;
381
+ // Nothing at all was written to the session somebody else owns.
382
+ const touched = (prisma.flowSession.update as jest.Mock).mock.calls.filter(
383
+ (call: [{ where: { id: string } }]) => call[0].where.id === "flsess_walk",
384
+ );
385
+ expect(touched).toHaveLength(0);
386
+ expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
387
+ expect(result.sessionId).toBe("flsess_new");
388
+ expect(result.leadId).toBe("lead_1");
389
+ });
390
+
391
+ it("compares the owner email case-insensitively, after trimming", async () => {
392
+ arm();
393
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
394
+
395
+ const result = await captureRecommenderChoice(
396
+ saveInput({ sessionId: "flsess_walk", email: " Buyer@Example.COM " }),
397
+ );
398
+
399
+ expect(result.ok).toBe(true);
400
+ if (!result.ok) return;
401
+ expect(result.sessionId).toBe("flsess_walk");
402
+ expect(result.leadId).toBe("lead_owner");
403
+ });
404
+
405
+ it("falls back to create when the row carries a leadId but no email to own it", async () => {
406
+ arm();
407
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({ ...OWNED, email: null });
408
+
409
+ const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
410
+
411
+ expect(result.ok).toBe(true);
412
+ if (!result.ok) return;
413
+ expect(prisma.flowSession.create).toHaveBeenCalledTimes(1);
414
+ expect(result.sessionId).toBe("flsess_new");
415
+ });
416
+
417
+ it("still resumes an unowned walk session (no leadId yet) and stamps the new Lead on it", async () => {
418
+ arm();
419
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
420
+ ...OWNED,
421
+ leadId: null,
422
+ email: null,
423
+ });
424
+
425
+ const result = await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
426
+
427
+ expect(result.ok).toBe(true);
428
+ if (!result.ok) return;
429
+ expect(result.sessionId).toBe("flsess_walk");
430
+ expect(result.leadId).toBe("lead_1");
431
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
432
+ });
433
+
434
+ it("does not re-mint the T+0 grant on an owned resume", async () => {
435
+ arm({ config: { deliverables: DELIVERABLES } });
436
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue(OWNED);
437
+
438
+ await captureRecommenderChoice(saveInput({ sessionId: "flsess_walk" }));
439
+
440
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
441
+ expect(mockSendDeliverableEmail).not.toHaveBeenCalled();
442
+ });
443
+ });
444
+
445
+ // ─── AC-3 — the T+0 grant path: reused, opt-in, fail-soft ───────────────────
446
+
447
+ describe("AC-3 — the T+0 grant path is reused, opt-in, and fail-soft", () => {
448
+ it("mints one grant for the session + lead ids when the config carries deliverables (the real fulfillDeliverable)", async () => {
449
+ arm({ config: { deliverables: DELIVERABLES } });
450
+ const result = await captureRecommenderChoice(saveInput());
451
+ expect(result.ok).toBe(true);
452
+ // The asset lookup is keyed on the block's assetKey for THIS site …
453
+ expect(prisma.deliverableAsset.findUnique).toHaveBeenCalledWith(
454
+ expect.objectContaining({ where: { siteId_key: { siteId: SITE, key: "plan-pdf" } } }),
455
+ );
456
+ // … and the grant is minted once, against the new session + the lead.
457
+ expect(prisma.deliverableGrant.create).toHaveBeenCalledTimes(1);
458
+ expect((prisma.deliverableGrant.create as jest.Mock).mock.calls[0][0].data).toEqual({
459
+ siteId: SITE,
460
+ assetId: "dasset_1",
461
+ leadId: "lead_1",
462
+ sessionId: "flsess_new",
463
+ });
464
+ expect(mockSendDeliverableEmail).toHaveBeenCalledTimes(1);
465
+ expect(mockSendDeliverableEmail.mock.calls[0][0].to).toBe(EMAIL);
466
+ });
467
+
468
+ it("never touches the grant path without a deliverables block", async () => {
469
+ arm();
470
+ await captureRecommenderChoice(saveInput());
471
+ expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
472
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
473
+ });
474
+
475
+ it("a grant-path rejection (the asset read throwing) leaves the capture ok", async () => {
476
+ arm({ config: { deliverables: DELIVERABLES } });
477
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockRejectedValue(new Error("db down"));
478
+ const result = await captureRecommenderChoice(saveInput());
479
+ expect(result.ok).toBe(true);
480
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
481
+ });
482
+ });
483
+
484
+ // ─── AC-4 — recordCheckout: MINOR units, and every refusal ──────────────────
485
+
486
+ describe("AC-4 — the checkout row carries a server-recomputed MINOR-unit amount", () => {
487
+ function checkoutInput(extra: Record<string, unknown> = {}) {
488
+ return {
489
+ siteId: SITE,
490
+ flowSlug: SLUG,
491
+ experimentKey: "reco-exp",
492
+ sessionId: "flsess_new",
493
+ leadId: "lead_1",
494
+ email: EMAIL,
495
+ offerKey: "pro",
496
+ seats: 1,
497
+ period: "annual" as const,
498
+ creditStop: { kind: "none" as const },
499
+ permalink: "eyJ2ZXJzaW9uIjoxfQ",
500
+ ...extra,
501
+ };
502
+ }
503
+
504
+ it("writes one row with amountMinor 24900 (never 249), the currency, the quote and the permalink", async () => {
505
+ arm();
506
+ const result = await recordCheckout(checkoutInput());
507
+ expect(result.ok).toBe(true);
508
+ if (!result.ok) return;
509
+ expect(prisma.flowCheckout.create).toHaveBeenCalledTimes(1);
510
+ const data = (prisma.flowCheckout.create as jest.Mock).mock.calls[0][0].data;
511
+ expect(data.amountMinor).toBe(24900);
512
+ expect(data.amountMinor).not.toBe(249);
513
+ expect(data.currency).toBe("usd");
514
+ expect(data.status).toBe("created");
515
+ expect(data.stripeSessionId).toBeNull();
516
+ expect(data.quote).toEqual(price(register() as never, "pro", 1, "annual", { kind: "none" }));
517
+ expect(data.permalink).toBe("eyJ2ZXJzaW9uIjoxfQ");
518
+ expect(data).toMatchObject({
519
+ siteId: SITE,
520
+ flowSlug: SLUG,
521
+ experimentKey: "reco-exp",
522
+ sessionId: "flsess_new",
523
+ leadId: "lead_1",
524
+ offerKey: "pro",
525
+ seats: 1,
526
+ period: "annual",
527
+ creditStop: { kind: "none" },
528
+ });
529
+ expect(result.flowCheckoutId).toBe("flchk_1");
530
+ expect(result.amountMinor).toBe(24900);
531
+ });
532
+
533
+ it.each([
534
+ ["unknown_offer", { offerKey: "nope" }, {}],
535
+ ["custom_stop", { creditStop: { kind: "custom" as const } }, {}],
536
+ ["invalid_seats", { seats: 1000 }, {}],
537
+ ["invalid_seats", { seats: 0 }, {}],
538
+ ["no_rate_table", {}, { register: { rateTable: undefined } }],
539
+ ["no_currency", {}, { register: { rateTable: rateTable() } }],
540
+ ["no_register", { flowSlug: "no-such-flow" }, {}],
541
+ ["session_mismatch", { sessionId: "flsess_other" }, {}],
542
+ ["session_mismatch", { leadId: "lead_other" }, {}],
543
+ ])("returns %s and writes no row", async (kind, extra, armOpts) => {
544
+ arm(armOpts as never);
545
+ const result = await recordCheckout(checkoutInput(extra));
546
+ expect(result.ok).toBe(false);
547
+ if (result.ok) return;
548
+ expect(result.error.kind).toBe(kind);
549
+ expect(prisma.flowCheckout.create).not.toHaveBeenCalled();
550
+ });
551
+
552
+ it("resolves the register from the DB itself — the caller supplies no register", async () => {
553
+ arm();
554
+ await recordCheckout(checkoutInput());
555
+ expect(prisma.waitlistExperiment.findMany).toHaveBeenCalledTimes(1);
556
+ expect((prisma.waitlistExperiment.findMany as jest.Mock).mock.calls[0][0].where).toEqual({ siteId: SITE });
557
+ });
558
+
559
+ it("attachStripeSession stamps the session id and the replay URL, scoped to the site", async () => {
560
+ arm();
561
+ const attached = await attachStripeSession(
562
+ SITE,
563
+ "flchk_1",
564
+ "cs_test_abc",
565
+ "https://checkout.stripe.com/c/pay/cs_test_abc",
566
+ );
567
+ expect(attached).toEqual({ count: 1 });
568
+ expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
569
+ // #4912 — `status: "created"` mirrors the #4838 webhook's own join, so a
570
+ // row something else already finished or expired never takes the stamp.
571
+ where: { id: "flchk_1", siteId: SITE, status: "created" },
572
+ data: {
573
+ stripeSessionId: "cs_test_abc",
574
+ checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_abc",
575
+ },
576
+ });
577
+ });
578
+ });
579
+
580
+ // ─── #4912 AC-3 / AC-4 — checkout idempotency, replay and reconcile ──────────
581
+
582
+ describe("#4912 AC-3 — /checkout is keyed by a server-derived fingerprint", () => {
583
+ function checkoutInput(extra: Record<string, unknown> = {}) {
584
+ return {
585
+ siteId: SITE,
586
+ flowSlug: SLUG,
587
+ experimentKey: "reco-exp",
588
+ sessionId: "flsess_new",
589
+ leadId: "lead_1",
590
+ email: EMAIL,
591
+ offerKey: "pro",
592
+ seats: 1,
593
+ period: "annual" as const,
594
+ creditStop: { kind: "none" as const },
595
+ permalink: "eyJ2ZXJzaW9uIjoxfQ",
596
+ ...extra,
597
+ };
598
+ }
599
+
600
+ /** A keyed row as the lookup sees it; `createdAt` defaults to "just now". */
601
+ function keyedRow(over: Record<string, unknown> = {}) {
602
+ return {
603
+ id: "flchk_first",
604
+ status: "created",
605
+ stripeSessionId: "cs_test_first",
606
+ checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_first",
607
+ createdAt: new Date(Date.now() - 60_000),
608
+ ...over,
609
+ };
610
+ }
611
+
612
+ function lastKey(): string {
613
+ const calls = (prisma.flowCheckout.findUnique as jest.Mock).mock.calls;
614
+ return calls[calls.length - 1][0].where.siteId_idempotencyKey.idempotencyKey;
615
+ }
616
+
617
+ function createdKeys(): unknown[] {
618
+ return (prisma.flowCheckout.create as jest.Mock).mock.calls.map(
619
+ (call: [{ data: { idempotencyKey: unknown } }]) => call[0].data.idempotencyKey,
620
+ );
621
+ }
622
+
623
+ it("looks the attempt up on a site-scoped sha256 digest and writes that key onto the new row", async () => {
624
+ arm();
625
+ const result = await recordCheckout(checkoutInput());
626
+
627
+ expect(result.ok).toBe(true);
628
+ if (!result.ok) return;
629
+ const where = (prisma.flowCheckout.findUnique as jest.Mock).mock.calls[0][0].where;
630
+ expect(where.siteId_idempotencyKey.siteId).toBe(SITE);
631
+ expect(where.siteId_idempotencyKey.idempotencyKey).toMatch(/^[0-9a-f]{64}$/);
632
+ expect(createdKeys()).toEqual([where.siteId_idempotencyKey.idempotencyKey]);
633
+ expect(result.replayed).toBe(false);
634
+ });
635
+
636
+ it("gives two identical requests the same key, and any changed field a different one", async () => {
637
+ arm();
638
+ await recordCheckout(checkoutInput());
639
+ const base = lastKey();
640
+ await recordCheckout(checkoutInput());
641
+ expect(lastKey()).toBe(base);
642
+
643
+ for (const extra of [
644
+ { seats: 2 },
645
+ { offerKey: "free_oss" },
646
+ { period: "monthly" as const },
647
+ { creditStop: { kind: "tier" as const, index: 0 } },
648
+ { permalink: "eyJ2ZXJzaW9uIjoyfQ" },
649
+ { sessionId: "flsess_other_walk" },
650
+ { email: "someone-else@example.com" },
651
+ { flowSlug: SLUG.toUpperCase() },
652
+ ]) {
653
+ await recordCheckout(checkoutInput(extra));
654
+ expect(lastKey()).not.toBe(base);
655
+ }
656
+ // The email is hashed into the key and never written to the row.
657
+ for (const call of (prisma.flowCheckout.create as jest.Mock).mock.calls) {
658
+ expect(JSON.stringify(call[0].data)).not.toContain(EMAIL);
659
+ }
660
+ });
661
+
662
+ it("distinguishes two credit-stop TIERS, not merely the stop kind", async () => {
663
+ // A two-rung ladder, so index 1 is a priceable stop rather than a refusal.
664
+ arm({
665
+ register: {
666
+ rateTable: {
667
+ ...rateTable("usd"),
668
+ creditLadder: {
669
+ denomination: "monthly" as const,
670
+ tiers: [
671
+ { credits: 10, amount: 99 },
672
+ { credits: 25, amount: 199 },
673
+ ],
674
+ custom: en("Contact us for larger packages"),
675
+ },
676
+ },
677
+ },
678
+ });
679
+ const first = await recordCheckout(checkoutInput({ creditStop: { kind: "tier" as const, index: 0 } }));
680
+ expect(first.ok).toBe(true);
681
+ const tier0 = lastKey();
682
+ const second = await recordCheckout(checkoutInput({ creditStop: { kind: "tier" as const, index: 1 } }));
683
+ expect(second.ok).toBe(true);
684
+ expect(lastKey()).not.toBe(tier0);
685
+ });
686
+
687
+ it("distinguishes the same request against a DIFFERENT site", async () => {
688
+ arm();
689
+ await recordCheckout(checkoutInput());
690
+ const here = lastKey();
691
+ (prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
692
+ id: "flow_1",
693
+ siteId: "site_other",
694
+ slug: SLUG,
695
+ config: flowConfig(),
696
+ });
697
+ (prisma.flowSession.findUnique as jest.Mock).mockResolvedValue({
698
+ siteId: "site_other",
699
+ flowSlug: SLUG,
700
+ leadId: "lead_1",
701
+ });
702
+ await recordCheckout(checkoutInput({ siteId: "site_other" }));
703
+ expect(lastKey()).not.toBe(here);
704
+ });
705
+
706
+ it("changes the key when the SERVER's price moves, so a replay can never serve a stale amount", async () => {
707
+ arm();
708
+ await recordCheckout(checkoutInput());
709
+ const at249 = lastKey();
710
+ // Only the register's seat rate moves; the request body is identical.
711
+ arm({ register: { rateTable: { ...rateTable("usd"), seatRates: [{ offer: "pro", amount: 300, period: "annual" as const }] } } });
712
+ const result = await recordCheckout(checkoutInput());
713
+ expect(result.ok).toBe(true);
714
+ if (!result.ok) return;
715
+ expect(result.amountMinor).toBe(30000);
716
+ expect(lastKey()).not.toBe(at249);
717
+ });
718
+
719
+ it("replays a live keyed row inside the TTL — no second row, no second Stripe session", async () => {
720
+ arm();
721
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(keyedRow());
722
+
723
+ const result = await recordCheckout(checkoutInput());
724
+
725
+ expect(result.ok).toBe(true);
726
+ if (!result.ok) return;
727
+ expect(result.replayed).toBe(true);
728
+ if (!result.replayed) return;
729
+ expect(result.flowCheckoutId).toBe("flchk_first");
730
+ expect(result.stripeSessionId).toBe("cs_test_first");
731
+ expect(result.checkoutUrl).toBe("https://checkout.stripe.com/c/pay/cs_test_first");
732
+ // The price is still the server's own, recomputed on the replay.
733
+ expect(result.amountMinor).toBe(24900);
734
+ expect(prisma.flowCheckout.create).not.toHaveBeenCalled();
735
+ expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
736
+ });
737
+
738
+ it("replays exactly AT the TTL boundary, and no longer one millisecond past it", async () => {
739
+ arm();
740
+ const at = Date.now();
741
+ jest.spyOn(Date, "now").mockReturnValue(at);
742
+ try {
743
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
744
+ keyedRow({ createdAt: new Date(at - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS) }),
745
+ );
746
+ const onBoundary = await recordCheckout(checkoutInput());
747
+ expect(onBoundary.ok && onBoundary.replayed).toBe(true);
748
+
749
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
750
+ keyedRow({ createdAt: new Date(at - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 1) }),
751
+ );
752
+ const pastIt = await recordCheckout(checkoutInput());
753
+ expect(pastIt.ok && pastIt.replayed).toBe(false);
754
+ } finally {
755
+ (Date.now as jest.Mock).mockRestore();
756
+ }
757
+ });
758
+
759
+ it("never replays a row the webhook already completed — the visitor gets a new session", async () => {
760
+ arm();
761
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
762
+ keyedRow({ status: "completed" }),
763
+ );
764
+
765
+ const result = await recordCheckout(checkoutInput());
766
+
767
+ expect(result.ok).toBe(true);
768
+ if (!result.ok) return;
769
+ expect(result.replayed).toBe(false);
770
+ // A completed row keeps its status and its Stripe ids; only the key is
771
+ // released, so this new purchase can take a keyed row of its own.
772
+ expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
773
+ where: { id: "flchk_first", siteId: SITE },
774
+ data: { idempotencyKey: null },
775
+ });
776
+ expect(createdKeys()).toHaveLength(1);
777
+ expect(createdKeys()[0]).toMatch(/^[0-9a-f]{64}$/);
778
+ });
779
+
780
+ it("reconciles a STALE row that never took a Stripe session, then writes a fresh keyed one", async () => {
781
+ arm();
782
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
783
+ keyedRow({
784
+ id: "flchk_stale",
785
+ stripeSessionId: null,
786
+ checkoutUrl: null,
787
+ createdAt: new Date(Date.now() - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 60_000),
788
+ }),
789
+ );
790
+
791
+ const result = await recordCheckout(checkoutInput());
792
+
793
+ expect(result.ok).toBe(true);
794
+ if (!result.ok) return;
795
+ expect(result.replayed).toBe(false);
796
+ // Nothing can ever join it, so it is marked dead and its stored URL dropped.
797
+ expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
798
+ where: { id: "flchk_stale", siteId: SITE },
799
+ data: { status: "expired", idempotencyKey: null, checkoutUrl: null },
800
+ });
801
+ expect(result.flowCheckoutId).toBe("flchk_1");
802
+ });
803
+
804
+ it("leaves a STALE row that DID take a Stripe session joinable — key released, status untouched", async () => {
805
+ arm();
806
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
807
+ keyedRow({
808
+ id: "flchk_stale",
809
+ createdAt: new Date(Date.now() - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 60_000),
810
+ }),
811
+ );
812
+
813
+ await recordCheckout(checkoutInput());
814
+
815
+ // Marking it `expired` would make the #4838 webhook's `status: "created"`
816
+ // update miss, so a payment on that still-open session would go unrecorded.
817
+ expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
818
+ where: { id: "flchk_stale", siteId: SITE },
819
+ data: { idempotencyKey: null },
820
+ });
821
+ });
822
+
823
+ it("leaves an IN-FLIGHT row completely alone and takes an unkeyed row instead", async () => {
824
+ arm();
825
+ // A row created moments ago whose own request is still inside its Stripe
826
+ // call: stealing its key would strand the session about to be stamped on it.
827
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
828
+ keyedRow({ id: "flchk_inflight", stripeSessionId: null, checkoutUrl: null }),
829
+ );
830
+
831
+ const result = await recordCheckout(checkoutInput());
832
+
833
+ expect(result.ok).toBe(true);
834
+ if (!result.ok) return;
835
+ expect(result.replayed).toBe(false);
836
+ expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
837
+ expect(mockLoggerWarn).not.toHaveBeenCalledWith(
838
+ expect.objectContaining({ outcome: "checkout_reconciled" }),
839
+ expect.anything(),
840
+ );
841
+ expect(createdKeys()).toEqual([null]);
842
+ });
843
+
844
+ it("logs the reconcile with the row-id prefix and an outcome, and nothing else", async () => {
845
+ arm();
846
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue(
847
+ keyedRow({
848
+ id: "flchk_stale_row_id",
849
+ stripeSessionId: null,
850
+ checkoutUrl: null,
851
+ createdAt: new Date(Date.now() - FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS - 60_000),
852
+ }),
853
+ );
854
+
855
+ await recordCheckout(checkoutInput());
856
+
857
+ const line = mockLoggerWarn.mock.calls.find(
858
+ (call: [Record<string, unknown>]) => call[0]?.["outcome"] === "checkout_reconciled",
859
+ );
860
+ expect(line).toBeDefined();
861
+ expect(line?.[0]).toEqual({
862
+ flowCheckoutIdPrefix: "flchk_st",
863
+ outcome: "checkout_reconciled",
864
+ });
865
+ expect((line?.[0] as { flowCheckoutIdPrefix: string }).flowCheckoutIdPrefix).toHaveLength(8);
866
+ });
867
+
868
+ it("survives a lost create race: the unique violation becomes an unkeyed row, never a throw", async () => {
869
+ arm();
870
+ const conflict = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
871
+ (prisma.flowCheckout.create as jest.Mock)
872
+ .mockRejectedValueOnce(conflict)
873
+ .mockResolvedValue({ id: "flchk_2" });
874
+ // The winner has not stamped its Stripe session yet, so there is nothing
875
+ // to replay.
876
+ (prisma.flowCheckout.findUnique as jest.Mock)
877
+ .mockResolvedValueOnce(null)
878
+ .mockResolvedValue(keyedRow({ stripeSessionId: null, checkoutUrl: null }));
879
+
880
+ const result = await recordCheckout(checkoutInput());
881
+
882
+ expect(result.ok).toBe(true);
883
+ if (!result.ok) return;
884
+ expect(result.replayed).toBe(false);
885
+ expect(result.flowCheckoutId).toBe("flchk_2");
886
+ expect(createdKeys()[1]).toBeNull();
887
+ });
888
+
889
+ it("replays instead, when the create race is lost to a winner that already has its session", async () => {
890
+ arm();
891
+ const conflict = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
892
+ (prisma.flowCheckout.create as jest.Mock).mockRejectedValueOnce(conflict);
893
+ (prisma.flowCheckout.findUnique as jest.Mock)
894
+ .mockResolvedValueOnce(null)
895
+ .mockResolvedValue(keyedRow());
896
+
897
+ const result = await recordCheckout(checkoutInput());
898
+
899
+ expect(result.ok).toBe(true);
900
+ if (!result.ok) return;
901
+ expect(result.replayed).toBe(true);
902
+ if (!result.replayed) return;
903
+ expect(result.stripeSessionId).toBe("cs_test_first");
904
+ });
905
+
906
+ it("rethrows any create failure that is NOT a unique violation", async () => {
907
+ arm();
908
+ (prisma.flowCheckout.create as jest.Mock).mockRejectedValue(new Error("db down"));
909
+ await expect(recordCheckout(checkoutInput())).rejects.toThrow("db down");
910
+ });
911
+
912
+ it("expireFlowCheckout kills only a still-created row, and releases its key and URL", async () => {
913
+ arm();
914
+ const result = await expireFlowCheckout(SITE, "flchk_1");
915
+ expect(result).toEqual({ count: 1 });
916
+ expect(prisma.flowCheckout.updateMany).toHaveBeenCalledWith({
917
+ where: { id: "flchk_1", siteId: SITE, status: "created" },
918
+ data: { status: "expired", idempotencyKey: null, checkoutUrl: null },
919
+ });
920
+ });
921
+ });
922
+
923
+ // ─── AC-6 — failure asymmetry + PII boundary ────────────────────────────────
924
+
925
+ describe("AC-6 — failure asymmetry + PII boundary", () => {
926
+ it("a failed routeLead is a failed capture: completedAt stamped, no checkout row, no grant", async () => {
927
+ arm({ config: { deliverables: DELIVERABLES } });
928
+ // The way production fails: the DB write rejects AND the file fallback
929
+ // cannot write (an unwritable dir — the lead-routing.test.ts idiom), so the
930
+ // REAL routeLead returns persist_failed.
931
+ process.env.AILK_INBOX_FALLBACK_DIR = "/dev/null/ailk-fallback-unwritable";
932
+ (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db down"));
933
+ const result = await captureRecommenderChoice(saveInput());
934
+ expect(result.ok).toBe(false);
935
+ if (result.ok) return;
936
+ expect(result.error.kind).toBe("lead_failed");
937
+ expect(prisma.flowSession.update).toHaveBeenCalledTimes(1);
938
+ expect((prisma.flowSession.update as jest.Mock).mock.calls[0][0].data.completedAt).toBeInstanceOf(
939
+ Date,
940
+ );
941
+ expect(prisma.flowCheckout.create).not.toHaveBeenCalled();
942
+ expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
943
+ });
944
+
945
+ it("a grant rejection whose message embeds the recipient address never reaches a log line", async () => {
946
+ arm({ config: { deliverables: DELIVERABLES } });
947
+ mockSendDeliverableEmail.mockRejectedValue(new Error(`Resend refused delivery to ${EMAIL}`));
948
+ const result = await captureRecommenderChoice(saveInput());
949
+ expect(result.ok).toBe(true);
950
+ const lines = [...mockLoggerInfo.mock.calls, ...mockLoggerWarn.mock.calls, ...mockLoggerError.mock.calls]
951
+ .map((call) => JSON.stringify(call))
952
+ .filter((line) => line.includes("[recommender-capture]"));
953
+ expect(lines.length).toBeGreaterThan(0);
954
+ for (const line of lines) expect(line).not.toContain(EMAIL);
955
+ const grantLine = mockLoggerWarn.mock.calls.find((c) => c[1] === "[recommender-capture] grant_failed");
956
+ expect(grantLine?.[0].err).toBe("Error");
957
+ });
958
+
959
+ it("every logger call this module emits carries only the allow-listed keys", async () => {
960
+ const ALLOWED = new Set(["siteId", "flowSlug", "sessionId", "leadId", "choice", "outcome", "err"]);
961
+ arm({ config: { deliverables: DELIVERABLES } });
962
+ (prisma.deliverableAsset.findUnique as jest.Mock).mockRejectedValue(new Error("db down"));
963
+ await captureRecommenderChoice(saveInput());
964
+ process.env.AILK_INBOX_FALLBACK_DIR = "/dev/null/ailk-fallback-unwritable";
965
+ (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db down"));
966
+ await captureRecommenderChoice(saveInput());
967
+ // Only THIS module's lines — routeLead / fulfillDeliverable log through the
968
+ // same logger with their own (already-tested) boundaries.
969
+ const calls = [
970
+ ...mockLoggerInfo.mock.calls,
971
+ ...mockLoggerWarn.mock.calls,
972
+ ...mockLoggerError.mock.calls,
973
+ ].filter((call) => typeof call[1] === "string" && call[1].startsWith("[recommender-capture]"));
974
+ expect(calls.length).toBeGreaterThan(0);
975
+ for (const call of calls) {
976
+ const ctx = call[0] as Record<string, unknown>;
977
+ for (const key of Object.keys(ctx)) expect(ALLOWED.has(key)).toBe(true);
978
+ const serialized = JSON.stringify(ctx);
979
+ expect(serialized).not.toContain(EMAIL);
980
+ expect(serialized).not.toContain("small_team");
981
+ }
982
+ });
983
+ });