create-ailk 0.24.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 (599) hide show
  1. package/README.md +42 -20
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  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/module-architecture.d.ts +20 -3
  20. package/dist/module-architecture.js +117 -550
  21. package/dist/paid-sections.d.ts +25 -1
  22. package/dist/paid-sections.js +88 -3
  23. package/dist/surfaces.js +10 -2
  24. package/dist/template-record.d.ts +14 -4
  25. package/dist/template-record.js +23 -5
  26. package/dist/topology.d.ts +16 -3
  27. package/dist/topology.js +31 -19
  28. package/dist/update.d.ts +10 -1
  29. package/dist/update.js +540 -16
  30. package/package.json +3 -3
  31. package/release-scope.json +1 -1
  32. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  33. package/templates/.claude/hooks/template-guard.sh +137 -0
  34. package/templates/.claude/rules/extending.md +106 -0
  35. package/templates/.claude/rules/loop-contract.md +2 -2
  36. package/templates/.claude/settings.json +11 -0
  37. package/templates/.env.example +5 -5
  38. package/templates/.github/workflows/ci.yml +7 -4
  39. package/templates/CLAUDE.md +11 -1
  40. package/templates/CONVENTIONS.md +8 -10
  41. package/templates/README.md +14 -13
  42. package/templates/apps/api/.env.example +2 -2
  43. package/templates/apps/api/CLAUDE.md +102 -76
  44. package/templates/apps/api/jest.config.cjs +15 -5
  45. package/templates/apps/api/package.json +16 -20
  46. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  47. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  48. package/templates/apps/api/src/index.ts +1 -1
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  50. package/templates/apps/api/src/modules/commerce.ts +10 -0
  51. package/templates/apps/api/src/modules/content-write.ts +10 -0
  52. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  53. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  54. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  55. package/templates/apps/api/src/modules/workspace.ts +10 -0
  56. package/templates/apps/api/src/server.ts +117 -1882
  57. package/templates/apps/api/vercel.json +2 -2
  58. package/templates/apps/mcp/CLAUDE.md +67 -20
  59. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  60. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  61. package/templates/apps/mcp/jest.config.cjs +6 -1
  62. package/templates/apps/mcp/package.json +5 -13
  63. package/templates/apps/mcp/src/app.ts +8 -7
  64. package/templates/apps/mcp/src/index.ts +6 -34
  65. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  66. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  67. package/templates/apps/mcp/src/site.ts +12 -0
  68. package/templates/apps/web/.env.example +1 -1
  69. package/templates/apps/web/CLAUDE.md +10 -3
  70. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  71. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  72. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  73. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  74. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  75. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  76. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  77. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  78. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  79. package/templates/apps/web/auth.ts +2 -2
  80. package/templates/apps/web/jest.config.cjs +1 -0
  81. package/templates/apps/web/lib/auth-email.ts +1 -1
  82. package/templates/apps/web/lib/relay.ts +2 -2
  83. package/templates/apps/web/lib/release-scope.ts +47 -0
  84. package/templates/apps/web/lib/server-api.ts +1 -1
  85. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  86. package/templates/customizations.yaml +37 -0
  87. package/templates/database/prisma.config.ts +10 -14
  88. package/templates/database/seed-test.ts +2 -2
  89. package/templates/database/seed.ts +5 -5
  90. package/templates/package.json +12 -6
  91. package/templates/pnpm-workspace.yaml +0 -1
  92. package/templates/project.yaml +16 -8
  93. package/templates/tools/jest/reporters.cjs +65 -0
  94. package/templates/tools/lint-architecture.ts +553 -7
  95. package/templates/tsconfig.json +1 -2
  96. package/versions.json +48 -35
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  98. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  99. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  100. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  101. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  102. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  103. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  104. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  105. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  106. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  107. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  108. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  109. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  110. package/templates/apps/api/src/bin/spokes.ts +0 -198
  111. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  112. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  113. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  114. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  115. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  116. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  117. package/templates/apps/api/src/config/index.ts +0 -596
  118. package/templates/apps/api/src/config/modules.ts +0 -83
  119. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  120. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  121. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  122. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  123. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  124. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  125. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  126. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  127. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  128. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  129. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  130. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  131. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  132. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  133. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  134. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  135. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  136. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  137. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  138. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  139. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  140. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  141. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  142. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  143. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  144. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  145. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  146. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  147. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  148. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  149. package/templates/apps/api/src/lib/errors.ts +0 -95
  150. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  151. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  152. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  153. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  154. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  155. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  156. package/templates/apps/api/src/lib/locales.ts +0 -201
  157. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  158. package/templates/apps/api/src/lib/prisma.ts +0 -21
  159. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  160. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  161. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  162. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  163. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  164. package/templates/apps/api/src/lib/site.ts +0 -257
  165. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  166. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  167. package/templates/apps/api/src/lib/stripe.ts +0 -321
  168. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  169. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  170. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  171. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  172. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  173. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  174. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  175. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  176. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  177. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  178. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  179. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  180. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  181. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  182. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  183. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  184. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  185. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  186. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  187. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  188. package/templates/apps/api/src/middleware/auth.ts +0 -864
  189. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  190. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  191. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  192. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  193. package/templates/apps/api/src/openapi/index.ts +0 -18
  194. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  195. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  196. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  197. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  198. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  199. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  200. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  201. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  202. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  203. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  204. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  205. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  206. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  207. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  208. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  209. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  210. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  211. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  212. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  213. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  214. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  215. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  216. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  217. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  218. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  219. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  220. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  221. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  222. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  223. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  224. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  225. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  226. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  227. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  228. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  229. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  230. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  231. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  232. package/templates/apps/api/src/routes/content/index.ts +0 -49
  233. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  234. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  235. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  236. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  237. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  238. package/templates/apps/api/src/routes/content/search.ts +0 -100
  239. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  240. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  241. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  242. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  243. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  244. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  245. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  246. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  247. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  248. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  249. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  250. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  251. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  252. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  253. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  254. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  255. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  256. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  257. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  258. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  259. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  260. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  261. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  262. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  263. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  264. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  265. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  266. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  267. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  268. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  269. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  270. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  271. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  272. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  273. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  274. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  275. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  276. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  277. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  278. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  279. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  280. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  281. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  282. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  285. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  286. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  287. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  288. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  289. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  290. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  291. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  292. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  293. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  294. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  295. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  296. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  297. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  298. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  299. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  300. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  301. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  302. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  303. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  304. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  305. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  306. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  307. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  308. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  309. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  310. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  311. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  312. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  313. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  314. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  315. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  316. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  317. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  318. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  319. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  320. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  321. package/templates/apps/api/src/services/audit-log.ts +0 -961
  322. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  323. package/templates/apps/api/src/services/consent.ts +0 -834
  324. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  325. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  326. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  327. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  328. package/templates/apps/api/src/services/entitlements.ts +0 -338
  329. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  330. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  331. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  332. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  333. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  334. package/templates/apps/api/src/services/listing-config.ts +0 -178
  335. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  336. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  337. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  338. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  339. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  340. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  341. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  342. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  343. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  344. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  345. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  346. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  347. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  348. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  349. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  350. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  351. package/templates/apps/api/src/services/person.ts +0 -1211
  352. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  353. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  354. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  355. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  356. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  357. package/templates/apps/api/src/services/revalidation.ts +0 -235
  358. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  359. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  360. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  361. package/templates/apps/api/src/services/verified-email.ts +0 -71
  362. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  363. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  364. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  365. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  366. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  367. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  368. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  369. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  370. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  371. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  372. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  373. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  374. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  375. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  376. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  377. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  378. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  379. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  380. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  381. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  382. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  383. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  384. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  385. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  386. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  387. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  388. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  389. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  390. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  391. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  392. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  393. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  394. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  395. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  396. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  397. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  398. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  399. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  400. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  401. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  402. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  403. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  404. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  405. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  406. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  407. package/templates/apps/mcp/src/boot.ts +0 -167
  408. package/templates/apps/mcp/src/config/modules.ts +0 -43
  409. package/templates/apps/mcp/src/http-transport.ts +0 -564
  410. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  411. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  412. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  413. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  414. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  415. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  416. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  417. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  418. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  419. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  420. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  421. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  422. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  423. package/templates/apps/mcp/src/request-context.ts +0 -52
  424. package/templates/apps/mcp/src/server.ts +0 -343
  425. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  426. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  427. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  428. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  429. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  430. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  431. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  432. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  433. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  434. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  435. package/templates/apps/mcp/src/tools/index.ts +0 -138
  436. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  437. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  438. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  439. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  440. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  441. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  442. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  443. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  444. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  445. package/templates/database/CHANGELOG.md +0 -981
  446. package/templates/database/LICENSE +0 -202
  447. package/templates/database/NOTICE +0 -2
  448. package/templates/database/README.md +0 -243
  449. package/templates/database/__tests__/agents.db.test.ts +0 -320
  450. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  451. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  452. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  453. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  454. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  455. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  456. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  457. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  458. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  459. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  460. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  461. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  462. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  463. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  464. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  465. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  466. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  467. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  468. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  469. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  470. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  471. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  472. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  473. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  474. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  475. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  476. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  477. package/templates/database/__tests__/person.db.test.ts +0 -586
  478. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  479. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  480. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  481. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  482. package/templates/database/__tests__/seed.test.ts +0 -272
  483. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  484. package/templates/database/__tests__/tenancy.test.ts +0 -141
  485. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  486. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  487. package/templates/database/_gitignore +0 -8
  488. package/templates/database/auth/.gitkeep +0 -0
  489. package/templates/database/auth/schema.prisma +0 -641
  490. package/templates/database/config/.gitkeep +0 -0
  491. package/templates/database/config/schema.prisma +0 -441
  492. package/templates/database/content/.gitkeep +0 -0
  493. package/templates/database/content/schema.prisma +0 -339
  494. package/templates/database/eslint.config.mjs +0 -69
  495. package/templates/database/flows/listing-diagnostic.ts +0 -182
  496. package/templates/database/flows/presets.ts +0 -315
  497. package/templates/database/inbox/.gitkeep +0 -0
  498. package/templates/database/inbox/README.md +0 -3
  499. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  500. package/templates/database/inbox/schema.prisma +0 -1447
  501. package/templates/database/jest.config.cjs +0 -36
  502. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  503. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  504. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  505. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  506. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  507. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  508. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  509. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  510. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  511. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  512. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  513. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  514. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  515. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  516. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  517. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  518. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  519. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  520. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  521. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  522. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  523. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  524. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  525. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  526. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  527. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  528. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  529. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  530. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  531. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  532. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  533. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  534. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  535. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  536. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  537. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  538. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  539. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  540. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  541. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  542. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  543. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  544. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  545. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  546. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  547. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  548. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  549. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  550. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  551. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  552. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  553. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  554. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  555. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  556. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  557. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  558. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  559. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  560. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  561. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  562. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  563. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  564. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  565. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  566. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  567. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  568. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  569. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  570. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  571. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  572. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  573. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  574. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  575. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  576. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  577. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  578. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  579. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  580. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  581. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  582. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  583. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  584. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  585. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  586. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  587. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  588. package/templates/database/migrations/migration_lock.toml +0 -3
  589. package/templates/database/ops/.gitkeep +0 -0
  590. package/templates/database/ops/schema.prisma +0 -43
  591. package/templates/database/package.json +0 -89
  592. package/templates/database/schema.prisma +0 -71
  593. package/templates/database/scripts/db-generate-locked.sh +0 -80
  594. package/templates/database/scripts/person-backfill.sql +0 -132
  595. package/templates/database/tenancy.ts +0 -183
  596. package/templates/database/tsconfig.build.json +0 -16
  597. package/templates/database/tsconfig.flows.json +0 -15
  598. package/templates/database/tsconfig.json +0 -16
  599. 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
- });