create-ailk 0.21.0 → 0.22.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/component-catalog.json +37 -3
- package/component-catalog.md +6 -5
- package/dist/add-module.js +9 -5
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-package.js +7 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +19 -5
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/surfaces.js +19 -14
- package/dist/sync-routes.js +3 -2
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.js +5 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/templates/apps/api/CLAUDE.md +15 -6
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +120 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +6 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +24 -7
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +38 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +34 -1
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +34 -3
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -57,6 +57,47 @@
|
|
|
57
57
|
* file leaves nothing to find, and its retry writes again. `subscribe` and
|
|
58
58
|
* `callback` never read the header. The key is never logged or echoed.
|
|
59
59
|
*
|
|
60
|
+
* CONTACT TERMS ACCEPTANCE (#8730). A `submit` whose `source` is a contact
|
|
61
|
+
* source (`isContactLeadSource` in @working-theory/validation:
|
|
62
|
+
* `contact-form`, `conversational-intake`, `flow:contact`) must carry a
|
|
63
|
+
* top-level `consent: true` — the boolean, nothing else — or it is
|
|
64
|
+
* `400 consent_required` before the tenant read or any write. An accepted one
|
|
65
|
+
* gets `payload.consent = { statement, acceptedAt }` written HERE, replacing
|
|
66
|
+
* any `payload.consent` the client sent: `statement` is the platform's
|
|
67
|
+
* contact terms statement (`CONTACT_FORM_TERMS_STATEMENT`) and `acceptedAt`
|
|
68
|
+
* this request's receive time. The `consent` flag itself is never stored. A
|
|
69
|
+
* lead with any other source, `subscribe` and `callback` are unchanged. A
|
|
70
|
+
* direct caller can still send `consent: true`; the gate makes acceptance an
|
|
71
|
+
* explicit, recorded assertion, not proof that a person read the terms.
|
|
72
|
+
*
|
|
73
|
+
* API↔MCP PARITY for `consent` (#8730): no guard entry, for the reason the
|
|
74
|
+
* Idempotency-Key paragraph below gives — this route parses no validation
|
|
75
|
+
* export, so it binds as `api-args-unbound`. `capture_lead` stamps `mcp`, a
|
|
76
|
+
* source this check never reads, so the tool is unchanged.
|
|
77
|
+
*
|
|
78
|
+
* A MAGNET'S COMPLETION (#8757 D6). After a `flow:<slug>` submit is written
|
|
79
|
+
* to the database, `services/magnet-completion.ts` reads that LIVE magnet's
|
|
80
|
+
* completion setting. On `invite` the 201 gains `next: { completion:
|
|
81
|
+
* "invite" }` and — on a first write, never a replay — the submitter is sent
|
|
82
|
+
* one confirmation email whose link starts sign-in with their address
|
|
83
|
+
* prefilled (capped per recipient and per magnet, counted in the database);
|
|
84
|
+
* on `checkout`, re-checked against the `commerce` claim and the published
|
|
85
|
+
* offerings, it gains `next: { completion: "checkout", checkoutOffering }`.
|
|
86
|
+
* `lead`, a draft or missing magnet, or a caller with nowhere to sign in
|
|
87
|
+
* leaves the 201 as it was. A failed read or send never changes the 201,
|
|
88
|
+
* and the completion read has a time budget (`MAGNET_COMPLETION_BUDGET_MS`).
|
|
89
|
+
* `next` is reachable only through a `flow:` source: `capture_lead` posts
|
|
90
|
+
* `source: "mcp"`, so the tool's arguments get the same answer as before and
|
|
91
|
+
* no parity guard row changes (NFR5).
|
|
92
|
+
*
|
|
93
|
+
* A CHECKOUT'S ACCOUNT LINK (#8757 D14). A checkout fulfilment's submit
|
|
94
|
+
* (`source: "web"`, `payload.page: "checkout"`) carries the caller's account
|
|
95
|
+
* origin into `routeLead` as `accountOrigin`, resolved exactly as the invite
|
|
96
|
+
* link's (`accountTargetFor`): the spoke's enabled sign-in client's callback
|
|
97
|
+
* origin, or `FRONTEND_URL`'s on a keyless build with an account area. With
|
|
98
|
+
* none, or a failed read, it is left out and the buyer's purchase email goes
|
|
99
|
+
* without the link. No other submit makes this read, and the 201 is unchanged.
|
|
100
|
+
*
|
|
60
101
|
* API↔MCP PARITY for the header (#7869 D9, roadmap §12 Q5): NO guard entry.
|
|
61
102
|
* The guard binds a route's arguments to the validation export its handler
|
|
62
103
|
* `.safeParse`s on `request.params|query|body`; the header check below is a
|
|
@@ -67,6 +108,11 @@
|
|
|
67
108
|
*/
|
|
68
109
|
|
|
69
110
|
import { track } from "@working-theory/analytics/server";
|
|
111
|
+
import {
|
|
112
|
+
CONTACT_FORM_TERMS_STATEMENT,
|
|
113
|
+
isContactLeadSource,
|
|
114
|
+
type LeadSubmitNext,
|
|
115
|
+
} from "@working-theory/validation";
|
|
70
116
|
import type { FastifyPluginAsync } from "fastify";
|
|
71
117
|
|
|
72
118
|
import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
|
|
@@ -81,6 +127,11 @@ import {
|
|
|
81
127
|
routeLead,
|
|
82
128
|
type SourceMeta,
|
|
83
129
|
} from "../../services/lead-routing.js";
|
|
130
|
+
import {
|
|
131
|
+
accountTargetFor,
|
|
132
|
+
magnetCompletionWithinBudget,
|
|
133
|
+
sendInviteConfirmation,
|
|
134
|
+
} from "../../services/magnet-completion.js";
|
|
84
135
|
import { resolveCallerSite } from "../../services/public-site-resolver.js";
|
|
85
136
|
import { sendRequestConfirmLink } from "../../services/request-confirm.js";
|
|
86
137
|
|
|
@@ -105,6 +156,28 @@ function liftString(
|
|
|
105
156
|
return (typeof fromPayload === "string" ? fromPayload : topLevel) as string;
|
|
106
157
|
}
|
|
107
158
|
|
|
159
|
+
/** #8730 — the refusal a contact lead without terms acceptance gets. */
|
|
160
|
+
const CONSENT_REQUIRED_BODY = {
|
|
161
|
+
error: "consent_required",
|
|
162
|
+
message: "Accept the terms to send a contact message.",
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* #8730 D3 — the accepted terms, recorded by the server on a contact lead's
|
|
167
|
+
* payload, replacing any `consent` the client wrote there. Only a plain
|
|
168
|
+
* object payload is stamped: anything else is left for `routeLead`'s schema
|
|
169
|
+
* to refuse, never spread into an object that would then pass it.
|
|
170
|
+
*/
|
|
171
|
+
function withConsentRecord(payload: unknown, acceptedAt: string): unknown {
|
|
172
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
173
|
+
return payload;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...(payload as Record<string, unknown>),
|
|
177
|
+
consent: { statement: CONTACT_FORM_TERMS_STATEMENT, acceptedAt },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
108
181
|
/**
|
|
109
182
|
* Build a typed RouteLeadInput from the raw request body for a given kind.
|
|
110
183
|
* Explicit destructure per kind prevents unknown fields (e.g. attacker-supplied
|
|
@@ -122,6 +195,7 @@ function buildInput(
|
|
|
122
195
|
kind: LeadKind,
|
|
123
196
|
body: Record<string, unknown>,
|
|
124
197
|
workspaceId: string,
|
|
198
|
+
receivedAt: string,
|
|
125
199
|
): RouteLeadInput {
|
|
126
200
|
const payload = (body["payload"] ?? {}) as Record<string, unknown>;
|
|
127
201
|
|
|
@@ -131,7 +205,9 @@ function buildInput(
|
|
|
131
205
|
siteId: workspaceId,
|
|
132
206
|
source: body["source"] as string,
|
|
133
207
|
email: liftString(body, payload, "email"),
|
|
134
|
-
payload
|
|
208
|
+
payload: isContactLeadSource(body["source"])
|
|
209
|
+
? (withConsentRecord(payload, receivedAt) as Record<string, unknown>)
|
|
210
|
+
: payload,
|
|
135
211
|
};
|
|
136
212
|
}
|
|
137
213
|
if (kind === "subscribe") {
|
|
@@ -154,10 +230,31 @@ function buildInput(
|
|
|
154
230
|
};
|
|
155
231
|
}
|
|
156
232
|
|
|
233
|
+
/** A spoke checkout fulfilment's submit (#8757 D14): `source: "web"`, `payload.page: "checkout"`. */
|
|
234
|
+
function isCheckoutSubmit(body: Record<string, unknown>): boolean {
|
|
235
|
+
const payload = body["payload"];
|
|
236
|
+
return (
|
|
237
|
+
body["source"] === "web" &&
|
|
238
|
+
typeof payload === "object" &&
|
|
239
|
+
payload !== null &&
|
|
240
|
+
(payload as Record<string, unknown>)["page"] === "checkout"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
157
244
|
function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
158
245
|
return async (fastify) => {
|
|
159
246
|
fastify.post(`/v1/leads/${kind}`, async (request, reply) => {
|
|
160
247
|
const body = request.body as Record<string, unknown>;
|
|
248
|
+
|
|
249
|
+
// #8730 D1 — a contact lead carries the visitor's terms acceptance, or
|
|
250
|
+
// it is refused before the tenant read and any write.
|
|
251
|
+
if (
|
|
252
|
+
kind === "submit" &&
|
|
253
|
+
isContactLeadSource(body?.["source"]) &&
|
|
254
|
+
body?.["consent"] !== true
|
|
255
|
+
) {
|
|
256
|
+
return reply.status(400).send(CONSENT_REQUIRED_BODY);
|
|
257
|
+
}
|
|
161
258
|
// request.ip became attacker-influenceable when trustProxy: true went
|
|
162
259
|
// instance-wide (server.ts, #4730/#4778) — leads/index.ts was never
|
|
163
260
|
// itself touched by that PR. Traced every consumer of this value
|
|
@@ -264,10 +361,20 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
264
361
|
sourceMeta.consentVisitorId = consentVisitorId;
|
|
265
362
|
}
|
|
266
363
|
|
|
364
|
+
// #8757 D14 — where a buyer's account lives, for the purchase email.
|
|
365
|
+
let accountOrigin: string | undefined;
|
|
366
|
+
if (kind === "submit" && isCheckoutSubmit(body)) {
|
|
367
|
+
const target = await accountTargetFor(caller).catch(() => null);
|
|
368
|
+
accountOrigin = target?.origin;
|
|
369
|
+
}
|
|
370
|
+
|
|
267
371
|
const result = await routeLead(
|
|
268
|
-
buildInput(kind, body, workspaceId),
|
|
372
|
+
buildInput(kind, body, workspaceId, sourceMeta.receivedAt),
|
|
269
373
|
sourceMeta,
|
|
270
|
-
|
|
374
|
+
{
|
|
375
|
+
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
376
|
+
...(accountOrigin !== undefined && { accountOrigin }),
|
|
377
|
+
},
|
|
271
378
|
);
|
|
272
379
|
|
|
273
380
|
if (!result.ok) {
|
|
@@ -357,10 +464,12 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
357
464
|
result.persisted === "db" &&
|
|
358
465
|
caller.kind !== "spoke"
|
|
359
466
|
) {
|
|
360
|
-
const callbackInput = buildInput(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
467
|
+
const callbackInput = buildInput(
|
|
468
|
+
kind,
|
|
469
|
+
body,
|
|
470
|
+
workspaceId,
|
|
471
|
+
sourceMeta.receivedAt,
|
|
472
|
+
) as Extract<RouteLeadInput, { kind: "callback" }>;
|
|
364
473
|
sendRequestConfirmLink(result.id, {
|
|
365
474
|
name: callbackInput.name,
|
|
366
475
|
phone: callbackInput.phone,
|
|
@@ -370,6 +479,50 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
370
479
|
});
|
|
371
480
|
}
|
|
372
481
|
|
|
482
|
+
// #8757 D6 — a written `flow:<slug>` submit answers its magnet's
|
|
483
|
+
// completion. A read that fails, or outlasts its budget, leaves the 201
|
|
484
|
+
// as it was: the lead is saved.
|
|
485
|
+
let next: LeadSubmitNext | undefined;
|
|
486
|
+
const source = body["source"];
|
|
487
|
+
if (
|
|
488
|
+
kind === "submit" &&
|
|
489
|
+
result.persisted === "db" &&
|
|
490
|
+
typeof source === "string" &&
|
|
491
|
+
source.startsWith("flow:")
|
|
492
|
+
) {
|
|
493
|
+
const slug = source.slice("flow:".length);
|
|
494
|
+
try {
|
|
495
|
+
const completion = await magnetCompletionWithinBudget(workspaceId, slug, () =>
|
|
496
|
+
accountTargetFor(caller),
|
|
497
|
+
);
|
|
498
|
+
if (completion !== null) {
|
|
499
|
+
next = completion.next;
|
|
500
|
+
if (completion.next.completion === "invite" && !result.replayed) {
|
|
501
|
+
const submitInput = buildInput(
|
|
502
|
+
kind,
|
|
503
|
+
body,
|
|
504
|
+
workspaceId,
|
|
505
|
+
sourceMeta.receivedAt,
|
|
506
|
+
) as Extract<RouteLeadInput, { kind: "submit" }>;
|
|
507
|
+
const locale = submitInput.payload["locale"];
|
|
508
|
+
void sendInviteConfirmation({
|
|
509
|
+
workspaceId,
|
|
510
|
+
slug,
|
|
511
|
+
email: submitInput.email,
|
|
512
|
+
locale: typeof locale === "string" ? locale : undefined,
|
|
513
|
+
completion,
|
|
514
|
+
now: new Date(),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch (error) {
|
|
519
|
+
request.log.error(
|
|
520
|
+
{ event: "magnet-completion", error: error instanceof Error ? error.name : "unknown" },
|
|
521
|
+
"lead magnet completion read failed",
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
373
526
|
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
374
527
|
// #7869 D6 — a replay records nothing new: the first write counted it.
|
|
375
528
|
if (!result.replayed) {
|
|
@@ -385,6 +538,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
385
538
|
id: result.id,
|
|
386
539
|
persisted: result.persisted,
|
|
387
540
|
email: result.email,
|
|
541
|
+
...(next === undefined ? {} : { next }),
|
|
388
542
|
});
|
|
389
543
|
});
|
|
390
544
|
};
|