create-ailk 0.24.0 → 0.26.0

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