create-ailk 0.23.0 → 0.25.1

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 (604) hide show
  1. package/README.md +68 -33
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +10 -3
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +38 -4
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +75 -22
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +1 -1
  14. package/dist/lib/fetch-template-snapshot.js +7 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +34 -12
  19. package/dist/lib/scaffold-render-sweep.d.ts +33 -0
  20. package/dist/lib/scaffold-render-sweep.js +45 -0
  21. package/dist/module-architecture.d.ts +20 -3
  22. package/dist/module-architecture.js +122 -538
  23. package/dist/paid-sections.d.ts +25 -1
  24. package/dist/paid-sections.js +88 -3
  25. package/dist/surfaces.js +10 -2
  26. package/dist/sync-routes.d.ts +30 -4
  27. package/dist/sync-routes.js +271 -18
  28. package/dist/template-record.d.ts +14 -4
  29. package/dist/template-record.js +23 -5
  30. package/dist/topology.d.ts +16 -3
  31. package/dist/topology.js +31 -19
  32. package/dist/update.d.ts +10 -1
  33. package/dist/update.js +540 -16
  34. package/package.json +3 -3
  35. package/release-scope.json +1 -1
  36. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  37. package/templates/.claude/hooks/template-guard.sh +137 -0
  38. package/templates/.claude/rules/extending.md +106 -0
  39. package/templates/.claude/rules/loop-contract.md +2 -2
  40. package/templates/.claude/settings.json +11 -0
  41. package/templates/.env.example +9 -5
  42. package/templates/.github/workflows/ci.yml +7 -4
  43. package/templates/CLAUDE.md +15 -3
  44. package/templates/CONVENTIONS.md +8 -10
  45. package/templates/README.md +14 -13
  46. package/templates/apps/api/.env.example +9 -2
  47. package/templates/apps/api/CLAUDE.md +122 -82
  48. package/templates/apps/api/jest.config.cjs +15 -5
  49. package/templates/apps/api/package.json +16 -20
  50. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  51. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  52. package/templates/apps/api/src/index.ts +1 -1
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -4
  54. package/templates/apps/api/src/modules/commerce.ts +10 -0
  55. package/templates/apps/api/src/modules/content-write.ts +10 -0
  56. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  57. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  58. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  59. package/templates/apps/api/src/modules/workspace.ts +10 -0
  60. package/templates/apps/api/src/server.ts +117 -1857
  61. package/templates/apps/api/vercel.json +2 -2
  62. package/templates/apps/mcp/CLAUDE.md +77 -24
  63. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  64. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  65. package/templates/apps/mcp/jest.config.cjs +6 -1
  66. package/templates/apps/mcp/package.json +5 -12
  67. package/templates/apps/mcp/src/app.ts +8 -7
  68. package/templates/apps/mcp/src/index.ts +6 -34
  69. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  70. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  71. package/templates/apps/mcp/src/site.ts +12 -0
  72. package/templates/apps/web/.env.example +1 -1
  73. package/templates/apps/web/CLAUDE.md +21 -5
  74. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  75. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  76. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
  77. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  78. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  79. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  80. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +28 -7
  81. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  82. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  83. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  84. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  85. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  86. package/templates/apps/web/auth.ts +2 -2
  87. package/templates/apps/web/jest.config.cjs +1 -0
  88. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
  89. package/templates/apps/web/lib/auth-email.ts +1 -1
  90. package/templates/apps/web/lib/magnet-routes.ts +3 -1
  91. package/templates/apps/web/lib/newsletter.actions.ts +9 -6
  92. package/templates/apps/web/lib/relay.ts +2 -2
  93. package/templates/apps/web/lib/release-scope.ts +47 -0
  94. package/templates/apps/web/lib/server-api.ts +1 -1
  95. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  96. package/templates/customizations.yaml +37 -0
  97. package/templates/database/prisma.config.ts +10 -14
  98. package/templates/database/seed-test.ts +2 -2
  99. package/templates/database/seed.ts +32 -5
  100. package/templates/package.json +12 -6
  101. package/templates/pnpm-workspace.yaml +0 -1
  102. package/templates/project.yaml +16 -8
  103. package/templates/tools/jest/reporters.cjs +65 -0
  104. package/templates/tools/lint-architecture.ts +757 -7
  105. package/templates/tsconfig.json +1 -2
  106. package/versions.json +48 -35
  107. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  108. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  109. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  110. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  111. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  112. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  113. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  114. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  115. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  116. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  117. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  118. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  119. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  120. package/templates/apps/api/src/bin/spokes.ts +0 -198
  121. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  122. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  123. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  124. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  125. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  126. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  127. package/templates/apps/api/src/config/index.ts +0 -596
  128. package/templates/apps/api/src/config/modules.ts +0 -83
  129. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  130. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  131. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  132. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  133. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  134. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  135. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  136. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  137. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  138. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  139. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  140. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  141. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  142. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  143. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  144. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  145. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  146. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  147. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  148. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -292
  149. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  150. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  151. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  152. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  153. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  154. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  155. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  156. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  157. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  158. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  159. package/templates/apps/api/src/lib/errors.ts +0 -95
  160. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  161. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  162. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  163. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  164. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  165. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  166. package/templates/apps/api/src/lib/locales.ts +0 -201
  167. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  168. package/templates/apps/api/src/lib/prisma.ts +0 -21
  169. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  170. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  171. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  172. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  173. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  174. package/templates/apps/api/src/lib/site.ts +0 -254
  175. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  176. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  177. package/templates/apps/api/src/lib/stripe.ts +0 -318
  178. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  179. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  180. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  181. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  182. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  183. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  184. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  185. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  186. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  187. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  188. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  189. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  190. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  191. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  192. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  193. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  194. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  195. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  196. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  197. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  198. package/templates/apps/api/src/middleware/auth.ts +0 -864
  199. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  200. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  201. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -264
  202. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  203. package/templates/apps/api/src/openapi/index.ts +0 -18
  204. package/templates/apps/api/src/openapi/spec.ts +0 -1815
  205. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  206. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  207. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  208. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  209. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  210. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  211. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  212. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  213. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  214. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  215. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  216. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  217. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  218. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -325
  219. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  220. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1300
  221. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  222. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  223. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  224. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  225. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  226. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  227. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  228. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  229. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  230. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  231. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  232. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  233. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  234. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  235. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  236. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  237. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  238. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  239. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  240. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  241. package/templates/apps/api/src/routes/content/get-page.ts +0 -268
  242. package/templates/apps/api/src/routes/content/index.ts +0 -49
  243. package/templates/apps/api/src/routes/content/list-pages.ts +0 -95
  244. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  245. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  246. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  247. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  248. package/templates/apps/api/src/routes/content/search.ts +0 -129
  249. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  250. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  251. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  252. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  253. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  254. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  255. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  256. package/templates/apps/api/src/routes/leads/index.ts +0 -573
  257. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  258. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  259. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  260. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  261. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -480
  262. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -465
  263. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  264. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  265. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  266. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  267. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  268. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  269. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  270. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  271. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  272. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  273. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  274. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  275. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  276. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  277. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  278. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  279. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  280. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  281. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  282. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  285. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -448
  286. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  287. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  288. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  289. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  290. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  291. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  292. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  293. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  294. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -808
  295. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  296. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  297. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  298. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2026
  299. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -528
  300. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  301. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  302. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  303. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  304. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  305. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  306. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  307. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -867
  308. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  309. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  310. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  311. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  312. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  313. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  314. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  315. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  316. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  317. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1176
  318. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -444
  319. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -154
  320. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  321. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  322. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  323. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  324. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  325. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  326. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  327. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  328. package/templates/apps/api/src/services/abandon-email.ts +0 -313
  329. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  330. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  331. package/templates/apps/api/src/services/audit-log.ts +0 -961
  332. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  333. package/templates/apps/api/src/services/consent.ts +0 -834
  334. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  335. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -755
  336. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  337. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  338. package/templates/apps/api/src/services/entitlements.ts +0 -338
  339. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  340. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  341. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  342. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  343. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  344. package/templates/apps/api/src/services/listing-config.ts +0 -178
  345. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  346. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -295
  347. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  348. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -868
  349. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  350. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -396
  351. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -681
  352. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  353. package/templates/apps/api/src/services/offerings/catalog.ts +0 -308
  354. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -385
  355. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  356. package/templates/apps/api/src/services/offerings/connection.ts +0 -1003
  357. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  358. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  359. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  360. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  361. package/templates/apps/api/src/services/person.ts +0 -1188
  362. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  363. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  364. package/templates/apps/api/src/services/project-listings.ts +0 -2073
  365. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  366. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  367. package/templates/apps/api/src/services/revalidation.ts +0 -235
  368. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  369. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  370. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  371. package/templates/apps/api/src/services/verified-email.ts +0 -71
  372. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  373. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  374. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  375. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  376. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  377. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  378. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  379. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  380. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  381. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  382. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  383. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  384. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  385. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  386. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  387. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  388. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1151
  389. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  390. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  391. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  392. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -65
  393. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -187
  394. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  395. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  396. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -165
  397. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  398. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -228
  399. package/templates/apps/mcp/__tests__/locale.test.ts +0 -135
  400. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  401. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  402. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -474
  403. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -225
  404. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -256
  405. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  406. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  407. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -307
  408. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -223
  409. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  410. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  411. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  412. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  413. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  414. package/templates/apps/mcp/src/abuse/index.ts +0 -342
  415. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  416. package/templates/apps/mcp/src/boot.ts +0 -167
  417. package/templates/apps/mcp/src/config/modules.ts +0 -43
  418. package/templates/apps/mcp/src/http-transport.ts +0 -526
  419. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  420. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  421. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -142
  422. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -139
  423. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  424. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  425. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  426. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  427. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  428. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  429. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  430. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  431. package/templates/apps/mcp/src/request-context.ts +0 -52
  432. package/templates/apps/mcp/src/server.ts +0 -343
  433. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  434. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  435. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -64
  436. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  437. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  438. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  439. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  440. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  441. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  442. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  443. package/templates/apps/mcp/src/tools/index.ts +0 -137
  444. package/templates/apps/mcp/src/tools/list_pages.ts +0 -74
  445. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  446. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  447. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  448. package/templates/apps/mcp/src/tools/read_content.ts +0 -75
  449. package/templates/apps/mcp/src/tools/request_callback.ts +0 -59
  450. package/templates/apps/mcp/src/tools/search_content.ts +0 -67
  451. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -57
  452. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  453. package/templates/database/CHANGELOG.md +0 -961
  454. package/templates/database/LICENSE +0 -202
  455. package/templates/database/NOTICE +0 -2
  456. package/templates/database/README.md +0 -243
  457. package/templates/database/__tests__/agents.db.test.ts +0 -320
  458. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  459. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  460. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  461. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  462. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  463. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  464. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  465. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  466. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  467. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  468. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  469. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  470. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  471. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  472. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  473. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  474. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  475. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  476. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  477. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  478. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  479. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  480. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  481. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  482. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  483. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  484. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  485. package/templates/database/__tests__/person.db.test.ts +0 -586
  486. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  487. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  488. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  489. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  490. package/templates/database/__tests__/seed.test.ts +0 -240
  491. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  492. package/templates/database/__tests__/tenancy.test.ts +0 -141
  493. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  494. package/templates/database/_gitignore +0 -8
  495. package/templates/database/auth/.gitkeep +0 -0
  496. package/templates/database/auth/schema.prisma +0 -641
  497. package/templates/database/config/.gitkeep +0 -0
  498. package/templates/database/config/schema.prisma +0 -441
  499. package/templates/database/content/.gitkeep +0 -0
  500. package/templates/database/content/schema.prisma +0 -339
  501. package/templates/database/eslint.config.mjs +0 -69
  502. package/templates/database/flows/listing-diagnostic.ts +0 -182
  503. package/templates/database/flows/presets.ts +0 -315
  504. package/templates/database/inbox/.gitkeep +0 -0
  505. package/templates/database/inbox/README.md +0 -3
  506. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  507. package/templates/database/inbox/schema.prisma +0 -1447
  508. package/templates/database/jest.config.cjs +0 -36
  509. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  510. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  511. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  512. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  513. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  514. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  515. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  516. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  517. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  518. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  519. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  520. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  521. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  522. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  523. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  524. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  525. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  526. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  527. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  528. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  529. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  530. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  531. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  532. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  533. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  534. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  535. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  536. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  537. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  538. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  539. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  540. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  541. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  542. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  543. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  544. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  545. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  546. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  547. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  548. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  549. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  550. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  551. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  552. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  553. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  554. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  555. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  556. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  557. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  558. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  559. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  560. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  561. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  562. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  563. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  564. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  565. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  566. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  567. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  568. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  569. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  570. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  571. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  572. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  573. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  574. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  575. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  576. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  577. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  578. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  579. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  580. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  581. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  582. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  583. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  584. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  585. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  586. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  587. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  588. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  589. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  590. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  591. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  592. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  593. package/templates/database/migrations/migration_lock.toml +0 -3
  594. package/templates/database/ops/.gitkeep +0 -0
  595. package/templates/database/ops/schema.prisma +0 -43
  596. package/templates/database/package.json +0 -89
  597. package/templates/database/schema.prisma +0 -71
  598. package/templates/database/scripts/db-generate-locked.sh +0 -80
  599. package/templates/database/scripts/person-backfill.sql +0 -132
  600. package/templates/database/tenancy.ts +0 -183
  601. package/templates/database/tsconfig.build.json +0 -16
  602. package/templates/database/tsconfig.flows.json +0 -15
  603. package/templates/database/tsconfig.json +0 -16
  604. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,2660 +0,0 @@
