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
@@ -48,8 +48,16 @@
48
48
  * throws, never refuses the lead and never changes `routeLead`'s result: an
49
49
  * unstorable address or a failed step writes the lead with `personId: null`
50
50
  * and logs one bare line (`person-skipped` / `person-failed`, never the
51
- * email). `subscribe` and `callback` create no Person; the `email`, `local`
52
- * and `none` deliveries never reach `persistToDb`.
51
+ * email). The `email`, `local` and `none` deliveries never reach
52
+ * `persistToDb`.
53
+ *
54
+ * CONTACT AND LINKS (#8435 D5). A `submit` may carry `options.contact` (the
55
+ * magnet's tagged answers): `findOrCreatePerson` fills the Person's BLANK
56
+ * contact fields from it while the Person is uncurated, and the lead's own
57
+ * `payload` is unchanged. A `subscribe` and a `callback` with a storable email
58
+ * (a callback's is `payload.email`) now also resolve a Person and stamp its id
59
+ * as `personId`; a callback with no email makes no Person call and writes no
60
+ * `personId`. Every one of these steps is best-effort exactly like the lead's.
53
61
  *
54
62
  * CHECKOUT PLAN (#7877 D10). A spoke's checkout fulfilment posts one
55
63
  * `submit` with the Checkout Session id as its `Idempotency-Key`. When that
@@ -63,6 +71,24 @@
63
71
  * `routeLead`'s result, and logs one bare `checkout-plan` line — never the
64
72
  * offering, the email or the key. A lead that does not qualify makes no
65
73
  * Person call at all.
74
+ *
75
+ * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate, plus "not relayed
76
+ * for a visitor" (`SourceMeta.relayed`), is evaluated once BEFORE the lead's
77
+ * create and stored as `Lead.spokeCheckout` — the hub's conclusion at write
78
+ * time, which `GET /v1/member/purchases` serves as a purchase
79
+ * (`lib/member-store.ts`). Written only in the submit create below, never by
80
+ * an update; an unmarked create carries no `spokeCheckout` key at all and
81
+ * relies on the column default. The plan rule above is unchanged: it still
82
+ * runs after the write, from the same candidate.
83
+ *
84
+ * VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
85
+ * id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
86
+ * `x-ailk-consent-visitor` only when it is a v4 UUID) and the submit resolved
87
+ * a Person, `linkConsentVisitor` joins the two — source `magnet` for a
88
+ * `flow:` source, else `api`. A link never fails a lead and is never
89
+ * returned: `RouteLeadResult.personId` is internal, for the flow engine's
90
+ * consent write. A subscribe or callback resolves no Person, so it links
91
+ * nothing. The header is never echoed and never logged.
66
92
  */
67
93
 
68
94
  import { randomUUID } from "node:crypto";
@@ -82,8 +108,15 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
82
108
  import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
83
109
  import { prisma } from "../lib/prisma.js";
84
110
 
111
+ import { linkConsentVisitor } from "./consent.js";
85
112
  import { enrichLead } from "./enrichment-shim.js";
86
- import { findOrCreatePerson, setPersonPlanOffering } from "./person.js";
113
+ import {
114
+ findOrCreatePerson,
115
+ normalizeCapturedContact,
116
+ normalizeTelephone,
117
+ setPersonPlanOffering,
118
+ } from "./person.js";
119
+ import type { LeadContact, PersonContact } from "./person.js";
87
120
  import { requestConfirmSupersedesNotification } from "./request-confirm.js";
88
121
 
89
122
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -117,6 +150,19 @@ export type SourceMeta = {
117
150
  * `false`. Never persisted — `captureRecordFor` copies fields by name.
118
151
  */
119
152
  viaSpoke?: boolean;
153
+ /**
154
+ * The request carried `x-ailk-relayed`, i.e. it was forwarded for a
155
+ * visitor by a spoke's same-origin relay (#8185 D3). Absent reads as
156
+ * `false`. Never persisted. It can only REMOVE the spoke-checkout marker,
157
+ * never grant it.
158
+ */
159
+ relayed?: boolean;
160
+ /**
161
+ * The cookie banner's visitor id from the request's `x-ailk-consent-visitor`
162
+ * header, set only when it is a v4 UUID (#8438 D11). Used to link the
163
+ * visitor to the lead's Person; never persisted, echoed or logged.
164
+ */
165
+ consentVisitorId?: string;
120
166
  };
121
167
 
122
168
  /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
@@ -126,6 +172,12 @@ export type Persisted = "db" | "file" | "email";
126
172
  export type RouteLeadOptions = {
127
173
  /** The caller's validated `Idempotency-Key`; honoured for `submit` only. */
128
174
  idempotencyKey?: string;
175
+ /**
176
+ * The contact the capture offers (#8435 D5) — the magnet's tagged answers —
177
+ * honoured for `submit` only and never persisted as such: the lead's
178
+ * `payload` stays the raw answers.
179
+ */
180
+ contact?: LeadContact;
129
181
  };
130
182
 
131
183
  export type RouteLeadResult =
@@ -138,6 +190,12 @@ export type RouteLeadResult =
138
190
  */
139
191
  replayed?: true;
140
192
  id: string;
193
+ /**
194
+ * The Person a `submit` resolved (#8438 D11), `null`/absent when none.
195
+ * Internal only — the route builds its 201 body from `id`, `persisted`
196
+ * and `email`, so this never reaches a wire.
197
+ */
198
+ personId?: string | null;
141
199
  persisted: Persisted;
142
200
  email:
143
201
  | { ok: true; messageId: string }
@@ -240,6 +298,8 @@ type PersistResult =
240
298
  source: "db";
241
299
  skipEmail: boolean;
242
300
  skipReason?: string;
301
+ /** The Person a `submit` resolved (#8438 D11). */
302
+ personId?: string | null;
243
303
  }
244
304
  // #7869 D5 — a keyed create lost a race to a row it now answers with.
245
305
  | { ok: "keyed"; outcome: KeyedOutcome }
@@ -251,25 +311,41 @@ type PersistResult =
251
311
  * must never refuse, lose or delay a lead. Logs the outcome kind and the
252
312
  * site only.
253
313
  */
254
- async function resolveLeadPersonId(input: SubmitInput): Promise<string | null> {
314
+ async function resolveCapturePersonId(
315
+ siteId: string,
316
+ capture: { email: string; name?: string | null; contact?: Partial<PersonContact> },
317
+ ): Promise<string | null> {
255
318
  try {
256
- const name = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
257
- const person = await findOrCreatePerson(input.siteId, { email: input.email, name });
319
+ const person = await findOrCreatePerson(siteId, capture);
258
320
  if (person.ok) return person.value.personId;
259
321
  if (person.error.kind === "invalid_email") {
260
322
  logger.warn(
261
- { event: "person-skipped", kind: person.error.kind, siteId: input.siteId },
323
+ { event: "person-skipped", kind: person.error.kind, siteId },
262
324
  "lead person skipped",
263
325
  );
264
326
  } else {
265
- logger.error({ event: "person-failed", siteId: input.siteId }, "lead person failed");
327
+ logger.error({ event: "person-failed", siteId }, "lead person failed");
266
328
  }
267
329
  } catch {
268
- logger.error({ event: "person-failed", siteId: input.siteId }, "lead person failed");
330
+ logger.error({ event: "person-failed", siteId }, "lead person failed");
269
331
  }
270
332
  return null;
271
333
  }
272
334
 
335
+ async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): Promise<string | null> {
336
+ const payloadName = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
337
+ if (contact === undefined) {
338
+ return resolveCapturePersonId(input.siteId, { email: input.email, name: payloadName });
339
+ }
340
+ // #8435 D5 — an explicit contact name wins over the payload's.
341
+ const name = typeof contact.name === "string" ? contact.name : payloadName;
342
+ return resolveCapturePersonId(input.siteId, {
343
+ email: input.email,
344
+ name,
345
+ contact: normalizeCapturedContact(contact),
346
+ });
347
+ }
348
+
273
349
  /** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
274
350
  const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
275
351
  const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -340,9 +416,16 @@ async function persistToDb(
340
416
  input: RouteLeadInput,
341
417
  idempotencyKey?: string,
342
418
  viaSpoke = false,
419
+ relayed = false,
420
+ contact?: LeadContact,
343
421
  ): Promise<PersistResult> {
344
422
  if (input.kind === "submit") {
345
- const personId = await resolveLeadPersonId(input);
423
+ const personId = await resolveLeadPersonId(input, contact);
424
+ // #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
425
+ // evaluated before the create so its result can be stored. A relayed
426
+ // request is never a spoke's server call (D3).
427
+ const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
428
+ const spokeCheckout = candidate !== null && relayed !== true;
346
429
  let row: DbRow;
347
430
  try {
348
431
  row = (await prisma.lead.create({
@@ -371,6 +454,8 @@ async function persistToDb(
371
454
  // #852 D6 — the Person `resolveLeadPersonId` found or created.
372
455
  personId,
373
456
  ...(idempotencyKey !== undefined && { idempotencyKey }),
457
+ // #8185 D4 — only a marked create names the column.
458
+ ...(spokeCheckout && { spokeCheckout: true }),
374
459
  },
375
460
  select: { id: true },
376
461
  })) as DbRow;
@@ -386,14 +471,15 @@ async function persistToDb(
386
471
  return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
387
472
  }
388
473
  // #7877 D10 — only after THIS request's own write, so a replay sets nothing.
389
- const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
390
474
  if (candidate !== null && personId !== null) {
391
475
  await applyCheckoutPlan(input, personId, candidate);
392
476
  }
393
- return { ok: true, id: row.id, source: "db", skipEmail: false };
477
+ return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
394
478
  }
395
479
 
396
480
  if (input.kind === "subscribe") {
481
+ // #8435 D5 — the subscriber's Person; `null` when the step skips or fails.
482
+ const personId = await resolveCapturePersonId(input.siteId, { email: input.email });
397
483
  try {
398
484
  const row = (await prisma.subscriber.create({
399
485
  data: {
@@ -407,6 +493,7 @@ async function persistToDb(
407
493
  workspaceId: input.siteId,
408
494
  email: input.email,
409
495
  source: input.source,
496
+ personId,
410
497
  },
411
498
  select: { id: true },
412
499
  })) as DbRow;
@@ -444,6 +531,18 @@ async function persistToDb(
444
531
  }
445
532
 
446
533
  // kind === "callback"
534
+ // #8435 D5 — a callback with a string `payload.email` resolves a Person; one
535
+ // without makes no call and writes no `personId`.
536
+ const callbackEmail = input.payload["email"];
537
+ const telephone = normalizeTelephone(input.phone);
538
+ const callbackPersonId =
539
+ typeof callbackEmail === "string"
540
+ ? await resolveCapturePersonId(input.siteId, {
541
+ email: callbackEmail,
542
+ name: input.name,
543
+ contact: telephone === null ? {} : { telephone },
544
+ })
545
+ : undefined;
447
546
  const row = (await prisma.callback.create({
448
547
  data: {
449
548
  siteId: input.siteId,
@@ -457,6 +556,7 @@ async function persistToDb(
457
556
  preferredTime: input.preferredTime,
458
557
  }),
459
558
  payload: input.payload as Prisma.InputJsonValue,
559
+ ...(callbackPersonId !== undefined && { personId: callbackPersonId }),
460
560
  },
461
561
  select: { id: true },
462
562
  })) as DbRow;
@@ -727,9 +827,16 @@ export async function routeLead(
727
827
  let persisted: Persisted;
728
828
  let skipEmail = false;
729
829
  let skipReason: string | undefined;
830
+ let personId: string | null = null;
730
831
 
731
832
  try {
732
- const dbResult = await persistToDb(input, idempotencyKey, sourceMeta.viaSpoke === true);
833
+ const dbResult = await persistToDb(
834
+ input,
835
+ idempotencyKey,
836
+ sourceMeta.viaSpoke === true,
837
+ sourceMeta.relayed === true,
838
+ input.kind === "submit" ? options.contact : undefined,
839
+ );
733
840
  // #7869 D5 — a lost race answers like the lookup above: no capture
734
841
  // record, no notification.
735
842
  if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
@@ -744,6 +851,20 @@ export async function routeLead(
744
851
  persisted = "db";
745
852
  skipEmail = dbResult.skipEmail;
746
853
  skipReason = dbResult.skipReason;
854
+ personId = dbResult.personId ?? null;
855
+ // #8438 D11 — only after THIS request's own write (a keyed replay
856
+ // returned above), and only when a Person was resolved.
857
+ if (sourceMeta.consentVisitorId !== undefined && personId !== null) {
858
+ await linkConsentVisitor({
859
+ workspaceId: input.siteId,
860
+ visitorId: sourceMeta.consentVisitorId,
861
+ personId,
862
+ source:
863
+ input.kind === "submit" && input.source.startsWith("flow:")
864
+ ? "magnet"
865
+ : "api",
866
+ });
867
+ }
747
868
  } catch (dbErr) {
748
869
  // #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
749
870
  // derivation producing a row Postgres itself refuses (an unresolved
@@ -863,5 +984,5 @@ export async function routeLead(
863
984
  emailResult = await sendEmail(input, id!, persisted!, sourceMeta.viaSpoke);
864
985
  }
865
986
 
866
- return { ok: true, id: id!, persisted, email: emailResult };
987
+ return { ok: true, id: id!, personId, persisted, email: emailResult };
867
988
  }