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.
Files changed (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. 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 { leadInputSchema } from "@working-theory/validation";
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
- async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
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 row = (await prisma.lead.create({
135
- data: {
136
- siteId: input.siteId,
137
- // #6203/#6254 — the isolation key, equal to `siteId` by
138
- // construction. `input.siteId` is the RESOLVED workspace id at
139
- // EVERY `routeLead(kind: "submit")` caller now — the primary route
140
- // (`routes/leads/index.ts`, never reads it from the body — see that
141
- // file's header) and the three service-layer callers
142
- // (`flow-engine.ts`'s `completeSubmission`,
143
- // `recommender-capture.ts`'s `captureRecommenderChoice`,
144
- // `project-listings.ts`'s `submitProjectListingDraft`) all resolve
145
- // the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
146
- // is NOT NULL as of
147
- // database/migrations/20260922120000_tenancy_fence, so this can no
148
- // longer stamp `null` for an unresolved alias the way it once did
149
- // (spec D2's old rule) — a caller that somehow still passed one
150
- // would instead hit the pre-existing `<table>_workspace_id_format`
151
- // CHECK loudly, via the AC-11 re-raise below, rather than landing a
152
- // silently-unfenced row.
153
- workspaceId: input.siteId,
154
- source: input.source,
155
- email: input.email,
156
- payload: input.payload as Prisma.InputJsonValue,
157
- },
158
- select: { id: true },
159
- })) as DbRow;
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 {