create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -27,13 +27,55 @@
|
|
|
27
27
|
* under it already contradicted it.) The one READ in this file — the
|
|
28
28
|
* duplicate-subscriber lookup on the P2002 arm — is scoped by it too
|
|
29
29
|
* (#6257).
|
|
30
|
+
*
|
|
31
|
+
* IDEMPOTENCY KEY (#7869, roadmap D5). A `submit` may carry a caller's
|
|
32
|
+
* `Idempotency-Key` (`routes/leads/index.ts` reads and validates the header;
|
|
33
|
+
* it travels beside the input as `routeLead`'s third argument, never inside
|
|
34
|
+
* the `.strict()` input). On `inbox` delivery only, a key already stored on a
|
|
35
|
+
* `Lead` of THIS workspace short-circuits the write: the same `email`,
|
|
36
|
+
* `source` and `payload` is a replay (the first lead's id, nothing written,
|
|
37
|
+
* no capture record, no notification); anything else is an
|
|
38
|
+
* `idempotency_conflict` (nothing written). The two reads — the lookup
|
|
39
|
+
* before the write and the re-read after a `P2002` race — are both keyed on
|
|
40
|
+
* `(workspaceId, idempotencyKey)`, so a key can never reach another tenant's
|
|
41
|
+
* row. `email`, `local` and `none` deliveries ignore the key: there is no
|
|
42
|
+
* row to dedupe against. The key itself is never logged.
|
|
43
|
+
*
|
|
44
|
+
* PERSON (#852 D6). Before a `submit` writes its `Lead`, `findOrCreatePerson`
|
|
45
|
+
* (`services/person.ts`, the only Person creator) resolves the client of
|
|
46
|
+
* this workspace for the lead's email, and its id joins the create as
|
|
47
|
+
* `personId`. Lead capture is a never-gate surface, so the Person step never
|
|
48
|
+
* throws, never refuses the lead and never changes `routeLead`'s result: an
|
|
49
|
+
* unstorable address or a failed step writes the lead with `personId: null`
|
|
50
|
+
* and logs one bare line (`person-skipped` / `person-failed`, never the
|
|
51
|
+
* email). `subscribe` and `callback` create no Person; the `email`, `local`
|
|
52
|
+
* and `none` deliveries never reach `persistToDb`.
|
|
53
|
+
*
|
|
54
|
+
* CHECKOUT PLAN (#7877 D10). A spoke's checkout fulfilment posts one
|
|
55
|
+
* `submit` with the Checkout Session id as its `Idempotency-Key`. When that
|
|
56
|
+
* lead is written (never on a replay) with a Person, and it is a LIVE,
|
|
57
|
+
* paid checkout lead from a registered spoke's server — `viaSpoke`, a
|
|
58
|
+
* `cs_live_` key, `source: "web"`, `payload.page: "checkout"`, a string
|
|
59
|
+
* `payload.offering` and `payload.site`, and an `amountTotal` that is
|
|
60
|
+
* absent, `null` or a positive integer — the Person's plan becomes the
|
|
61
|
+
* offering minus a leading `<site>_` (`setPersonPlanOffering`, the plan's
|
|
62
|
+
* only writer). No spoke file changes. The step never throws, never changes
|
|
63
|
+
* `routeLead`'s result, and logs one bare `checkout-plan` line — never the
|
|
64
|
+
* offering, the email or the key. A lead that does not qualify makes no
|
|
65
|
+
* Person call at all.
|
|
30
66
|
*/
|
|
31
67
|
|
|
32
68
|
import { randomUUID } from "node:crypto";
|
|
69
|
+
import { isDeepStrictEqual } from "node:util";
|
|
33
70
|
|
|
34
71
|
import type { Prisma } from "@working-theory/database";
|
|
35
72
|
import { sendLeadNotification } from "@working-theory/email";
|
|
36
|
-
import {
|
|
73
|
+
import { logger } from "@working-theory/observability";
|
|
74
|
+
import {
|
|
75
|
+
LEAD_SITE_MARKER_KEY,
|
|
76
|
+
leadInputSchema,
|
|
77
|
+
planOfferingSchema,
|
|
78
|
+
} from "@working-theory/validation";
|
|
37
79
|
|
|
38
80
|
import { writeLeadFallback } from "../lib/file-fallback.js";
|
|
39
81
|
import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
@@ -41,6 +83,7 @@ import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
|
|
|
41
83
|
import { prisma } from "../lib/prisma.js";
|
|
42
84
|
|
|
43
85
|
import { enrichLead } from "./enrichment-shim.js";
|
|
86
|
+
import { findOrCreatePerson, setPersonPlanOffering } from "./person.js";
|
|
44
87
|
import { requestConfirmSupersedesNotification } from "./request-confirm.js";
|
|
45
88
|
|
|
46
89
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -79,9 +122,21 @@ export type SourceMeta = {
|
|
|
79
122
|
/** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
|
|
80
123
|
export type Persisted = "db" | "file" | "email";
|
|
81
124
|
|
|
125
|
+
/** Per-call options carried beside the input (#7869 D2). */
|
|
126
|
+
export type RouteLeadOptions = {
|
|
127
|
+
/** The caller's validated `Idempotency-Key`; honoured for `submit` only. */
|
|
128
|
+
idempotencyKey?: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
82
131
|
export type RouteLeadResult =
|
|
83
132
|
| {
|
|
84
133
|
ok: true;
|
|
134
|
+
/**
|
|
135
|
+
* The answer to a repeat of an already-stored idempotency key (#7869
|
|
136
|
+
* D4): nothing was written. Never on the wire — the route builds its
|
|
137
|
+
* 201 body from `id`, `persisted` and `email` only.
|
|
138
|
+
*/
|
|
139
|
+
replayed?: true;
|
|
85
140
|
id: string;
|
|
86
141
|
persisted: Persisted;
|
|
87
142
|
email:
|
|
@@ -96,7 +151,8 @@ export type RouteLeadResult =
|
|
|
96
151
|
| "invalid_input"
|
|
97
152
|
| "persist_failed"
|
|
98
153
|
| "config_missing"
|
|
99
|
-
| "delivery_failed"
|
|
154
|
+
| "delivery_failed"
|
|
155
|
+
| "idempotency_conflict";
|
|
100
156
|
message: string;
|
|
101
157
|
};
|
|
102
158
|
};
|
|
@@ -116,6 +172,64 @@ function validateInput(
|
|
|
116
172
|
return { ok: true };
|
|
117
173
|
}
|
|
118
174
|
|
|
175
|
+
// ─── Idempotency key (#7869) ─────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
type SubmitInput = Extract<RouteLeadInput, { kind: "submit" }>;
|
|
178
|
+
type KeyedRow = { id: string; email: string; source: string; payload: unknown };
|
|
179
|
+
/** What a stored key means for this request (#7869 D4). */
|
|
180
|
+
type KeyedOutcome = { kind: "replay"; id: string } | { kind: "conflict" };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The one read of `Lead.idempotencyKey` (#7869 D3, reused by D5's re-read).
|
|
184
|
+
* The compound unique carries `workspaceId`, so the lookup can only ever
|
|
185
|
+
* reach this workspace's rows.
|
|
186
|
+
*/
|
|
187
|
+
async function findLeadByKey(
|
|
188
|
+
workspaceId: string,
|
|
189
|
+
idempotencyKey: string,
|
|
190
|
+
): Promise<KeyedRow | null> {
|
|
191
|
+
return (await prisma.lead.findUnique({
|
|
192
|
+
where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey } },
|
|
193
|
+
select: { id: true, email: true, source: true, payload: true },
|
|
194
|
+
})) as KeyedRow | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A stored row is a replay only when it carries this request's exact `email`
|
|
199
|
+
* and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
|
|
200
|
+
* any other row is a conflict (#7869 D4).
|
|
201
|
+
*/
|
|
202
|
+
function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
|
|
203
|
+
return row.email === input.email &&
|
|
204
|
+
row.source === input.source &&
|
|
205
|
+
isDeepStrictEqual(row.payload, input.payload)
|
|
206
|
+
? { kind: "replay", id: row.id }
|
|
207
|
+
: { kind: "conflict" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function keyedResult(outcome: KeyedOutcome): RouteLeadResult {
|
|
211
|
+
if (outcome.kind === "conflict") {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: { kind: "idempotency_conflict", message: "idempotency key reused" },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
replayed: true,
|
|
220
|
+
id: outcome.id,
|
|
221
|
+
persisted: "db",
|
|
222
|
+
email: { skipped: true, reason: "idempotent_replay" },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The structural `P2002` test `recommender-capture.ts` already uses. */
|
|
227
|
+
function isUniqueViolation(err: unknown): boolean {
|
|
228
|
+
return (
|
|
229
|
+
typeof err === "object" && err !== null && (err as { code?: unknown }).code === "P2002"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
119
233
|
// ─── DB persist ───────────────────────────────────────────────────────────────
|
|
120
234
|
|
|
121
235
|
type DbRow = { id: string };
|
|
@@ -127,36 +241,155 @@ type PersistResult =
|
|
|
127
241
|
skipEmail: boolean;
|
|
128
242
|
skipReason?: string;
|
|
129
243
|
}
|
|
244
|
+
// #7869 D5 — a keyed create lost a race to a row it now answers with.
|
|
245
|
+
| { ok: "keyed"; outcome: KeyedOutcome }
|
|
130
246
|
| { ok: false; error: string };
|
|
131
247
|
|
|
132
|
-
|
|
248
|
+
/**
|
|
249
|
+
* The Person of a submitted lead (#852 D6) — its id, or `null` when the email
|
|
250
|
+
* is not a storable address or the step failed. Never throws: a Person step
|
|
251
|
+
* must never refuse, lose or delay a lead. Logs the outcome kind and the
|
|
252
|
+
* site only.
|
|
253
|
+
*/
|
|
254
|
+
async function resolveLeadPersonId(input: SubmitInput): Promise<string | null> {
|
|
255
|
+
try {
|
|
256
|
+
const name = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
|
|
257
|
+
const person = await findOrCreatePerson(input.siteId, { email: input.email, name });
|
|
258
|
+
if (person.ok) return person.value.personId;
|
|
259
|
+
if (person.error.kind === "invalid_email") {
|
|
260
|
+
logger.warn(
|
|
261
|
+
{ event: "person-skipped", kind: person.error.kind, siteId: input.siteId },
|
|
262
|
+
"lead person skipped",
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
logger.error({ event: "person-failed", siteId: input.siteId }, "lead person failed");
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
logger.error({ event: "person-failed", siteId: input.siteId }, "lead person failed");
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
|
|
274
|
+
const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
|
|
275
|
+
const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The raw offering of a lead that qualifies for the checkout plan rule, or
|
|
279
|
+
* `null` when it does not (#7877 D10). Reads the lead's own fields only.
|
|
280
|
+
*/
|
|
281
|
+
function checkoutPlanCandidate(
|
|
282
|
+
input: SubmitInput,
|
|
283
|
+
idempotencyKey: string | undefined,
|
|
284
|
+
viaSpoke: boolean,
|
|
285
|
+
): { offering: string; site: string } | null {
|
|
286
|
+
if (viaSpoke !== true) return null;
|
|
287
|
+
if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const { payload } = input;
|
|
291
|
+
const amountTotal = payload["amountTotal"];
|
|
292
|
+
if (
|
|
293
|
+
amountTotal !== undefined &&
|
|
294
|
+
amountTotal !== null &&
|
|
295
|
+
!(typeof amountTotal === "number" && Number.isInteger(amountTotal) && amountTotal > 0)
|
|
296
|
+
) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
if (input.source !== "web" || payload["page"] !== "checkout") return null;
|
|
300
|
+
const offering = payload["offering"];
|
|
301
|
+
const site = payload[LEAD_SITE_MARKER_KEY];
|
|
302
|
+
if (typeof offering !== "string" || typeof site !== "string" || !SITE_SLUG.test(site)) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
return { offering, site };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Set the Person's plan from a qualifying checkout lead (#7877 D10). Never
|
|
310
|
+
* throws and never changes the lead's result; logs the outcome kind and the
|
|
311
|
+
* site only.
|
|
312
|
+
*/
|
|
313
|
+
async function applyCheckoutPlan(
|
|
314
|
+
input: SubmitInput,
|
|
315
|
+
personId: string,
|
|
316
|
+
candidate: { offering: string; site: string },
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const prefix = `${candidate.site}_`;
|
|
319
|
+
const offering = candidate.offering.startsWith(prefix)
|
|
320
|
+
? candidate.offering.slice(prefix.length)
|
|
321
|
+
: candidate.offering;
|
|
322
|
+
let outcome: "set" | "skipped" | "failed";
|
|
323
|
+
// The operator's PUT accepts the same keys (`planOfferingSchema`).
|
|
324
|
+
if (!planOfferingSchema.safeParse(offering).success) {
|
|
325
|
+
outcome = "skipped";
|
|
326
|
+
} else {
|
|
327
|
+
try {
|
|
328
|
+
const result = await setPersonPlanOffering(input.siteId, personId, offering);
|
|
329
|
+
outcome = result === "set" ? "set" : "failed";
|
|
330
|
+
} catch {
|
|
331
|
+
outcome = "failed";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const line = { event: "checkout-plan", outcome, siteId: input.siteId };
|
|
335
|
+
if (outcome === "failed") logger.error(line, "checkout plan failed");
|
|
336
|
+
else logger.info(line, "checkout plan");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function persistToDb(
|
|
340
|
+
input: RouteLeadInput,
|
|
341
|
+
idempotencyKey?: string,
|
|
342
|
+
viaSpoke = false,
|
|
343
|
+
): Promise<PersistResult> {
|
|
133
344
|
if (input.kind === "submit") {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
345
|
+
const personId = await resolveLeadPersonId(input);
|
|
346
|
+
let row: DbRow;
|
|
347
|
+
try {
|
|
348
|
+
row = (await prisma.lead.create({
|
|
349
|
+
data: {
|
|
350
|
+
siteId: input.siteId,
|
|
351
|
+
// #6203/#6254 — the isolation key, equal to `siteId` by
|
|
352
|
+
// construction. `input.siteId` is the RESOLVED workspace id at
|
|
353
|
+
// EVERY `routeLead(kind: "submit")` caller now — the primary route
|
|
354
|
+
// (`routes/leads/index.ts`, never reads it from the body — see that
|
|
355
|
+
// file's header) and the three service-layer callers
|
|
356
|
+
// (`flow-engine.ts`'s `completeSubmission`,
|
|
357
|
+
// `recommender-capture.ts`'s `captureRecommenderChoice`,
|
|
358
|
+
// `project-listings.ts`'s `submitProjectListingDraft`) all resolve
|
|
359
|
+
// the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
|
|
360
|
+
// is NOT NULL as of
|
|
361
|
+
// database/migrations/20260922120000_tenancy_fence, so this can no
|
|
362
|
+
// longer stamp `null` for an unresolved alias the way it once did
|
|
363
|
+
// (spec D2's old rule) — a caller that somehow still passed one
|
|
364
|
+
// would instead hit the pre-existing `<table>_workspace_id_format`
|
|
365
|
+
// CHECK loudly, via the AC-11 re-raise below, rather than landing a
|
|
366
|
+
// silently-unfenced row.
|
|
367
|
+
workspaceId: input.siteId,
|
|
368
|
+
source: input.source,
|
|
369
|
+
email: input.email,
|
|
370
|
+
payload: input.payload as Prisma.InputJsonValue,
|
|
371
|
+
// #852 D6 — the Person `resolveLeadPersonId` found or created.
|
|
372
|
+
personId,
|
|
373
|
+
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
374
|
+
},
|
|
375
|
+
select: { id: true },
|
|
376
|
+
})) as DbRow;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
// #7869 D5 — two keyed requests both missed the lookup and both
|
|
379
|
+
// created; the unique index refused the second. Answer with the
|
|
380
|
+
// winner. Anything else — no key, another error, a winner that cannot
|
|
381
|
+
// be read back — is rethrown untouched, so `routeLead`'s existing
|
|
382
|
+
// constraint and outage handling decide it exactly as before.
|
|
383
|
+
if (idempotencyKey === undefined || !isUniqueViolation(err)) throw err;
|
|
384
|
+
const winner = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
|
|
385
|
+
if (!winner) throw err;
|
|
386
|
+
return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
|
|
387
|
+
}
|
|
388
|
+
// #7877 D10 — only after THIS request's own write, so a replay sets nothing.
|
|
389
|
+
const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
|
|
390
|
+
if (candidate !== null && personId !== null) {
|
|
391
|
+
await applyCheckoutPlan(input, personId, candidate);
|
|
392
|
+
}
|
|
160
393
|
return { ok: true, id: row.id, source: "db", skipEmail: false };
|
|
161
394
|
}
|
|
162
395
|
|
|
@@ -426,6 +659,7 @@ function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceM
|
|
|
426
659
|
export async function routeLead(
|
|
427
660
|
input: RouteLeadInput,
|
|
428
661
|
sourceMeta: SourceMeta,
|
|
662
|
+
options: RouteLeadOptions = {},
|
|
429
663
|
): Promise<RouteLeadResult> {
|
|
430
664
|
// 1. Validate
|
|
431
665
|
const validation = validateInput(input);
|
|
@@ -452,6 +686,17 @@ export async function routeLead(
|
|
|
452
686
|
};
|
|
453
687
|
}
|
|
454
688
|
|
|
689
|
+
// 2a. Idempotency key (#7869 D3/D7) — submit, a key, and a database to
|
|
690
|
+
// dedupe against. A stored key answers before any enrichment or write. A
|
|
691
|
+
// failed read is not an answer: the create below meets the same database
|
|
692
|
+
// and takes the existing outage path.
|
|
693
|
+
const idempotencyKey =
|
|
694
|
+
input.kind === "submit" && delivery === "inbox" ? options.idempotencyKey : undefined;
|
|
695
|
+
if (idempotencyKey !== undefined && input.kind === "submit") {
|
|
696
|
+
const stored = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
|
|
697
|
+
if (stored) return keyedResult(classifyKeyedRow(stored, input));
|
|
698
|
+
}
|
|
699
|
+
|
|
455
700
|
// 3. Enrich (reserved for Phase 3 — today returns lead unchanged).
|
|
456
701
|
// Cast via unknown: enrichLead accepts CreateLead (B3 shim type) but the
|
|
457
702
|
// pass-through contract means RouteLeadInput is safe here until Phase 3 wires
|
|
@@ -484,7 +729,10 @@ export async function routeLead(
|
|
|
484
729
|
let skipReason: string | undefined;
|
|
485
730
|
|
|
486
731
|
try {
|
|
487
|
-
const dbResult = await persistToDb(input);
|
|
732
|
+
const dbResult = await persistToDb(input, idempotencyKey, sourceMeta.viaSpoke === true);
|
|
733
|
+
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
734
|
+
// record, no notification.
|
|
735
|
+
if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
|
|
488
736
|
if (!dbResult.ok) {
|
|
489
737
|
// persistToDb returned ok:false — defensive guard; current impl doesn't reach this
|
|
490
738
|
return {
|