create-ailk 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -1014,12 +1014,121 @@ describe("#852 — the lead's Person (AC-3)", () => {
1014
1014
  expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1015
1015
  });
1016
1016
 
1017
- it("subscribe and callback make no person call", async () => {
1017
+ // ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
1018
+ const LINKED = {
1019
+ ok: true as const,
1020
+ value: { personId: MOCK_PERSON_ID as never, created: false, billing: "unchanged" as const },
1021
+ };
1022
+ const findSpies: jest.SpyInstance[] = [];
1023
+ const spyFind = () => {
1024
+ const spy = jest.spyOn(personService, "findOrCreatePerson");
1025
+ findSpies.push(spy);
1026
+ return spy;
1027
+ };
1028
+ afterEach(() => {
1029
+ while (findSpies.length > 0) findSpies.pop()?.mockRestore();
1030
+ });
1031
+ const subscriberData = () =>
1032
+ jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1033
+ const callbackData = () =>
1034
+ jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1035
+
1036
+ it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
1018
1037
  (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1019
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1038
+ const find = spyFind().mockResolvedValue(LINKED);
1020
1039
  await routeLead(subscribeInput, sourceMeta);
1021
- await routeLead(callbackInput, sourceMeta);
1040
+ expect(find).toHaveBeenCalledTimes(1);
1041
+ expect(find).toHaveBeenCalledWith("site-abc", { email: "subscriber@example.com" });
1042
+ expect(subscriberData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1043
+ });
1044
+
1045
+ 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" });
1047
+ jest
1048
+ .spyOn(personService, "findOrCreatePerson")
1049
+ .mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
1050
+ const result = await routeLead(subscribeInput, sourceMeta);
1051
+ 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");
1054
+ expect(skipped).toHaveLength(1);
1055
+ expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1056
+ });
1057
+
1058
+ 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" });
1060
+ const find = spyFind().mockResolvedValue(LINKED);
1061
+ const linked = await routeLead(subscribeInput, sourceMeta);
1062
+ jest.mocked(prisma.subscriber.create).mockClear();
1063
+
1064
+ find.mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
1065
+ const failed = await routeLead(subscribeInput, sourceMeta);
1066
+ 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" });
1069
+ jest.mocked(prisma.subscriber.create).mockClear();
1070
+
1071
+ find.mockRejectedValueOnce(new Error("boom"));
1072
+ const thrown = await routeLead(subscribeInput, sourceMeta);
1073
+ expect(thrown).toEqual(linked);
1074
+ expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1075
+ });
1076
+
1077
+ 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" });
1080
+ const find = spyFind().mockResolvedValue(LINKED);
1081
+ const result = await routeLead(subscribeInput, sourceMeta);
1082
+ expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
1083
+ expect(find).toHaveBeenCalledTimes(1);
1084
+ });
1085
+
1086
+ it("a callback with payload.email and a spaced phone links its Person, carrying the E.164 telephone", async () => {
1087
+ (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1088
+ const find = spyFind().mockResolvedValue(LINKED);
1089
+ await routeLead(
1090
+ { ...callbackInput, phone: "+1 415 555 0123", payload: { email: "c@x.io" } },
1091
+ sourceMeta,
1092
+ );
1093
+ expect(find).toHaveBeenCalledWith("site-abc", {
1094
+ email: "c@x.io",
1095
+ name: "Alice",
1096
+ contact: { telephone: "+14155550123" },
1097
+ });
1098
+ expect(callbackData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1099
+ });
1100
+
1101
+ it.each([
1102
+ ["no payload.email", { notes: "urgent" }],
1103
+ ["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);
1022
1107
  expect(personCalls()).toBe(0);
1108
+ expect(Object.keys(callbackData())).not.toContain("personId");
1109
+ });
1110
+
1111
+ // ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
1112
+ it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
1113
+ 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
+ );
1119
+ expect(find).toHaveBeenCalledWith("site-abc", {
1120
+ email: "test@example.com",
1121
+ name: "Ann",
1122
+ contact: { givenName: "Ann", telephone: "+14155550123" },
1123
+ });
1124
+ // the lead's payload is the raw answers, unchanged
1125
+ expect(createData()["payload"]).toEqual({ name: "Bob" });
1126
+ });
1127
+
1128
+ it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
1129
+ const find = spyFind().mockResolvedValue(LINKED);
1130
+ await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
1131
+ expect(find).toHaveBeenCalledWith("site-abc", { email: "test@example.com", name: "Bob" });
1023
1132
  });
