create-ailk 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -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/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- 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/README.md +33 -5
- 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-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- 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/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- 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/public-site-resolver.ts +21 -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/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- 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__/agreements-prompt.test.tsx +281 -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 +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- 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 +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- 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 +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- 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 +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- 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 +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -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__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- 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/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -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
|
});
|
|
@@ -352,14 +352,33 @@ export async function resolvePublicContentAdapter(
|
|
|
352
352
|
if (site.kind === "not-required" || resolvedAdapterKind() !== "database") {
|
|
353
353
|
return getContentAdapter();
|
|
354
354
|
}
|
|
355
|
+
return workspaceContentAdapter(site.workspaceId);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The content adapter holding ONE workspace's own pages, or `null` when no
|
|
360
|
+
* content is that workspace's (#8514 D9, C13). On the database adapter it is
|
|
361
|
+
* that workspace's database adapter. Off it, the shared content is the
|
|
362
|
+
* deployment's one site — the default workspace's — so only that workspace
|
|
363
|
+
* gets it and every other workspace gets `null`, never the hub's pages
|
|
364
|
+
* (SEC-8514-I). The Admin's plan picker reads its offerings through it.
|
|
365
|
+
*/
|
|
366
|
+
export async function workspaceContentAdapter(
|
|
367
|
+
workspaceId: string,
|
|
368
|
+
): Promise<ContentAdapter | null> {
|
|
369
|
+
if (resolvedAdapterKind() !== "database") {
|
|
370
|
+
// Deferred — see the module-load note in this file's header (#5852 D6).
|
|
371
|
+
const { resolveDefaultWorkspaceId } = await import("./default-tenant.js");
|
|
372
|
+
return (await resolveDefaultWorkspaceId()) === workspaceId ? getContentAdapter() : null;
|
|
373
|
+
}
|
|
355
374
|
|
|
356
|
-
// The database path, and the only
|
|
375
|
+
// The database path, and the only one that needs apps/api's client. The
|
|
357
376
|
// import is deferred for the reason the header gives (#5852 D6), and the
|
|
358
377
|
// generated client is structurally wider than the narrow read surface the
|
|
359
378
|
// adapter declares — the cast names that, and nothing else.
|
|
360
379
|
const { prisma } = await import("../lib/prisma.js");
|
|
361
380
|
return getContentAdapter({
|
|
362
|
-
workspaceId
|
|
381
|
+
workspaceId,
|
|
363
382
|
prisma: prisma as unknown as ArtifactPrismaClient,
|
|
364
383
|
});
|
|
365
384
|
}
|