1
- /**
2
- * @file lead-routing.test.ts
3
- * @description Unit tests for the lead-routing service (ADR 0009 D3 service-layer pattern).
4
- *
5
- * External dependency boundaries mocked:
6
- * - @working-theory/database (via moduleNameMapper → prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
7
- * - @working-theory/email sendLeadNotification
8
- *
9
- * Internal modules run real:
10
- * - apps/api/src/services/enrichment-shim — real; @working-theory/license catches + returns lead unchanged
11
- * - apps/api/src/lib/file-fallback — real; uses AILK_INBOX_FALLBACK_DIR temp dir set per test
12
- * - apps/api/src/lib/prisma — uses __mocks__/prisma.ts (manual mock via jest.mock without factory)
13
- */
14
-
15
- import type { RouteLeadResult } from "../lead-routing";
16
-
17
- // ─── Mock: Prisma ─────────────────────────────────────────────────────────────
18
- // lib/prisma.ts is a thin wrapper that instantiates PrismaClient (external DB
19
- // boundary). The manual mock at src/lib/__mocks__/prisma.ts provides a full
20
- // in-memory stub — this call auto-resolves to that file.
21
- // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
22
- jest.mock("../../lib/prisma.js");
23
-
24
- // ─── Mock: email (external boundary) ─────────────────────────────────────────
25
- const mockSendLeadNotification = jest.fn();
26
- const mockSendPurchaseConfirmationEmail = jest.fn();
27
- jest.mock("@working-theory/email", () => ({
28
- sendLeadNotification: (...args: unknown[]) =>
29
- mockSendLeadNotification(...args),
30
- sendPurchaseConfirmationEmail: (...args: unknown[]) =>
31
- mockSendPurchaseConfirmationEmail(...args),
32
- }));
33
-
34
- // ─── Mock: the plan-offerings lister (a content read) ────────────────────────
35
- const mockListPlanOfferings = jest.fn();
36
- // eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam routes/leads/__tests__/magnet-completion.test.ts mocks.
37
- jest.mock("../plan-offerings.js", () => ({
38
- listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
39
- }));
40
-
41
- // ─── Import after mocks ───────────────────────────────────────────────────────
42
- import * as fs from "fs/promises";
43
- import * as os from "os";
44
- import * as path from "path";
45
-
46
- import { logger } from "@working-theory/observability";
47
- import {
48
- ACCOUNT_TERMS_PURPOSE_ID,
49
- CONSENT_LEAD_SOURCE_LIST,
50
- consentPurposeById,
51
- SITE_CONSENT_CONFIG_DEFAULTS,
52
- } from "@working-theory/validation";
53
-
54
- import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
55
- import {
56
- armConsentLedger,
57
- type ConsentLedgerDouble,
58
- } from "../../lib/__tests__/consent-ledger-double.js";
59
- import { prisma } from "../../lib/prisma.js";
60
- import { routeLead } from "../lead-routing";
61
- import * as consentService from "../consent";
62
- import * as personService from "../person";
63
-
64
- // ─── Helpers ──────────────────────────────────────────────────────────────────
65
- const sourceMeta = {
66
- ip: "127.0.0.1",
67
- userAgent: "jest",
68
- receivedAt: new Date().toISOString(),
69
- };
70
-
71
- const leadInput = {
72
- kind: "submit" as const,
73
- siteId: "site-abc",
74
- source: "contact",
75
- email: "test@example.com",
76
- payload: { message: "hello" },
77
- };
78
-
79
- const subscribeInput = {
80
- kind: "subscribe" as const,
81
- siteId: "site-abc",
82
- email: "subscriber@example.com",
83
- source: "footer",
84
- };
85
-
86
- const callbackInput = {
87
- kind: "callback" as const,
88
- siteId: "site-abc",
89
- name: "Alice",
90
- phone: "+15551234567",
91
- payload: { notes: "urgent" },
92
- };
93
-
94
- // ─── Tests ────────────────────────────────────────────────────────────────────
95
- describe("routeLead", () => {
96
- let tmpDir: string;
97
-
98
- beforeAll(async () => {
99
- // Real file-fallback module uses AILK_INBOX_FALLBACK_DIR for writes.
100
- // Point it at a temp dir so tests remain hermetic.
101
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-lead-test-"));
102
- process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
103
- });
104
-
105
- afterAll(async () => {
106
- delete process.env.AILK_INBOX_FALLBACK_DIR;
107
- await fs.rm(tmpDir, { recursive: true, force: true });
108
- });
109
-
110
- beforeEach(() => {
111
- jest.clearAllMocks();
112
- // Default: DB up, email up
113
- (prisma.lead.create as jest.Mock).mockResolvedValue({
114
- id: "lead_00000000-0000-7000-8000-0000000000c1",
115
- siteId: "site-abc",
116
- source: "contact",
117
- email: "test@example.com",
118
- payload: {},
119
- createdAt: new Date(),
120
- });
121
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({
122
- id: "subscr_00000000-0000-7000-8000-0000000000c2",
123
- siteId: "site-abc",
124
- email: "subscriber@example.com",
125
- source: "footer",
126
- createdAt: new Date(),
127
- });
128
- (prisma.callback.create as jest.Mock).mockResolvedValue({
129
- id: "callbk_00000000-0000-7000-8000-0000000000c3",
130
- siteId: "site-abc",
131
- name: "Alice",
132
- phone: "+15551234567",
133
- payload: {},
134
- createdAt: new Date(),
135
- });
136
- mockSendLeadNotification.mockResolvedValue({
137
- ok: true,
138
- messageId: "msg-123",
139
- });
140
- // Set required env
141
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
142
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
143
- });
144
-
145
- afterEach(() => {
146
- delete process.env.AILK_LEAD_NOTIFICATION_TO;
147
- delete process.env.AILK_LEAD_NOTIFICATION_FROM;
148
- });
149
-
150
- // ── 1. Happy path lead — DB up, email up ──────────────────────────────────
151
- it("happy path lead — returns ok:true with persisted:db and email:ok", async () => {
152
- const result = await routeLead(leadInput, sourceMeta);
153
-
154
- expect(result).toMatchObject({
155
- ok: true,
156
- id: "lead_00000000-0000-7000-8000-0000000000c1",
157
- persisted: "db",
158
- email: { ok: true, messageId: "msg-123" },
159
- });
160
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
161
- });
162
-
163
- // ── 2. DB down → fallback engages → persisted:file ───────────────────────
164
- it("DB down → file fallback → persisted:file", async () => {
165
- (prisma.lead.create as jest.Mock).mockRejectedValue(
166
- new Error("connection refused"),
167
- );
168
-
169
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
170
- RouteLeadResult,
171
- { ok: true }
172
- >;
173
-
174
- expect(result.ok).toBe(true);
175
- expect(result.persisted).toBe("file");
176
- // Verify a file was actually written to the temp dir
177
- const files = await fs.readdir(tmpDir);
178
- expect(files.length).toBeGreaterThan(0);
179
- });
180
-
181
- // ── 3. DB down + fallback fails → persist_failed ─────────────────────────
182
- it("DB down + fallback fails → ok:false persist_failed", async () => {
183
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db error"));
184
- // Point fallback at a nonexistent dir so mkdir fails
185
- process.env.AILK_INBOX_FALLBACK_DIR = "/nonexistent-ailk-test-dir/inbox";
186
-
187
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
188
- RouteLeadResult,
189
- { ok: false }
190
- >;
191
-
192
- // Restore good temp dir for other tests
193
- process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
194
-
195
- expect(result.ok).toBe(false);
196
- expect(result.error.kind).toBe("persist_failed");
197
- });
198
-
199
- // ── 4. Email down → persist succeeds, email error returned ───────────────
200
- it("email failure does NOT roll back DB write — returns ok:true with email error", async () => {
201
- mockSendLeadNotification.mockResolvedValue({
202
- ok: false,
203
- error: { kind: "send_failed", message: "timeout" },
204
- });
205
-
206
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
207
- RouteLeadResult,
208
- { ok: true }
209
- >;
210
-
211
- expect(result.ok).toBe(true);
212
- expect(result.persisted).toBe("db");
213
- expect(result.email).toMatchObject({
214
- ok: false,
215
- error: "send_failed: timeout",
216
- });
217
- expect(prisma.lead.create).toHaveBeenCalledTimes(1); // DB write happened
218
- });
219
-
220
- // ── 5. Resend env unset → email skipped ──────────────────────────────────
221
- it("AILK_LEAD_NOTIFICATION_TO unset → email skipped with reason env_unset", async () => {
222
- delete process.env.AILK_LEAD_NOTIFICATION_TO;
223
-
224
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
225
- RouteLeadResult,
226
- { ok: true }
227
- >;
228
-
229
- expect(result.ok).toBe(true);
230
- expect(result.email).toMatchObject({ skipped: true, reason: "env_unset" });
231
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
232
- });
233
-
234
- it("config_missing from Resend → email coerced to skipped:resend_key_unset", async () => {
235
- mockSendLeadNotification.mockResolvedValue({
236
- ok: false,
237
- error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
238
- });
239
-
240
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
241
- RouteLeadResult,
242
- { ok: true }
243
- >;
244
-
245
- expect(result.ok).toBe(true);
246
- expect(result.email).toMatchObject({
247
- skipped: true,
248
- reason: "resend_key_unset",
249
- });
250
- });
251
-
252
- // ── 6. Subscribe duplicate (P2002) → sentinel id, email skipped:duplicate ─
253
- it("subscribe duplicate P2002 → ok:true with email.skipped.reason duplicate", async () => {
254
- const p2002 = Object.assign(new Error("Unique constraint"), {
255
- code: "P2002",
256
- });
257
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(p2002);
258
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
259
- id: "existing-sub-id",
260
- siteId: "site-abc",
261
- email: "subscriber@example.com",
262
- });
263
-
264
- const result = (await routeLead(subscribeInput, sourceMeta)) as Extract<
265
- RouteLeadResult,
266
- { ok: true }
267
- >;
268
-
269
- expect(result.ok).toBe(true);
270
- expect(result.id).toBe("existing-sub-id");
271
- expect(result.persisted).toBe("db");
272
- expect(result.email).toMatchObject({ skipped: true, reason: "duplicate" });
273
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
274
- });
275
-
276
- it("subscribe duplicate P2002 with lookup miss → sentinel id duplicate-no-id", async () => {
277
- const p2002 = Object.assign(new Error("Unique constraint"), {
278
- code: "P2002",
279
- });
280
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(p2002);
281
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue(null);
282
-
283
- const result = (await routeLead(subscribeInput, sourceMeta)) as Extract<
284
- RouteLeadResult,
285
- { ok: true }
286
- >;
287
-
288
- expect(result.ok).toBe(true);
289
- expect(result.id).toBe("duplicate-no-id");
290
- expect(result.email).toMatchObject({ skipped: true, reason: "duplicate" });
291
- });
292
-
293
- // ── 7. Validation failure → invalid_input ────────────────────────────────
294
- it("malformed input (missing email) → ok:false invalid_input", async () => {
295
- const bad = {
296
- kind: "submit" as const,
297
- siteId: "site",
298
- source: "contact",
299
- email: "not-an-email",
300
- payload: {},
301
- };
302
-
303
- const result = (await routeLead(bad, sourceMeta)) as Extract<
304
- RouteLeadResult,
305
- { ok: false }
306
- >;
307
-
308
- expect(result.ok).toBe(false);
309
- expect(result.error.kind).toBe("invalid_input");
310
- });
311
-
312
- // ── 8. PII non-leak — log spy never sees email/phone/name/payload ─────────
313
- it("PII non-leak — log output never contains email, phone, name, or payload values", async () => {
314
- const logLines: string[] = [];
315
- const infoSpy = jest
316
- .spyOn(console, "info")
317
- .mockImplementation((...args) => {
318
- logLines.push(args.map(String).join(" "));
319
- });
320
- const warnSpy = jest
321
- .spyOn(console, "warn")
322
- .mockImplementation((...args) => {
323
- logLines.push(args.map(String).join(" "));
324
- });
325
- const logSpy = jest.spyOn(console, "log").mockImplementation((...args) => {
326
- logLines.push(args.map(String).join(" "));
327
- });
328
-
329
- await routeLead(leadInput, sourceMeta);
330
- await routeLead(callbackInput, sourceMeta);
331
-
332
- const combined = logLines.join("\n");
333
-
334
- // PII values must not appear in any log output
335
- expect(combined).not.toContain("test@example.com");
336
- expect(combined).not.toContain("+15551234567");
337
- expect(combined).not.toContain("Alice");
338
- // payload values must not appear
339
- expect(combined).not.toContain("hello");
340
- expect(combined).not.toContain("urgent");
341
-
342
- infoSpy.mockRestore();
343
- warnSpy.mockRestore();
344
- logSpy.mockRestore();
345
- });
346
-
347
- // ── Callback happy path ───────────────────────────────────────────────────
348
- it("callback happy path → persisted:db", async () => {
349
- const result = (await routeLead(callbackInput, sourceMeta)) as Extract<
350
- RouteLeadResult,
351
- { ok: true }
352
- >;
353
-
354
- expect(result.ok).toBe(true);
355
- expect(result.id).toBe("callbk_00000000-0000-7000-8000-0000000000c3");
356
- expect(result.persisted).toBe("db");
357
- expect(prisma.callback.create).toHaveBeenCalledTimes(1);
358
- });
359
-
360
- // ── routeLead never throws ────────────────────────────────────────────────
361
- it("routeLead never throws even when all dependencies fail", async () => {
362
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("db down"));
363
- // Point fallback at a bad dir so file write also fails
364
- process.env.AILK_INBOX_FALLBACK_DIR = "/nonexistent-ailk-test-dir-2/inbox";
365
-
366
- // Should not throw — returns ok:false instead
367
- await expect(routeLead(leadInput, sourceMeta)).resolves.toBeDefined();
368
-
369
- // Restore
370
- process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
371
- });
372
-
373
- // ── Regression: HIGH-1 — extra field in input must not be passed through ──
374
- // The route handler must NOT spread unknown fields from the request body into
375
- // the service call. The service's Zod validation via .strict() must reject
376
- // unknown keys; the id field is controlled server-side only.
377
- it("regression HIGH-1: input with extra 'id' field is rejected by strict schema validation", async () => {
378
- const inputWithExtraId = {
379
- kind: "submit" as const,
380
- siteId: "site-abc",
381
- source: "contact",
382
- email: "test@example.com",
383
- payload: { message: "hello" },
384
- id: "attacker-supplied-id", // extra field — must be rejected by .strict()
385
- };
386
-
387
- // .strict() on leadSubmitSchema rejects unknown keys → invalid_input
388
- // Omit the extra `id` field from the type so only one cast is needed.
389
- const { id: _id, ...inputWithoutId } = inputWithExtraId;
390
- const inputForService = {
391
- ...inputWithoutId,
392
- // Re-attach as a plain unknown property so Zod's strict() can reject it.
393
- ...({ id: inputWithExtraId.id } as Record<string, unknown>),
394
- } as Parameters<typeof routeLead>[0];
395
- const result = (await routeLead(inputForService, sourceMeta)) as Extract<
396
- RouteLeadResult,
397
- { ok: false }
398
- >;
399
-
400
- expect(result.ok).toBe(false);
401
- expect(result.error.kind).toBe("invalid_input");
402
- // The DB must not have been called with an attacker-supplied id
403
- expect(prisma.lead.create).not.toHaveBeenCalled();
404
- });
405
-
406
- // ── Regression: MEDIUM-4 — CRLF injection in siteId must be rejected ─────
407
- // siteId is constrained to /^[a-zA-Z0-9._-]+$/ which excludes \r\n.
408
- it("regression MEDIUM-4: CRLF injection in siteId returns invalid_input", async () => {
409
- const inputWithCrlf = {
410
- kind: "subscribe" as const,
411
- siteId: "x\r\nBcc: attacker@x.com", // header injection attempt
412
- email: "victim@example.com",
413
- source: "footer",
414
- };
415
-
416
- const result = (await routeLead(inputWithCrlf, sourceMeta)) as Extract<
417
- RouteLeadResult,
418
- { ok: false }
419
- >;
420
-
421
- expect(result.ok).toBe(false);
422
- expect(result.error.kind).toBe("invalid_input");
423
- expect(prisma.subscriber.create).not.toHaveBeenCalled();
424
- });
425
-
426
- // ── #6203/#6254 — the workspaceId stamp is ALWAYS a copy of siteId ────────
427
- //
428
- // routeLead(kind: "submit") has FOUR callers in this repo, and as of
429
- // #6254 ALL FOUR resolve `siteId` to a real `wksp_<uuidv7>` workspace id
430
- // before calling in — the three that once passed an unresolved alias
431
- // (flow-engine.ts, recommender-capture.ts, project-listings.ts's
432
- // submitProjectListingDraft) now resolve it themselves
433
- // (`resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`,
434
- // `apps/api/src/lib/site.ts`). `workspaceId` is NOT NULL as of
435
- // database/migrations/20260922120000_tenancy_fence, so `persistToDb` can
436
- // no longer stamp `null` for an unresolved alias the way #6203 once did —
437
- // it copies `siteId` unconditionally, and a caller that still somehow
438
- // passed an unresolved alias hits the pre-existing
439
- // `leads_workspace_id_format` CHECK loudly (via the AC-11 re-raise this
440
- // file's other describe block proves) instead of landing a
441
- // silently-unfenced row.
442
- describe("the workspaceId stamp copies siteId unconditionally (#6203, #6254)", () => {
443
- it("workspaceId is always the SAME value as siteId, whatever shape siteId carries", async () => {
444
- await routeLead(leadInput, sourceMeta); // leadInput.siteId === "site-abc"
445
-
446
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
447
- const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
448
- expect(data.siteId).toBe("site-abc");
449
- expect(data.workspaceId).toBe("site-abc");
450
- });
451
-
452
- it("a workspace-shaped siteId (the routes/leads/index.ts path) stamps workspaceId to the SAME value", async () => {
453
- const resolvedInput = {
454
- ...leadInput,
455
- siteId: "wksp_00000000-0000-7000-8000-000000000001",
456
- };
457
-
458
- await routeLead(resolvedInput, sourceMeta);
459
-
460
- const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
461
- expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
462
- expect(data.workspaceId).toBe(
463
- "wksp_00000000-0000-7000-8000-000000000001",
464
- );
465
- });
466
- });
467
-
468
- // #6254 AC-11 (spec D6, SEC-02) — a constraint violation (the new
469
- // `leads_workspace_site_agree` CHECK, or the pre-existing NOT NULL) must
470
- // never be swallowed into the file fallback and a silent `ok:true`: it
471
- // means OUR OWN derivation produced a row Postgres itself refuses, which
472
- // is a bug to surface, not an outage to degrade through. A GENUINE
473
- // infrastructure failure (the existing "DB down → file fallback" test
474
- // above, a plain `Error("connection refused")` that carries none of the
475
- // signals below) must still take the file fallback, unchanged.
476
- describe("a constraint violation re-raises as a deliberate error, never a silent success (#6254 AC-11)", () => {
477
- const listTmpDirFiles = () => fs.readdir(tmpDir);
478
-
479
- it("a Prisma-mapped class-23 code (e.g. P2011, null constraint) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
480
- const before = await listTmpDirFiles();
481
- const constraintErr = Object.assign(
482
- new Error("Null constraint violation on the field: (`workspace_id`)"),
483
- { code: "P2011" },
484
- );
485
- (prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
486
-
487
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
488
- RouteLeadResult,
489
- { ok: false }
490
- >;
491
-
492
- expect(result.ok).toBe(false);
493
- expect(result.error.kind).toBe("persist_failed");
494
- // No file was written — the fallback was never attempted.
495
- const after = await listTmpDirFiles();
496
- expect(after).toEqual(before);
497
- });
498
-
499
- it("a raw SQLSTATE the message carries (no Prisma-mapped code — the new CHECK constraint's own shape) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
500
- const before = await listTmpDirFiles();
501
- const checkErr = new Error(
502
- 'new row for relation "leads" violates check constraint "leads_workspace_site_agree"',
503
- );
504
- (prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
505
-
506
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
507
- RouteLeadResult,
508
- { ok: false }
509
- >;
510
-
511
- expect(result.ok).toBe(false);
512
- expect(result.error.kind).toBe("persist_failed");
513
- const after = await listTmpDirFiles();
514
- expect(after).toEqual(before);
515
- });
516
-
517
- it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
518
- const before = await listTmpDirFiles();
519
- const notNullErr = Object.assign(
520
- new Error(
521
- 'null value in column "workspace_id" violates not-null constraint (23502)',
522
- ),
523
- {},
524
- );
525
- (prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
526
-
527
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
528
- RouteLeadResult,
529
- { ok: false }
530
- >;
531
-
532
- expect(result.ok).toBe(false);
533
- expect(result.error.kind).toBe("persist_failed");
534
- const after = await listTmpDirFiles();
535
- expect(after).toEqual(before);
536
- });
537
-
538
- it("a genuine outage (no class-23 signal) still takes the file fallback — persisted:file, not persist_failed", async () => {
539
- // Regression guard for the branch above: an ordinary connection error
540
- // must not be misclassified as a constraint violation.
541
- (prisma.lead.create as jest.Mock).mockRejectedValue(
542
- new Error("connection refused"),
543
- );
544
-
545
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
546
- RouteLeadResult,
547
- { ok: true }
548
- >;
549
-
550
- expect(result.ok).toBe(true);
551
- expect(result.persisted).toBe("file");
552
- });
553
- });
554
- });
555
-
556
- // ─── #6257 — the fence: every fenced read carries workspaceId ───────────────
557
-
558
- /**
559
- * Unit C2's contract for this service (#6257 AC-1/AC-2). `routeLead`'s one
560
- * READ is the duplicate-subscriber lookup on the P2002 arm. `input.siteId` is
561
- * the resolved workspace id at every caller (the same guarantee the three
562
- * `create` sites above stamp on), so the lookup is scoped by it rather than
563
- * resolving a second time.
564
- */
565
- describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () => {
566
- beforeEach(() => {
567
- jest.clearAllMocks();
568
- process.env.DATABASE_URL = "postgres://test";
569
- });
570
-
571
- it("scopes the existing-subscriber read by the caller's resolved workspace id", async () => {
572
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(
573
- Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
574
- );
575
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
576
- id: "sub_1",
577
- });
578
-
579
- await routeLead(
580
- { ...subscribeInput, email: "dupe@example.com" },
581
- sourceMeta,
582
- );
583
-
584
- const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
585
- .calls[0][0];
586
- expect(where.workspaceId).toBe(subscribeInput.siteId);
587
- expect(where.siteId_email).toEqual({
588
- siteId: subscribeInput.siteId,
589
- email: "dupe@example.com",
590
- });
591
- });
592
- });
593
-
594
- // ─── #6730 D1/D2 — success only for a durable row or an accepted email ───────
595
-
596
- describe("#6730 — routeLead delivery modes", () => {
597
- const ENV_KEYS = [
598
- "DATABASE_URL",
599
- "NODE_ENV",
600
- "RESEND_API_KEY",
601
- "AILK_LEAD_NOTIFICATION_TO",
602
- "AILK_LEAD_NOTIFICATION_FROM",
603
- "AILK_INBOX_FALLBACK_DIR",
604
- "LEAD_CAPTURE_FILE",
605
- ] as const;
606
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
607
- let tmpDir: string;
608
- const listFiles = () => fs.readdir(tmpDir);
609
-
610
- const EMAIL_ENV = {
611
- RESEND_API_KEY: "re_test",
612
- AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
613
- AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
614
- };
615
-
616
- beforeEach(async () => {
617
- jest.clearAllMocks();
618
- saved = {};
619
- for (const k of ENV_KEYS) saved[k] = process.env[k];
620
- for (const k of ENV_KEYS) delete process.env[k];
621
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
622
- process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
623
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
624
- mockSendLeadNotification.mockResolvedValue({
625
- ok: true,
626
- messageId: "msg-1",
627
- });
628
- });
629
-
630
- afterEach(async () => {
631
- for (const k of ENV_KEYS) {
632
- if (saved[k] === undefined) delete process.env[k];
633
- else process.env[k] = saved[k];
634
- }
635
- await fs.rm(tmpDir, { recursive: true, force: true });
636
- });
637
-
638
- describe("email delivery (the three email vars, no DATABASE_URL, production)", () => {
639
- beforeEach(() => {
640
- Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
641
- });
642
-
643
- it("an accepted email → ok:true persisted:email with an email- id; no Prisma, no file fallback", async () => {
644
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
645
- RouteLeadResult,
646
- { ok: true }
647
- >;
648
-
649
- expect(result).toMatchObject({
650
- ok: true,
651
- persisted: "email",
652
- email: { ok: true, messageId: "msg-1" },
653
- });
654
- expect(result.id).toMatch(/^email-/);
655
- expect(result.id).toMatch(/^[a-z0-9_-]{8,64}$/i);
656
- expect(prisma.lead.create).not.toHaveBeenCalled();
657
- expect(await listFiles()).toEqual([]);
658
- expect(mockSendLeadNotification).toHaveBeenCalledTimes(1);
659
- });
660
-
661
- it("a refused email → ok:false delivery_failed (never reported as sent)", async () => {
662
- mockSendLeadNotification.mockResolvedValue({
663
- ok: false,
664
- error: { kind: "provider_error", message: "rejected" },
665
- });
666
-
667
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
668
- RouteLeadResult,
669
- { ok: false }
670
- >;
671
-
672
- expect(result.ok).toBe(false);
673
- expect(result.error.kind).toBe("delivery_failed");
674
- expect(prisma.lead.create).not.toHaveBeenCalled();
675
- expect(await listFiles()).toEqual([]);
676
- });
677
-
678
- it("a skipped email (provider reports the key missing) → delivery_failed", async () => {
679
- mockSendLeadNotification.mockResolvedValue({
680
- ok: false,
681
- error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
682
- });
683
-
684
- const result = await routeLead(leadInput, sourceMeta);
685
- expect(result).toMatchObject({
686
- ok: false,
687
- error: { kind: "delivery_failed" },
688
- });
689
- });
690
-
691
- it("a callback is delivered by email too — carrying the name and number to call", async () => {
692
- const result = await routeLead(
693
- { ...callbackInput, preferredTime: "mornings" },
694
- sourceMeta,
695
- );
696
- expect(result).toMatchObject({ ok: true, persisted: "email" });
697
- expect(prisma.callback.create).not.toHaveBeenCalled();
698
- const arg = mockSendLeadNotification.mock.calls[0][0];
699
- expect(arg.lead.payload).toMatchObject({
700
- name: "Alice",
701
- phone: "+15551234567",
702
- preferredTime: "mornings",
703
- notes: "urgent",
704
- });
705
- // No visitor address to reply to — never an empty Reply-To.
706
- expect(arg.replyToOverride).toBe("owner@example.com");
707
- });
708
-
709
- it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
710
- const result = await routeLead(subscribeInput, sourceMeta);
711
- expect(result).toMatchObject({
712
- ok: false,
713
- error: { kind: "config_missing" },
714
- });
715
- expect(prisma.subscriber.create).not.toHaveBeenCalled();
716
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
717
- });
718
- });
719
-
720
- describe("production with neither a database nor the email vars", () => {
721
- beforeEach(() => {
722
- process.env.NODE_ENV = "production";
723
- });
724
-
725
- it("refuses with config_missing before any write", async () => {
726
- const result = await routeLead(leadInput, sourceMeta);
727
-
728
- expect(result).toMatchObject({
729
- ok: false,
730
- error: { kind: "config_missing" },
731
- });
732
- expect(prisma.lead.create).not.toHaveBeenCalled();
733
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
734
- expect(await listFiles()).toEqual([]);
735
- });
736
- });
737
-
738
- describe("inbox delivery in production with the database failing (not a constraint)", () => {
739
- beforeEach(() => {
740
- process.env.DATABASE_URL = "postgres://test";
741
- process.env.NODE_ENV = "production";
742
- (prisma.lead.create as jest.Mock).mockRejectedValue(
743
- new Error("connection refused"),
744
- );
745
- });
746
-
747
- it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
748
- const result = await routeLead(leadInput, sourceMeta);
749
-
750
- expect(result).toMatchObject({
751
- ok: false,
752
- error: { kind: "persist_failed" },
753
- });
754
- expect(await listFiles()).toEqual([]);
755
- });
756
-
757
- it("email vars set and the send accepted → persisted:email", async () => {
758
- Object.assign(process.env, EMAIL_ENV);
759
-
760
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
761
- RouteLeadResult,
762
- { ok: true }
763
- >;
764
-
765
- expect(result).toMatchObject({ ok: true, persisted: "email" });
766
- expect(result.id).toMatch(/^email-/);
767
- expect(await listFiles()).toEqual([]);
768
- });
769
-
770
- it("a newsletter signup is never emailed — persist_failed", async () => {
771
- Object.assign(process.env, EMAIL_ENV);
772
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(
773
- new Error("connection refused"),
774
- );
775
-
776
- const result = await routeLead(subscribeInput, sourceMeta);
777
-
778
- expect(result).toMatchObject({
779
- ok: false,
780
- error: { kind: "persist_failed" },
781
- });
782
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
783
- });
784
-
785
- it("email vars set but the send refused → persist_failed", async () => {
786
- Object.assign(process.env, EMAIL_ENV);
787
- mockSendLeadNotification.mockResolvedValue({
788
- ok: false,
789
- error: { kind: "provider_error", message: "rejected" },
790
- });
791
-
792
- const result = await routeLead(leadInput, sourceMeta);
793
- expect(result).toMatchObject({
794
- ok: false,
795
- error: { kind: "persist_failed" },
796
- });
797
- });
798
- });
799
-
800
- describe("inbox delivery outside production keeps today's file fallback", () => {
801
- it("DB down in test → persisted:file", async () => {
802
- process.env.DATABASE_URL = "postgres://test";
803
- process.env.NODE_ENV = "test";
804
- (prisma.lead.create as jest.Mock).mockRejectedValue(
805
- new Error("connection refused"),
806
- );
807
-
808
- const result = await routeLead(leadInput, sourceMeta);
809
- expect(result).toMatchObject({ ok: true, persisted: "file" });
810
- expect((await listFiles()).length).toBeGreaterThan(0);
811
- });
812
- });
813
- });
814
-
815
- // ── #7869 — the Idempotency-Key on submit (spec D3–D5, D10 cases 9–14) ──────
816
- //
817
- // Cases 9–13 run on `inbox` delivery (DATABASE_URL set), where the key is
818
- // honoured; case 14 drops DATABASE_URL to prove a non-inbox delivery ignores
819
- // it. A keyed create that loses the race throws P2002 and is answered from a
820
- // re-read of the winner.
821
- describe("#7869 — routeLead with an idempotency key", () => {
822
- const KEY = "fixture-retry-0000-0000-0001";
823
- const WINNER_ID = "lead_00000000-0000-7000-8000-00000000f001";
824
- const ENV_KEYS = [
825
- "DATABASE_URL",
826
- "NODE_ENV",
827
- "AILK_INBOX_FALLBACK_DIR",
828
- "LEAD_CAPTURE_FILE",
829
- "AILK_LEAD_NOTIFICATION_TO",
830
- "AILK_LEAD_NOTIFICATION_FROM",
831
- ] as const;
832
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
833
- let tmpDir: string;
834
-
835
- const p2002 = () =>
836
- Object.assign(
837
- new Error(
838
- "Unique constraint failed on the fields: (`workspace_id`,`idempotency_key`)",
839
- ),
840
- { code: "P2002" },
841
- );
842
- const keyWhere = (workspaceId: string) => ({
843
- where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey: KEY } },
844
- select: { id: true, email: true, source: true, payload: true },
845
- });
846
- const createData = () =>
847
- jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
848
- string,
849
- unknown
850
- >;
851
-
852
- beforeEach(async () => {
853
- jest.clearAllMocks();
854
- saved = {};
855
- for (const k of ENV_KEYS) saved[k] = process.env[k];
856
- for (const k of ENV_KEYS) delete process.env[k];
857
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-idem-test-"));
858
- process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
859
- process.env.DATABASE_URL = "postgres://stub";
860
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
861
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
862
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
863
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
864
- mockSendLeadNotification.mockResolvedValue({
865
- ok: true,
866
- messageId: "msg-1",
867
- });
868
- });
869
-
870
- afterEach(async () => {
871
- for (const k of ENV_KEYS) {
872
- if (saved[k] === undefined) delete process.env[k];
873
- else process.env[k] = saved[k];
874
- }
875
- await fs.rm(tmpDir, { recursive: true, force: true });
876
- });
877
-
878
- it("case 9 — a P2002 race whose winner matches answers the winner's id: no fallback, no persist_failed, no notification, no capture record", async () => {
879
- // Positive control first: a keyed create with no race writes exactly one
880
- // line to the capture file, so the negative assertion below is load-bearing.
881
- const controlFile = path.join(tmpDir, "control.jsonl");
882
- process.env.LEAD_CAPTURE_FILE = controlFile;
883
- const control = await routeLead(leadInput, sourceMeta, {
884
- idempotencyKey: KEY,
885
- });
886
- expect(control).toMatchObject({
887
- ok: true,
888
- id: "lead_db_new",
889
- persisted: "db",
890
- });
891
- expect(
892
- (await fs.readFile(controlFile, "utf8")).trim().split("\n"),
893
- ).toHaveLength(1);
894
- expect(createData()).toMatchObject({ idempotencyKey: KEY });
895
-
896
- jest.clearAllMocks();
897
- const raceFile = path.join(tmpDir, "race.jsonl");
898
- process.env.LEAD_CAPTURE_FILE = raceFile;
899
- (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
900
- (prisma.lead.findUnique as jest.Mock)
901
- .mockResolvedValueOnce(null) // the pre-write lookup misses
902
- .mockResolvedValueOnce({
903
- id: WINNER_ID,
904
- email: leadInput.email,
905
- source: leadInput.source,
906
- payload: { ...leadInput.payload },
907
- });
908
- const before = await fs.readdir(tmpDir);
909
-
910
- const result = await routeLead(leadInput, sourceMeta, {
911
- idempotencyKey: KEY,
912
- });
913
-
914
- expect(result).toEqual({
915
- ok: true,
916
- replayed: true,
917
- id: WINNER_ID,
918
- persisted: "db",
919
- email: { skipped: true, reason: "idempotent_replay" },
920
- });
921
- expect(prisma.lead.findUnique).toHaveBeenNthCalledWith(
922
- 2,
923
- keyWhere(leadInput.siteId),
924
- );
925
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
926
- await expect(fs.access(raceFile)).rejects.toThrow();
927
- expect(await fs.readdir(tmpDir)).toEqual(before); // no file fallback
928
- });
929
-
930
- it("case 10 — a P2002 race whose winner carries a different email is idempotency_conflict", async () => {
931
- (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
932
- (prisma.lead.findUnique as jest.Mock)
933
- .mockResolvedValueOnce(null)
934
- .mockResolvedValueOnce({
935
- id: WINNER_ID,
936
- email: "someone-else@example.com",
937
- source: leadInput.source,
938
- payload: leadInput.payload,
939
- });
940
-
941
- const result = await routeLead(leadInput, sourceMeta, {
942
- idempotencyKey: KEY,
943
- });
944
-
945
- expect(result).toEqual({
946
- ok: false,
947
- error: {
948
- kind: "idempotency_conflict",
949
- message: "idempotency key reused",
950
- },
951
- });
952
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
953
- });
954
-
955
- it("case 11 — a P2002 race whose re-read finds nothing takes the existing persist_failed path", async () => {
956
- (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
957
-
958
- const result = (await routeLead(leadInput, sourceMeta, {
959
- idempotencyKey: KEY,
960
- })) as Extract<RouteLeadResult, { ok: false }>;
961
-
962
- expect(result.ok).toBe(false);
963
- expect(result.error.kind).toBe("persist_failed");
964
- expect(prisma.lead.findUnique).toHaveBeenCalledTimes(2);
965
- });
966
-
967
- it("case 12 — a P2002 with no key passed is persist_failed, as today, with no key lookup", async () => {
968
- (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
969
-
970
- const result = (await routeLead(leadInput, sourceMeta)) as Extract<
971
- RouteLeadResult,
972
- { ok: false }
973
- >;
974
-
975
- expect(result.ok).toBe(false);
976
- expect(result.error.kind).toBe("persist_failed");
977
- expect(prisma.lead.findUnique).not.toHaveBeenCalled();
978
- expect(createData()).not.toHaveProperty("idempotencyKey");
979
- });
980
-
981
- it("case 13 — a lookup that throws still reaches the create (the existing path)", async () => {
982
- (prisma.lead.findUnique as jest.Mock).mockRejectedValue(
983
- new Error("connection reset"),
984
- );
985
-
986
- const result = await routeLead(leadInput, sourceMeta, {
987
- idempotencyKey: KEY,
988
- });
989
-
990
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
991
- expect(result).toMatchObject({
992
- ok: true,
993
- id: "lead_db_new",
994
- persisted: "db",
995
- });
996
- });
997
-
998
- it("case 14 — a non-inbox delivery ignores the key: no lookup, no key on the create, the existing file path", async () => {
999
- delete process.env.DATABASE_URL; // `local` delivery outside production
1000
- (prisma.lead.create as jest.Mock).mockRejectedValue(
1001
- new Error("connection refused"),
1002
- );
1003
-
1004
- const result = await routeLead(leadInput, sourceMeta, {
1005
- idempotencyKey: KEY,
1006
- });
1007
-
1008
- expect(prisma.lead.findUnique).not.toHaveBeenCalled();
1009
- expect(createData()).not.toHaveProperty("idempotencyKey");
1010
- expect(result).toMatchObject({ ok: true, persisted: "file" });
1011
- });
1012
- });
1013
-
1014
- // ─── #8730, #9130 — a keyed consent-bearing lead replays ─────────────────────
1015
- //
1016
- // The route writes `payload.consent` on every request, its `acceptedAt` that
1017
- // request's receive time, so a retry's payload differs from the stored one
1018
- // there and nowhere else. The replay comparison leaves it out for EVERY
1019
- // consent-bearing source — iterating the list, so a source added to it later
1020
- // cannot be gated by the route yet compared here.
1021
- describe("#8730, #9130 — routeLead replays a keyed consent-bearing lead", () => {
1022
- const KEY = "fixture-retry-0000-0000-9130";
1023
- const STORED_ID = "lead_00000000-0000-7000-8000-000000009131";
1024
- const ENV_KEYS = [
1025
- "DATABASE_URL",
1026
- "NODE_ENV",
1027
- "AILK_INBOX_FALLBACK_DIR",
1028
- "LEAD_CAPTURE_FILE",
1029
- "AILK_LEAD_NOTIFICATION_TO",
1030
- "AILK_LEAD_NOTIFICATION_FROM",
1031
- ] as const;
1032
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1033
- let tmpDir: string;
1034
-
1035
- const consentAt = (acceptedAt: string) => ({
1036
- statement: "I accept the terms.",
1037
- acceptedAt,
1038
- });
1039
-
1040
- beforeEach(async () => {
1041
- jest.clearAllMocks();
1042
- saved = {};
1043
- for (const k of ENV_KEYS) saved[k] = process.env[k];
1044
- for (const k of ENV_KEYS) delete process.env[k];
1045
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-consent-replay-"));
1046
- process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
1047
- process.env.DATABASE_URL = "postgres://stub";
1048
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1049
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1050
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
1051
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
1052
- });
1053
-
1054
- afterEach(async () => {
1055
- for (const k of ENV_KEYS) {
1056
- if (saved[k] === undefined) delete process.env[k];
1057
- else process.env[k] = saved[k];
1058
- }
1059
- await fs.rm(tmpDir, { recursive: true, force: true });
1060
- });
1061
-
1062
- it("covers every consent-bearing source, the job application included", () => {
1063
- expect(CONSENT_LEAD_SOURCE_LIST).toContain("flow:job-application");
1064
- });
1065
-
1066
- it.each(CONSENT_LEAD_SOURCE_LIST)(
1067
- "%s — a retry whose only difference is the server-written payload.consent replays the stored lead",
1068
- async (source) => {
1069
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1070
- id: STORED_ID,
1071
- email: "visitor@example.com",
1072
- source,
1073
- payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1074
- });
1075
-
1076
- const result = await routeLead(
1077
- {
1078
- kind: "submit",
1079
- siteId: "site-abc",
1080
- source,
1081
- email: "visitor@example.com",
1082
- payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
1083
- },
1084
- sourceMeta,
1085
- { idempotencyKey: KEY },
1086
- );
1087
-
1088
- expect(result).toMatchObject({ ok: true, replayed: true, id: STORED_ID });
1089
- expect(prisma.lead.create).not.toHaveBeenCalled();
1090
- expect(mockSendLeadNotification).not.toHaveBeenCalled();
1091
- },
1092
- );
1093
-
1094
- it.each(CONSENT_LEAD_SOURCE_LIST)(
1095
- "%s — a retry with a different form field is still a conflict",
1096
- async (source) => {
1097
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1098
- id: STORED_ID,
1099
- email: "visitor@example.com",
1100
- source,
1101
- payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1102
- });
1103
-
1104
- const result = await routeLead(
1105
- {
1106
- kind: "submit",
1107
- siteId: "site-abc",
1108
- source,
1109
- email: "visitor@example.com",
1110
- payload: { name: "Someone else", consent: consentAt("2026-10-18T12:00:00.000Z") },
1111
- },
1112
- sourceMeta,
1113
- { idempotencyKey: KEY },
1114
- );
1115
-
1116
- expect(result).toEqual({
1117
- ok: false,
1118
- error: { kind: "idempotency_conflict", message: "idempotency key reused" },
1119
- });
1120
- expect(prisma.lead.create).not.toHaveBeenCalled();
1121
- },
1122
- );
1123
-
1124
- it("a source outside the list still compares payload.consent", async () => {
1125
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1126
- id: STORED_ID,
1127
- email: "visitor@example.com",
1128
- source: "flow:quiz",
1129
- payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1130
- });
1131
-
1132
- const result = await routeLead(
1133
- {
1134
- kind: "submit",
1135
- siteId: "site-abc",
1136
- source: "flow:quiz",
1137
- email: "visitor@example.com",
1138
- payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
1139
- },
1140
- sourceMeta,
1141
- { idempotencyKey: KEY },
1142
- );
1143
-
1144
- expect(result).toMatchObject({ ok: false, error: { kind: "idempotency_conflict" } });
1145
- });
1146
- });
1147
-
1148
- // ─── #852 D6 — every submitted lead carries its Person ──────────────────────
1149
- //
1150
- // The Person step runs through the shared mock's `person` delegate, whose
1151
- // `findFirst` default resolves one fixed Person (`MOCK_PERSON_ID`). Lead
1152
- // capture is a never-gate surface: an unstorable address or a failed step
1153
- // writes the lead with `personId: null` and changes nothing else.
1154
- describe("#852 — the lead's Person (AC-3)", () => {
1155
- const ENV_KEYS = [
1156
- "DATABASE_URL",
1157
- "NODE_ENV",
1158
- "AILK_LEAD_NOTIFICATION_TO",
1159
- "AILK_LEAD_NOTIFICATION_FROM",
1160
- ] as const;
1161
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1162
- const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
1163
- const createData = () =>
1164
- jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
1165
- string,
1166
- unknown
1167
- >;
1168
- const personDelegate = () => ({
1169
- findFirst: jest.mocked(prisma.person.findFirst),
1170
- createMany: jest.mocked(prisma.person.createMany),
1171
- updateMany: jest.mocked(prisma.person.updateMany),
1172
- });
1173
- const personCalls = () =>
1174
- Object.values(personDelegate()).reduce(
1175
- (n, fn) => n + fn.mock.calls.length,
1176
- 0,
1177
- );
1178
- let warn: jest.SpyInstance;
1179
- let error: jest.SpyInstance;
1180
-
1181
- beforeEach(() => {
1182
- jest.clearAllMocks();
1183
- saved = {};
1184
- for (const k of ENV_KEYS) saved[k] = process.env[k];
1185
- process.env.DATABASE_URL = "postgres://stub";
1186
- delete process.env.NODE_ENV;
1187
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1188
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1189
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1190
- mockSendLeadNotification.mockResolvedValue({
1191
- ok: true,
1192
- messageId: "msg-852",
1193
- });
1194
- warn = jest
1195
- .spyOn(logger, "warn")
1196
- .mockImplementation(() => undefined as never);
1197
- error = jest
1198
- .spyOn(logger, "error")
1199
- .mockImplementation(() => undefined as never);
1200
- });
1201
-
1202
- afterEach(() => {
1203
- for (const k of ENV_KEYS) {
1204
- if (saved[k] === undefined) delete process.env[k];
1205
- else process.env[k] = saved[k];
1206
- }
1207
- warn.mockRestore();
1208
- error.mockRestore();
1209
- });
1210
-
1211
- it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
1212
- const result = await routeLead(
1213
- {
1214
- ...leadInput,
1215
- email: "Test@Example.com",
1216
- payload: { name: "Tess", message: "hi" },
1217
- },
1218
- sourceMeta,
1219
- );
1220
-
1221
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1222
- expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
1223
- expect.objectContaining({
1224
- where: { workspaceId: "site-abc", email: "test@example.com" },
1225
- }),
1226
- );
1227
- expect(createData()).toEqual(
1228
- expect.objectContaining({ personId: MOCK_PERSON_ID }),
1229
- );
1230
- });
1231
-
1232
- // ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
1233
- const LINKED = {
1234
- ok: true as const,
1235
- value: {
1236
- personId: MOCK_PERSON_ID as never,
1237
- created: false,
1238
- billing: "unchanged" as const,
1239
- },
1240
- };
1241
- const findSpies: jest.SpyInstance[] = [];
1242
- const spyFind = () => {
1243
- const spy = jest.spyOn(personService, "findOrCreatePerson");
1244
- findSpies.push(spy);
1245
- return spy;
1246
- };
1247
- afterEach(() => {
1248
- while (findSpies.length > 0) findSpies.pop()?.mockRestore();
1249
- });
1250
- const subscriberData = () =>
1251
- jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
1252
- string,
1253
- unknown
1254
- >;
1255
- const callbackData = () =>
1256
- jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
1257
- string,
1258
- unknown
1259
- >;
1260
-
1261
- it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
1262
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1263
- id: "subscr_x",
1264
- });
1265
- const find = spyFind().mockResolvedValue(LINKED);
1266
- await routeLead(subscribeInput, sourceMeta);
1267
- expect(find).toHaveBeenCalledTimes(1);
1268
- expect(find).toHaveBeenCalledWith("site-abc", {
1269
- email: "subscriber@example.com",
1270
- });
1271
- expect(subscriberData()).toEqual(
1272
- expect.objectContaining({ personId: MOCK_PERSON_ID }),
1273
- );
1274
- });
1275
-
1276
- it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
1277
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1278
- id: "subscr_x",
1279
- });
1280
- jest
1281
- .spyOn(personService, "findOrCreatePerson")
1282
- .mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
1283
- const result = await routeLead(subscribeInput, sourceMeta);
1284
- expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
1285
- expect(subscriberData()).toEqual(
1286
- expect.objectContaining({ personId: null }),
1287
- );
1288
- const skipped = warn.mock.calls.filter(
1289
- ([fields]) => (fields as { event?: string }).event === "person-skipped",
1290
- );
1291
- expect(skipped).toHaveLength(1);
1292
- expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1293
- });
1294
-
1295
- it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
1296
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1297
- id: "subscr_x",
1298
- });
1299
- const find = spyFind().mockResolvedValue(LINKED);
1300
- const linked = await routeLead(subscribeInput, sourceMeta);
1301
- jest.mocked(prisma.subscriber.create).mockClear();
1302
-
1303
- find.mockResolvedValueOnce({
1304
- ok: false,
1305
- error: { kind: "internal_error" },
1306
- });
1307
- const failed = await routeLead(subscribeInput, sourceMeta);
1308
- expect(failed).toEqual(linked);
1309
- expect(subscriberData()).toEqual(
1310
- expect.objectContaining({ personId: null }),
1311
- );
1312
- expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
1313
- event: "person-failed",
1314
- siteId: "site-abc",
1315
- });
1316
- jest.mocked(prisma.subscriber.create).mockClear();
1317
-
1318
- find.mockRejectedValueOnce(new Error("boom"));
1319
- const thrown = await routeLead(subscribeInput, sourceMeta);
1320
- expect(thrown).toEqual(linked);
1321
- expect(subscriberData()).toEqual(
1322
- expect.objectContaining({ personId: null }),
1323
- );
1324
- });
1325
-
1326
- it("a duplicate subscribe (P2002) makes no second Person call", async () => {
1327
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(
1328
- Object.assign(new Error("dup"), { code: "P2002" }),
1329
- );
1330
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
1331
- id: "subscr_existing",
1332
- });
1333
- const find = spyFind().mockResolvedValue(LINKED);
1334
- const result = await routeLead(subscribeInput, sourceMeta);
1335
- expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
1336
- expect(find).toHaveBeenCalledTimes(1);
1337
- });
1338
-
1339
- it("a callback with payload.email and a spaced phone links its Person, carrying the E.164 telephone", async () => {
1340
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1341
- const find = spyFind().mockResolvedValue(LINKED);
1342
- await routeLead(
1343
- {
1344
- ...callbackInput,
1345
- phone: "+1 415 555 0123",
1346
- payload: { email: "c@x.io" },
1347
- },
1348
- sourceMeta,
1349
- );
1350
- expect(find).toHaveBeenCalledWith("site-abc", {
1351
- email: "c@x.io",
1352
- name: "Alice",
1353
- contact: { telephone: "+14155550123" },
1354
- });
1355
- expect(callbackData()).toEqual(
1356
- expect.objectContaining({ personId: MOCK_PERSON_ID }),
1357
- );
1358
- });
1359
-
1360
- it.each([
1361
- ["no payload.email", { notes: "urgent" }],
1362
- ["a numeric payload.email", { email: 12345 }],
1363
- ])(
1364
- "a callback with %s makes no person delegate call and writes no personId key",
1365
- async (_label, payload) => {
1366
- (prisma.callback.create as jest.Mock).mockResolvedValue({
1367
- id: "callbk_x",
1368
- });
1369
- await routeLead({ ...callbackInput, payload }, sourceMeta);
1370
- expect(personCalls()).toBe(0);
1371
- expect(Object.keys(callbackData())).not.toContain("personId");
1372
- },
1373
- );
1374
-
1375
- // ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
1376
- it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
1377
- const find = spyFind().mockResolvedValue(LINKED);
1378
- await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta, {
1379
- contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" },
1380
- });
1381
- expect(find).toHaveBeenCalledWith("site-abc", {
1382
- email: "test@example.com",
1383
- name: "Ann",
1384
- contact: { givenName: "Ann", telephone: "+14155550123" },
1385
- });
1386
- // the lead's payload is the raw answers, unchanged
1387
- expect(createData()["payload"]).toEqual({ name: "Bob" });
1388
- });
1389
-
1390
- it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
1391
- const find = spyFind().mockResolvedValue(LINKED);
1392
- await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
1393
- expect(find).toHaveBeenCalledWith("site-abc", {
1394
- email: "test@example.com",
1395
- name: "Bob",
1396
- });
1397
- });
1398
-
1399
- it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
1400
- // `leadInputSchema`'s email check refuses most unstorable addresses before
1401
- // this step, so the Person result is stubbed to reach the branch.
1402
- jest
1403
- .spyOn(personService, "findOrCreatePerson")
1404
- .mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
1405
- const result = await routeLead(leadInput, sourceMeta);
1406
-
1407
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1408
- expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1409
- const skipped = warn.mock.calls.filter(
1410
- ([fields]) => (fields as { event?: string }).event === "person-skipped",
1411
- );
1412
- expect(skipped).toHaveLength(1);
1413
- expect(skipped[0]![0]).toEqual({
1414
- event: "person-skipped",
1415
- kind: "invalid_email",
1416
- siteId: "site-abc",
1417
- });
1418
- expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1419
- });
1420
-
1421
- it("an internal_error Person result is the same never-gate path as a throwing delegate", async () => {
1422
- jest
1423
- .spyOn(personService, "findOrCreatePerson")
1424
- .mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
1425
- const result = await routeLead(leadInput, sourceMeta);
1426
-
1427
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1428
- expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1429
- expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
1430
- event: "person-failed",
1431
- siteId: "site-abc",
1432
- });
1433
- });
1434
-
1435
- it("a Person step that fails writes the lead with personId: null, logs person-failed with no email, and answers like a successful capture", async () => {
1436
- const ok = await routeLead(leadInput, sourceMeta);
1437
- jest.mocked(prisma.lead.create).mockClear();
1438
- mockSendLeadNotification.mockClear();
1439
- personDelegate()["findFirst"]!.mockRejectedValueOnce(
1440
- new Error('relation "people" does not exist'),
1441
- );
1442
-
1443
- const failed = await routeLead(leadInput, sourceMeta);
1444
-
1445
- // The answer is the same as a successful capture, apart from the internal
1446
- // `personId` (#8438 D11) the route never puts on a wire.
1447
- expect(failed).toEqual({ ...ok, personId: null });
1448
- expect(failed).toMatchObject({
1449
- ok: true,
1450
- id: LEAD_ID,
1451
- persisted: "db",
1452
- email: { ok: true },
1453
- });
1454
- expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1455
- const lines = error.mock.calls.filter(
1456
- ([fields]) => (fields as { event?: string }).event === "person-failed",
1457
- );
1458
- expect(lines).toHaveLength(1);
1459
- expect(lines[0]![0]).toEqual({
1460
- event: "person-failed",
1461
- siteId: "site-abc",
1462
- });
1463
- expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
1464
- });
1465
- });
1466
-
1467
- // ─── #7877 D10 — a spoke's checkout lead sets the Person's plan (AC-5) ──────
1468
- //
1469
- // The plan step runs only after this request's own `lead.create`, for a LIVE,
1470
- // paid checkout lead a registered spoke's server posted with the session id
1471
- // as its key. Every other lead makes no plan call; a failing plan step never
1472
- // changes the lead's result.
1473
- // #8793 D1 — a marked checkout lead HOLDS its plan: it never writes a
1474
- // Person's plan, logs `held` (or `skipped` for an offering that is no key),
1475
- // and the plan is released only at the Person's verified first link
1476
- // (`person.test.ts`, `checkout-plan.test.ts`).
1477
- describe("#7877 — the checkout lead holds the plan (AC-5; #8793 D1, AC-7)", () => {
1478
- const ENV_KEYS = [
1479
- "DATABASE_URL",
1480
- "NODE_ENV",
1481
- "AILK_LEAD_NOTIFICATION_TO",
1482
- "AILK_LEAD_NOTIFICATION_FROM",
1483
- ] as const;
1484
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1485
- const LEAD_ID = "lead_00000000-0000-7000-8000-000000007877";
1486
- const WS = "wksp_00000000-0000-7000-8000-000000007877";
1487
- const KEY = "cs_live_a1B2c3"; // gitleaks:allow -- a synthetic Checkout Session id shape (#7877 AC-5), not a credential
1488
- const spokeMeta = { ...sourceMeta, viaSpoke: true };
1489
- const checkoutLead = {
1490
- kind: "submit" as const,
1491
- siteId: WS,
1492
- source: "web",
1493
- email: "cleo@x.io",
1494
- payload: {
1495
- site: "wendi",
1496
- page: "checkout",
1497
- offering: "wendi_retainer",
1498
- } as Record<string, unknown>,
1499
- };
1500
- let setPlan: jest.SpyInstance;
1501
- let info: jest.SpyInstance;
1502
- let error: jest.SpyInstance;
1503
-
1504
- beforeEach(() => {
1505
- jest.clearAllMocks();
1506
- saved = {};
1507
- for (const k of ENV_KEYS) saved[k] = process.env[k];
1508
- process.env.DATABASE_URL = "postgres://stub";
1509
- delete process.env.NODE_ENV;
1510
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1511
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1512
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1513
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1514
- mockSendLeadNotification.mockResolvedValue({
1515
- ok: true,
1516
- messageId: "msg-7877",
1517
- });
1518
- setPlan = jest
1519
- .spyOn(personService, "setPersonPlanOffering")
1520
- .mockResolvedValue("set");
1521
- info = jest
1522
- .spyOn(logger, "info")
1523
- .mockImplementation(() => undefined as never);
1524
- error = jest
1525
- .spyOn(logger, "error")
1526
- .mockImplementation(() => undefined as never);
1527
- });
1528
-
1529
- afterEach(() => {
1530
- for (const k of ENV_KEYS) {
1531
- if (saved[k] === undefined) delete process.env[k];
1532
- else process.env[k] = saved[k];
1533
- }
1534
- setPlan.mockRestore();
1535
- info.mockRestore();
1536
- error.mockRestore();
1537
- });
1538
-
1539
- const planLines = () =>
1540
- [...info.mock.calls, ...error.mock.calls]
1541
- .map(([fields]) => fields as Record<string, unknown>)
1542
- .filter((fields) => fields.event === "checkout-plan");
1543
- /** Every `checkout-plan` line is logged at `info`, never at `error`. */
1544
- const infoPlanLines = () =>
1545
- info.mock.calls
1546
- .map(([fields]) => fields as Record<string, unknown>)
1547
- .filter((fields) => fields.event === "checkout-plan");
1548
- /** The lead's answer, as it was before #8793. */
1549
- const answered = {
1550
- ok: true,
1551
- id: LEAD_ID,
1552
- personId: MOCK_PERSON_ID,
1553
- persisted: "db",
1554
- email: { ok: true, messageId: "msg-7877" },
1555
- };
1556
-
1557
- it("a live spoke checkout lead holds the plan: no plan write, one held line", async () => {
1558
- const result = await routeLead(checkoutLead, spokeMeta, {
1559
- idempotencyKey: KEY,
1560
- });
1561
- expect(result).toEqual(answered);
1562
- expect(setPlan).not.toHaveBeenCalled();
1563
- expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
1564
- expect(infoPlanLines()).toEqual([
1565
- { event: "checkout-plan", outcome: "held", siteId: WS },
1566
- ]);
1567
- expect(planLines()).toEqual(infoPlanLines());
1568
- });
1569
-
1570
- it("a bare slug offering is held as it is", async () => {
1571
- await routeLead(
1572
- {
1573
- ...checkoutLead,
1574
- payload: { ...checkoutLead.payload, offering: "retainer" },
1575
- },
1576
- spokeMeta,
1577
- { idempotencyKey: KEY },
1578
- );
1579
- expect(setPlan).not.toHaveBeenCalled();
1580
- expect(planLines()).toEqual([
1581
- { event: "checkout-plan", outcome: "held", siteId: WS },
1582
- ]);
1583
- });
1584
-
1585
- it("a positive integer amountTotal still qualifies", async () => {
1586
- await routeLead(
1587
- {
1588
- ...checkoutLead,
1589
- payload: { ...checkoutLead.payload, amountTotal: 4900 },
1590
- },
1591
- spokeMeta,
1592
- { idempotencyKey: KEY },
1593
- );
1594
- expect(setPlan).not.toHaveBeenCalled();
1595
- expect(planLines()).toEqual([
1596
- { event: "checkout-plan", outcome: "held", siteId: WS },
1597
- ]);
1598
- });
1599
-
1600
- it.each([
1601
- ["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
1602
- ["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
1603
- [
1604
- "amountTotal 0",
1605
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
1606
- spokeMeta,
1607
- KEY,
1608
- ],
1609
- [
1610
- "a negative amountTotal",
1611
- {
1612
- ...checkoutLead,
1613
- payload: { ...checkoutLead.payload, amountTotal: -5 },
1614
- },
1615
- spokeMeta,
1616
- KEY,
1617
- ],
1618
- [
1619
- "a key that is not a session id",
1620
- checkoutLead,
1621
- spokeMeta,
1622
- "abcdefghijklmnop",
1623
- ],
1624
- ["no key", checkoutLead, spokeMeta, undefined],
1625
- ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1626
- [
1627
- "page contact",
1628
- {
1629
- ...checkoutLead,
1630
- payload: { ...checkoutLead.payload, page: "contact" },
1631
- },
1632
- spokeMeta,
1633
- KEY,
1634
- ],
1635
- [
1636
- "a numeric offering",
1637
- { ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
1638
- spokeMeta,
1639
- KEY,
1640
- ],
1641
- ] as const)("no plan call for %s", async (_label, input, meta, key) => {
1642
- const result = await routeLead(
1643
- input as typeof checkoutLead,
1644
- meta,
1645
- key === undefined ? {} : { idempotencyKey: key },
1646
- );
1647
- expect(result).toMatchObject({ ok: true, persisted: "db" });
1648
- expect(setPlan).not.toHaveBeenCalled();
1649
- expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
1650
- expect(planLines()).toEqual([]);
1651
- });
1652
-
1653
- // #8185 — the hub stores its conclusion that a lead is a spoke's live paid
1654
- // checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
1655
- // relayed". An unmarked create names no `spokeCheckout` key at all.
1656
- const createData = () =>
1657
- (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
1658
- string,
1659
- unknown
1660
- >;
1661
-
1662
- it("a live spoke checkout lead is created with spokeCheckout true and holds the plan once", async () => {
1663
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1664
- expect(createData()).toMatchObject({ spokeCheckout: true });
1665
- expect(setPlan).not.toHaveBeenCalled();
1666
- expect(planLines()).toHaveLength(1);
1667
- });
1668
-
1669
- it("the same lead relayed for a visitor is created with no spokeCheckout key and holds no plan", async () => {
1670
- await routeLead(
1671
- checkoutLead,
1672
- { ...spokeMeta, relayed: true },
1673
- { idempotencyKey: KEY },
1674
- );
1675
- expect(createData()).not.toHaveProperty("spokeCheckout");
1676
- expect(setPlan).not.toHaveBeenCalled();
1677
- expect(planLines()).toEqual([]);
1678
- });
1679
-
1680
- it.each([
1681
- ["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
1682
- ["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
1683
- [
1684
- "amountTotal 0",
1685
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
1686
- spokeMeta,
1687
- KEY,
1688
- ],
1689
- [
1690
- "a negative amountTotal",
1691
- {
1692
- ...checkoutLead,
1693
- payload: { ...checkoutLead.payload, amountTotal: -5 },
1694
- },
1695
- spokeMeta,
1696
- KEY,
1697
- ],
1698
- [
1699
- "a key that is not a session id",
1700
- checkoutLead,
1701
- spokeMeta,
1702
- "abcdefghijklmnop",
1703
- ],
1704
- ["no key", checkoutLead, spokeMeta, undefined],
1705
- ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1706
- [
1707
- "page contact",
1708
- {
1709
- ...checkoutLead,
1710
- payload: { ...checkoutLead.payload, page: "contact" },
1711
- },
1712
- spokeMeta,
1713
- KEY,
1714
- ],
1715
- [
1716
- "a numeric offering",
1717
- { ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
1718
- spokeMeta,
1719
- KEY,
1720
- ],
1721
- ] as const)(
1722
- "no spokeCheckout key in the create data for %s",
1723
- async (_label, input, meta, key) => {
1724
- await routeLead(
1725
- input as typeof checkoutLead,
1726
- meta,
1727
- key === undefined ? {} : { idempotencyKey: key },
1728
- );
1729
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1730
- expect(createData()).not.toHaveProperty("spokeCheckout");
1731
- },
1732
- );
1733
-
1734
- it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1735
- const result = await routeLead(
1736
- {
1737
- ...checkoutLead,
1738
- payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
1739
- },
1740
- spokeMeta,
1741
- { idempotencyKey: KEY },
1742
- );
1743
- expect(result).toEqual(answered);
1744
- expect(setPlan).not.toHaveBeenCalled();
1745
- expect(infoPlanLines()).toEqual([
1746
- { event: "checkout-plan", outcome: "skipped", siteId: WS },
1747
- ]);
1748
- expect(planLines()).toEqual(infoPlanLines());
1749
- });
1750
-
1751
- it("no checkout-plan line names an email, an offering or a key", async () => {
1752
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1753
- expect(planLines()).toHaveLength(1);
1754
- for (const line of planLines()) {
1755
- expect(line).not.toHaveProperty("email");
1756
- expect(line).not.toHaveProperty("offering");
1757
- expect(line).not.toHaveProperty("key");
1758
- }
1759
- const logged = JSON.stringify(planLines());
1760
- expect(logged).not.toContain("retainer");
1761
- expect(logged).not.toContain("cleo@x.io");
1762
- expect(logged).not.toContain(KEY);
1763
- });
1764
-
1765
- it("a replayed key writes nothing and sets nothing", async () => {
1766
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1767
- id: LEAD_ID,
1768
- email: checkoutLead.email,
1769
- source: checkoutLead.source,
1770
- payload: checkoutLead.payload,
1771
- });
1772
- const result = await routeLead(checkoutLead, spokeMeta, {
1773
- idempotencyKey: KEY,
1774
- });
1775
- expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
1776
- expect(prisma.lead.create).not.toHaveBeenCalled();
1777
- expect(setPlan).not.toHaveBeenCalled();
1778
- });
1779
- });
1780
-
1781
- describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
1782
- const ENV_KEYS = [
1783
- "DATABASE_URL",
1784
- "NODE_ENV",
1785
- "AILK_LEAD_NOTIFICATION_TO",
1786
- "AILK_LEAD_NOTIFICATION_FROM",
1787
- "AILK_DELIVERABLE_EMAIL_FROM",
1788
- ] as const;
1789
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1790
- const LEAD_ID = "lead_00000000-0000-7000-8000-000000008757";
1791
- const WS = "wksp_00000000-0000-7000-8000-000000008757";
1792
- const KEY = "cs_live_d14Purchase"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8757 AC-11), not a credential
1793
- const ORIGIN = "https://spoke.example";
1794
- const spokeMeta = { ...sourceMeta, viaSpoke: true };
1795
- const checkoutLead = {
1796
- kind: "submit" as const,
1797
- siteId: WS,
1798
- source: "web",
1799
- email: "Cleo@X.io",
1800
- payload: {
1801
- site: "wendi",
1802
- page: "checkout",
1803
- offering: "wendi_retainer",
1804
- amountTotal: 4900,
1805
- currency: "usd",
1806
- } as Record<string, unknown>,
1807
- };
1808
- // The lead table's count — the per-recipient cap's one read. The shared
1809
- // prisma double does not carry it, so this suite arranges it.
1810
- const leadCount = jest.fn();
1811
- let setPlan: jest.SpyInstance;
1812
- let info: jest.SpyInstance;
1813
- let error: jest.SpyInstance;
1814
-
1815
- /** Let the fired (void) email step run to its end. */
1816
- const settle = async () => {
1817
- for (let i = 0; i < 5; i += 1)
1818
- await new Promise((resolve) => setImmediate(resolve));
1819
- };
1820
- const interactionCreates = () =>
1821
- jest.mocked(prisma.interaction.create).mock.calls;
1822
- const emailCalls = () => mockSendPurchaseConfirmationEmail.mock.calls;
1823
-
1824
- beforeEach(() => {
1825
- jest.clearAllMocks();
1826
- saved = {};
1827
- for (const k of ENV_KEYS) saved[k] = process.env[k];
1828
- process.env.DATABASE_URL = "postgres://stub";
1829
- delete process.env.NODE_ENV;
1830
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1831
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1832
- delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
1833
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1834
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1835
- Object.assign(prisma.lead, { count: leadCount });
1836
- leadCount.mockReset().mockResolvedValue(1);
1837
- jest
1838
- .mocked(prisma.interaction.create)
1839
- .mockResolvedValue({ id: "intx_1" } as never);
1840
- mockSendLeadNotification.mockResolvedValue({
1841
- ok: true,
1842
- messageId: "msg-8757",
1843
- });
1844
- mockSendPurchaseConfirmationEmail.mockResolvedValue({
1845
- ok: true,
1846
- messageId: "msg-buy",
1847
- });
1848
- mockListPlanOfferings.mockResolvedValue([
1849
- { key: "retainer", name: "Retainer", type: "service" },
1850
- ]);
1851
- setPlan = jest
1852
- .spyOn(personService, "setPersonPlanOffering")
1853
- .mockResolvedValue("set");
1854
- info = jest
1855
- .spyOn(logger, "info")
1856
- .mockImplementation(() => undefined as never);
1857
- error = jest
1858
- .spyOn(logger, "error")
1859
- .mockImplementation(() => undefined as never);
1860
- });
1861
-
1862
- afterEach(() => {
1863
- for (const k of ENV_KEYS) {
1864
- if (saved[k] === undefined) delete process.env[k];
1865
- else process.env[k] = saved[k];
1866
- }
1867
- Reflect.deleteProperty(prisma.lead, "count");
1868
- setPlan.mockRestore();
1869
- info.mockRestore();
1870
- error.mockRestore();
1871
- });
1872
-
1873
- it("the first write sends one purchase email with the spoke's account link and writes one Inbox message", async () => {
1874
- const result = await routeLead(checkoutLead, spokeMeta, {
1875
- idempotencyKey: KEY,
1876
- accountOrigin: ORIGIN,
1877
- });
1878
- await settle();
1879
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1880
-
1881
- expect(emailCalls()).toHaveLength(1);
1882
- const [email] = emailCalls()[0] as [Record<string, string>];
1883
- expect(email).toMatchObject({
1884
- to: "Cleo@X.io",
1885
- fromAddress: "noreply@example.com",
1886
- subject: "Your purchase",
1887
- cta: "Open your account",
1888
- accountLink: `${ORIGIN}/api/hub-session/sign-in?next=%2Faccount&email=Cleo%40X.io`,
1889
- });
1890
- expect(email.body).toContain("Retainer");
1891
- expect(email.body).toContain("$49.00");
1892
-
1893
- expect(interactionCreates()).toHaveLength(1);
1894
- const [{ data }] = interactionCreates()[0] as [
1895
- { data: Record<string, unknown> },
1896
- ];
1897
- expect(data).toEqual({
1898
- workspaceId: WS,
1899
- personId: MOCK_PERSON_ID,
1900
- channel: "member",
1901
- direction: "outbound",
1902
- subject: "Your purchase",
1903
- body: email.body,
1904
- createdBy: "system:checkout",
1905
- });
1906
- expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
1907
- });
1908
-
1909
- it("an offering that is not listed is named 'your purchase' in both, never the raw string", async () => {
1910
- await routeLead(
1911
- {
1912
- ...checkoutLead,
1913
- payload: { ...checkoutLead.payload, offering: "wendi_<b>Mystery</b>" },
1914
- },
1915
- spokeMeta,
1916
- { idempotencyKey: KEY, accountOrigin: ORIGIN },
1917
- );
1918
- await settle();
1919
- const [email] = emailCalls()[0] as [Record<string, string>];
1920
- const [{ data }] = interactionCreates()[0] as [
1921
- { data: Record<string, string> },
1922
- ];
1923
- for (const body of [email.body, data.body]) {
1924
- expect(body).toContain("your purchase");
1925
- expect(body).not.toContain("Mystery");
1926
- expect(body).not.toContain("wendi_");
1927
- }
1928
- });
1929
-
1930
- it.each([
1931
- [
1932
- "the lister fails",
1933
- () => mockListPlanOfferings.mockResolvedValueOnce({ failed: "Error" }),
1934
- ],
1935
- [
1936
- "the lister throws",
1937
- () => mockListPlanOfferings.mockRejectedValueOnce(new Error("down")),
1938
- ],
1939
- ])(
1940
- "when %s, the offering is 'your purchase' and both still go out",
1941
- async (_label, arrange) => {
1942
- arrange();
1943
- await routeLead(checkoutLead, spokeMeta, {
1944
- idempotencyKey: KEY,
1945
- accountOrigin: ORIGIN,
1946
- });
1947
- await settle();
1948
- expect(emailCalls()).toHaveLength(1);
1949
- expect(interactionCreates()).toHaveLength(1);
1950
- const [{ data }] = interactionCreates()[0] as [
1951
- { data: Record<string, string> },
1952
- ];
1953
- expect(data.body).toContain("your purchase");
1954
- },
1955
- );
1956
-
1957
- it("a listed name holding a bidi override comes out with it stripped", async () => {
1958
- mockListPlanOfferings.mockResolvedValue([
1959
- { key: "retainer", name: "Re\u202Etainer\u0007", type: "service" },
1960
- ]);
1961
- await routeLead(checkoutLead, spokeMeta, {
1962
- idempotencyKey: KEY,
1963
- accountOrigin: ORIGIN,
1964
- });
1965
- await settle();
1966
- const [email] = emailCalls()[0] as [Record<string, string>];
1967
- const [{ data }] = interactionCreates()[0] as [
1968
- { data: Record<string, string> },
1969
- ];
1970
- for (const body of [email.body, data.body]) {
1971
- expect(body).toContain("Retainer");
1972
-
1973
- // The two code points by name, not in a regex: a scaffold's standalone
1974
- // lint refuses a control character in a regular expression.
1975
- expect(body).not.toContain("\u202E");
1976
- expect(body).not.toContain("\u0007");
1977
- }
1978
- });
1979
-
1980
- it("with no amount, the body names the offering alone", async () => {
1981
- const { amountTotal: _a, currency: _c, ...payload } = checkoutLead.payload;
1982
- await routeLead({ ...checkoutLead, payload }, spokeMeta, {
1983
- idempotencyKey: KEY,
1984
- accountOrigin: ORIGIN,
1985
- });
1986
- await settle();
1987
- const [{ data }] = interactionCreates()[0] as [
1988
- { data: Record<string, string> },
1989
- ];
1990
- expect(data.body).toBe("Thanks for your purchase of Retainer.");
1991
- });
1992
-
1993
- it("a Stripe special-case currency (ISK) leaves the amount out rather than misstate it", async () => {
1994
- await routeLead(
1995
- {
1996
- ...checkoutLead,
1997
- payload: {
1998
- ...checkoutLead.payload,
1999
- amountTotal: 990000,
2000
- currency: "isk",
2001
- },
2002
- },
2003
- spokeMeta,
2004
- { idempotencyKey: KEY, accountOrigin: ORIGIN },
2005
- );
2006
- await settle();
2007
- const [{ data }] = interactionCreates()[0] as [
2008
- { data: Record<string, string> },
2009
- ];
2010
- expect(data.body).toBe("Thanks for your purchase of Retainer.");
2011
- });
2012
-
2013
- it("an offering read that outlasts its budget names 'your purchase' and does not hold the lead", async () => {
2014
- mockListPlanOfferings.mockReturnValue(new Promise(() => undefined));
2015
- const started = Date.now();
2016
- const result = await routeLead(checkoutLead, spokeMeta, {
2017
- idempotencyKey: KEY,
2018
- accountOrigin: ORIGIN,
2019
- });
2020
- expect(Date.now() - started).toBeLessThan(4000);
2021
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2022
- const [{ data }] = interactionCreates()[0] as [
2023
- { data: Record<string, string> },
2024
- ];
2025
- expect(data.body).toContain("your purchase");
2026
- }, 10_000);
2027
-
2028
- it("with no account origin, the email carries no link", async () => {
2029
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2030
- await settle();
2031
- expect(emailCalls()).toHaveLength(1);
2032
- expect(emailCalls()[0]?.[0]).not.toHaveProperty("accountLink");
2033
- expect(interactionCreates()).toHaveLength(1);
2034
- });
2035
-
2036
- it("a replay of the same Idempotency-Key makes neither", async () => {
2037
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2038
- id: LEAD_ID,
2039
- email: checkoutLead.email,
2040
- source: checkoutLead.source,
2041
- payload: checkoutLead.payload,
2042
- });
2043
- const result = await routeLead(checkoutLead, spokeMeta, {
2044
- idempotencyKey: KEY,
2045
- accountOrigin: ORIGIN,
2046
- });
2047
- await settle();
2048
- expect(result).toMatchObject({ ok: true, replayed: true });
2049
- expect(emailCalls()).toHaveLength(0);
2050
- expect(interactionCreates()).toHaveLength(0);
2051
- });
2052
-
2053
- it.each([
2054
- ["a test-mode checkout lead", spokeMeta, "cs_test_d14Purchase"],
2055
- ["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
2056
- ["an anonymous (non-spoke) lead", sourceMeta, KEY],
2057
- ] as const)("%s makes neither", async (_label, meta, key) => {
2058
- const result = await routeLead(checkoutLead, meta, {
2059
- idempotencyKey: key,
2060
- accountOrigin: ORIGIN,
2061
- });
2062
- await settle();
2063
- expect(result).toMatchObject({ ok: true, persisted: "db" });
2064
- expect(emailCalls()).toHaveLength(0);
2065
- expect(interactionCreates()).toHaveLength(0);
2066
- });
2067
-
2068
- it("a lead with no Person makes neither", async () => {
2069
- const find = jest
2070
- .spyOn(personService, "findOrCreatePerson")
2071
- .mockResolvedValue({
2072
- ok: false,
2073
- error: { kind: "invalid_email" },
2074
- } as never);
2075
- try {
2076
- const result = await routeLead(checkoutLead, spokeMeta, {
2077
- idempotencyKey: KEY,
2078
- accountOrigin: ORIGIN,
2079
- });
2080
- await settle();
2081
- expect(result).toMatchObject({ ok: true, persisted: "db" });
2082
- expect(emailCalls()).toHaveLength(0);
2083
- expect(interactionCreates()).toHaveLength(0);
2084
- } finally {
2085
- find.mockRestore();
2086
- }
2087
- });
2088
-
2089
- it("with five marked leads for the address in the last day, the sixth sends no email and still writes the message", async () => {
2090
- const now = Date.now();
2091
- leadCount.mockResolvedValue(6); // the five before, and this one
2092
- await routeLead(checkoutLead, spokeMeta, {
2093
- idempotencyKey: KEY,
2094
- accountOrigin: ORIGIN,
2095
- });
2096
- await settle();
2097
- expect(emailCalls()).toHaveLength(0);
2098
- expect(interactionCreates()).toHaveLength(1);
2099
- const [{ where }] = leadCount.mock.calls[0] as [
2100
- { where: Record<string, unknown> },
2101
- ];
2102
- expect(where).toMatchObject({
2103
- workspaceId: WS,
2104
- spokeCheckout: true,
2105
- email: { equals: "Cleo@X.io", mode: "insensitive" },
2106
- });
2107
- const since = (where.createdAt as { gte: Date }).gte.getTime();
2108
- expect(now - since).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000 - 1000);
2109
- expect(now - since).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000);
2110
- });
2111
-
2112
- it("with more than 50 marked leads in the workspace in the last hour, no email (any address) and the message is still written", async () => {
2113
- const now = Date.now();
2114
- // The recipient count first, then the workspace's hourly count.
2115
- leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(51);
2116
- await routeLead(checkoutLead, spokeMeta, {
2117
- idempotencyKey: KEY,
2118
- accountOrigin: ORIGIN,
2119
- });
2120
- await settle();
2121
- expect(emailCalls()).toHaveLength(0);
2122
- expect(interactionCreates()).toHaveLength(1);
2123
- const [{ where }] = leadCount.mock.calls[1] as [
2124
- { where: Record<string, unknown> },
2125
- ];
2126
- expect(where).toMatchObject({ workspaceId: WS, spokeCheckout: true });
2127
- expect(where).not.toHaveProperty("email");
2128
- const since = (where.createdAt as { gte: Date }).gte.getTime();
2129
- expect(now - since).toBeGreaterThanOrEqual(60 * 60 * 1000 - 1000);
2130
- expect(now - since).toBeLessThanOrEqual(60 * 60 * 1000 + 5000);
2131
- });
2132
-
2133
- it("with exactly 50 in the hour, the email still goes", async () => {
2134
- leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(50);
2135
- await routeLead(checkoutLead, spokeMeta, {
2136
- idempotencyKey: KEY,
2137
- accountOrigin: ORIGIN,
2138
- });
2139
- await settle();
2140
- expect(emailCalls()).toHaveLength(1);
2141
- });
2142
-
2143
- it("with five in the day including this one, the email still goes", async () => {
2144
- leadCount.mockResolvedValue(5);
2145
- await routeLead(checkoutLead, spokeMeta, {
2146
- idempotencyKey: KEY,
2147
- accountOrigin: ORIGIN,
2148
- });
2149
- await settle();
2150
- expect(emailCalls()).toHaveLength(1);
2151
- });
2152
-
2153
- it.each([
2154
- [
2155
- "the Inbox write throws",
2156
- () =>
2157
- jest
2158
- .mocked(prisma.interaction.create)
2159
- .mockRejectedValueOnce(new Error("down")),
2160
- ],
2161
- [
2162
- "the cap read throws",
2163
- () => leadCount.mockRejectedValueOnce(new Error("down")),
2164
- ],
2165
- [
2166
- "the email send rejects",
2167
- () =>
2168
- mockSendPurchaseConfirmationEmail.mockRejectedValueOnce(
2169
- new Error("down"),
2170
- ),
2171
- ],
2172
- ])(
2173
- "a purchase step that fails (%s) never changes the lead's answer",
2174
- async (_label, arrange) => {
2175
- arrange();
2176
- const result = await routeLead(checkoutLead, spokeMeta, {
2177
- idempotencyKey: KEY,
2178
- accountOrigin: ORIGIN,
2179
- });
2180
- await settle();
2181
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2182
- expect(setPlan).not.toHaveBeenCalled();
2183
- const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
2184
- expect(logged).not.toContain("Cleo@X.io");
2185
- expect(logged).not.toContain("retainer");
2186
- },
2187
- );
2188
-
2189
- it("with no sender configured, no email is sent and the message is still written", async () => {
2190
- delete process.env.AILK_LEAD_NOTIFICATION_FROM;
2191
- await routeLead(checkoutLead, spokeMeta, {
2192
- idempotencyKey: KEY,
2193
- accountOrigin: ORIGIN,
2194
- });
2195
- await settle();
2196
- expect(emailCalls()).toHaveLength(0);
2197
- expect(interactionCreates()).toHaveLength(1);
2198
- });
2199
- });
2200
-
2201
- describe("#8778 — a spoke checkout's terms grant", () => {
2202
- const ENV_KEYS = [
2203
- "DATABASE_URL",
2204
- "NODE_ENV",
2205
- "AILK_LEAD_NOTIFICATION_TO",
2206
- "AILK_LEAD_NOTIFICATION_FROM",
2207
- ] as const;
2208
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
2209
- const LEAD_ID = "lead_00000000-0000-7000-8000-000000008778";
2210
- const WS = "wksp_00000000-0000-7000-8000-000000008778";
2211
- const KEY = "cs_live_g8778Grant"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8778), not a credential
2212
- const ACCOUNT_TERMS = consentPurposeById(
2213
- SITE_CONSENT_CONFIG_DEFAULTS,
2214
- ACCOUNT_TERMS_PURPOSE_ID,
2215
- )!;
2216
- const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
2217
- const spokeMeta = { ...sourceMeta, viaSpoke: true };
2218
- const checkoutLead = {
2219
- kind: "submit" as const,
2220
- siteId: WS,
2221
- source: "web",
2222
- email: "Cleo@X.io",
2223
- payload: {
2224
- site: "wendi",
2225
- page: "checkout",
2226
- offering: "wendi_retainer",
2227
- amountTotal: 4900,
2228
- currency: "usd",
2229
- checkoutSession: KEY,
2230
- termsAccepted: STAMP,
2231
- } as Record<string, unknown>,
2232
- };
2233
- const leadCount = jest.fn();
2234
- let double: ConsentLedgerDouble;
2235
- let setPlan: jest.SpyInstance;
2236
- let info: jest.SpyInstance;
2237
- let error: jest.SpyInstance;
2238
-
2239
- /** The ledger double, plus the per-session replay read it does not model. */
2240
- function armLedger(): void {
2241
- double = armConsentLedger(prisma);
2242
- const ledgerRead = (prisma.$queryRaw as jest.Mock).getMockImplementation()!;
2243
- (prisma.$queryRaw as jest.Mock).mockImplementation(
2244
- (strings: TemplateStringsArray, ...values: unknown[]) => {
2245
- if (strings.join("?").includes(`"source" = 'checkout'`)) {
2246
- const [workspaceId, personId, purposeId, sessionId] =
2247
- values as string[];
2248
- return Promise.resolve(
2249
- double.ledger
2250
- .filter(
2251
- (r) =>
2252
- r.workspaceId === workspaceId &&
2253
- r.personId === personId &&
2254
- r.purposeId === purposeId &&
2255
- r.source === "checkout" &&
2256
- r.sourceRef === sessionId,
2257
- )
2258
- .map(() => ({ found: 1 })),
2259
- );
2260
- }
2261
- return ledgerRead(strings, ...values);
2262
- },
2263
- );
2264
- }
2265
-
2266
- const consentLines = () =>
2267
- [...info.mock.calls, ...error.mock.calls]
2268
- .map(([obj]) => obj as Record<string, unknown>)
2269
- .filter((obj) => obj?.["event"] === "checkout-consent");
2270
-
2271
- const settle = async () => {
2272
- for (let i = 0; i < 5; i += 1)
2273
- await new Promise((resolve) => setImmediate(resolve));
2274
- };
2275
-
2276
- beforeEach(() => {
2277
- jest.clearAllMocks();
2278
- saved = {};
2279
- for (const k of ENV_KEYS) saved[k] = process.env[k];
2280
- process.env.DATABASE_URL = "postgres://stub";
2281
- delete process.env.NODE_ENV;
2282
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
2283
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
2284
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
2285
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
2286
- Object.assign(prisma.lead, { count: leadCount });
2287
- leadCount.mockReset().mockResolvedValue(1);
2288
- jest
2289
- .mocked(prisma.interaction.create)
2290
- .mockResolvedValue({ id: "intx_1" } as never);
2291
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
2292
- mockSendPurchaseConfirmationEmail.mockResolvedValue({
2293
- ok: true,
2294
- messageId: "m2",
2295
- });
2296
- mockListPlanOfferings.mockResolvedValue([]);
2297
- setPlan = jest
2298
- .spyOn(personService, "setPersonPlanOffering")
2299
- .mockResolvedValue("set");
2300
- info = jest
2301
- .spyOn(logger, "info")
2302
- .mockImplementation(() => undefined as never);
2303
- error = jest
2304
- .spyOn(logger, "error")
2305
- .mockImplementation(() => undefined as never);
2306
- armLedger();
2307
- });
2308
-
2309
- afterEach(() => {
2310
- for (const k of ENV_KEYS) {
2311
- if (saved[k] === undefined) delete process.env[k];
2312
- else process.env[k] = saved[k];
2313
- }
2314
- Reflect.deleteProperty(prisma.lead, "count");
2315
- for (const fn of [
2316
- prisma.$transaction,
2317
- prisma.$executeRaw,
2318
- prisma.$queryRaw,
2319
- prisma.consentGrant.createMany,
2320
- prisma.consentVisitorLink.createMany,
2321
- ]) {
2322
- (fn as jest.Mock).mockReset();
2323
- }
2324
- setPlan.mockRestore();
2325
- info.mockRestore();
2326
- error.mockRestore();
2327
- });
2328
-
2329
- it("a lead whose session accepted the current terms writes one dated checkout grant for the buyer's Person", async () => {
2330
- const result = await routeLead(checkoutLead, spokeMeta, {
2331
- idempotencyKey: KEY,
2332
- });
2333
- await settle();
2334
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2335
-
2336
- expect(double.ledger).toHaveLength(1);
2337
- const [row] = double.ledger;
2338
- expect(row).toMatchObject({
2339
- workspaceId: WS,
2340
- subjectType: "person",
2341
- subjectId: MOCK_PERSON_ID,
2342
- personId: MOCK_PERSON_ID,
2343
- purposeId: ACCOUNT_TERMS_PURPOSE_ID,
2344
- granted: true,
2345
- source: "checkout",
2346
- sourceRef: KEY,
2347
- policyVersion: ACCOUNT_TERMS.policyVersion,
2348
- locale: "en",
2349
- });
2350
- expect(row!.createdAt).toBeInstanceOf(Date);
2351
- expect(consentLines()).toEqual([
2352
- expect.objectContaining({ outcome: "recorded", siteId: WS }),
2353
- ]);
2354
- // #8793 D1 — the grant is written; the plan is held, never set here.
2355
- expect(setPlan).not.toHaveBeenCalled();
2356
- });
2357
-
2358
- it.each([
2359
- ["termsAccepted: null (the box was not accepted)", null, "consent_absent"],
2360
- ["no termsAccepted key (an older spoke)", undefined, "consent_absent"],
2361
- ["an older version's stamp", `${ACCOUNT_TERMS_PURPOSE_ID}@1`, "terms_unstamped"],
2362
- ["another purpose's stamp", "wt_platform_terms@1", "terms_unstamped"],
2363
- ["a non-string value", true, "terms_unstamped"],
2364
- ])("a lead with %s writes no grant", async (_label, terms, outcome) => {
2365
- const { termsAccepted: _t, ...rest } = checkoutLead.payload;
2366
- const payload = terms === undefined ? rest : { ...rest, termsAccepted: terms };
2367
- const result = await routeLead({ ...checkoutLead, payload }, spokeMeta, {
2368
- idempotencyKey: KEY,
2369
- });
2370
- await settle();
2371
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2372
- expect(double.ledger).toHaveLength(0);
2373
- expect(consentLines()).toEqual([expect.objectContaining({ outcome })]);
2374
- });
2375
-
2376
- it("a replayed spoke webhook (the same Idempotency-Key and body) writes no second grant", async () => {
2377
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2378
- expect(double.ledger).toHaveLength(1);
2379
-
2380
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2381
- id: LEAD_ID,
2382
- email: checkoutLead.email,
2383
- source: checkoutLead.source,
2384
- payload: checkoutLead.payload,
2385
- });
2386
- const replay = await routeLead(checkoutLead, spokeMeta, {
2387
- idempotencyKey: KEY,
2388
- });
2389
- await settle();
2390
- expect(replay).toMatchObject({ ok: true, replayed: true });
2391
- expect(double.ledger).toHaveLength(1);
2392
- });
2393
-
2394
- it("a redelivery whose stored body predates termsAccepted is a replay, not a conflict, and writes no grant", async () => {
2395
- const { termsAccepted: _t, ...storedPayload } = checkoutLead.payload;
2396
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2397
- id: LEAD_ID,
2398
- email: checkoutLead.email,
2399
- source: checkoutLead.source,
2400
- payload: storedPayload,
2401
- });
2402
- const result = await routeLead(checkoutLead, spokeMeta, {
2403
- idempotencyKey: KEY,
2404
- });
2405
- await settle();
2406
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, replayed: true });
2407
- expect(double.ledger).toHaveLength(0);
2408
- });
2409
-
2410
- it("a redelivery that differs in any other payload key is still a conflict", async () => {
2411
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2412
- id: LEAD_ID,
2413
- email: checkoutLead.email,
2414
- source: checkoutLead.source,
2415
- payload: { ...checkoutLead.payload, amountTotal: 1 },
2416
- });
2417
- const result = await routeLead(checkoutLead, spokeMeta, {
2418
- idempotencyKey: KEY,
2419
- });
2420
- expect(result).toMatchObject({
2421
- ok: false,
2422
- error: { kind: "idempotency_conflict" },
2423
- });
2424
- });
2425
-
2426
- it("a second write for a session whose grant the Person already holds writes none (the store's own guard)", async () => {
2427
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2428
- // The lead row lost (the lookup misses), the ledger kept: the grant is
2429
- // still written at most once per Person and session.
2430
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2431
- await settle();
2432
- expect(double.ledger).toHaveLength(1);
2433
- expect(consentLines().map((l) => l["outcome"])).toEqual([
2434
- "recorded",
2435
- "unchanged",
2436
- ]);
2437
- });
2438
-
2439
- it.each([
2440
- ["a test-mode checkout lead", spokeMeta, "cs_test_g8778Grant"],
2441
- ["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
2442
- ["an anonymous (non-spoke) lead", sourceMeta, KEY],
2443
- ] as const)("%s writes no grant", async (_label, meta, key) => {
2444
- const result = await routeLead(checkoutLead, meta, { idempotencyKey: key });
2445
- await settle();
2446
- expect(result).toMatchObject({ ok: true, persisted: "db" });
2447
- expect(double.ledger).toHaveLength(0);
2448
- expect(consentLines()).toEqual([]);
2449
- });
2450
-
2451
- it("a lead with no Person writes no grant", async () => {
2452
- const find = jest
2453
- .spyOn(personService, "findOrCreatePerson")
2454
- .mockResolvedValue({
2455
- ok: false,
2456
- error: { kind: "invalid_email" },
2457
- } as never);
2458
- try {
2459
- await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2460
- await settle();
2461
- expect(double.ledger).toHaveLength(0);
2462
- } finally {
2463
- find.mockRestore();
2464
- }
2465
- });
2466
-
2467
- it("a ledger write that fails never changes the lead's answer, and logs no address or Person id", async () => {
2468
- (prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(
2469
- new Error("down Cleo@X.io"),
2470
- );
2471
- const result = await routeLead(checkoutLead, spokeMeta, {
2472
- idempotencyKey: KEY,
2473
- });
2474
- await settle();
2475
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2476
- expect(consentLines()).toEqual([
2477
- expect.objectContaining({ outcome: "failed", errName: "Error" }),
2478
- ]);
2479
- const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
2480
- expect(logged).not.toContain("Cleo@X.io");
2481
- expect(logged).not.toContain(MOCK_PERSON_ID);
2482
- });
2483
- });
2484
-
2485
- describe("#8932 D7 — the per-Organization webhook's Offerings checkout lead (AC-5)", () => {
2486
- const ENV_KEYS = [
2487
- "DATABASE_URL",
2488
- "NODE_ENV",
2489
- "AILK_LEAD_NOTIFICATION_TO",
2490
- "AILK_LEAD_NOTIFICATION_FROM",
2491
- ] as const;
2492
- let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
2493
- const LEAD_ID = "lead_00000000-0000-7000-8000-000000008932";
2494
- const WS = "wksp_00000000-0000-7000-8000-000000008932";
2495
- const OTHER_WS = "wksp_00000000-0000-7000-8000-000000008933";
2496
- const SESSION = "cs_live_a8932Offerings"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8932 AC-5), not a credential
2497
- const KEY = `stripe-offerings ${SESSION}`;
2498
- const ACCOUNT_TERMS = consentPurposeById(
2499
- SITE_CONSENT_CONFIG_DEFAULTS,
2500
- ACCOUNT_TERMS_PURPOSE_ID,
2501
- )!;
2502
- const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
2503
- /** The input the webhook builds (D6) — the offering is the record's full key. */
2504
- const offeringsLead = {
2505
- kind: "submit" as const,
2506
- siteId: WS,
2507
- source: "web",
2508
- email: "buyer@example.com",
2509
- payload: {
2510
- page: "checkout",
2511
- offering: "growth_monthly",
2512
- site: WS,
2513
- amountTotal: 4900,
2514
- currency: "usd",
2515
- termsAccepted: STAMP,
2516
- } as Record<string, unknown>,
2517
- };
2518
- const webhookMeta = {
2519
- ip: "203.0.113.9",
2520
- userAgent: "Stripe/1.0",
2521
- receivedAt: "2026-10-18T00:00:00.000Z",
2522
- };
2523
- const twin = { idempotencyKey: KEY, offeringsCheckout: true as const };
2524
- const leadCount = jest.fn();
2525
- let setPlan: jest.SpyInstance;
2526
- let consent: jest.SpyInstance;
2527
- let info: jest.SpyInstance;
2528
- let error: jest.SpyInstance;
2529
-
2530
- beforeEach(() => {
2531
- jest.clearAllMocks();
2532
- saved = {};
2533
- for (const k of ENV_KEYS) saved[k] = process.env[k];
2534
- process.env.DATABASE_URL = "postgres://stub";
2535
- delete process.env.NODE_ENV;
2536
- process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
2537
- process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
2538
- (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
2539
- (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
2540
- Object.assign(prisma.lead, { count: leadCount });
2541
- leadCount.mockReset().mockResolvedValue(1);
2542
- jest
2543
- .mocked(prisma.interaction.create)
2544
- .mockResolvedValue({ id: "intx_1" } as never);
2545
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
2546
- mockSendPurchaseConfirmationEmail.mockResolvedValue({ ok: true, messageId: "m2" });
2547
- mockListPlanOfferings.mockResolvedValue([]);
2548
- setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
2549
- consent = jest
2550
- .spyOn(consentService, "recordCheckoutConsent")
2551
- .mockResolvedValue("recorded");
2552
- info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
2553
- error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
2554
- });
2555
-
2556
- afterEach(() => {
2557
- for (const k of ENV_KEYS) {
2558
- if (saved[k] === undefined) delete process.env[k];
2559
- else process.env[k] = saved[k];
2560
- }
2561
- Reflect.deleteProperty(prisma.lead, "count");
2562
- setPlan.mockRestore();
2563
- consent.mockRestore();
2564
- info.mockRestore();
2565
- error.mockRestore();
2566
- });
2567
-
2568
- const createData = () =>
2569
- (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
2570
- const planLines = () =>
2571
- [...info.mock.calls, ...error.mock.calls]
2572
- .map(([fields]) => fields as Record<string, unknown>)
2573
- .filter((fields) => fields?.["event"] === "checkout-plan");
2574
- const planWrites = () =>
2575
- jest
2576
- .mocked(prisma.person.updateMany)
2577
- .mock.calls.filter(([args]) => "planOffering" in ((args as { data?: object }).data ?? {}));
2578
-
2579
- it("creates one marked lead on the prefixed key, holds the full key and grants consent on the bare session id", async () => {
2580
- const result = await routeLead(offeringsLead, webhookMeta, twin);
2581
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2582
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
2583
- expect(createData()).toMatchObject({
2584
- spokeCheckout: true,
2585
- idempotencyKey: KEY,
2586
- payload: expect.objectContaining({ offering: "growth_monthly" }),
2587
- });
2588
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "held", siteId: WS }]);
2589
- expect(consent).toHaveBeenCalledTimes(1);
2590
- expect(consent).toHaveBeenCalledWith(
2591
- expect.objectContaining({ workspaceId: WS, sessionId: SESSION }),
2592
- );
2593
- expect(setPlan).not.toHaveBeenCalled();
2594
- expect(planWrites()).toEqual([]);
2595
- });
2596
-
2597
- it.each([
2598
- ["without offeringsCheckout", offeringsLead, { idempotencyKey: KEY }],
2599
- [
2600
- "an offering the grammar refuses",
2601
- { ...offeringsLead, payload: { ...offeringsLead.payload, offering: "growth_quarterly" } },
2602
- twin,
2603
- ],
2604
- [
2605
- "a site that is another workspace",
2606
- { ...offeringsLead, payload: { ...offeringsLead.payload, site: OTHER_WS } },
2607
- twin,
2608
- ],
2609
- ["a test-mode session key", offeringsLead, { ...twin, idempotencyKey: "stripe-offerings cs_test_a1" }],
2610
- ["the bare session id as the key", offeringsLead, { ...twin, idempotencyKey: SESSION }],
2611
- ["source contact", { ...offeringsLead, source: "contact" }, twin],
2612
- [
2613
- "page contact",
2614
- { ...offeringsLead, payload: { ...offeringsLead.payload, page: "contact" } },
2615
- twin,
2616
- ],
2617
- ] as const)("creates an unmarked lead and holds nothing for %s", async (_label, input, options) => {
2618
- const result = await routeLead(input as typeof offeringsLead, webhookMeta, options);
2619
- expect(result).toMatchObject({ ok: true, persisted: "db" });
2620
- expect(createData()).not.toHaveProperty("spokeCheckout");
2621
- expect(planLines()).toEqual([]);
2622
- expect(consent).not.toHaveBeenCalled();
2623
- expect(prisma.interaction.create).not.toHaveBeenCalled();
2624
- expect(planWrites()).toEqual([]);
2625
- });
2626
-
2627
- it("leaves a spoke checkout lead marked exactly as before", async () => {
2628
- await routeLead(
2629
- {
2630
- kind: "submit",
2631
- siteId: WS,
2632
- source: "web",
2633
- email: "cleo@x.io",
2634
- payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
2635
- },
2636
- { ...webhookMeta, viaSpoke: true },
2637
- { idempotencyKey: "cs_live_a1B2c3" }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
2638
- );
2639
- expect(createData()).toMatchObject({ spokeCheckout: true });
2640
- expect(consent).not.toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION }));
2641
- });
2642
-
2643
- it("the verified first link releases the full lookup key into the Person's plan", async () => {
2644
- setPlan.mockRestore();
2645
- jest.mocked(prisma.lead.findMany).mockResolvedValue([
2646
- { email: offeringsLead.email, payload: offeringsLead.payload },
2647
- ] as never);
2648
- jest.mocked(prisma.person.updateMany).mockResolvedValue({ count: 1 } as never);
2649
- const outcome = await personService.releaseHeldCheckoutPlan(
2650
- WS,
2651
- MOCK_PERSON_ID,
2652
- "usr_buyer_8932",
2653
- "buyer@example.com",
2654
- );
2655
- expect(outcome).toBe("set");
2656
- expect(prisma.person.updateMany).toHaveBeenCalledWith(
2657
- expect.objectContaining({ data: { planOffering: "growth_monthly" } }),
2658
- );
2659
- });
2660
- });