1024
1133
 
1025
1134
  it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
@@ -1060,7 +1169,9 @@ describe("#852 — the lead's Person (AC-3)", () => {
1060
1169
 
1061
1170
  const failed = await routeLead(leadInput, sourceMeta);
1062
1171
 
1063
- expect(failed).toEqual(ok);
1172
+ // The answer is the same as a successful capture, apart from the internal
1173
+ // `personId` (#8438 D11) the route never puts on a wire.
1174
+ expect(failed).toEqual({ ...ok, personId: null });
1064
1175
  expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db", email: { ok: true } });
1065
1176
  expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1066
1177
  const lines = error.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-failed");
@@ -1198,6 +1309,64 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1198
1309
  expect(planLines()).toEqual([]);
1199
1310
  });
1200
1311
 
1312
+ // #8185 — the hub stores its conclusion that a lead is a spoke's live paid
1313
+ // checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
1314
+ // relayed". An unmarked create names no `spokeCheckout` key at all.
1315
+ const createData = () =>
1316
+ (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
1317
+
1318
+ it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
1319
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1320
+ expect(createData()).toMatchObject({ spokeCheckout: true });
1321
+ expect(setPlan).toHaveBeenCalledTimes(1);
1322
+ });
1323
+
1324
+ 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 });
1326
+ expect(createData()).not.toHaveProperty("spokeCheckout");
1327
+ expect(setPlan).toHaveBeenCalledTimes(1);
1328
+ });
1329
+
1330
+ it.each([
1331
+ ["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
1332
+ ["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
1333
+ [
1334
+ "amountTotal 0",
1335
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
1336
+ spokeMeta,
1337
+ KEY,
1338
+ ],
1339
+ [
1340
+ "a negative amountTotal",
1341
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1342
+ spokeMeta,
1343
+ KEY,
1344
+ ],
1345
+ ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1346
+ ["no key", checkoutLead, spokeMeta, undefined],
1347
+ ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1348
+ [
1349
+ "page contact",
1350
+ { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1351
+ spokeMeta,
1352
+ KEY,
1353
+ ],
1354
+ [
1355
+ "a numeric offering",
1356
+ { ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
1357
+ spokeMeta,
1358
+ KEY,
1359
+ ],
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
+ });
1369
+
1201
1370
  it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1202
1371
  await routeLead(
1203
1372
  { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
@@ -33,15 +33,24 @@ import { readFileSync } from "node:fs";
33
33
  import { join } from "node:path";
34
34
 
35
35
  import { prisma } from "../../lib/prisma.js";
36
+ import { personContactPatchSchema } from "@working-theory/validation";
37
+
36
38
  import {
39
+ contactConfirmedFromRow,
40
+ ensureMemberPerson,
37
41
  findOrCreatePerson,
38
42
  isStorablePersonEmail,
43
+ normalizeCapturedContact,
39
44
  normalizePersonEmail,
45
+ normalizeTelephone,
46
+ personContactFromRow,
40
47
  recordCheckoutPerson,
41
48
  resolveCallerBillingCustomerId,
42
49
  resolveMemberPersonId,
50
+ setOwnPersonContact,
43
51
  setPersonPlanOffering,
44
52
  stampPersonUser,
53
+ updatePersonContact,
45
54
  } from "../person";
46
55
 
47
56
  type Row = Record<string, unknown>;
@@ -77,6 +86,20 @@ function seedPerson(row: Row): Row {
77
86
  billingProvider: null,
78
87
  billingCustomerId: null,
79
88
  planOffering: null,
89
+ // #8435 — the contact columns, blank the way Postgres reads them back.
90
+ givenName: null,
91
+ familyName: null,
92
+ telephone: null,
93
+ addressLine1: null,
94
+ addressLine2: null,
95
+ addressCity: null,
96
+ addressRegion: null,
97
+ addressPostalCode: null,
98
+ addressCountry: null,
99
+ companyName: null,
100
+ jobTitle: null,
101
+ contactConfirmedAt: null,
102
+ contactConfirmedBy: null,
80
103
  createdAt: new Date(Date.UTC(2026, 0, 1 + people().length)),
81
104
  updatedAt: new Date(),
82
105
  ...row,
@@ -531,6 +554,375 @@ describe("resolveCallerBillingCustomerId (#7961, spec 7894 D13, AC-8)", () => {
531
554
  });
532
555
  });
533
556
 
557
+ const ADDRESS = {
558
+ addressLine1: "1 Main St",
559
+ addressLine2: null,
560
+ city: "Austin",
561
+ region: "TX",
562
+ postalCode: "78701",
563
+ country: "US",
564
+ };
565
+ const ADDRESS_COLUMNS = {
566
+ addressLine1: "1 Main St",
567
+ addressLine2: null,
568
+ addressCity: "Austin",
569
+ addressRegion: "TX",
570
+ addressPostalCode: "78701",
571
+ addressCountry: "US",
572
+ };
573
+
574
+ describe("findOrCreatePerson fills blank contact fields (#8435 D4, AC-2)", () => {
575
+ const contactCalls = () =>
576
+ store.__calls.filter((c) => c.model === "person" && c.op === "updateMany");
577
+
578
+ it("a new email with a contact creates one Person carrying it, named from given + family", async () => {
579
+ const result = await findOrCreatePerson(WS_A1, {
580
+ email: "new@x.io",
581
+ contact: { givenName: "Ada", familyName: "Lovelace", telephone: "+442071234567" },
582
+ });
583
+ expect(result.ok && result.value.created).toBe(true);
584
+ expect(people()).toHaveLength(1);
585
+ expect(people()[0]).toEqual(
586
+ expect.objectContaining({
587
+ givenName: "Ada",
588
+ familyName: "Lovelace",
589
+ telephone: "+442071234567",
590
+ name: "Ada Lovelace",
591
+ }),
592
+ );
593
+ });
594
+
595
+ it("given name alone names the Person; a supplied name wins over given + family", async () => {
596
+ await findOrCreatePerson(WS_A1, { email: "a@x.io", contact: { givenName: "Ada" } });
597
+ await findOrCreatePerson(WS_A1, {
598
+ email: "b@x.io",
599
+ name: "Countess",
600
+ contact: { givenName: "Ada", familyName: "Lovelace" },
601
+ });
602
+ expect(people().map((p) => p["name"])).toEqual(["Ada", "Countess"]);
603
+ });
604
+
605
+ it("a matched Person's stored value is never overwritten; its blank fields are filled", async () => {
606
+ seedPerson({ email: "m@x.io", telephone: "+15550000001" });
607
+ await findOrCreatePerson(WS_A1, {
608
+ email: "m@x.io",
609
+ contact: { telephone: "+15550000002", companyName: "Acme" },
610
+ });
611
+ expect(people()[0]).toEqual(
612
+ expect.objectContaining({ telephone: "+15550000001", companyName: "Acme" }),
613
+ );
614
+ });
615
+
616
+ it("a stored address is left whole; a Person with none gains all six parts", async () => {
617
+ seedPerson({
618
+ email: "has@x.io",
619
+ addressLine1: "9 Old Rd",
620
+ addressCity: "Leeds",
621
+ addressRegion: "WYK",
622
+ addressPostalCode: "LS1",
623
+ addressCountry: "GB",
624
+ });
625
+ seedPerson({ email: "none@x.io" });
626
+ await findOrCreatePerson(WS_A1, { email: "has@x.io", contact: { address: ADDRESS } });
627
+ await findOrCreatePerson(WS_A1, { email: "none@x.io", contact: { address: ADDRESS } });
628
+ expect(people()[0]).toEqual(
629
+ expect.objectContaining({ addressLine1: "9 Old Rd", addressCity: "Leeds", addressCountry: "GB" }),
630
+ );
631
+ expect(people()[1]).toEqual(expect.objectContaining(ADDRESS_COLUMNS));
632
+ });
633
+
634
+ it("a name already on the Person is not replaced by given + family", async () => {
635
+ seedPerson({ email: "n@x.io", name: "A. L." });
636
+ await findOrCreatePerson(WS_A1, {
637
+ email: "n@x.io",
638
+ contact: { givenName: "Ada", familyName: "Lovelace" },
639
+ });
640
+ expect(people()[0]).toEqual(
641
+ expect.objectContaining({ name: "A. L.", givenName: "Ada", familyName: "Lovelace" }),
642
+ );
643
+ });
644
+
645
+ it("a nameless uncurated Person gains the joined name", async () => {
646
+ seedPerson({ email: "n@x.io" });
647
+ await findOrCreatePerson(WS_A1, {
648
+ email: "n@x.io",
649
+ contact: { givenName: "Ada", familyName: "Lovelace" },
650
+ });
651
+ expect(people()[0]!["name"]).toBe("Ada Lovelace");
652
+ });
653
+
654
+ it("a customer-id hit (step 2) writes nothing — contact included", async () => {
655
+ seedPerson({ email: "c@x.io", billingProvider: "stripe", billingCustomerId: "cus_1" });
656
+ await findOrCreatePerson(WS_A1, {
657
+ email: "other@x.io",
658
+ billingCustomerId: "cus_1",
659
+ contact: { telephone: "+15550000003" },
660
+ });
661
+ expect(contactCalls()).toHaveLength(0);
662
+ expect(people()[0]!["telephone"]).toBeNull();
663
+ });
664
+
665
+ it("a double whose matched row carries no contact keys triggers no contact write", async () => {
666
+ seedPerson({ email: "d@x.io" });
667
+ store.person["findFirst"]!.mockResolvedValueOnce({
668
+ id: people()[0]!["id"],
669
+ name: null,
670
+ userId: null,
671
+ billingCustomerId: null,
672
+ });
673
+ await findOrCreatePerson(WS_A1, { email: "d@x.io", contact: { telephone: "+15550000004" } });
674
+ expect(contactCalls()).toHaveLength(0);
675
+ });
676
+
677
+ describe("only an uncurated Person is filled (SEC-8435-A)", () => {
678
+ it("a Person linked to a user is not filled", async () => {
679
+ seedPerson({ email: "u@x.io", userId: "user_00000000-0000-7000-8000-0000000000e1" });
680
+ await findOrCreatePerson(WS_A1, { email: "u@x.io", contact: { telephone: "+15550000005" } });
681
+ expect(people()[0]!["telephone"]).toBeNull();
682
+ expect(contactCalls()).toHaveLength(0);
683
+ });
684
+
685
+ it("a Person a human saved is not filled — a field they cleared is not refilled", async () => {
686
+ seedPerson({ email: "h@x.io", contactConfirmedAt: new Date(), contactConfirmedBy: "client" });
687
+ await findOrCreatePerson(WS_A1, { email: "h@x.io", contact: { telephone: "+15550000006" } });
688
+ expect(people()[0]!["telephone"]).toBeNull();
689
+ expect(contactCalls()).toHaveLength(0);
690
+ });
691
+
692
+ it("an uncurated Person is filled", async () => {
693
+ seedPerson({ email: "free@x.io" });
694
+ await findOrCreatePerson(WS_A1, { email: "free@x.io", contact: { telephone: "+15550000007" } });
695
+ expect(people()[0]!["telephone"]).toBe("+15550000007");
696
+ });
697
+
698
+ it("a link that lands between the match read and the fill wins: the guarded write matches nothing", async () => {
699
+ seedPerson({ email: "race@x.io" });
700
+ const read = store.person["findFirst"]!.getMockImplementation()!;
701
+ store.person["findFirst"]!.mockImplementationOnce(async (args: unknown) => {
702
+ const row = await read(args);
703
+ people()[0]!["userId"] = "user_00000000-0000-7000-8000-0000000000e2";
704
+ return row;
705
+ });
706
+ await findOrCreatePerson(WS_A1, { email: "race@x.io", contact: { telephone: "+15550000008" } });
707
+ expect(people()[0]!["telephone"]).toBeNull();
708
+ });
709
+ });
710
+
711
+ it("a failing fill never changes the capture's outcome", async () => {
712
+ seedPerson({ email: "fail@x.io" });
713
+ store.person["updateMany"]!.mockRejectedValueOnce(new Error("down"));
714
+ const result = await findOrCreatePerson(WS_A1, {
715
+ email: "fail@x.io",
716
+ contact: { telephone: "+15550000009" },
717
+ });
718
+ expect(result.ok && result.value.created).toBe(false);
719
+ });
720
+ });
721
+
722
+ describe("the contact normalizers (#8435 D3, AC-2)", () => {
723
+ it.each([
724
+ ["+1 (415) 555-0123", "+14155550123"],
725
+ ["+44 20 7123 4567", "+442071234567"],
726
+ ["+1.415.555.0123", "+14155550123"],
727
+ ["415-555-0123", null],
728
+ ["+0 123", null],
729
+ ["+1234567890123456", null],
730
+ ["", null],
731
+ [4155550123, null],
732
+ [null, null],
733
+ ])("normalizeTelephone(%p) → %p", (raw, expected) => {
734
+ expect(normalizeTelephone(raw)).toBe(expected);
735
+ });
736
+
737
+ it("strips bidi, zero-width, separator and control characters from a given name", () => {
738
+ expect(normalizeCapturedContact({ givenName: "A‮d​a
\u0007" }).givenName).toBe("Ada");
739
+ });
740
+
741
+ it("cuts a 300-character company to 200 code points", () => {
742
+ const company = normalizeCapturedContact({ company: "c".repeat(300) }).companyName;
743
+ expect(Array.from(company ?? "")).toHaveLength(200);
744
+ });
745
+
746
+ it("stores a short region upper-cased, a full region as typed, and drops an address missing a part whole", () => {
747
+ const base = { addressLine1: "1 Main", city: "Albany", postalCode: "12207", country: "US" };
748
+ expect(normalizeCapturedContact({ address: { ...base, region: "ny" } }).address?.region).toBe("NY");
749
+ expect(normalizeCapturedContact({ address: { ...base, region: "New York" } }).address?.region).toBe(
750
+ "New York",
751
+ );
752
+ expect(normalizeCapturedContact({ address: { ...base, region: "NY", postalCode: undefined } }).address).toBeUndefined();
753
+ expect(normalizeCapturedContact({ address: { ...base, region: "NY", country: "us" } }).address).toBeUndefined();
754
+ });
755
+
756
+ it("drops a non-string value and an empty-after-cleaning one", () => {
757
+ expect(
758
+ normalizeCapturedContact({ givenName: 12, familyName: "​ ", company: {}, jobTitle: null, phone: "555" }),
759
+ ).toEqual({});
760
+ });
761
+
762
+ describe("the capture normalizer and the wire's edit schema agree on one table", () => {
763
+ // (field, raw) — within its width, so a capture would keep every character.
764
+ const TABLE: Array<[string, string]> = [
765
+ ["givenName", "Ada"],
766
+ ["givenName", " Ada "],
767
+ ["givenName", "A‮d​a
\u0007"],
768
+ ["givenName", "\u{1D56B}".repeat(100)],
769
+ ["givenName", "​ "],
770
+ ["familyName", "Lovelace"],
771
+ ["companyName", "c".repeat(200)],
772
+ ["jobTitle", "Founder"],
773
+ ["telephone", "+1 (415) 555-0123"],
774
+ ["telephone", "415-555-0123"],
775
+ ["telephone", "+1 415 555 0123"],
776
+ ];
777
+ const CAPTURE_KEY: Record<string, string> = {
778
+ givenName: "givenName",
779
+ familyName: "familyName",
780
+ companyName: "company",
781
+ jobTitle: "jobTitle",
782
+ telephone: "phone",
783
+ };
784
+
785
+ it.each(TABLE)("%s %j", (field, raw) => {
786
+ const captured = normalizeCapturedContact({ [CAPTURE_KEY[field]!]: raw }) as Record<string, unknown>;
787
+ const parsed = personContactPatchSchema.safeParse({ [field]: raw });
788
+ if (parsed.success) {
789
+ expect(captured[field]).toBe((parsed.data as Record<string, unknown>)[field]);
790
+ } else {
791
+ expect(captured[field]).toBeUndefined();
792
+ }
793
+ });
794
+
795
+ it("an address and its short region agree", () => {
796
+ const address = { addressLine1: "1 Main", city: "Albany", region: "ny", postalCode: "12207", country: "US" };
797
+ const captured = normalizeCapturedContact({ address }).address;
798
+ const parsed = personContactPatchSchema.parse({ address }).address;
799
+ expect(captured).toEqual(parsed);
800
+ });
801
+ });
802
+ });
803
+
804
+ describe("the two human edits of the contact (#8435 D9, D11)", () => {
805
+ const U = "user_00000000-0000-7000-8000-0000000000f1";
806
+ const users = () => (store.__db["user"] ??= []);
807
+
808
+ it("updatePersonContact writes only the keys present, stamps the operator and serves the saved contact", async () => {
809
+ const p = seedPerson({ email: "o@x.io", companyName: "Keep" });
810
+ const outcome = await updatePersonContact(WS_A1, p["id"] as string, {
811
+ telephone: "+14155550123",
812
+ address: ADDRESS,
813
+ });
814
+ expect(outcome.kind).toBe("saved");
815
+ expect(people()[0]).toEqual(
816
+ expect.objectContaining({
817
+ telephone: "+14155550123",
818
+ companyName: "Keep",
819
+ contactConfirmedBy: "operator",
820
+ ...ADDRESS_COLUMNS,
821
+ }),
822
+ );
823
+ expect(people()[0]!["contactConfirmedAt"]).toBeInstanceOf(Date);
824
+ if (outcome.kind !== "saved") return;
825
+ expect(outcome.value.confirmed?.by).toBe("operator");
826
+ expect(outcome.value.contact.address).toEqual(ADDRESS);
827
+ expect(outcome.value.contact.companyName).toBe("Keep");
828
+ });
829
+
830
+ it("an address of null clears all six parts", async () => {
831
+ const p = seedPerson({ email: "o@x.io", ...ADDRESS_COLUMNS });
832
+ await updatePersonContact(WS_A1, p["id"] as string, { address: null });
833
+ expect(people()[0]).toEqual(
834
+ expect.objectContaining({ addressLine1: null, addressCity: null, addressCountry: null }),
835
+ );
836
+ });
837
+
838
+ it("another workspace's Person, and an absent one, are not_found and unchanged", async () => {
839
+ const p = seedPerson({ workspaceId: WS_A2, email: "o@x.io" });
840
+ expect((await updatePersonContact(WS_A1, p["id"] as string, { jobTitle: "x" })).kind).toBe("not_found");
841
+ expect((await updatePersonContact(WS_A1, "person_nope", { jobTitle: "x" })).kind).toBe("not_found");
842
+ expect(people()[0]!["jobTitle"]).toBeNull();
843
+ });
844
+
845
+ it("a store error is internal_error, never a throw", async () => {
846
+ store.person["updateMany"]!.mockRejectedValueOnce(new Error("down"));
847
+ expect((await updatePersonContact(WS_A1, "person_x", { jobTitle: "x" })).kind).toBe("internal_error");
848
+ });
849
+
850
+ it("setOwnPersonContact replaces all six keys on the caller's own Person only", async () => {
851
+ const mine = seedPerson({ email: "me@x.io", userId: U, companyName: "Capture-filled", jobTitle: "Boss" });
852
+ const other = seedPerson({ email: "other@x.io", userId: "user_00000000-0000-7000-8000-0000000000f2" });
853
+ const outcome = await setOwnPersonContact(WS_A1, mine["id"] as string, U, {
854
+ givenName: "Me",
855
+ familyName: null,
856
+ telephone: "+14155550123",
857
+ address: null,
858
+ companyName: null,
859
+ jobTitle: null,
860
+ });
861
+ expect(outcome.kind).toBe("saved");
862
+ expect(people()[0]).toEqual(
863
+ expect.objectContaining({
864
+ givenName: "Me",
865
+ companyName: null,
866
+ jobTitle: null,
867
+ contactConfirmedBy: "client",
868
+ }),
869
+ );
870
+ expect(people()[1]).toBe(other);
871
+ expect(people()[1]!["givenName"]).toBeNull();
872
+ });
873
+
874
+ it("setOwnPersonContact is not_found for a Person linked to someone else, or of another workspace", async () => {
875
+ const linkedElsewhere = seedPerson({ email: "a@x.io", userId: "user_00000000-0000-7000-8000-0000000000f3" });
876
+ const otherWorkspace = seedPerson({ workspaceId: WS_A2, email: "me@x.io", userId: U });
877
+ const body = { givenName: "X", familyName: null, telephone: null, address: null, companyName: null, jobTitle: null };
878
+ expect((await setOwnPersonContact(WS_A1, linkedElsewhere["id"] as string, U, body)).kind).toBe("not_found");
879
+ expect((await setOwnPersonContact(WS_A1, otherWorkspace["id"] as string, U, body)).kind).toBe("not_found");
880
+ expect(people().map((p) => p["givenName"])).toEqual([null, null]);
881
+ });
882
+
883
+ it("ensureMemberPerson creates the verified email's Person and links it", async () => {
884
+ users().push({ id: U, email: " Me@X.io ", emailVerified: true });
885
+ const result = await ensureMemberPerson(WS_A1, U);
886
+ expect(result.ok).toBe(true);
887
+ expect(people()).toHaveLength(1);
888
+ expect(people()[0]).toEqual(expect.objectContaining({ email: "me@x.io", userId: U }));
889
+ });
890
+
891
+ it("ensureMemberPerson finds the existing Person of that email and links it", async () => {
892
+ const p = seedPerson({ email: "me@x.io" });
893
+ users().push({ id: U, email: "me@x.io", emailVerified: true });
894
+ const result = await ensureMemberPerson(WS_A1, U);
895
+ expect(result).toEqual({ ok: true, personId: p["id"] });
896
+ expect(people()).toHaveLength(1);
897
+ expect(people()[0]!["userId"]).toBe(U);
898
+ });
899
+
900
+ it("ensureMemberPerson is unavailable for an unverified caller and for a Person linked to another user", async () => {
901
+ users().push({ id: U, email: "me@x.io", emailVerified: false });
902
+ expect(await ensureMemberPerson(WS_A1, U)).toEqual({ ok: false });
903
+ users()[0]!["emailVerified"] = true;
904
+ seedPerson({ email: "me@x.io", userId: "user_00000000-0000-7000-8000-0000000000f4" });
905
+ expect(await ensureMemberPerson(WS_A1, U)).toEqual({ ok: false });
906
+ expect(people()[0]!["userId"]).toBe("user_00000000-0000-7000-8000-0000000000f4");
907
+ });
908
+
909
+ it("personContactFromRow serves a whole address or none, and every key even from a sparse row", () => {
910
+ expect(personContactFromRow({ ...(seedPerson({ email: "r@x.io", ...ADDRESS_COLUMNS }) as never) }).address).toEqual(ADDRESS);
911
+ expect(personContactFromRow({} as never)).toEqual({
912
+ givenName: null,
913
+ familyName: null,
914
+ telephone: null,
915
+ address: null,
916
+ companyName: null,
917
+ jobTitle: null,
918
+ });
919
+ expect(contactConfirmedFromRow({ contactConfirmedAt: null, contactConfirmedBy: null })).toBeNull();
920
+ expect(
921
+ contactConfirmedFromRow({ contactConfirmedAt: new Date("2026-10-12T00:00:00.000Z"), contactConfirmedBy: "client" }),
922
+ ).toEqual({ by: "client", at: "2026-10-12T00:00:00.000Z" });
923
+ });
924
+ });
925
+
534
926
  describe("setPersonPlanOffering (#7877 D8, AC-4, AC-5)", () => {
535
927
  it("sets and clears the plan, scoped to the workspace", async () => {
536
928
  const p = seedPerson({ email: "cleo@x.io" });