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
@@ -21,6 +21,10 @@
21
21
  * only while its `userId` is the caller.
22
22
  * - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
23
23
  * the Admin's plan route and the checkout-lead rule in `lead-routing.ts`.
24
+ * - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
25
+ * fields of an uncurated Person (D4); `updatePersonContact` (the
26
+ * operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
27
+ * client's save) overwrite and stamp who confirmed them (D9, D11).
24
28
  *
25
29
  * ## Match order (D3) — fixed, and there is no merge
26
30
  *
@@ -76,6 +80,7 @@
76
80
 
77
81
  import type { Prisma } from "@working-theory/database";
78
82
  import type { Id } from "@working-theory/ids";
83
+ import type { PersonContactPatch } from "@working-theory/validation";
79
84
 
80
85
  import { prisma } from "../lib/prisma.js";
81
86
 
@@ -127,6 +132,317 @@ export function normalizePersonName(raw: unknown): string | null {
127
132
  return cleaned === "" ? null : cleaned;
128
133
  }
129
134
 
135
+ // ─── Contact normalization (#8435 D3) ────────────────────────────────────────
136
+
137
+ export type PersonAddress = {
138
+ addressLine1: string;
139
+ addressLine2: string | null;
140
+ city: string;
141
+ region: string;
142
+ postalCode: string;
143
+ country: string;
144
+ };
145
+
146
+ export type PersonContact = {
147
+ givenName: string | null;
148
+ familyName: string | null;
149
+ telephone: string | null;
150
+ address: PersonAddress | null;
151
+ companyName: string | null;
152
+ jobTitle: string | null;
153
+ };
154
+
155
+ /**
156
+ * What a capture offers, as untrusted `unknown` values: the magnet's tagged
157
+ * answers or a caller's explicit contact (`RouteLeadOptions.contact`).
158
+ */
159
+ export type LeadContact = {
160
+ name?: unknown;
161
+ givenName?: unknown;
162
+ familyName?: unknown;
163
+ phone?: unknown;
164
+ address?: unknown;
165
+ company?: unknown;
166
+ jobTitle?: unknown;
167
+ };
168
+
169
+ /** The widths of the person-level text columns, in code points. */
170
+ const CONTACT_MAX = {
171
+ givenName: 100,
172
+ familyName: 100,
173
+ companyName: 200,
174
+ jobTitle: 100,
175
+ } as const;
176
+ /** The address parts' widths — `ADDRESS_PART_MAX_LENGTHS` in `packages/validation`. */
177
+ const ADDRESS_MAX = {
178
+ addressLine1: 100,
179
+ addressLine2: 60,
180
+ city: 60,
181
+ region: 40,
182
+ postalCode: 20,
183
+ } as const;
184
+ const E164 = /^\+[1-9]\d{6,14}$/;
185
+ const SHORT_REGION = /^[A-Za-z0-9]{1,3}$/;
186
+ const ALPHA2 = /^[A-Z]{2}$/;
187
+ /**
188
+ * The code-point ranges `CONTACT_STRIP` removes (SEC-8435-L): `NAME_STRIP`'s
189
+ * C0/C1 and bidi controls plus the zero-width characters U+200B–U+200D and
190
+ * U+FEFF and the separators U+2028/U+2029. Numbers, not a regex literal, so no
191
+ * tool can rewrite a separator into a line break inside it. The same set
192
+ * `packages/validation`'s `people-contact.ts` strips on the wire.
193
+ */
194
+ const CONTACT_STRIPPED: ReadonlyArray<readonly [number, number]> = [
195
+ [0x0000, 0x001f],
196
+ [0x007f, 0x009f],
197
+ [0x200b, 0x200f],
198
+ [0x2028, 0x2029],
199
+ [0x202a, 0x202e],
200
+ [0x2066, 0x2069],
201
+ [0xfeff, 0xfeff],
202
+ ];
203
+
204
+ function stripContactChars(raw: string): string {
205
+ let out = "";
206
+ for (const ch of raw) {
207
+ const cp = ch.codePointAt(0) ?? 0;
208
+ if (!CONTACT_STRIPPED.some(([lo, hi]) => cp >= lo && cp <= hi)) out += ch;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ const cutToCodePoints = (value: string, max: number): string =>
214
+ Array.from(value).slice(0, max).join("");
215
+
216
+ /** Stripped, trimmed, cut to `max` code points; empty or not a string → `null`. */
217
+ function normalizeContactText(raw: unknown, max: number): string | null {
218
+ if (typeof raw !== "string") return null;
219
+ const cleaned = cutToCodePoints(stripContactChars(raw).trim(), max);
220
+ return cleaned === "" ? null : cleaned;
221
+ }
222
+
223
+ /**
224
+ * Strip spaces, `( ) . -` (and the invisible characters), then the result
225
+ * must be an E.164 number — else `null`.
226
+ */
227
+ export function normalizeTelephone(raw: unknown): string | null {
228
+ if (typeof raw !== "string") return null;
229
+ const compact = stripContactChars(raw)
230
+ .replace(/[ ().-]/g, "")
231
+ .trim();
232
+ return E164.test(compact) ? compact : null;
233
+ }
234
+
235
+ /**
236
+ * An address as stored: whole or not at all. Every required part must be a
237
+ * non-empty string within its width after stripping; a short region is
238
+ * upper-cased (C3); the country must be an upper-case alpha-2 code.
239
+ */
240
+ function normalizeAddress(raw: unknown): PersonAddress | null {
241
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
242
+ return null;
243
+ const value = raw as Record<string, unknown>;
244
+ const part = (key: keyof typeof ADDRESS_MAX): string | null => {
245
+ const field = value[key];
246
+ if (typeof field !== "string") return null;
247
+ const cleaned = stripContactChars(field).trim();
248
+ return cleaned === "" || Array.from(cleaned).length > ADDRESS_MAX[key]
249
+ ? null
250
+ : cleaned;
251
+ };
252
+ const addressLine1 = part("addressLine1");
253
+ const city = part("city");
254
+ const region = part("region");
255
+ const postalCode = part("postalCode");
256
+ const country = value["country"];
257
+ if (
258
+ addressLine1 === null ||
259
+ city === null ||
260
+ region === null ||
261
+ postalCode === null ||
262
+ typeof country !== "string" ||
263
+ !ALPHA2.test(country)
264
+ ) {
265
+ return null;
266
+ }
267
+ const line2Raw = value["addressLine2"];
268
+ const line2 = part("addressLine2");
269
+ // An over-long or non-string second line makes the address unusable; an
270
+ // absent or blank one is simply null.
271
+ if (line2 === null && line2Raw !== undefined && line2Raw !== null) {
272
+ if (
273
+ typeof line2Raw !== "string" ||
274
+ stripContactChars(line2Raw).trim() !== ""
275
+ )
276
+ return null;
277
+ }
278
+ return {
279
+ addressLine1,
280
+ addressLine2: line2,
281
+ city,
282
+ region: SHORT_REGION.test(region) ? region.toUpperCase() : region,
283
+ postalCode,
284
+ country,
285
+ };
286
+ }
287
+
288
+ /** Capture-side: every usable field, each normalized; unusable ones dropped. */
289
+ export function normalizeCapturedContact(
290
+ raw: LeadContact,
291
+ ): Partial<PersonContact> {
292
+ const out: Partial<PersonContact> = {};
293
+ const givenName = normalizeContactText(raw.givenName, CONTACT_MAX.givenName);
294
+ if (givenName !== null) out.givenName = givenName;
295
+ const familyName = normalizeContactText(
296
+ raw.familyName,
297
+ CONTACT_MAX.familyName,
298
+ );
299
+ if (familyName !== null) out.familyName = familyName;
300
+ const telephone = normalizeTelephone(raw.phone);
301
+ if (telephone !== null) out.telephone = telephone;
302
+ const address = normalizeAddress(raw.address);
303
+ if (address !== null) out.address = address;
304
+ const companyName = normalizeContactText(
305
+ raw.company,
306
+ CONTACT_MAX.companyName,
307
+ );
308
+ if (companyName !== null) out.companyName = companyName;
309
+ const jobTitle = normalizeContactText(raw.jobTitle, CONTACT_MAX.jobTitle);
310
+ if (jobTitle !== null) out.jobTitle = jobTitle;
311
+ return out;
312
+ }
313
+
314
+ // ─── The contact columns, as rows and as the wire's shape ───────────────────
315
+
316
+ /** The thirteen contact columns a read names (`select`). */
317
+ export const PERSON_CONTACT_SELECT = {
318
+ givenName: true,
319
+ familyName: true,
320
+ telephone: true,
321
+ addressLine1: true,
322
+ addressLine2: true,
323
+ addressCity: true,
324
+ addressRegion: true,
325
+ addressPostalCode: true,
326
+ addressCountry: true,
327
+ companyName: true,
328
+ jobTitle: true,
329
+ contactConfirmedAt: true,
330
+ contactConfirmedBy: true,
331
+ } as const;
332
+
333
+ export type PersonContactRow = {
334
+ givenName: string | null;
335
+ familyName: string | null;
336
+ telephone: string | null;
337
+ addressLine1: string | null;
338
+ addressLine2: string | null;
339
+ addressCity: string | null;
340
+ addressRegion: string | null;
341
+ addressPostalCode: string | null;
342
+ addressCountry: string | null;
343
+ companyName: string | null;
344
+ jobTitle: string | null;
345
+ contactConfirmedAt: Date | null;
346
+ contactConfirmedBy: string | null;
347
+ };
348
+
349
+ /** The wire's contact: every key present, `null` where blank, the address whole or `null`. */
350
+ export function personContactFromRow(row: PersonContactRow): PersonContact {
351
+ // `?? null` so a row that does not carry a column reads as blank, never as
352
+ // `undefined` on the wire.
353
+ const line1 = row.addressLine1 ?? null;
354
+ const city = row.addressCity ?? null;
355
+ const region = row.addressRegion ?? null;
356
+ const postalCode = row.addressPostalCode ?? null;
357
+ const country = row.addressCountry ?? null;
358
+ const address: PersonAddress | null =
359
+ line1 !== null && city !== null && region !== null && postalCode !== null && country !== null
360
+ ? {
361
+ addressLine1: line1,
362
+ addressLine2: row.addressLine2 ?? null,
363
+ city,
364
+ region,
365
+ postalCode,
366
+ country,
367
+ }
368
+ : null;
369
+ return {
370
+ givenName: row.givenName ?? null,
371
+ familyName: row.familyName ?? null,
372
+ telephone: row.telephone ?? null,
373
+ address,
374
+ companyName: row.companyName ?? null,
375
+ jobTitle: row.jobTitle ?? null,
376
+ };
377
+ }
378
+
379
+ /** Who last saved the contact by hand, and when — `null` while only captures have written it. */
380
+ export function contactConfirmedFromRow(
381
+ row: Pick<PersonContactRow, "contactConfirmedAt" | "contactConfirmedBy">,
382
+ ): { by: "operator" | "client"; at: string } | null {
383
+ if (row.contactConfirmedAt === null || row.contactConfirmedAt === undefined)
384
+ return null;
385
+ if (
386
+ row.contactConfirmedBy !== "operator" &&
387
+ row.contactConfirmedBy !== "client"
388
+ )
389
+ return null;
390
+ return {
391
+ by: row.contactConfirmedBy,
392
+ at: row.contactConfirmedAt.toISOString(),
393
+ };
394
+ }
395
+
396
+ type AddressColumns = {
397
+ addressLine1: string | null;
398
+ addressLine2: string | null;
399
+ addressCity: string | null;
400
+ addressRegion: string | null;
401
+ addressPostalCode: string | null;
402
+ addressCountry: string | null;
403
+ };
404
+
405
+ /** The six address columns for an address — all six `null` for `null`. */
406
+ function addressColumns(address: PersonAddress | null): AddressColumns {
407
+ return {
408
+ addressLine1: address?.addressLine1 ?? null,
409
+ addressLine2: address?.addressLine2 ?? null,
410
+ addressCity: address?.city ?? null,
411
+ addressRegion: address?.region ?? null,
412
+ addressPostalCode: address?.postalCode ?? null,
413
+ addressCountry: address?.country ?? null,
414
+ };
415
+ }
416
+
417
+ /** The scalar contact fields, each its own column of the same name. */
418
+ const SCALAR_CONTACT_FIELDS = [
419
+ "givenName",
420
+ "familyName",
421
+ "telephone",
422
+ "companyName",
423
+ "jobTitle",
424
+ ] as const;
425
+
426
+ /** Every supplied contact field as insert columns. */
427
+ function contactInsertColumns(
428
+ contact: Partial<PersonContact>,
429
+ ): Record<string, string | null> {
430
+ const columns: Record<string, string | null> = {};
431
+ for (const field of SCALAR_CONTACT_FIELDS) {
432
+ const value = contact[field];
433
+ if (value !== undefined && value !== null) columns[field] = value;
434
+ }
435
+ if (contact.address) Object.assign(columns, addressColumns(contact.address));
436
+ return columns;
437
+ }
438
+
439
+ /** `"Ada" + "Lovelace"` → `"Ada Lovelace"`, `"Ada"` alone → `"Ada"`, neither → `null`. */
440
+ function joinedName(contact: Partial<PersonContact>): string | null {
441
+ return normalizePersonName(
442
+ [contact.givenName, contact.familyName].filter((part) => part).join(" "),
443
+ );
444
+ }
445
+
130
446
  function usableCustomerId(raw: string | null | undefined): string | null {
131
447
  return typeof raw === "string" &&
132
448
  raw.length > 0 &&
@@ -156,6 +472,16 @@ type MatchedPerson = {
156
472
  name: string | null;
157
473
  userId: string | null;
158
474
  billingCustomerId: string | null;
475
+ // #8435 D4.4 — the blank-sentinels the contact fill reads. A value reading
476
+ // `undefined` (a test double that does not carry the key) is "not blank",
477
+ // and an undefined `contactConfirmedAt` is "confirmed": no fill runs.
478
+ givenName?: string | null;
479
+ familyName?: string | null;
480
+ telephone?: string | null;
481
+ addressLine1?: string | null;
482
+ companyName?: string | null;
483
+ jobTitle?: string | null;
484
+ contactConfirmedAt?: Date | null;
159
485
  };
160
486
 
161
487
  const MATCH_SELECT = {
@@ -163,6 +489,13 @@ const MATCH_SELECT = {
163
489
  name: true,
164
490
  userId: true,
165
491
  billingCustomerId: true,
492
+ givenName: true,
493
+ familyName: true,
494
+ telephone: true,
495
+ addressLine1: true,
496
+ companyName: true,
497
+ jobTitle: true,
498
+ contactConfirmedAt: true,
166
499
  } as const;
167
500
 
168
501
  /**
@@ -199,6 +532,53 @@ async function enrichMatched(
199
532
  return billing;
200
533
  }
201
534
 
535
+ /**
536
+ * The contact fill on an email match (#8435 D4.3): for each supplied field
537
+ * whose stored value is `null`, one guarded `updateMany`; the address is one
538
+ * statement guarded on `addressLine1: null` writing all six. It runs ONLY
539
+ * while the Person is uncurated — linked to no user and never saved by hand
540
+ * (`contactConfirmedAt` null) — and every guard repeats that condition, so a
541
+ * link or a save that lands between the read and the write still wins
542
+ * (SEC-8435-A). A stored value, whoever wrote it, is never overwritten.
543
+ * Best-effort: a failed write is swallowed, never refusing the capture.
544
+ */
545
+ async function fillBlankContact(
546
+ db: PersonDb,
547
+ workspaceId: string,
548
+ person: MatchedPerson,
549
+ contact: Partial<PersonContact>,
550
+ plainName: string | null,
551
+ ): Promise<void> {
552
+ if (person.userId !== null || person.contactConfirmedAt !== null) return;
553
+ const uncurated = { id: person.id, workspaceId, userId: null, contactConfirmedAt: null };
554
+ try {
555
+ for (const field of SCALAR_CONTACT_FIELDS) {
556
+ const value = contact[field];
557
+ if (value !== undefined && value !== null && person[field] === null) {
558
+ await db.person.updateMany({
559
+ where: { ...uncurated, [field]: null } as Prisma.PersonWhereInput,
560
+ data: { [field]: value } as Prisma.PersonUpdateManyMutationInput,
561
+ });
562
+ }
563
+ }
564
+ if (contact.address && person.addressLine1 === null) {
565
+ await db.person.updateMany({
566
+ where: { ...uncurated, addressLine1: null },
567
+ data: addressColumns(contact.address),
568
+ });
569
+ }
570
+ // The display name from given + family, only when nothing named it.
571
+ if (plainName === null && person.name === null) {
572
+ const derived = joinedName(contact);
573
+ if (derived !== null) {
574
+ await db.person.updateMany({ where: { ...uncurated, name: null }, data: { name: derived } });
575
+ }
576
+ }
577
+ } catch {
578
+ // A contact fill never changes the capture's outcome.
579
+ }
580
+ }
581
+
202
582
  /**
203
583
  * Find this workspace's Person for an email, or create it.
204
584
  *
@@ -206,6 +586,9 @@ async function enrichMatched(
206
586
  * @param input.email the raw address; normalized here.
207
587
  * @param input.name an optional display name, from anonymous text.
208
588
  * @param input.billingCustomerId an optional provider customer id.
589
+ * @param input.contact optional, already-normalized contact fields
590
+ * (`normalizeCapturedContact`): a created Person carries them all; a matched
591
+ * uncurated Person gains only the ones it has blank (#8435 D4).
209
592
  * @param db the shared client by default; the appointment path passes its
210
593
  * transaction client so the step runs on the lock's own connection.
211
594
  */
@@ -215,6 +598,7 @@ export async function findOrCreatePerson(
215
598
  email: string;
216
599
  name?: string | null;
217
600
  billingCustomerId?: string | null;
601
+ contact?: Partial<PersonContact>;
218
602
  },
219
603
  db: PersonDb = prisma,
220
604
  ): Promise<FindOrCreatePersonResult> {
@@ -225,6 +609,8 @@ export async function findOrCreatePerson(
225
609
  }
226
610
  const name = normalizePersonName(input.name);
227
611
  const customerId = usableCustomerId(input.billingCustomerId);
612
+ const contact = input.contact ?? {};
613
+ const hasContact = Object.keys(contact).length > 0;
228
614
 
229
615
  try {
230
616
  // Step 2 — a customer id names its Person; nothing is written.
@@ -253,6 +639,7 @@ export async function findOrCreatePerson(
253
639
  });
254
640
  if (byEmail) {
255
641
  const billing = await enrichMatched(db, workspaceId, byEmail, name, customerId);
642
+ if (hasContact) await fillBlankContact(db, workspaceId, byEmail, contact, name);
256
643
  return {
257
644
  ok: true,
258
645
  value: { personId: byEmail.id as Id<"person">, created: false, billing },
@@ -265,11 +652,13 @@ export async function findOrCreatePerson(
265
652
  {
266
653
  workspaceId,
267
654
  email,
268
- name,
655
+ // A supplied name wins; else given + family joined (#8435 D4.2).
656
+ name: name ?? joinedName(contact),
269
657
  ...(customerId !== null && {
270
658
  billingProvider: "stripe",
271
659
  billingCustomerId: customerId,
272
660
  }),
661
+ ...(hasContact && contactInsertColumns(contact)),
273
662
  },
274
663
  ],
275
664
  skipDuplicates: true,
@@ -291,6 +680,7 @@ export async function findOrCreatePerson(
291
680
  }
292
681
  // A concurrent create won the race: treat its row as an email match.
293
682
  const billing = await enrichMatched(db, workspaceId, winner, name, customerId);
683
+ if (hasContact) await fillBlankContact(db, workspaceId, winner, contact, name);
294
684
  return {
295
685
  ok: true,
296
686
  value: { personId: winner.id as Id<"person">, created: false, billing },
@@ -539,3 +929,113 @@ export async function setPersonPlanOffering(
539
929
  return "internal_error";
540
930
  }
541
931
  }
932
+
933
+ // ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
934
+
935
+ export type SavedContact = {
936
+ contact: PersonContact;
937
+ confirmed: { by: "operator" | "client"; at: string } | null;
938
+ };
939
+
940
+ export type SaveContactOutcome =
941
+ | { kind: "saved"; value: SavedContact }
942
+ | { kind: "not_found" | "internal_error" };
943
+
944
+ /** The columns a patch writes: only the keys present; the address key writes (or clears) all six. */
945
+ function patchColumns(patch: PersonContactPatch): Record<string, string | null> {
946
+ const columns: Record<string, string | null> = {};
947
+ for (const field of SCALAR_CONTACT_FIELDS) {
948
+ const value = patch[field];
949
+ if (value !== undefined) columns[field] = value;
950
+ }
951
+ if (patch.address !== undefined) Object.assign(columns, addressColumns(patch.address));
952
+ return columns;
953
+ }
954
+
955
+ async function readSavedContact(workspaceId: string, personId: string): Promise<SaveContactOutcome> {
956
+ const row = await prisma.person.findFirst({
957
+ where: { id: personId, workspaceId },
958
+ select: PERSON_CONTACT_SELECT,
959
+ });
960
+ if (!row) return { kind: "not_found" };
961
+ return { kind: "saved", value: { contact: personContactFromRow(row), confirmed: contactConfirmedFromRow(row) } };
962
+ }
963
+
964
+ /**
965
+ * The operator's save (#8435 D9): one `updateMany` scoped by the
966
+ * server-resolved workspace, writing only the keys present plus the
967
+ * confirmation stamp (`operator`). No row → `not_found`. Never throws.
968
+ */
969
+ export async function updatePersonContact(
970
+ workspaceId: string,
971
+ personId: string,
972
+ patch: PersonContactPatch,
973
+ ): Promise<SaveContactOutcome> {
974
+ try {
975
+ const { count } = await prisma.person.updateMany({
976
+ where: { id: personId, workspaceId },
977
+ data: {
978
+ ...patchColumns(patch),
979
+ contactConfirmedAt: new Date(),
980
+ contactConfirmedBy: "operator",
981
+ } as Prisma.PersonUpdateManyMutationInput,
982
+ });
983
+ if (count === 0) return { kind: "not_found" };
984
+ return await readSavedContact(workspaceId, personId);
985
+ } catch {
986
+ return { kind: "internal_error" };
987
+ }
988
+ }
989
+
990
+ /**
991
+ * The Person a client's first contact save belongs to (#8435 D11): the
992
+ * caller's VERIFIED email's Person in this workspace, found or created, and
993
+ * linked to the caller through `stampPersonUser`'s own rule. Anything but a
994
+ * stamped or already-linked outcome is `unavailable` — nothing is written for
995
+ * the contact. Never throws.
996
+ */
997
+ export async function ensureMemberPerson(
998
+ workspaceId: string,
999
+ userId: string,
1000
+ ): Promise<{ ok: true; personId: string } | { ok: false }> {
1001
+ try {
1002
+ const verified = await findVerifiedEmail(userId);
1003
+ if (!verified.ok) return { ok: false };
1004
+ const person = await findOrCreatePerson(workspaceId, { email: verified.value.email });
1005
+ if (!person.ok) return { ok: false };
1006
+ const outcome = await stampPersonUser(workspaceId, person.value.personId, userId);
1007
+ if (outcome !== "stamped" && outcome !== "already_linked") return { ok: false };
1008
+ return { ok: true, personId: person.value.personId };
1009
+ } catch {
1010
+ return { ok: false };
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * The client's save (#8435 D11): ONE `updateMany` guarded on `{ id,
1016
+ * workspaceId, userId }` — only the caller's own linked Person is ever
1017
+ * written — replacing all six contact keys with the body and stamping
1018
+ * `client`, so no value the client never saw survives it (SEC-8435-B). No
1019
+ * row → `not_found`. Never throws.
1020
+ */
1021
+ export async function setOwnPersonContact(
1022
+ workspaceId: string,
1023
+ personId: string,
1024
+ userId: string,
1025
+ contact: PersonContactPatch,
1026
+ ): Promise<SaveContactOutcome> {
1027
+ try {
1028
+ const { count } = await prisma.person.updateMany({
1029
+ where: { id: personId, workspaceId, userId },
1030
+ data: {
1031
+ ...patchColumns(contact),
1032
+ contactConfirmedAt: new Date(),
1033
+ contactConfirmedBy: "client",
1034
+ } as Prisma.PersonUpdateManyMutationInput,
1035
+ });
1036
+ if (count === 0) return { kind: "not_found" };
1037
+ return await readSavedContact(workspaceId, personId);
1038
+ } catch {
1039
+ return { kind: "internal_error" };
1040
+ }
1041
+ }
@@ -868,10 +868,15 @@ export async function startProjectListingDraft(
868
868
  select: { id: true },
869
869
  });
870
870
  const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
871
+ // #8438 D3 — a listing names no magnet slug (ProjectListing carries no
872
+ // link to its flow), so `magnet` would be unprovable: the source is `api`.
871
873
  if (consents) {
872
- await recordConsent({ ...subject, locale, config: consentConfig, consents }, tx);
874
+ await recordConsent(
875
+ { ...subject, personId: null, source: "api", locale, config: consentConfig, consents },
876
+ tx,
877
+ );
873
878
  } else {
874
- await recordLegacyConsent({ ...subject, config: consentConfig }, tx);
879
+ await recordLegacyConsent({ ...subject, source: "api", config: consentConfig }, tx);
875
880
  }
876
881
  return created;
877
882
  });
@@ -57,6 +57,8 @@ import { logger } from "@working-theory/observability";
57
57
  import { isModuleEnabled } from "../config/modules.js";
58
58
  import { prisma } from "../lib/prisma.js";
59
59
 
60
+ import { findOrCreatePerson, normalizeTelephone } from "./person.js";
61
+
60
62
  // ─── The credential (spec D5) ──────────────────────────────────────────────
61
63
 
62
64
  /**
@@ -580,6 +582,56 @@ export async function recordPaymentLink(
580
582
  where: { id },
581
583
  data: { stripePaymentLinkId: paymentLinkId },
582
584
  });
585
+ // #8435 D5 — only now, with the Payment Link recorded: the Person step can
586
+ // never strand a spent token without its link (SEC-8435-G).
587
+ await linkConfirmedCallbackPerson(id);
588
+ }
589
+
590
+ /**
591
+ * Link a confirmed callback to its Person (#8435 D5): when the capture did not
592
+ * (`personId` null — no `payload.email`) and the tenant typed a buyer email on
593
+ * the confirm form, find or create that email's Person in the callback's own
594
+ * workspace and stamp it, guarded on `personId: null` so a link a capture
595
+ * already made is never replaced. Best-effort like every Person step: it never
596
+ * throws and never changes `recordPaymentLink`'s outcome. Logs the outcome
597
+ * kind only.
598
+ */
599
+ async function linkConfirmedCallbackPerson(id: string): Promise<void> {
600
+ let outcome: "linked" | "skipped" | "failed";
601
+ try {
602
+ // fence: global-key — `Callback.id`, the row `recordPaymentLink` just
603
+ // wrote. The Person it names is found or created in THIS row's own
604
+ // `workspaceId`, so the link can only ever name a Person of that workspace.
605
+ const callback = await prisma.callback.findFirst({
606
+ where: { id },
607
+ select: { workspaceId: true, buyerEmail: true, name: true, phone: true, personId: true },
608
+ });
609
+ if (!callback || callback.personId !== null || callback.buyerEmail === null) {
610
+ outcome = "skipped";
611
+ } else {
612
+ const telephone = normalizeTelephone(callback.phone);
613
+ const person = await findOrCreatePerson(callback.workspaceId, {
614
+ email: callback.buyerEmail,
615
+ name: callback.name,
616
+ contact: telephone === null ? {} : { telephone },
617
+ });
618
+ if (!person.ok) {
619
+ outcome = person.error.kind === "invalid_email" ? "skipped" : "failed";
620
+ } else {
621
+ // fence: global-key — same `Callback.id`; `workspaceId` is the row's
622
+ // own, and `personId: null` keeps a capture's link.
623
+ await prisma.callback.updateMany({
624
+ where: { id, workspaceId: callback.workspaceId, personId: null },
625
+ data: { personId: person.value.personId },
626
+ });
627
+ outcome = "linked";
628
+ }
629
+ }
630
+ } catch {
631
+ outcome = "failed";
632
+ }
633
+ if (outcome === "failed") logger.error({ event: "callback-person", outcome }, "callback person failed");
634
+ else logger.info({ event: "callback-person", outcome }, "callback person");
583
635
  }
584
636
 
585
637
  /** What the buyer is told, once the tenant has confirmed. */
@@ -94,9 +94,18 @@ export interface RevalidateTarget {
94
94
 
95
95
  export interface RevalidateOptions {
96
96
  event: "publish" | "unpublish";
97
+ /**
98
+ * Waits out one backoff. A test seam (#8418): the publish routes never pass
99
+ * it, so production always waits on the real timer. A test passes one that
100
+ * records the delay and resolves at once, so the retry schedule is asserted
101
+ * as scheduled rather than measured against a loaded runner's clock.
102
+ *
103
+ * @internal
104
+ */
105
+ sleep?: (ms: number) => Promise<void>;
97
106
  }
98
107
 
99
- function sleep(ms: number): Promise<void> {
108
+ function realSleep(ms: number): Promise<void> {
100
109
  return new Promise((resolve) => setTimeout(resolve, ms));
101
110
  }
102
111
 
@@ -125,6 +134,7 @@ export async function notifyRevalidate(
125
134
  locale: target.locale,
126
135
  });
127
136
 
137
+ const { sleep = realSleep } = options;
128
138
  const url = config.CONTENT_REVALIDATE_URL;
129
139
  const secret = config.CONTENT_REVALIDATE_SECRET;
130
140