create-ailk 0.20.0 → 0.22.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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -23,9 +23,19 @@ jest.mock("../../lib/prisma.js");
23
23
 
24
24
  // ─── Mock: email (external boundary) ─────────────────────────────────────────
25
25
  const mockSendLeadNotification = jest.fn();
26
+ const mockSendPurchaseConfirmationEmail = jest.fn();
26
27
  jest.mock("@working-theory/email", () => ({
27
28
  sendLeadNotification: (...args: unknown[]) =>
28
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),
29
39
  }));
30
40
 
31
41
  // ─── Import after mocks ───────────────────────────────────────────────────────
@@ -34,8 +44,17 @@ import * as os from "os";
34
44
  import * as path from "path";
35
45
 
36
46
  import { logger } from "@working-theory/observability";
47
+ import {
48
+ ACCOUNT_TERMS_PURPOSE_ID,
49
+ consentPurposeById,
50
+ SITE_CONSENT_CONFIG_DEFAULTS,
51
+ } from "@working-theory/validation";
37
52
 
38
53
  import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
54
+ import {
55
+ armConsentLedger,
56
+ type ConsentLedgerDouble,
57
+ } from "../../lib/__tests__/consent-ledger-double.js";
39
58
  import { prisma } from "../../lib/prisma.js";
40
59
  import { routeLead } from "../lead-routing";
41
60
  import * as personService from "../person";
