create-ailk 0.19.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.
- package/dist/add-module.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +9 -3
- package/dist/programmatic.js +8 -1
- package/dist/site-roster.js +21 -4
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +31 -1
- package/dist/topology.js +29 -1
- package/package.json +3 -3
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +10 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +11 -3
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +111 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +54 -14
- package/templates/database/CHANGELOG.md +16 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/inbox/schema.prisma +76 -10
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -35
|
@@ -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(
|
|
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
|
|
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
|
|