@@ -141,7 +160,9 @@ describe("routeLead", () => {
141
160
 
142
161
  // ── 2. DB down → fallback engages → persisted:file ───────────────────────
143
162
  it("DB down → file fallback → persisted:file", async () => {
144
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
163
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
164
+ new Error("connection refused"),
165
+ );
145
166
 
146
167
  const result = (await routeLead(leadInput, sourceMeta)) as Extract<
147
168
  RouteLeadResult,
@@ -367,9 +388,12 @@ describe("routeLead", () => {
367
388
  const inputForService = {
368
389
  ...inputWithoutId,
369
390
  // Re-attach as a plain unknown property so Zod's strict() can reject it.
370
- ...({"id": inputWithExtraId.id} as Record<string, unknown>),
391
+ ...({ id: inputWithExtraId.id } as Record<string, unknown>),
371
392
  } as Parameters<typeof routeLead>[0];
372
- const result = (await routeLead(inputForService, sourceMeta)) as Extract<RouteLeadResult, { ok: false }>;
393
+ const result = (await routeLead(inputForService, sourceMeta)) as Extract<
394
+ RouteLeadResult,
395
+ { ok: false }
396
+ >;
373
397
 
374
398
  expect(result.ok).toBe(false);
375
399
  expect(result.error.kind).toBe("invalid_input");
@@ -433,7 +457,9 @@ describe("routeLead", () => {
433
457
 
434
458
  const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
435
459
  expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
436
- expect(data.workspaceId).toBe("wksp_00000000-0000-7000-8000-000000000001");
460
+ expect(data.workspaceId).toBe(
461
+ "wksp_00000000-0000-7000-8000-000000000001",
462
+ );
437
463
  });
438
464
  });
439
465
 
@@ -456,10 +482,10 @@ describe("routeLead", () => {
456
482
  );
457
483
  (prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
458
484
 
459
- const result = (await routeLead(
460
- leadInput,
461
- sourceMeta,
462
- )) as Extract<RouteLeadResult, { ok: false }>;
485
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
486
+ RouteLeadResult,
487
+ { ok: false }
488
+ >;
463
489
 
464
490
  expect(result.ok).toBe(false);
465
491
  expect(result.error.kind).toBe("persist_failed");
@@ -475,10 +501,10 @@ describe("routeLead", () => {
475
501
  );
476
502
  (prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
477
503
 
478
- const result = (await routeLead(
479
- leadInput,
480
- sourceMeta,
481
- )) as Extract<RouteLeadResult, { ok: false }>;
504
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
505
+ RouteLeadResult,
506
+ { ok: false }
507
+ >;
482
508
 
483
509
  expect(result.ok).toBe(false);
484
510
  expect(result.error.kind).toBe("persist_failed");
@@ -489,15 +515,17 @@ describe("routeLead", () => {
489
515
  it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
490
516
  const before = await listTmpDirFiles();
491
517
  const notNullErr = Object.assign(
492
- new Error("null value in column \"workspace_id\" violates not-null constraint (23502)"),
518
+ new Error(
519
+ 'null value in column "workspace_id" violates not-null constraint (23502)',
520
+ ),
493
521
  {},
494
522
  );
495
523
  (prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
496
524
 
497
- const result = (await routeLead(
498
- leadInput,
499
- sourceMeta,
500
- )) as Extract<RouteLeadResult, { ok: false }>;
525
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
526
+ RouteLeadResult,
527
+ { ok: false }
528
+ >;
501
529
 
502
530
  expect(result.ok).toBe(false);
503
531
  expect(result.error.kind).toBe("persist_failed");
@@ -512,10 +540,10 @@ describe("routeLead", () => {
512
540
  new Error("connection refused"),
513
541
  );
514
542
 
515
- const result = (await routeLead(
516
- leadInput,
517
- sourceMeta,
518
- )) as Extract<RouteLeadResult, { ok: true }>;
543
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
544
+ RouteLeadResult,
545
+ { ok: true }
546
+ >;
519
547
 
520
548
  expect(result.ok).toBe(true);
521
549
  expect(result.persisted).toBe("file");
@@ -542,14 +570,17 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
542
570
  (prisma.subscriber.create as jest.Mock).mockRejectedValue(
543
571
  Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
544
572
  );
545
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "sub_1" });
573
+ (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
574
+ id: "sub_1",
575
+ });
546
576
 
547
577
  await routeLead(
548
578
  { ...subscribeInput, email: "dupe@example.com" },
549
579
  sourceMeta,
550
580
  );
551
581
 
552
- const { where } = (prisma.subscriber.findUnique as jest.Mock).mock.calls[0][0];
582
+ const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
583
+ .calls[0][0];
553
584
  expect(where.workspaceId).toBe(subscribeInput.siteId);
554
585
  expect(where.siteId_email).toEqual({
555
586
  siteId: subscribeInput.siteId,
@@ -588,7 +619,10 @@ describe("#6730 — routeLead delivery modes", () => {
588
619
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
589
620
  process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
590
621
  (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
591
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
622
+ mockSendLeadNotification.mockResolvedValue({
623
+ ok: true,
624
+ messageId: "msg-1",
625
+ });
592
626
  });
593
627
 
594
628
  afterEach(async () => {
@@ -646,7 +680,10 @@ describe("#6730 — routeLead delivery modes", () => {
646
680
  });
647
681
 
648
682
  const result = await routeLead(leadInput, sourceMeta);
649
- expect(result).toMatchObject({ ok: false, error: { kind: "delivery_failed" } });
683
+ expect(result).toMatchObject({
684
+ ok: false,
685
+ error: { kind: "delivery_failed" },
686
+ });
650
687
  });
651
688
 
652
689
  it("a callback is delivered by email too — carrying the name and number to call", async () => {
@@ -669,7 +706,10 @@ describe("#6730 — routeLead delivery modes", () => {
669
706
 
670
707
  it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
671
708
  const result = await routeLead(subscribeInput, sourceMeta);
672
- expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
709
+ expect(result).toMatchObject({
710
+ ok: false,
711
+ error: { kind: "config_missing" },
712
+ });
673
713
  expect(prisma.subscriber.create).not.toHaveBeenCalled();
674
714
  expect(mockSendLeadNotification).not.toHaveBeenCalled();
675
715
  });
@@ -683,7 +723,10 @@ describe("#6730 — routeLead delivery modes", () => {
683
723
  it("refuses with config_missing before any write", async () => {
684
724
  const result = await routeLead(leadInput, sourceMeta);
685
725
 
686
- expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
726
+ expect(result).toMatchObject({
727
+ ok: false,
728
+ error: { kind: "config_missing" },
729
+ });
687
730
  expect(prisma.lead.create).not.toHaveBeenCalled();
688
731
  expect(mockSendLeadNotification).not.toHaveBeenCalled();
689
732
  expect(await listFiles()).toEqual([]);
@@ -694,13 +737,18 @@ describe("#6730 — routeLead delivery modes", () => {
694
737
  beforeEach(() => {
695
738
  process.env.DATABASE_URL = "postgres://test";
696
739
  process.env.NODE_ENV = "production";
697
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
740
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
741
+ new Error("connection refused"),
742
+ );
698
743
  });
699
744
 
700
745
  it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
701
746
  const result = await routeLead(leadInput, sourceMeta);
702
747
 
703
- expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
748
+ expect(result).toMatchObject({
749
+ ok: false,
750
+ error: { kind: "persist_failed" },
751
+ });
704
752
  expect(await listFiles()).toEqual([]);
705
753
  });
706
754
 
@@ -719,11 +767,16 @@ describe("#6730 — routeLead delivery modes", () => {
719
767
 
720
768
  it("a newsletter signup is never emailed — persist_failed", async () => {
721
769
  Object.assign(process.env, EMAIL_ENV);
722
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
770
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(
771
+ new Error("connection refused"),
772
+ );
723
773
 
724
774
  const result = await routeLead(subscribeInput, sourceMeta);
725
775
 
726
- expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
776
+ expect(result).toMatchObject({
777
+ ok: false,
778
+ error: { kind: "persist_failed" },
779
+ });
727
780
  expect(mockSendLeadNotification).not.toHaveBeenCalled();
728
781
  });
729
782
 
@@ -735,7 +788,10 @@ describe("#6730 — routeLead delivery modes", () => {
735
788
  });
736
789
 
737
790
  const result = await routeLead(leadInput, sourceMeta);
738
- expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
791
+ expect(result).toMatchObject({
792
+ ok: false,
793
+ error: { kind: "persist_failed" },
794
+ });
739
795
  });
740
796
  });
741
797
 
@@ -743,7 +799,9 @@ describe("#6730 — routeLead delivery modes", () => {
743
799
  it("DB down in test → persisted:file", async () => {
744
800
  process.env.DATABASE_URL = "postgres://test";
745
801
  process.env.NODE_ENV = "test";
746
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
802
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
803
+ new Error("connection refused"),
804
+ );
747
805
 
748
806
  const result = await routeLead(leadInput, sourceMeta);
749
807
  expect(result).toMatchObject({ ok: true, persisted: "file" });
@@ -967,14 +1025,20 @@ describe("#852 — the lead's Person (AC-3)", () => {
967
1025
  let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
968
1026
  const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
969
1027
  const createData = () =>
970
- jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1028
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
1029
+ string,
1030
+ unknown
1031
+ >;
971
1032
  const personDelegate = () => ({
972
1033
  findFirst: jest.mocked(prisma.person.findFirst),
973
1034
  createMany: jest.mocked(prisma.person.createMany),
974
1035
  updateMany: jest.mocked(prisma.person.updateMany),
975
1036
  });
976
1037
  const personCalls = () =>
977
- Object.values(personDelegate()).reduce((n, fn) => n + fn.mock.calls.length, 0);
1038
+ Object.values(personDelegate()).reduce(
1039
+ (n, fn) => n + fn.mock.calls.length,
1040
+ 0,
1041
+ );
978
1042
  let warn: jest.SpyInstance;
979
1043
  let error: jest.SpyInstance;
980
1044
 
@@ -987,9 +1051,16 @@ describe("#852 — the lead's Person (AC-3)", () => {
987
1051
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
988
1052
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
989
1053
  (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
990
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-852" });
991
- warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
992
- error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
1054
+ mockSendLeadNotification.mockResolvedValue({
1055
+ ok: true,
1056
+ messageId: "msg-852",
1057
+ });
1058
+ warn = jest
1059
+ .spyOn(logger, "warn")
1060
+ .mockImplementation(() => undefined as never);
1061
+ error = jest
1062
+ .spyOn(logger, "error")
1063
+ .mockImplementation(() => undefined as never);
993
1064
  });
994
1065
 
995
1066
  afterEach(() => {
@@ -1003,21 +1074,33 @@ describe("#852 — the lead's Person (AC-3)", () => {
1003
1074
 
1004
1075
  it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
1005
1076
  const result = await routeLead(
1006
- { ...leadInput, email: "Test@Example.com", payload: { name: "Tess", message: "hi" } },
1077
+ {
1078
+ ...leadInput,
1079
+ email: "Test@Example.com",
1080
+ payload: { name: "Tess", message: "hi" },
1081
+ },
1007
1082
  sourceMeta,
1008
1083
  );
1009
1084
 
1010
1085
  expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1011
1086
  expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
1012
- expect.objectContaining({ where: { workspaceId: "site-abc", email: "test@example.com" } }),
1087
+ expect.objectContaining({
1088
+ where: { workspaceId: "site-abc", email: "test@example.com" },
1089
+ }),
1090
+ );
1091
+ expect(createData()).toEqual(
1092
+ expect.objectContaining({ personId: MOCK_PERSON_ID }),
1013
1093
  );
1014
- expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1015
1094
  });
1016
1095
 
1017
1096
  // ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
1018
1097
  const LINKED = {
1019
1098
  ok: true as const,
1020
- value: { personId: MOCK_PERSON_ID as never, created: false, billing: "unchanged" as const },
1099
+ value: {
1100
+ personId: MOCK_PERSON_ID as never,
1101
+ created: false,
1102
+ billing: "unchanged" as const,
1103
+ },
1021
1104
  };
1022
1105
  const findSpies: jest.SpyInstance[] = [];
1023
1106
  const spyFind = () => {
@@ -1029,54 +1112,88 @@ describe("#852 — the lead's Person (AC-3)", () => {
1029
1112
  while (findSpies.length > 0) findSpies.pop()?.mockRestore();
1030
1113
  });
1031
1114
  const subscriberData = () =>
1032
- jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1115
+ jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
1116
+ string,
1117
+ unknown
1118
+ >;
1033
1119
  const callbackData = () =>
1034
- jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1120
+ jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
1121
+ string,
1122
+ unknown
1123
+ >;
1035
1124
 
1036
1125
  it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
1037
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1126
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1127
+ id: "subscr_x",
1128
+ });
1038
1129
  const find = spyFind().mockResolvedValue(LINKED);
1039
1130
  await routeLead(subscribeInput, sourceMeta);
1040
1131
  expect(find).toHaveBeenCalledTimes(1);
1041
- expect(find).toHaveBeenCalledWith("site-abc", { email: "subscriber@example.com" });
1042
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1132
+ expect(find).toHaveBeenCalledWith("site-abc", {
1133
+ email: "subscriber@example.com",
1134
+ });
1135
+ expect(subscriberData()).toEqual(
1136
+ expect.objectContaining({ personId: MOCK_PERSON_ID }),
1137
+ );
1043
1138
  });
1044
1139
 
1045
1140
  it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
1046
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1141
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1142
+ id: "subscr_x",
1143
+ });
1047
1144
  jest
1048
1145
  .spyOn(personService, "findOrCreatePerson")
1049
1146
  .mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
1050
1147
  const result = await routeLead(subscribeInput, sourceMeta);
1051
1148
  expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
1052
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1053
- const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
1149
+ expect(subscriberData()).toEqual(
1150
+ expect.objectContaining({ personId: null }),
1151
+ );
1152
+ const skipped = warn.mock.calls.filter(
1153
+ ([fields]) => (fields as { event?: string }).event === "person-skipped",
1154
+ );
1054
1155
  expect(skipped).toHaveLength(1);
1055
1156
  expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1056
1157
  });
1057
1158
 
1058
1159
  it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
1059
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1160
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1161
+ id: "subscr_x",
1162
+ });
1060
1163
  const find = spyFind().mockResolvedValue(LINKED);
1061
1164
  const linked = await routeLead(subscribeInput, sourceMeta);
1062
1165
  jest.mocked(prisma.subscriber.create).mockClear();
1063
1166
 
1064
- find.mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
1167
+ find.mockResolvedValueOnce({
1168
+ ok: false,
1169
+ error: { kind: "internal_error" },
1170
+ });
1065
1171
  const failed = await routeLead(subscribeInput, sourceMeta);
1066
1172
  expect(failed).toEqual(linked);
1067
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1068
- expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({ event: "person-failed", siteId: "site-abc" });
1173
+ expect(subscriberData()).toEqual(
1174
+ expect.objectContaining({ personId: null }),
1175
+ );
1176
+ expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
1177
+ event: "person-failed",
1178
+ siteId: "site-abc",
1179
+ });
1069
1180
  jest.mocked(prisma.subscriber.create).mockClear();
1070
1181
 
1071
1182
  find.mockRejectedValueOnce(new Error("boom"));
1072
1183
  const thrown = await routeLead(subscribeInput, sourceMeta);
1073
1184
  expect(thrown).toEqual(linked);
1074
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1185
+ expect(subscriberData()).toEqual(
1186
+ expect.objectContaining({ personId: null }),
1187
+ );
1075
1188
  });
1076
1189
 
1077
1190
  it("a duplicate subscribe (P2002) makes no second Person call", async () => {
1078
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(Object.assign(new Error("dup"), { code: "P2002" }));
1079
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "subscr_existing" });
1191
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(
1192
+ Object.assign(new Error("dup"), { code: "P2002" }),
1193
+ );
1194
+ (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
1195
+ id: "subscr_existing",
1196
+ });
1080
1197
  const find = spyFind().mockResolvedValue(LINKED);
1081
1198
  const result = await routeLead(subscribeInput, sourceMeta);
1082
1199
  expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
@@ -1087,7 +1204,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
1087
1204
  (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1088
1205
  const find = spyFind().mockResolvedValue(LINKED);
1089
1206
  await routeLead(
1090
- { ...callbackInput, phone: "+1 415 555 0123", payload: { email: "c@x.io" } },
1207
+ {
1208
+ ...callbackInput,
1209
+ phone: "+1 415 555 0123",
1210
+ payload: { email: "c@x.io" },
1211
+ },
1091
1212
  sourceMeta,
1092
1213
  );
1093
1214
  expect(find).toHaveBeenCalledWith("site-abc", {
@@ -1095,27 +1216,32 @@ describe("#852 — the lead's Person (AC-3)", () => {
1095
1216
  name: "Alice",
1096
1217
  contact: { telephone: "+14155550123" },
1097
1218
  });
1098
- expect(callbackData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1219
+ expect(callbackData()).toEqual(
1220
+ expect.objectContaining({ personId: MOCK_PERSON_ID }),
1221
+ );
1099
1222
  });
1100
1223
 
1101
1224
  it.each([
1102
1225
  ["no payload.email", { notes: "urgent" }],
1103
1226
  ["a numeric payload.email", { email: 12345 }],
1104
- ])("a callback with %s makes no person delegate call and writes no personId key", async (_label, payload) => {
1105
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1106
- await routeLead({ ...callbackInput, payload }, sourceMeta);
1107
- expect(personCalls()).toBe(0);
1108
- expect(Object.keys(callbackData())).not.toContain("personId");
1109
- });
1227
+ ])(
1228
+ "a callback with %s makes no person delegate call and writes no personId key",
1229
+ async (_label, payload) => {
1230
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
1231
+ id: "callbk_x",
1232
+ });
1233
+ await routeLead({ ...callbackInput, payload }, sourceMeta);
1234
+ expect(personCalls()).toBe(0);
1235
+ expect(Object.keys(callbackData())).not.toContain("personId");
1236
+ },
1237
+ );
1110
1238
 
1111
1239
  // ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
1112
1240
  it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
1113
1241
  const find = spyFind().mockResolvedValue(LINKED);
1114
- await routeLead(
1115
- { ...leadInput, payload: { name: "Bob" } },
1116
- sourceMeta,
1117
- { contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" } },
1118
- );
1242
+ await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta, {
1243
+ contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" },
1244
+ });
1119
1245
  expect(find).toHaveBeenCalledWith("site-abc", {
1120
1246
  email: "test@example.com",
1121
1247
  name: "Ann",
@@ -1128,7 +1254,10 @@ describe("#852 — the lead's Person (AC-3)", () => {
1128
1254
  it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
1129
1255
  const find = spyFind().mockResolvedValue(LINKED);
1130
1256
  await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
1131
- expect(find).toHaveBeenCalledWith("site-abc", { email: "test@example.com", name: "Bob" });
1257
+ expect(find).toHaveBeenCalledWith("site-abc", {
1258
+ email: "test@example.com",
1259
+ name: "Bob",
1260
+ });
1132
1261
  });
1133
1262
 
1134
1263
  it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
@@ -1141,9 +1270,15 @@ describe("#852 — the lead's Person (AC-3)", () => {
1141
1270
 
1142
1271
  expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1143
1272
  expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1144
- const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
1273
+ const skipped = warn.mock.calls.filter(
1274
+ ([fields]) => (fields as { event?: string }).event === "person-skipped",
1275
+ );
1145
1276
  expect(skipped).toHaveLength(1);
1146
- expect(skipped[0]![0]).toEqual({ event: "person-skipped", kind: "invalid_email", siteId: "site-abc" });
1277
+ expect(skipped[0]![0]).toEqual({
1278
+ event: "person-skipped",
1279
+ kind: "invalid_email",
1280
+ siteId: "site-abc",
1281
+ });
1147
1282
  expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1148
1283
  });
1149
1284
 
@@ -1165,18 +1300,30 @@ describe("#852 — the lead's Person (AC-3)", () => {
1165
1300
  const ok = await routeLead(leadInput, sourceMeta);
1166
1301
  jest.mocked(prisma.lead.create).mockClear();
1167
1302
  mockSendLeadNotification.mockClear();
1168
- personDelegate()["findFirst"]!.mockRejectedValueOnce(new Error("relation \"people\" does not exist"));
1303
+ personDelegate()["findFirst"]!.mockRejectedValueOnce(
1304
+ new Error('relation "people" does not exist'),
1305
+ );
1169
1306
 
1170
1307
  const failed = await routeLead(leadInput, sourceMeta);
1171
1308
 
1172
1309
  // The answer is the same as a successful capture, apart from the internal
1173
1310
  // `personId` (#8438 D11) the route never puts on a wire.
1174
1311
  expect(failed).toEqual({ ...ok, personId: null });
1175
- expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db", email: { ok: true } });
1312
+ expect(failed).toMatchObject({
1313
+ ok: true,
1314
+ id: LEAD_ID,
1315
+ persisted: "db",
1316
+ email: { ok: true },
1317
+ });
1176
1318
  expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1177
- const lines = error.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-failed");
1319
+ const lines = error.mock.calls.filter(
1320
+ ([fields]) => (fields as { event?: string }).event === "person-failed",
1321
+ );
1178
1322
  expect(lines).toHaveLength(1);
1179
- expect(lines[0]![0]).toEqual({ event: "person-failed", siteId: "site-abc" });
1323
+ expect(lines[0]![0]).toEqual({
1324
+ event: "person-failed",
1325
+ siteId: "site-abc",
1326
+ });
1180
1327
  expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
1181
1328
  });
1182
1329
  });
@@ -1204,7 +1351,11 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1204
1351
  siteId: WS,
1205
1352
  source: "web",
1206
1353
  email: "cleo@x.io",
1207
- payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" } as Record<string, unknown>,
1354
+ payload: {
1355
+ site: "wendi",
1356
+ page: "checkout",
1357
+ offering: "wendi_retainer",
1358
+ } as Record<string, unknown>,
1208
1359
  };
1209
1360
  let setPlan: jest.SpyInstance;
1210
1361
  let info: jest.SpyInstance;
@@ -1220,10 +1371,19 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1220
1371
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1221
1372
  (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1222
1373
  (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1223
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-7877" });
1224
- setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
1225
- info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
1226
- error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
1374
+ mockSendLeadNotification.mockResolvedValue({
1375
+ ok: true,
1376
+ messageId: "msg-7877",
1377
+ });
1378
+ setPlan = jest
1379
+ .spyOn(personService, "setPersonPlanOffering")
1380
+ .mockResolvedValue("set");
1381
+ info = jest
1382
+ .spyOn(logger, "info")
1383
+ .mockImplementation(() => undefined as never);
1384
+ error = jest
1385
+ .spyOn(logger, "error")
1386
+ .mockImplementation(() => undefined as never);
1227
1387
  });
1228
1388
 
1229
1389
  afterEach(() => {
@@ -1242,16 +1402,23 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1242
1402
  .filter((fields) => fields.event === "checkout-plan");
1243
1403
 
1244
1404
  it("a live spoke checkout lead sets the plan to the offering minus the <site>_ prefix", async () => {
1245
- const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1405
+ const result = await routeLead(checkoutLead, spokeMeta, {
1406
+ idempotencyKey: KEY,
1407
+ });
1246
1408
  expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1247
1409
  expect(setPlan).toHaveBeenCalledTimes(1);
1248
1410
  expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1249
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "set", siteId: WS }]);
1411
+ expect(planLines()).toEqual([
1412
+ { event: "checkout-plan", outcome: "set", siteId: WS },
1413
+ ]);
1250
1414
  });
1251
1415
 
1252
1416
  it("a bare slug offering is taken as it is", async () => {
1253
1417
  await routeLead(
1254
- { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "retainer" } },
1418
+ {
1419
+ ...checkoutLead,
1420
+ payload: { ...checkoutLead.payload, offering: "retainer" },
1421
+ },
1255
1422
  spokeMeta,
1256
1423
  { idempotencyKey: KEY },
1257
1424
  );
@@ -1260,7 +1427,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1260
1427
 
1261
1428
  it("a positive integer amountTotal still qualifies", async () => {
1262
1429
  await routeLead(
1263
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 4900 } },
1430
+ {
1431
+ ...checkoutLead,
1432
+ payload: { ...checkoutLead.payload, amountTotal: 4900 },
1433
+ },
1264
1434
  spokeMeta,
1265
1435
  { idempotencyKey: KEY },
1266
1436
  );
@@ -1278,16 +1448,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1278
1448
  ],
1279
1449
  [
1280
1450
  "a negative amountTotal",
1281
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1451
+ {
1452
+ ...checkoutLead,
1453
+ payload: { ...checkoutLead.payload, amountTotal: -5 },
1454
+ },
1282
1455
  spokeMeta,
1283
1456
  KEY,
1284
1457
  ],
1285
- ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1458
+ [
1459
+ "a key that is not a session id",
1460
+ checkoutLead,
1461
+ spokeMeta,
1462
+ "abcdefghijklmnop",
1463
+ ],
1286
1464
  ["no key", checkoutLead, spokeMeta, undefined],
1287
1465
  ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1288
1466
  [
1289
1467
  "page contact",
1290
- { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1468
+ {
1469
+ ...checkoutLead,
1470
+ payload: { ...checkoutLead.payload, page: "contact" },
1471
+ },
1291
1472
  spokeMeta,
1292
1473
  KEY,
1293
1474
  ],
@@ -1313,7 +1494,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1313
1494
  // checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
1314
1495
  // relayed". An unmarked create names no `spokeCheckout` key at all.
1315
1496
  const createData = () =>
1316
- (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
1497
+ (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
1498
+ string,
1499
+ unknown
1500
+ >;
1317
1501
 
1318
1502
  it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
1319
1503
  await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
@@ -1322,7 +1506,11 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1322
1506
  });
1323
1507
 
1324
1508
  it("the same lead relayed for a visitor is created with no spokeCheckout key and still sets the plan", async () => {
1325
- await routeLead(checkoutLead, { ...spokeMeta, relayed: true }, { idempotencyKey: KEY });
1509
+ await routeLead(
1510
+ checkoutLead,
1511
+ { ...spokeMeta, relayed: true },
1512
+ { idempotencyKey: KEY },
1513
+ );
1326
1514
  expect(createData()).not.toHaveProperty("spokeCheckout");
1327
1515
  expect(setPlan).toHaveBeenCalledTimes(1);
1328
1516
  });
@@ -1338,16 +1526,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1338
1526
  ],
1339
1527
  [
1340
1528
  "a negative amountTotal",
1341
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1529
+ {
1530
+ ...checkoutLead,
1531
+ payload: { ...checkoutLead.payload, amountTotal: -5 },
1532
+ },
1342
1533
  spokeMeta,
1343
1534
  KEY,
1344
1535
  ],
1345
- ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1536
+ [
1537
+ "a key that is not a session id",
1538
+ checkoutLead,
1539
+ spokeMeta,
1540
+ "abcdefghijklmnop",
1541
+ ],
1346
1542
  ["no key", checkoutLead, spokeMeta, undefined],
1347
1543
  ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1348
1544
  [
1349
1545
  "page contact",
1350
- { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1546
+ {
1547
+ ...checkoutLead,
1548
+ payload: { ...checkoutLead.payload, page: "contact" },
1549
+ },
1351
1550
  spokeMeta,
1352
1551
  KEY,
1353
1552
  ],
@@ -1357,24 +1556,32 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1357
1556
  spokeMeta,
1358
1557
  KEY,
1359
1558
  ],
1360
- ] as const)("no spokeCheckout key in the create data for %s", async (_label, input, meta, key) => {
1361
- await routeLead(
1362
- input as typeof checkoutLead,
1363
- meta,
1364
- key === undefined ? {} : { idempotencyKey: key },
1365
- );
1366
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1367
- expect(createData()).not.toHaveProperty("spokeCheckout");
1368
- });
1559
+ ] as const)(
1560
+ "no spokeCheckout key in the create data for %s",
1561
+ async (_label, input, meta, key) => {
1562
+ await routeLead(
1563
+ input as typeof checkoutLead,
1564
+ meta,
1565
+ key === undefined ? {} : { idempotencyKey: key },
1566
+ );
1567
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1568
+ expect(createData()).not.toHaveProperty("spokeCheckout");
1569
+ },
1570
+ );
1369
1571
 
1370
1572
  it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1371
1573
  await routeLead(
1372
- { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
1574
+ {
1575
+ ...checkoutLead,
1576
+ payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
1577
+ },
1373
1578
  spokeMeta,
1374
1579
  { idempotencyKey: KEY },
1375
1580
  );
1376
1581
  expect(setPlan).not.toHaveBeenCalled();
1377
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "skipped", siteId: WS }]);
1582
+ expect(planLines()).toEqual([
1583
+ { event: "checkout-plan", outcome: "skipped", siteId: WS },
1584
+ ]);
1378
1585
  });
1379
1586
 
1380
1587
  it("a replayed key writes nothing and sets nothing", async () => {
@@ -1384,7 +1591,9 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1384
1591
  source: checkoutLead.source,
1385
1592
  payload: checkoutLead.payload,
1386
1593
  });
1387
- const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1594
+ const result = await routeLead(checkoutLead, spokeMeta, {
1595
+ idempotencyKey: KEY,
1596
+ });
1388
1597
  expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
1389
1598
  expect(prisma.lead.create).not.toHaveBeenCalled();
1390
1599
  expect(setPlan).not.toHaveBeenCalled();
@@ -1393,18 +1602,729 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1393
1602
  it.each([
1394
1603
  ["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
1395
1604
  ["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
1396
- ])("a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key", async (_label, arrange) => {
1397
- const ok = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1398
- info.mockClear();
1399
- error.mockClear();
1400
- arrange();
1401
- const failed = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1402
- expect(failed).toEqual(ok);
1403
- expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1404
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "failed", siteId: WS }]);
1605
+ ])(
1606
+ "a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key",
1607
+ async (_label, arrange) => {
1608
+ const ok = await routeLead(checkoutLead, spokeMeta, {
1609
+ idempotencyKey: KEY,
1610
+ });
1611
+ info.mockClear();
1612
+ error.mockClear();
1613
+ arrange();
1614
+ const failed = await routeLead(checkoutLead, spokeMeta, {
1615
+ idempotencyKey: KEY,
1616
+ });
1617
+ expect(failed).toEqual(ok);
1618
+ expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1619
+ expect(planLines()).toEqual([
1620
+ { event: "checkout-plan", outcome: "failed", siteId: WS },
1621
+ ]);
1622
+ const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
1623
+ expect(logged).not.toContain("retainer");
1624
+ expect(logged).not.toContain("cleo@x.io");
1625
+ expect(logged).not.toContain(KEY);
1626
+ },
1627
+ );
1628
+ });
1629
+
1630
+ describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
1631
+ const ENV_KEYS = [
1632
+ "DATABASE_URL",
1633
+ "NODE_ENV",
1634
+ "AILK_LEAD_NOTIFICATION_TO",
1635
+ "AILK_LEAD_NOTIFICATION_FROM",
1636
+ "AILK_DELIVERABLE_EMAIL_FROM",
1637
+ ] as const;
1638
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1639
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000008757";
1640
+ const WS = "wksp_00000000-0000-7000-8000-000000008757";
1641
+ const KEY = "cs_live_d14Purchase"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8757 AC-11), not a credential
1642
+ const ORIGIN = "https://spoke.example";
1643
+ const spokeMeta = { ...sourceMeta, viaSpoke: true };
1644
+ const checkoutLead = {
1645
+ kind: "submit" as const,
1646
+ siteId: WS,
1647
+ source: "web",
1648
+ email: "Cleo@X.io",
1649
+ payload: {
1650
+ site: "wendi",
1651
+ page: "checkout",
1652
+ offering: "wendi_retainer",
1653
+ amountTotal: 4900,
1654
+ currency: "usd",
1655
+ } as Record<string, unknown>,
1656
+ };
1657
+ // The lead table's count — the per-recipient cap's one read. The shared
1658
+ // prisma double does not carry it, so this suite arranges it.
1659
+ const leadCount = jest.fn();
1660
+ let setPlan: jest.SpyInstance;
1661
+ let info: jest.SpyInstance;
1662
+ let error: jest.SpyInstance;
1663
+
1664
+ /** Let the fired (void) email step run to its end. */
1665
+ const settle = async () => {
1666
+ for (let i = 0; i < 5; i += 1)
1667
+ await new Promise((resolve) => setImmediate(resolve));
1668
+ };
1669
+ const interactionCreates = () =>
1670
+ jest.mocked(prisma.interaction.create).mock.calls;
1671
+ const emailCalls = () => mockSendPurchaseConfirmationEmail.mock.calls;
1672
+
1673
+ beforeEach(() => {
1674
+ jest.clearAllMocks();
1675
+ saved = {};
1676
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1677
+ process.env.DATABASE_URL = "postgres://stub";
1678
+ delete process.env.NODE_ENV;
1679
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1680
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1681
+ delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
1682
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1683
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1684
+ Object.assign(prisma.lead, { count: leadCount });
1685
+ leadCount.mockReset().mockResolvedValue(1);
1686
+ jest
1687
+ .mocked(prisma.interaction.create)
1688
+ .mockResolvedValue({ id: "intx_1" } as never);
1689
+ mockSendLeadNotification.mockResolvedValue({
1690
+ ok: true,
1691
+ messageId: "msg-8757",
1692
+ });
1693
+ mockSendPurchaseConfirmationEmail.mockResolvedValue({
1694
+ ok: true,
1695
+ messageId: "msg-buy",
1696
+ });
1697
+ mockListPlanOfferings.mockResolvedValue([
1698
+ { key: "retainer", name: "Retainer", type: "service" },
1699
+ ]);
1700
+ setPlan = jest
1701
+ .spyOn(personService, "setPersonPlanOffering")
1702
+ .mockResolvedValue("set");
1703
+ info = jest
1704
+ .spyOn(logger, "info")
1705
+ .mockImplementation(() => undefined as never);
1706
+ error = jest
1707
+ .spyOn(logger, "error")
1708
+ .mockImplementation(() => undefined as never);
1709
+ });
1710
+
1711
+ afterEach(() => {
1712
+ for (const k of ENV_KEYS) {
1713
+ if (saved[k] === undefined) delete process.env[k];
1714
+ else process.env[k] = saved[k];
1715
+ }
1716
+ Reflect.deleteProperty(prisma.lead, "count");
1717
+ setPlan.mockRestore();
1718
+ info.mockRestore();
1719
+ error.mockRestore();
1720
+ });
1721
+
1722
+ it("the first write sends one purchase email with the spoke's account link and writes one Inbox message", async () => {
1723
+ const result = await routeLead(checkoutLead, spokeMeta, {
1724
+ idempotencyKey: KEY,
1725
+ accountOrigin: ORIGIN,
1726
+ });
1727
+ await settle();
1728
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1729
+
1730
+ expect(emailCalls()).toHaveLength(1);
1731
+ const [email] = emailCalls()[0] as [Record<string, string>];
1732
+ expect(email).toMatchObject({
1733
+ to: "Cleo@X.io",
1734
+ fromAddress: "noreply@example.com",
1735
+ subject: "Your purchase",
1736
+ cta: "Open your account",
1737
+ accountLink: `${ORIGIN}/api/hub-session/sign-in?next=%2Faccount&email=Cleo%40X.io`,
1738
+ });
1739
+ expect(email.body).toContain("Retainer");
1740
+ expect(email.body).toContain("$49.00");
1741
+
1742
+ expect(interactionCreates()).toHaveLength(1);
1743
+ const [{ data }] = interactionCreates()[0] as [
1744
+ { data: Record<string, unknown> },
1745
+ ];
1746
+ expect(data).toEqual({
1747
+ workspaceId: WS,
1748
+ personId: MOCK_PERSON_ID,
1749
+ channel: "member",
1750
+ direction: "outbound",
1751
+ subject: "Your purchase",
1752
+ body: email.body,
1753
+ createdBy: "system:checkout",
1754
+ });
1755
+ expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
1756
+ });
1757
+
1758
+ it("an offering that is not listed is named 'your purchase' in both, never the raw string", async () => {
1759
+ await routeLead(
1760
+ {
1761
+ ...checkoutLead,
1762
+ payload: { ...checkoutLead.payload, offering: "wendi_<b>Mystery</b>" },
1763
+ },
1764
+ spokeMeta,
1765
+ { idempotencyKey: KEY, accountOrigin: ORIGIN },
1766
+ );
1767
+ await settle();
1768
+ const [email] = emailCalls()[0] as [Record<string, string>];
1769
+ const [{ data }] = interactionCreates()[0] as [
1770
+ { data: Record<string, string> },
1771
+ ];
1772
+ for (const body of [email.body, data.body]) {
1773
+ expect(body).toContain("your purchase");
1774
+ expect(body).not.toContain("Mystery");
1775
+ expect(body).not.toContain("wendi_");
1776
+ }
1777
+ });
1778
+
1779
+ it.each([
1780
+ [
1781
+ "the lister fails",
1782
+ () => mockListPlanOfferings.mockResolvedValueOnce({ failed: "Error" }),
1783
+ ],
1784
+ [
1785
+ "the lister throws",
1786
+ () => mockListPlanOfferings.mockRejectedValueOnce(new Error("down")),
1787
+ ],
1788
+ ])(
1789
+ "when %s, the offering is 'your purchase' and both still go out",
1790
+ async (_label, arrange) => {
1791
+ arrange();
1792
+ await routeLead(checkoutLead, spokeMeta, {
1793
+ idempotencyKey: KEY,
1794
+ accountOrigin: ORIGIN,
1795
+ });
1796
+ await settle();
1797
+ expect(emailCalls()).toHaveLength(1);
1798
+ expect(interactionCreates()).toHaveLength(1);
1799
+ const [{ data }] = interactionCreates()[0] as [
1800
+ { data: Record<string, string> },
1801
+ ];
1802
+ expect(data.body).toContain("your purchase");
1803
+ },
1804
+ );
1805
+
1806
+ it("a listed name holding a bidi override comes out with it stripped", async () => {
1807
+ mockListPlanOfferings.mockResolvedValue([
1808
+ { key: "retainer", name: "Re\u202Etainer\u0007", type: "service" },
1809
+ ]);
1810
+ await routeLead(checkoutLead, spokeMeta, {
1811
+ idempotencyKey: KEY,
1812
+ accountOrigin: ORIGIN,
1813
+ });
1814
+ await settle();
1815
+ const [email] = emailCalls()[0] as [Record<string, string>];
1816
+ const [{ data }] = interactionCreates()[0] as [
1817
+ { data: Record<string, string> },
1818
+ ];
1819
+ for (const body of [email.body, data.body]) {
1820
+ expect(body).toContain("Retainer");
1821
+
1822
+ // The two code points by name, not in a regex: a scaffold's standalone
1823
+ // lint refuses a control character in a regular expression.
1824
+ expect(body).not.toContain("\u202E");
1825
+ expect(body).not.toContain("\u0007");
1826
+ }
1827
+ });
1828
+
1829
+ it("with no amount, the body names the offering alone", async () => {
1830
+ const { amountTotal: _a, currency: _c, ...payload } = checkoutLead.payload;
1831
+ await routeLead({ ...checkoutLead, payload }, spokeMeta, {
1832
+ idempotencyKey: KEY,
1833
+ accountOrigin: ORIGIN,
1834
+ });
1835
+ await settle();
1836
+ const [{ data }] = interactionCreates()[0] as [
1837
+ { data: Record<string, string> },
1838
+ ];
1839
+ expect(data.body).toBe("Thanks for your purchase of Retainer.");
1840
+ });
1841
+
1842
+ it("a Stripe special-case currency (ISK) leaves the amount out rather than misstate it", async () => {
1843
+ await routeLead(
1844
+ {
1845
+ ...checkoutLead,
1846
+ payload: {
1847
+ ...checkoutLead.payload,
1848
+ amountTotal: 990000,
1849
+ currency: "isk",
1850
+ },
1851
+ },
1852
+ spokeMeta,
1853
+ { idempotencyKey: KEY, accountOrigin: ORIGIN },
1854
+ );
1855
+ await settle();
1856
+ const [{ data }] = interactionCreates()[0] as [
1857
+ { data: Record<string, string> },
1858
+ ];
1859
+ expect(data.body).toBe("Thanks for your purchase of Retainer.");
1860
+ });
1861
+
1862
+ it("an offering read that outlasts its budget names 'your purchase' and does not hold the lead", async () => {
1863
+ mockListPlanOfferings.mockReturnValue(new Promise(() => undefined));
1864
+ const started = Date.now();
1865
+ const result = await routeLead(checkoutLead, spokeMeta, {
1866
+ idempotencyKey: KEY,
1867
+ accountOrigin: ORIGIN,
1868
+ });
1869
+ expect(Date.now() - started).toBeLessThan(4000);
1870
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1871
+ const [{ data }] = interactionCreates()[0] as [
1872
+ { data: Record<string, string> },
1873
+ ];
1874
+ expect(data.body).toContain("your purchase");
1875
+ }, 10_000);
1876
+
1877
+ it("with no account origin, the email carries no link", async () => {
1878
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1879
+ await settle();
1880
+ expect(emailCalls()).toHaveLength(1);
1881
+ expect(emailCalls()[0]?.[0]).not.toHaveProperty("accountLink");
1882
+ expect(interactionCreates()).toHaveLength(1);
1883
+ });
1884
+
1885
+ it("a replay of the same Idempotency-Key makes neither", async () => {
1886
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1887
+ id: LEAD_ID,
1888
+ email: checkoutLead.email,
1889
+ source: checkoutLead.source,
1890
+ payload: checkoutLead.payload,
1891
+ });
1892
+ const result = await routeLead(checkoutLead, spokeMeta, {
1893
+ idempotencyKey: KEY,
1894
+ accountOrigin: ORIGIN,
1895
+ });
1896
+ await settle();
1897
+ expect(result).toMatchObject({ ok: true, replayed: true });
1898
+ expect(emailCalls()).toHaveLength(0);
1899
+ expect(interactionCreates()).toHaveLength(0);
1900
+ });
1901
+
1902
+ it.each([
1903
+ ["a test-mode checkout lead", spokeMeta, "cs_test_d14Purchase"],
1904
+ ["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
1905
+ ["an anonymous (non-spoke) lead", sourceMeta, KEY],
1906
+ ] as const)("%s makes neither", async (_label, meta, key) => {
1907
+ const result = await routeLead(checkoutLead, meta, {
1908
+ idempotencyKey: key,
1909
+ accountOrigin: ORIGIN,
1910
+ });
1911
+ await settle();
1912
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
1913
+ expect(emailCalls()).toHaveLength(0);
1914
+ expect(interactionCreates()).toHaveLength(0);
1915
+ });
1916
+
1917
+ it("a lead with no Person makes neither", async () => {
1918
+ const find = jest
1919
+ .spyOn(personService, "findOrCreatePerson")
1920
+ .mockResolvedValue({
1921
+ ok: false,
1922
+ error: { kind: "invalid_email" },
1923
+ } as never);
1924
+ try {
1925
+ const result = await routeLead(checkoutLead, spokeMeta, {
1926
+ idempotencyKey: KEY,
1927
+ accountOrigin: ORIGIN,
1928
+ });
1929
+ await settle();
1930
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
1931
+ expect(emailCalls()).toHaveLength(0);
1932
+ expect(interactionCreates()).toHaveLength(0);
1933
+ } finally {
1934
+ find.mockRestore();
1935
+ }
1936
+ });
1937
+
1938
+ it("with five marked leads for the address in the last day, the sixth sends no email and still writes the message", async () => {
1939
+ const now = Date.now();
1940
+ leadCount.mockResolvedValue(6); // the five before, and this one
1941
+ await routeLead(checkoutLead, spokeMeta, {
1942
+ idempotencyKey: KEY,
1943
+ accountOrigin: ORIGIN,
1944
+ });
1945
+ await settle();
1946
+ expect(emailCalls()).toHaveLength(0);
1947
+ expect(interactionCreates()).toHaveLength(1);
1948
+ const [{ where }] = leadCount.mock.calls[0] as [
1949
+ { where: Record<string, unknown> },
1950
+ ];
1951
+ expect(where).toMatchObject({
1952
+ workspaceId: WS,
1953
+ spokeCheckout: true,
1954
+ email: { equals: "Cleo@X.io", mode: "insensitive" },
1955
+ });
1956
+ const since = (where.createdAt as { gte: Date }).gte.getTime();
1957
+ expect(now - since).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000 - 1000);
1958
+ expect(now - since).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000);
1959
+ });
1960
+
1961
+ 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 () => {
1962
+ const now = Date.now();
1963
+ // The recipient count first, then the workspace's hourly count.
1964
+ leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(51);
1965
+ await routeLead(checkoutLead, spokeMeta, {
1966
+ idempotencyKey: KEY,
1967
+ accountOrigin: ORIGIN,
1968
+ });
1969
+ await settle();
1970
+ expect(emailCalls()).toHaveLength(0);
1971
+ expect(interactionCreates()).toHaveLength(1);
1972
+ const [{ where }] = leadCount.mock.calls[1] as [
1973
+ { where: Record<string, unknown> },
1974
+ ];
1975
+ expect(where).toMatchObject({ workspaceId: WS, spokeCheckout: true });
1976
+ expect(where).not.toHaveProperty("email");
1977
+ const since = (where.createdAt as { gte: Date }).gte.getTime();
1978
+ expect(now - since).toBeGreaterThanOrEqual(60 * 60 * 1000 - 1000);
1979
+ expect(now - since).toBeLessThanOrEqual(60 * 60 * 1000 + 5000);
1980
+ });
1981
+
1982
+ it("with exactly 50 in the hour, the email still goes", async () => {
1983
+ leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(50);
1984
+ await routeLead(checkoutLead, spokeMeta, {
1985
+ idempotencyKey: KEY,
1986
+ accountOrigin: ORIGIN,
1987
+ });
1988
+ await settle();
1989
+ expect(emailCalls()).toHaveLength(1);
1990
+ });
1991
+
1992
+ it("with five in the day including this one, the email still goes", async () => {
1993
+ leadCount.mockResolvedValue(5);
1994
+ await routeLead(checkoutLead, spokeMeta, {
1995
+ idempotencyKey: KEY,
1996
+ accountOrigin: ORIGIN,
1997
+ });
1998
+ await settle();
1999
+ expect(emailCalls()).toHaveLength(1);
2000
+ });
2001
+
2002
+ it.each([
2003
+ [
2004
+ "the Inbox write throws",
2005
+ () =>
2006
+ jest
2007
+ .mocked(prisma.interaction.create)
2008
+ .mockRejectedValueOnce(new Error("down")),
2009
+ ],
2010
+ [
2011
+ "the cap read throws",
2012
+ () => leadCount.mockRejectedValueOnce(new Error("down")),
2013
+ ],
2014
+ [
2015
+ "the email send rejects",
2016
+ () =>
2017
+ mockSendPurchaseConfirmationEmail.mockRejectedValueOnce(
2018
+ new Error("down"),
2019
+ ),
2020
+ ],
2021
+ ])(
2022
+ "a purchase step that fails (%s) never changes the lead's answer",
2023
+ async (_label, arrange) => {
2024
+ arrange();
2025
+ const result = await routeLead(checkoutLead, spokeMeta, {
2026
+ idempotencyKey: KEY,
2027
+ accountOrigin: ORIGIN,
2028
+ });
2029
+ await settle();
2030
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2031
+ expect(setPlan).toHaveBeenCalledTimes(1);
2032
+ const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
2033
+ expect(logged).not.toContain("Cleo@X.io");
2034
+ expect(logged).not.toContain("retainer");
2035
+ },
2036
+ );
2037
+
2038
+ it("with no sender configured, no email is sent and the message is still written", async () => {
2039
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
2040
+ await routeLead(checkoutLead, spokeMeta, {
2041
+ idempotencyKey: KEY,
2042
+ accountOrigin: ORIGIN,
2043
+ });
2044
+ await settle();
2045
+ expect(emailCalls()).toHaveLength(0);
2046
+ expect(interactionCreates()).toHaveLength(1);
2047
+ });
2048
+ });
2049
+
2050
+ describe("#8778 — a spoke checkout's terms grant", () => {
2051
+ const ENV_KEYS = [
2052
+ "DATABASE_URL",
2053
+ "NODE_ENV",
2054
+ "AILK_LEAD_NOTIFICATION_TO",
2055
+ "AILK_LEAD_NOTIFICATION_FROM",
2056
+ ] as const;
2057
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
2058
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000008778";
2059
+ const WS = "wksp_00000000-0000-7000-8000-000000008778";
2060
+ const KEY = "cs_live_g8778Grant"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8778), not a credential
2061
+ const ACCOUNT_TERMS = consentPurposeById(
2062
+ SITE_CONSENT_CONFIG_DEFAULTS,
2063
+ ACCOUNT_TERMS_PURPOSE_ID,
2064
+ )!;
2065
+ const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
2066
+ const spokeMeta = { ...sourceMeta, viaSpoke: true };
2067
+ const checkoutLead = {
2068
+ kind: "submit" as const,
2069
+ siteId: WS,
2070
+ source: "web",
2071
+ email: "Cleo@X.io",
2072
+ payload: {
2073
+ site: "wendi",
2074
+ page: "checkout",
2075
+ offering: "wendi_retainer",
2076
+ amountTotal: 4900,
2077
+ currency: "usd",
2078
+ checkoutSession: KEY,
2079
+ termsAccepted: STAMP,
2080
+ } as Record<string, unknown>,
2081
+ };
2082
+ const leadCount = jest.fn();
2083
+ let double: ConsentLedgerDouble;
2084
+ let setPlan: jest.SpyInstance;
2085
+ let info: jest.SpyInstance;
2086
+ let error: jest.SpyInstance;
2087
+
2088
+ /** The ledger double, plus the per-session replay read it does not model. */
2089
+ function armLedger(): void {
2090
+ double = armConsentLedger(prisma);
2091
+ const ledgerRead = (prisma.$queryRaw as jest.Mock).getMockImplementation()!;
2092
+ (prisma.$queryRaw as jest.Mock).mockImplementation(
2093
+ (strings: TemplateStringsArray, ...values: unknown[]) => {
2094
+ if (strings.join("?").includes(`"source" = 'checkout'`)) {
2095
+ const [workspaceId, personId, purposeId, sessionId] =
2096
+ values as string[];
2097
+ return Promise.resolve(
2098
+ double.ledger
2099
+ .filter(
2100
+ (r) =>
2101
+ r.workspaceId === workspaceId &&
2102
+ r.personId === personId &&
2103
+ r.purposeId === purposeId &&
2104
+ r.source === "checkout" &&
2105
+ r.sourceRef === sessionId,
2106
+ )
2107
+ .map(() => ({ found: 1 })),
2108
+ );
2109
+ }
2110
+ return ledgerRead(strings, ...values);
2111
+ },
2112
+ );
2113
+ }
2114
+
2115
+ const consentLines = () =>
2116
+ [...info.mock.calls, ...error.mock.calls]
2117
+ .map(([obj]) => obj as Record<string, unknown>)
2118
+ .filter((obj) => obj?.["event"] === "checkout-consent");
2119
+
2120
+ const settle = async () => {
2121
+ for (let i = 0; i < 5; i += 1)
2122
+ await new Promise((resolve) => setImmediate(resolve));
2123
+ };
2124
+
2125
+ beforeEach(() => {
2126
+ jest.clearAllMocks();
2127
+ saved = {};
2128
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
2129
+ process.env.DATABASE_URL = "postgres://stub";
2130
+ delete process.env.NODE_ENV;
2131
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
2132
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
2133
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
2134
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
2135
+ Object.assign(prisma.lead, { count: leadCount });
2136
+ leadCount.mockReset().mockResolvedValue(1);
2137
+ jest
2138
+ .mocked(prisma.interaction.create)
2139
+ .mockResolvedValue({ id: "intx_1" } as never);
2140
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
2141
+ mockSendPurchaseConfirmationEmail.mockResolvedValue({
2142
+ ok: true,
2143
+ messageId: "m2",
2144
+ });
2145
+ mockListPlanOfferings.mockResolvedValue([]);
2146
+ setPlan = jest
2147
+ .spyOn(personService, "setPersonPlanOffering")
2148
+ .mockResolvedValue("set");
2149
+ info = jest
2150
+ .spyOn(logger, "info")
2151
+ .mockImplementation(() => undefined as never);
2152
+ error = jest
2153
+ .spyOn(logger, "error")
2154
+ .mockImplementation(() => undefined as never);
2155
+ armLedger();
2156
+ });
2157
+
2158
+ afterEach(() => {
2159
+ for (const k of ENV_KEYS) {
2160
+ if (saved[k] === undefined) delete process.env[k];
2161
+ else process.env[k] = saved[k];
2162
+ }
2163
+ Reflect.deleteProperty(prisma.lead, "count");
2164
+ for (const fn of [
2165
+ prisma.$transaction,
2166
+ prisma.$executeRaw,
2167
+ prisma.$queryRaw,
2168
+ prisma.consentGrant.createMany,
2169
+ prisma.consentVisitorLink.createMany,
2170
+ ]) {
2171
+ (fn as jest.Mock).mockReset();
2172
+ }
2173
+ setPlan.mockRestore();
2174
+ info.mockRestore();
2175
+ error.mockRestore();
2176
+ });
2177
+
2178
+ it("a lead whose session accepted the current terms writes one dated checkout grant for the buyer's Person", async () => {
2179
+ const result = await routeLead(checkoutLead, spokeMeta, {
2180
+ idempotencyKey: KEY,
2181
+ });
2182
+ await settle();
2183
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2184
+
2185
+ expect(double.ledger).toHaveLength(1);
2186
+ const [row] = double.ledger;
2187
+ expect(row).toMatchObject({
2188
+ workspaceId: WS,
2189
+ subjectType: "person",
2190
+ subjectId: MOCK_PERSON_ID,
2191
+ personId: MOCK_PERSON_ID,
2192
+ purposeId: ACCOUNT_TERMS_PURPOSE_ID,
2193
+ granted: true,
2194
+ source: "checkout",
2195
+ sourceRef: KEY,
2196
+ policyVersion: ACCOUNT_TERMS.policyVersion,
2197
+ locale: "en",
2198
+ });
2199
+ expect(row!.createdAt).toBeInstanceOf(Date);
2200
+ expect(consentLines()).toEqual([
2201
+ expect.objectContaining({ outcome: "recorded", siteId: WS }),
2202
+ ]);
2203
+ });
2204
+
2205
+ it.each([
2206
+ ["termsAccepted: null (the box was not accepted)", null, "consent_absent"],
2207
+ ["no termsAccepted key (an older spoke)", undefined, "consent_absent"],
2208
+ ["an older version's stamp", `${ACCOUNT_TERMS_PURPOSE_ID}@1`, "terms_unstamped"],
2209
+ ["another purpose's stamp", "wt_platform_terms@1", "terms_unstamped"],
2210
+ ["a non-string value", true, "terms_unstamped"],
2211
+ ])("a lead with %s writes no grant", async (_label, terms, outcome) => {
2212
+ const { termsAccepted: _t, ...rest } = checkoutLead.payload;
2213
+ const payload = terms === undefined ? rest : { ...rest, termsAccepted: terms };
2214
+ const result = await routeLead({ ...checkoutLead, payload }, spokeMeta, {
2215
+ idempotencyKey: KEY,
2216
+ });
2217
+ await settle();
2218
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2219
+ expect(double.ledger).toHaveLength(0);
2220
+ expect(consentLines()).toEqual([expect.objectContaining({ outcome })]);
2221
+ });
2222
+
2223
+ it("a replayed spoke webhook (the same Idempotency-Key and body) writes no second grant", async () => {
2224
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2225
+ expect(double.ledger).toHaveLength(1);
2226
+
2227
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2228
+ id: LEAD_ID,
2229
+ email: checkoutLead.email,
2230
+ source: checkoutLead.source,
2231
+ payload: checkoutLead.payload,
2232
+ });
2233
+ const replay = await routeLead(checkoutLead, spokeMeta, {
2234
+ idempotencyKey: KEY,
2235
+ });
2236
+ await settle();
2237
+ expect(replay).toMatchObject({ ok: true, replayed: true });
2238
+ expect(double.ledger).toHaveLength(1);
2239
+ });
2240
+
2241
+ it("a redelivery whose stored body predates termsAccepted is a replay, not a conflict, and writes no grant", async () => {
2242
+ const { termsAccepted: _t, ...storedPayload } = checkoutLead.payload;
2243
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2244
+ id: LEAD_ID,
2245
+ email: checkoutLead.email,
2246
+ source: checkoutLead.source,
2247
+ payload: storedPayload,
2248
+ });
2249
+ const result = await routeLead(checkoutLead, spokeMeta, {
2250
+ idempotencyKey: KEY,
2251
+ });
2252
+ await settle();
2253
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, replayed: true });
2254
+ expect(double.ledger).toHaveLength(0);
2255
+ });
2256
+
2257
+ it("a redelivery that differs in any other payload key is still a conflict", async () => {
2258
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2259
+ id: LEAD_ID,
2260
+ email: checkoutLead.email,
2261
+ source: checkoutLead.source,
2262
+ payload: { ...checkoutLead.payload, amountTotal: 1 },
2263
+ });
2264
+ const result = await routeLead(checkoutLead, spokeMeta, {
2265
+ idempotencyKey: KEY,
2266
+ });
2267
+ expect(result).toMatchObject({
2268
+ ok: false,
2269
+ error: { kind: "idempotency_conflict" },
2270
+ });
2271
+ });
2272
+
2273
+ it("a second write for a session whose grant the Person already holds writes none (the store's own guard)", async () => {
2274
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2275
+ // The lead row lost (the lookup misses), the ledger kept: the grant is
2276
+ // still written at most once per Person and session.
2277
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2278
+ await settle();
2279
+ expect(double.ledger).toHaveLength(1);
2280
+ expect(consentLines().map((l) => l["outcome"])).toEqual([
2281
+ "recorded",
2282
+ "unchanged",
2283
+ ]);
2284
+ });
2285
+
2286
+ it.each([
2287
+ ["a test-mode checkout lead", spokeMeta, "cs_test_g8778Grant"],
2288
+ ["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
2289
+ ["an anonymous (non-spoke) lead", sourceMeta, KEY],
2290
+ ] as const)("%s writes no grant", async (_label, meta, key) => {
2291
+ const result = await routeLead(checkoutLead, meta, { idempotencyKey: key });
2292
+ await settle();
2293
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
2294
+ expect(double.ledger).toHaveLength(0);
2295
+ expect(consentLines()).toEqual([]);
2296
+ });
2297
+
2298
+ it("a lead with no Person writes no grant", async () => {
2299
+ const find = jest
2300
+ .spyOn(personService, "findOrCreatePerson")
2301
+ .mockResolvedValue({
2302
+ ok: false,
2303
+ error: { kind: "invalid_email" },
2304
+ } as never);
2305
+ try {
2306
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2307
+ await settle();
2308
+ expect(double.ledger).toHaveLength(0);
2309
+ } finally {
2310
+ find.mockRestore();
2311
+ }
2312
+ });
2313
+
2314
+ it("a ledger write that fails never changes the lead's answer, and logs no address or Person id", async () => {
2315
+ (prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(
2316
+ new Error("down Cleo@X.io"),
2317
+ );
2318
+ const result = await routeLead(checkoutLead, spokeMeta, {
2319
+ idempotencyKey: KEY,
2320
+ });
2321
+ await settle();
2322
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2323
+ expect(consentLines()).toEqual([
2324
+ expect.objectContaining({ outcome: "failed", errName: "Error" }),
2325
+ ]);
1405
2326
  const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
1406
- expect(logged).not.toContain("retainer");
1407
- expect(logged).not.toContain("cleo@x.io");
1408
- expect(logged).not.toContain(KEY);
2327
+ expect(logged).not.toContain("Cleo@X.io");
2328
+ expect(logged).not.toContain(MOCK_PERSON_ID);
1409
2329
  });
1410
2330
  });