create-ailk 0.20.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/README.md +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- 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/project-listings/__tests__/drafts.test.ts +26 -0
- 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/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- 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]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -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 +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- 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]/layout.tsx +23 -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/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- 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/jest.config.cjs +1 -0
- 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 +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- 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 +192 -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 +40 -12
- 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 +47 -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 +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- 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 +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -81,6 +81,43 @@
|
|
|
81
81
|
* relies on the column default. The plan rule above is unchanged: it still
|
|
82
82
|
* runs after the write, from the same candidate.
|
|
83
83
|
*
|
|
84
|
+
* PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
|
|
85
|
+
* write of a lead marked `spokeCheckout` with a Person — so never on a
|
|
86
|
+
* replay, a test-mode or relayed checkout, or a lead with no Person — the
|
|
87
|
+
* buyer gets one outbound `member` `Interaction` on that Person
|
|
88
|
+
* (`createdBy: "system:checkout"`, subject "Your purchase", no link, payload
|
|
89
|
+
* or attachment) and, fired with `void`, one `sendPurchaseConfirmationEmail`.
|
|
90
|
+
* Both name the offering only when its stripped key is one
|
|
91
|
+
* `listPlanOfferings` lists for the workspace, else "your purchase" — never
|
|
92
|
+
* the spoke-supplied `payload.offering` (SEC-8757-G) — and both bodies are
|
|
93
|
+
* stripped of control and bidi characters. The email's "Open your account"
|
|
94
|
+
* link is `options.accountOrigin` (the route resolves it, as for the invite
|
|
95
|
+
* email) plus the hub sign-in path: a marked lead is always a registered
|
|
96
|
+
* spoke's. With no origin the email goes without the link. The email is
|
|
97
|
+
* capped per recipient: more than `PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY`
|
|
98
|
+
* marked leads of this workspace for the address (any case) in the last 24
|
|
99
|
+
* hours, this one included, skips it; and per workspace: more than
|
|
100
|
+
* `PURCHASE_EMAILS_PER_SITE_PER_HOUR` marked leads in the last hour skips
|
|
101
|
+
* it. The message is still written. Neither
|
|
102
|
+
* step ever throws or changes the lead's result, and their one log line,
|
|
103
|
+
* `{ event: "checkout-purchase", step, outcome }`, names no address or
|
|
104
|
+
* offering.
|
|
105
|
+
*
|
|
106
|
+
* CHECKOUT CONSENT GRANT (#8778). After the same first write of a marked
|
|
107
|
+
* lead with a Person, a lead whose `payload.termsAccepted` equals the current
|
|
108
|
+
* `account_terms@<version>` stamp (`currentAccountTermsStamp`) — the spoke's
|
|
109
|
+
* fulfilment sends the stamp of the terms the buyer ticked in Stripe's box,
|
|
110
|
+
* else `null` — records the buyer's checkout `ConsentGrant` through #8765's
|
|
111
|
+
* `recordCheckoutConsent`: one `account_terms` row for that Person, source
|
|
112
|
+
* `checkout`, `sourceRef` the Checkout Session id (the lead's
|
|
113
|
+
* `Idempotency-Key`), at most once per Person and session. A `null` or absent
|
|
114
|
+
* value writes nothing (`consent_absent`), as does any other value
|
|
115
|
+
* (`terms_unstamped`). A replay never reaches it, and the key is left out of
|
|
116
|
+
* the replay comparison (`comparablePayload`). Never throws and never
|
|
117
|
+
* changes the lead's result; its one log line,
|
|
118
|
+
* `{ event: "checkout-consent", outcome, siteId }` (plus `errName` on
|
|
119
|
+
* `failed`), names no address or Person.
|
|
120
|
+
*
|
|
84
121
|
* VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
|
|
85
122
|
* id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
|
|
86
123
|
* `x-ailk-consent-visitor` only when it is a v4 UUID) and the submit resolved
|
|
@@ -95,12 +132,17 @@ import { randomUUID } from "node:crypto";
|
|
|
95
132
|
import { isDeepStrictEqual } from "node:util";
|
|
96
133
|
|
|
97
134
|
import type { Prisma } from "@working-theory/database";
|
|
98
|
-
import { sendLeadNotification } from "@working-theory/email";
|
|
135
|
+
import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-theory/email";
|
|
99
136
|
import { logger } from "@working-theory/observability";
|
|
100
137
|
import {
|
|
138
|
+
accountInvitePath,
|
|
139
|
+
isContactLeadSource,
|
|
101
140
|
LEAD_SITE_MARKER_KEY,
|
|
102
141
|
leadInputSchema,
|
|
142
|
+
PURCHASE_EMAIL_DEFAULTS,
|
|
143
|
+
PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
|
|
103
144
|
planOfferingSchema,
|
|
145
|
+
stripPlainText,
|
|
104
146
|
} from "@working-theory/validation";
|
|
105
147
|
|
|
106
148
|
import { writeLeadFallback } from "../lib/file-fallback.js";
|
|
@@ -108,7 +150,12 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
|
108
150
|
import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
|
|
109
151
|
import { prisma } from "../lib/prisma.js";
|
|
110
152
|
|
|
111
|
-
import {
|
|
153
|
+
import {
|
|
154
|
+
currentAccountTermsStamp,
|
|
155
|
+
linkConsentVisitor,
|
|
156
|
+
recordCheckoutConsent,
|
|
157
|
+
type RecordCheckoutConsentOutcome,
|
|
158
|
+
} from "./consent.js";
|
|
112
159
|
import { enrichLead } from "./enrichment-shim.js";
|
|
113
160
|
import {
|
|
114
161
|
findOrCreatePerson,
|
|
@@ -117,6 +164,7 @@ import {
|
|
|
117
164
|
setPersonPlanOffering,
|
|
118
165
|
} from "./person.js";
|
|
119
166
|
import type { LeadContact, PersonContact } from "./person.js";
|
|
167
|
+
import { listPlanOfferings } from "./plan-offerings.js";
|
|
120
168
|
import { requestConfirmSupersedesNotification } from "./request-confirm.js";
|
|
121
169
|
|
|
122
170
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -178,6 +226,12 @@ export type RouteLeadOptions = {
|
|
|
178
226
|
* `payload` stays the raw answers.
|
|
179
227
|
*/
|
|
180
228
|
contact?: LeadContact;
|
|
229
|
+
/**
|
|
230
|
+
* Where the buyer's account lives (#8757 D14): the origin the purchase
|
|
231
|
+
* email's "Open your account" link starts sign-in on, resolved by the route
|
|
232
|
+
* from the caller, never the body. Read for a marked checkout lead only.
|
|
233
|
+
*/
|
|
234
|
+
accountOrigin?: string;
|
|
181
235
|
};
|
|
182
236
|
|
|
183
237
|
export type RouteLeadResult =
|
|
@@ -252,15 +306,46 @@ async function findLeadByKey(
|
|
|
252
306
|
})) as KeyedRow | null;
|
|
253
307
|
}
|
|
254
308
|
|
|
309
|
+
/**
|
|
310
|
+
* A contact lead's `payload.consent` is written by the route on every request
|
|
311
|
+
* (#8730 D3) — its `acceptedAt` is that request's receive time — so it is left
|
|
312
|
+
* out of the replay comparison; every key the client sent still counts.
|
|
313
|
+
*
|
|
314
|
+
* A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
|
|
315
|
+
* updated between a stored attempt and Stripe's redelivery sends the key the
|
|
316
|
+
* stored body lacks, and a conflict there would fail every retry. A replay
|
|
317
|
+
* writes no grant, so the key decides nothing on one.
|
|
318
|
+
*/
|
|
319
|
+
function comparablePayload(payload: unknown, source: string): unknown {
|
|
320
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
321
|
+
return payload;
|
|
322
|
+
}
|
|
323
|
+
const record = payload as Record<string, unknown>;
|
|
324
|
+
if (isContactLeadSource(source)) {
|
|
325
|
+
const { consent: _serverWritten, ...rest } = record;
|
|
326
|
+
return rest;
|
|
327
|
+
}
|
|
328
|
+
if (record["page"] === "checkout") {
|
|
329
|
+
const { termsAccepted: _addedByUpdate, ...rest } = record;
|
|
330
|
+
return rest;
|
|
331
|
+
}
|
|
332
|
+
return payload;
|
|
333
|
+
}
|
|
334
|
+
|
|
255
335
|
/**
|
|
256
336
|
* A stored row is a replay only when it carries this request's exact `email`
|
|
257
337
|
* and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
|
|
258
|
-
* any other row is a conflict (#7869 D4).
|
|
338
|
+
* any other row is a conflict (#7869 D4). A contact lead's server-written
|
|
339
|
+
* `payload.consent` is not compared (#8730), nor a checkout lead's
|
|
340
|
+
* `payload.termsAccepted` (#8778).
|
|
259
341
|
*/
|
|
260
342
|
function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
|
|
261
343
|
return row.email === input.email &&
|
|
262
344
|
row.source === input.source &&
|
|
263
|
-
isDeepStrictEqual(
|
|
345
|
+
isDeepStrictEqual(
|
|
346
|
+
comparablePayload(row.payload, row.source),
|
|
347
|
+
comparablePayload(input.payload, input.source),
|
|
348
|
+
)
|
|
264
349
|
? { kind: "replay", id: row.id }
|
|
265
350
|
: { kind: "conflict" };
|
|
266
351
|
}
|
|
@@ -381,6 +466,14 @@ function checkoutPlanCandidate(
|
|
|
381
466
|
return { offering, site };
|
|
382
467
|
}
|
|
383
468
|
|
|
469
|
+
/** The candidate's offering minus a leading `<site>_` (#7877 D10). */
|
|
470
|
+
function stripSitePrefix(candidate: { offering: string; site: string }): string {
|
|
471
|
+
const prefix = `${candidate.site}_`;
|
|
472
|
+
return candidate.offering.startsWith(prefix)
|
|
473
|
+
? candidate.offering.slice(prefix.length)
|
|
474
|
+
: candidate.offering;
|
|
475
|
+
}
|
|
476
|
+
|
|
384
477
|
/**
|
|
385
478
|
* Set the Person's plan from a qualifying checkout lead (#7877 D10). Never
|
|
386
479
|
* throws and never changes the lead's result; logs the outcome kind and the
|
|
@@ -391,10 +484,7 @@ async function applyCheckoutPlan(
|
|
|
391
484
|
personId: string,
|
|
392
485
|
candidate: { offering: string; site: string },
|
|
393
486
|
): Promise<void> {
|
|
394
|
-
const
|
|
395
|
-
const offering = candidate.offering.startsWith(prefix)
|
|
396
|
-
? candidate.offering.slice(prefix.length)
|
|
397
|
-
: candidate.offering;
|
|
487
|
+
const offering = stripSitePrefix(candidate);
|
|
398
488
|
let outcome: "set" | "skipped" | "failed";
|
|
399
489
|
// The operator's PUT accepts the same keys (`planOfferingSchema`).
|
|
400
490
|
if (!planOfferingSchema.safeParse(offering).success) {
|
|
@@ -412,12 +502,242 @@ async function applyCheckoutPlan(
|
|
|
412
502
|
else logger.info(line, "checkout plan");
|
|
413
503
|
}
|
|
414
504
|
|
|
505
|
+
// ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
|
|
506
|
+
|
|
507
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
508
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
509
|
+
/**
|
|
510
|
+
* A workspace's purchase emails per hour, whatever the address (SEC review of
|
|
511
|
+
* #8770, M1): a marked lead's inputs are all the spoke's own, so the
|
|
512
|
+
* per-recipient cap alone would let a spoke key mail any number of distinct
|
|
513
|
+
* addresses. The invite email's per-site cap
|
|
514
|
+
* (`INVITE_EMAILS_PER_SITE_PER_HOUR`) has the same value.
|
|
515
|
+
*/
|
|
516
|
+
const PURCHASE_EMAILS_PER_SITE_PER_HOUR = 50;
|
|
517
|
+
const GENERIC_PURCHASE_BODY = "Thanks for your purchase.";
|
|
518
|
+
/** How long the lead write waits on the offering's name before it says "your purchase". */
|
|
519
|
+
const OFFERING_NAME_BUDGET_MS = 1500;
|
|
520
|
+
/**
|
|
521
|
+
* Stripe's special-case currencies, whose minor units differ from the ISO
|
|
522
|
+
* decimals `Intl` formats with: the amount is left out rather than misstated.
|
|
523
|
+
*/
|
|
524
|
+
const AMOUNT_UNSTATED_CURRENCIES = new Set(["HUF", "ISK", "TWD", "UGX"]);
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The purchase email's sender: every completion email's rule
|
|
528
|
+
* (`AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`; `||`, so
|
|
529
|
+
* "" names none), the same as `magnet-completion.ts`'s `inviteSenderAddress`,
|
|
530
|
+
* kept here so the lead path does not load that module's graph.
|
|
531
|
+
*/
|
|
532
|
+
function purchaseSenderAddress(): string | undefined {
|
|
533
|
+
return (
|
|
534
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM ||
|
|
535
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM ||
|
|
536
|
+
undefined
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
type PurchaseStep = "message" | "email";
|
|
541
|
+
|
|
542
|
+
function logPurchase(siteId: string, step: PurchaseStep, outcome: string): void {
|
|
543
|
+
const line = { event: "checkout-purchase", step, outcome, siteId };
|
|
544
|
+
if (outcome === "failed") logger.error(line, "checkout purchase step failed");
|
|
545
|
+
else logger.info(line, "checkout purchase step");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The offering's listed name, or `null` when its stripped key is not listed,
|
|
550
|
+
* the read fails, or it outlasts `OFFERING_NAME_BUDGET_MS`.
|
|
551
|
+
*/
|
|
552
|
+
async function listedOfferingName(siteId: string, offering: string): Promise<string | null> {
|
|
553
|
+
let timer: NodeJS.Timeout | undefined;
|
|
554
|
+
const budget = new Promise<null>((resolve) => {
|
|
555
|
+
timer = setTimeout(() => resolve(null), OFFERING_NAME_BUDGET_MS);
|
|
556
|
+
});
|
|
557
|
+
try {
|
|
558
|
+
const listed = await Promise.race([listPlanOfferings(siteId), budget]);
|
|
559
|
+
if (!Array.isArray(listed)) return null;
|
|
560
|
+
const name = listed.find((item) => item.key === offering)?.name;
|
|
561
|
+
const clean = name === undefined ? "" : stripPlainText(name).trim();
|
|
562
|
+
return clean === "" ? null : clean;
|
|
563
|
+
} catch {
|
|
564
|
+
return null;
|
|
565
|
+
} finally {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** `amountTotal` (minor units) in `currency`, formatted, or `null` when either is unusable. */
|
|
571
|
+
function formattedAmount(amountTotal: unknown, currency: unknown): string | null {
|
|
572
|
+
if (typeof amountTotal !== "number" || !Number.isInteger(amountTotal) || amountTotal <= 0) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) return null;
|
|
576
|
+
const code = currency.toUpperCase();
|
|
577
|
+
if (AMOUNT_UNSTATED_CURRENCIES.has(code)) return null;
|
|
578
|
+
try {
|
|
579
|
+
const format = new Intl.NumberFormat("en", { style: "currency", currency: code });
|
|
580
|
+
const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
|
|
581
|
+
return format.format(amountTotal / 10 ** digits);
|
|
582
|
+
} catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The one plain-text body the email and the message share. */
|
|
588
|
+
function purchaseBody(name: string | null, amount: string | null): string {
|
|
589
|
+
const sentence =
|
|
590
|
+
name === null ? GENERIC_PURCHASE_BODY : PURCHASE_EMAIL_DEFAULTS.body.replaceAll("{offering}", name);
|
|
591
|
+
return stripPlainText(amount === null ? sentence : `${sentence} Total: ${amount}.`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The purchase email, capped per recipient (D14). Never throws; fired with `void`. */
|
|
595
|
+
async function sendPurchaseEmail(
|
|
596
|
+
input: SubmitInput,
|
|
597
|
+
body: string,
|
|
598
|
+
accountOrigin: string | undefined,
|
|
599
|
+
): Promise<void> {
|
|
600
|
+
let outcome: string;
|
|
601
|
+
try {
|
|
602
|
+
const recent = await prisma.lead.count({
|
|
603
|
+
where: {
|
|
604
|
+
workspaceId: input.siteId,
|
|
605
|
+
spokeCheckout: true,
|
|
606
|
+
email: { equals: input.email, mode: "insensitive" },
|
|
607
|
+
createdAt: { gte: new Date(Date.now() - DAY_MS) },
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
const siteHour = await prisma.lead.count({
|
|
611
|
+
where: {
|
|
612
|
+
workspaceId: input.siteId,
|
|
613
|
+
spokeCheckout: true,
|
|
614
|
+
createdAt: { gte: new Date(Date.now() - HOUR_MS) },
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
const fromAddress = purchaseSenderAddress();
|
|
618
|
+
if (recent > PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY) {
|
|
619
|
+
outcome = "capped_recipient";
|
|
620
|
+
} else if (siteHour > PURCHASE_EMAILS_PER_SITE_PER_HOUR) {
|
|
621
|
+
outcome = "capped_site";
|
|
622
|
+
} else if (!fromAddress) {
|
|
623
|
+
outcome = "sender_unconfigured";
|
|
624
|
+
} else {
|
|
625
|
+
let accountLink: string | undefined;
|
|
626
|
+
if (accountOrigin !== undefined) {
|
|
627
|
+
try {
|
|
628
|
+
accountLink = accountOrigin + accountInvitePath("hub", input.email);
|
|
629
|
+
} catch {
|
|
630
|
+
// An address the sign-in hint refuses: the email goes without the link.
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const result = await sendPurchaseConfirmationEmail({
|
|
634
|
+
to: input.email,
|
|
635
|
+
fromAddress,
|
|
636
|
+
subject: PURCHASE_EMAIL_DEFAULTS.subject,
|
|
637
|
+
body,
|
|
638
|
+
cta: PURCHASE_EMAIL_DEFAULTS.cta,
|
|
639
|
+
...(accountLink !== undefined && { accountLink }),
|
|
640
|
+
});
|
|
641
|
+
outcome = result.ok ? "sent" : "failed";
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
outcome = "failed";
|
|
645
|
+
}
|
|
646
|
+
logPurchase(input.siteId, "email", outcome);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The buyer's Inbox message and purchase email for a marked checkout lead
|
|
651
|
+
* just written (D14). Never throws and never changes the lead's result.
|
|
652
|
+
*/
|
|
653
|
+
async function recordPurchase(
|
|
654
|
+
input: SubmitInput,
|
|
655
|
+
personId: string,
|
|
656
|
+
candidate: { offering: string; site: string },
|
|
657
|
+
accountOrigin: string | undefined,
|
|
658
|
+
): Promise<void> {
|
|
659
|
+
// One guard around the whole step: it runs inside `routeLead`'s persist
|
|
660
|
+
// `try`, where a throw after the row is written would be read as a failed
|
|
661
|
+
// write and the lead captured a second time.
|
|
662
|
+
let body: string;
|
|
663
|
+
try {
|
|
664
|
+
const name = await listedOfferingName(input.siteId, stripSitePrefix(candidate));
|
|
665
|
+
body = purchaseBody(name, formattedAmount(input.payload["amountTotal"], input.payload["currency"]));
|
|
666
|
+
} catch {
|
|
667
|
+
logPurchase(input.siteId, "message", "failed");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
await prisma.interaction.create({
|
|
672
|
+
data: {
|
|
673
|
+
workspaceId: input.siteId,
|
|
674
|
+
personId,
|
|
675
|
+
channel: "member",
|
|
676
|
+
direction: "outbound",
|
|
677
|
+
subject: PURCHASE_EMAIL_DEFAULTS.subject,
|
|
678
|
+
body,
|
|
679
|
+
createdBy: "system:checkout",
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
logPurchase(input.siteId, "message", "written");
|
|
683
|
+
} catch {
|
|
684
|
+
logPurchase(input.siteId, "message", "failed");
|
|
685
|
+
}
|
|
686
|
+
void sendPurchaseEmail(input, body, accountOrigin);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The buyer's checkout consent grant for a marked checkout lead just written
|
|
691
|
+
* (#8778). Never throws and never changes the lead's result.
|
|
692
|
+
*/
|
|
693
|
+
async function recordSpokeCheckoutConsent(
|
|
694
|
+
input: SubmitInput,
|
|
695
|
+
personId: string,
|
|
696
|
+
sessionId: string,
|
|
697
|
+
): Promise<void> {
|
|
698
|
+
const accepted = input.payload["termsAccepted"];
|
|
699
|
+
let outcome:
|
|
700
|
+
| RecordCheckoutConsentOutcome
|
|
701
|
+
| "consent_absent"
|
|
702
|
+
| "terms_unstamped"
|
|
703
|
+
| "failed";
|
|
704
|
+
let errName: string | undefined;
|
|
705
|
+
try {
|
|
706
|
+
const expected = currentAccountTermsStamp();
|
|
707
|
+
if (accepted === undefined || accepted === null) {
|
|
708
|
+
outcome = "consent_absent";
|
|
709
|
+
} else if (expected === null || accepted !== expected.stamp) {
|
|
710
|
+
outcome = "terms_unstamped";
|
|
711
|
+
} else {
|
|
712
|
+
outcome = await recordCheckoutConsent({
|
|
713
|
+
workspaceId: input.siteId,
|
|
714
|
+
personId,
|
|
715
|
+
sessionId,
|
|
716
|
+
policyVersion: expected.policyVersion,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// The error's class only (a Prisma error's message can carry values).
|
|
721
|
+
outcome = "failed";
|
|
722
|
+
errName = err instanceof Error ? err.name : "UnknownError";
|
|
723
|
+
}
|
|
724
|
+
const line = {
|
|
725
|
+
event: "checkout-consent",
|
|
726
|
+
outcome,
|
|
727
|
+
siteId: input.siteId,
|
|
728
|
+
...(errName !== undefined && { errName }),
|
|
729
|
+
};
|
|
730
|
+
if (outcome === "failed") logger.error(line, "checkout terms grant failed");
|
|
731
|
+
else logger.info(line, "checkout terms grant");
|
|
732
|
+
}
|
|
733
|
+
|
|
415
734
|
async function persistToDb(
|
|
416
735
|
input: RouteLeadInput,
|
|
417
736
|
idempotencyKey?: string,
|
|
418
737
|
viaSpoke = false,
|
|
419
738
|
relayed = false,
|
|
420
739
|
contact?: LeadContact,
|
|
740
|
+
accountOrigin?: string,
|
|
421
741
|
): Promise<PersistResult> {
|
|
422
742
|
if (input.kind === "submit") {
|
|
423
743
|
const personId = await resolveLeadPersonId(input, contact);
|
|
@@ -473,6 +793,14 @@ async function persistToDb(
|
|
|
473
793
|
// #7877 D10 — only after THIS request's own write, so a replay sets nothing.
|
|
474
794
|
if (candidate !== null && personId !== null) {
|
|
475
795
|
await applyCheckoutPlan(input, personId, candidate);
|
|
796
|
+
// #8757 D14 — the marked lead only: live, unrelayed, this write.
|
|
797
|
+
if (spokeCheckout) {
|
|
798
|
+
await recordPurchase(input, personId, candidate, accountOrigin);
|
|
799
|
+
// #8778 — a marked lead's key is its live Checkout Session id.
|
|
800
|
+
if (idempotencyKey !== undefined) {
|
|
801
|
+
await recordSpokeCheckoutConsent(input, personId, idempotencyKey);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
476
804
|
}
|
|
477
805
|
return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
|
|
478
806
|
}
|
|
@@ -836,6 +1164,7 @@ export async function routeLead(
|
|
|
836
1164
|
sourceMeta.viaSpoke === true,
|
|
837
1165
|
sourceMeta.relayed === true,
|
|
838
1166
|
input.kind === "submit" ? options.contact : undefined,
|
|
1167
|
+
input.kind === "submit" ? options.accountOrigin : undefined,
|
|
839
1168
|
);
|
|
840
1169
|
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
841
1170
|
// record, no notification.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What happens after someone submits a magnet (#8757, spec
|
|
3
|
+
* docs/specs/8757-a-per-magnet-completion-setting-spec.md D6).
|
|
4
|
+
*
|
|
5
|
+
* `POST /v1/leads/submit` calls `magnetCompletionFor` after a `flow:<slug>`
|
|
6
|
+
* lead is written to the database. It reads the LIVE magnet at that slug
|
|
7
|
+
* (`lib/flow-completion-store.ts`) and answers the 201's `next`:
|
|
8
|
+
*
|
|
9
|
+
* - `lead`, a missing or a draft magnet → `null` (the 201 is unchanged);
|
|
10
|
+
* - `invite` → `{ completion: "invite" }`, only where the caller has an
|
|
11
|
+
* account to sign in to (`accountTargetFor`); else `null` (NFR4);
|
|
12
|
+
* - `checkout` → `{ completion: "checkout", checkoutOffering }`, re-checked
|
|
13
|
+
* at submit time (SEC-8757-E): the `commerce` claim must read `claimed`
|
|
14
|
+
* and the offering must still be listed. Anything else is `null`.
|
|
15
|
+
*
|
|
16
|
+
* `sendInviteConfirmation` is the invite email: one `sendDeliverableEmail` to
|
|
17
|
+
* the lead's address whose one link (`downloadLink`, labelled by `cta`) starts
|
|
18
|
+
* sign-in with that address prefilled. It never signs anyone in (NFR2). Two
|
|
19
|
+
* caps run first, counted in the database (SEC-8757-A, SEC-8757-B): one email
|
|
20
|
+
* per recipient per magnet per day — which also stops the second email of an
|
|
21
|
+
* unkeyed double submit — and fifty per magnet per hour. A capped send still
|
|
22
|
+
* leaves the page's link in place. Every outcome is one
|
|
23
|
+
* `{ event: "magnet-invite", outcome }` line that never names the address.
|
|
24
|
+
*
|
|
25
|
+
* Where the link points (`accountTargetFor`): a keyless caller gets
|
|
26
|
+
* `FRONTEND_URL`'s origin with the site sign-in path, and only on a build that
|
|
27
|
+
* has an account area (`workspace` or `multi-tenant`); a spoke gets the origin
|
|
28
|
+
* of its enabled sign-in client's registered callback with the hub sign-in
|
|
29
|
+
* path, `https:` unless `NODE_ENV` is `development` (SEC-8757-I). The origin is never
|
|
30
|
+
* taken from the request.
|
|
31
|
+
*
|
|
32
|
+
* Base tree (SEC-8757-D, NFR6): no `oss: false` marker and no `MODULE_SLICES`
|
|
33
|
+
* entry, and it imports none — the never-gate lead route imports it. So the
|
|
34
|
+
* sender is resolved here (`inviteSenderAddress`, the same rule as
|
|
35
|
+
* `deliverable-fulfillment.ts`'s `deliverableSenderAddress`, which
|
|
36
|
+
* `module-architecture.ts` holds out of a slice only until #5364), and
|
|
37
|
+
* `@working-theory/email` — the templates the lead route otherwise never
|
|
38
|
+
* loads — is imported on the first send, not with the route.
|
|
39
|
+
*/
|
|
40
|
+
import { logger } from "@working-theory/observability";
|
|
41
|
+
import {
|
|
42
|
+
accountInvitePath,
|
|
43
|
+
completionEmailSchema,
|
|
44
|
+
INVITE_EMAIL_DEFAULTS,
|
|
45
|
+
INVITE_EMAILS_PER_RECIPIENT_PER_DAY,
|
|
46
|
+
INVITE_EMAILS_PER_SITE_PER_HOUR,
|
|
47
|
+
type AccountInviteProvider,
|
|
48
|
+
type CompletionEmailConfig,
|
|
49
|
+
type LeadSubmitNext,
|
|
50
|
+
} from "@working-theory/validation";
|
|
51
|
+
|
|
52
|
+
import { isModuleEnabled } from "../config/modules.js";
|
|
53
|
+
import { inviteSendCounts, liveCompletionBySlug } from "../lib/flow-completion-store.js";
|
|
54
|
+
import { findClientByClientId } from "../lib/hub-sign-in-store.js";
|
|
55
|
+
import { resolveCommerceClaim } from "../lib/require-commerce-claim.js";
|
|
56
|
+
|
|
57
|
+
import { listPlanOfferings } from "./plan-offerings.js";
|
|
58
|
+
import type { CallerSite } from "./public-site-resolver.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The invite email's sender: `AILK_DELIVERABLE_EMAIL_FROM`, else
|
|
62
|
+
* `AILK_LEAD_NOTIFICATION_FROM` — every completion email's sender. `||`, not
|
|
63
|
+
* `??`: an env var set to "" names no sender.
|
|
64
|
+
*/
|
|
65
|
+
export function inviteSenderAddress(): string | undefined {
|
|
66
|
+
return (
|
|
67
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM ||
|
|
68
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM ||
|
|
69
|
+
undefined
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Where an account link starts sign-in: the site's origin and its sign-in path. */
|
|
74
|
+
export interface AccountTarget {
|
|
75
|
+
origin: string;
|
|
76
|
+
provider: AccountInviteProvider;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The origin of `raw` when it is `https:` (or `http:` in development), else `null`. */
|
|
80
|
+
function linkOrigin(raw: string | undefined): string | null {
|
|
81
|
+
if (!raw) return null;
|
|
82
|
+
let url: URL;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (url.protocol === "https:") return url.origin;
|
|
89
|
+
if (url.protocol === "http:" && process.env.NODE_ENV === "development") return url.origin;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Where the caller's account link points, or `null` when it has nowhere to sign in. */
|
|
94
|
+
export async function accountTargetFor(
|
|
95
|
+
caller: Exclude<CallerSite, { kind: "refused" }>,
|
|
96
|
+
): Promise<AccountTarget | null> {
|
|
97
|
+
if (caller.kind === "spoke") {
|
|
98
|
+
const client = await findClientByClientId(caller.spokeId);
|
|
99
|
+
if (!client?.enabled) return null;
|
|
100
|
+
const origin = linkOrigin(client.redirectUris[0]);
|
|
101
|
+
return origin === null ? null : { origin, provider: "hub" };
|
|
102
|
+
}
|
|
103
|
+
// A build with no account area has no `/account` to land on.
|
|
104
|
+
if (!isModuleEnabled("workspace") && !isModuleEnabled("multi-tenant")) return null;
|
|
105
|
+
const origin = linkOrigin(process.env.FRONTEND_URL);
|
|
106
|
+
return origin === null ? null : { origin, provider: "site" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A submit's completion: the 201's `next`, plus what the invite email needs. */
|
|
110
|
+
export interface MagnetCompletion {
|
|
111
|
+
next: LeadSubmitNext;
|
|
112
|
+
title: string;
|
|
113
|
+
config: unknown;
|
|
114
|
+
target: AccountTarget | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* How long the anonymous submit waits on `magnetCompletionFor` before it
|
|
119
|
+
* answers without `next`: the lead is already written, and a slow licence or
|
|
120
|
+
* content read must not turn it into a client-side timeout and a resubmit.
|
|
121
|
+
*/
|
|
122
|
+
export const MAGNET_COMPLETION_BUDGET_MS = 1500;
|
|
123
|
+
|
|
124
|
+
/** `magnetCompletionFor`, or `null` once `MAGNET_COMPLETION_BUDGET_MS` has passed. */
|
|
125
|
+
export async function magnetCompletionWithinBudget(
|
|
126
|
+
...args: Parameters<typeof magnetCompletionFor>
|
|
127
|
+
): Promise<MagnetCompletion | null> {
|
|
128
|
+
let timer: NodeJS.Timeout | undefined;
|
|
129
|
+
const budget = new Promise<null>((resolve) => {
|
|
130
|
+
timer = setTimeout(() => resolve(null), MAGNET_COMPLETION_BUDGET_MS);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
return await Promise.race([magnetCompletionFor(...args), budget]);
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The completion of a `flow:<slug>` lead just written to `workspaceId`, or `null` for `lead`. */
|
|
140
|
+
export async function magnetCompletionFor(
|
|
141
|
+
workspaceId: string,
|
|
142
|
+
slug: string,
|
|
143
|
+
resolveTarget: () => Promise<AccountTarget | null>,
|
|
144
|
+
): Promise<MagnetCompletion | null> {
|
|
145
|
+
const magnet = await liveCompletionBySlug(workspaceId, slug);
|
|
146
|
+
if (magnet === null) return null;
|
|
147
|
+
const { title, config } = magnet;
|
|
148
|
+
if (magnet.completion === "invite") {
|
|
149
|
+
const target = await resolveTarget();
|
|
150
|
+
return target === null ? null : { next: { completion: "invite" }, title, config, target };
|
|
151
|
+
}
|
|
152
|
+
if (magnet.completion === "checkout") {
|
|
153
|
+
// A claim or offerings read that fails is never a pass: it degrades to `lead`.
|
|
154
|
+
if ((await resolveCommerceClaim()) !== "claimed") return null;
|
|
155
|
+
const offerings = await listPlanOfferings(workspaceId);
|
|
156
|
+
if (!Array.isArray(offerings)) return null;
|
|
157
|
+
const offering = magnet.checkoutOffering;
|
|
158
|
+
if (!offerings.some((item) => item.key === offering)) return null;
|
|
159
|
+
return { next: { completion: "checkout", checkoutOffering: offering }, title, config, target: null };
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type InviteOutcome =
|
|
165
|
+
| "sent"
|
|
166
|
+
| "send_failed"
|
|
167
|
+
| "capped_recipient"
|
|
168
|
+
| "capped_site"
|
|
169
|
+
| "sender_unconfigured"
|
|
170
|
+
| "no_link";
|
|
171
|
+
|
|
172
|
+
export interface InviteConfirmationInput {
|
|
173
|
+
workspaceId: string;
|
|
174
|
+
slug: string;
|
|
175
|
+
email: string;
|
|
176
|
+
locale: string | undefined;
|
|
177
|
+
completion: MagnetCompletion;
|
|
178
|
+
now: Date;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fill = (template: string, title: string) => template.replaceAll("{title}", title);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `copy[locale] ?? copy[defaultLocale]`, the completion email's own
|
|
185
|
+
* resolution. Own keys only: `locale` comes from the anonymous payload, and
|
|
186
|
+
* `"constructor"` must not read an inherited member.
|
|
187
|
+
*/
|
|
188
|
+
function resolveCopy(copy: Record<string, string>, locale: string, defaultLocale: string) {
|
|
189
|
+
const own = (key: string) => (Object.hasOwn(copy, key) ? copy[key] : undefined);
|
|
190
|
+
return own(locale) ?? own(defaultLocale) ?? Object.values(copy)[0] ?? "";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function declaredCopy(config: unknown): { copy: CompletionEmailConfig; defaultLocale: string } | null {
|
|
194
|
+
if (typeof config !== "object" || config === null) return null;
|
|
195
|
+
const record = config as { completionEmail?: unknown; defaultLocale?: unknown };
|
|
196
|
+
const parsed = completionEmailSchema.safeParse(record.completionEmail);
|
|
197
|
+
if (!parsed.success) return null;
|
|
198
|
+
const defaultLocale = typeof record.defaultLocale === "string" ? record.defaultLocale : "en";
|
|
199
|
+
return { copy: parsed.data, defaultLocale };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The invite email (D6). Never throws and never names the address in a log;
|
|
204
|
+
* the caller fires it with `void` after a first, unreplayed write.
|
|
205
|
+
*/
|
|
206
|
+
export async function sendInviteConfirmation(input: InviteConfirmationInput): Promise<InviteOutcome> {
|
|
207
|
+
const outcome = await send().catch(() => "send_failed" as const);
|
|
208
|
+
logger.info({ event: "magnet-invite", outcome }, "[magnet-completion] invite email");
|
|
209
|
+
return outcome;
|
|
210
|
+
|
|
211
|
+
async function send(): Promise<InviteOutcome> {
|
|
212
|
+
const { workspaceId, slug, email, locale, completion, now } = input;
|
|
213
|
+
if (completion.target === null) return "no_link";
|
|
214
|
+
const counts = await inviteSendCounts(workspaceId, slug, email, now);
|
|
215
|
+
if (counts.recipientDay > INVITE_EMAILS_PER_RECIPIENT_PER_DAY) return "capped_recipient";
|
|
216
|
+
if (counts.siteHour > INVITE_EMAILS_PER_SITE_PER_HOUR) return "capped_site";
|
|
217
|
+
const fromAddress = inviteSenderAddress();
|
|
218
|
+
if (!fromAddress) return "sender_unconfigured";
|
|
219
|
+
|
|
220
|
+
let downloadLink: string;
|
|
221
|
+
try {
|
|
222
|
+
downloadLink = completion.target.origin + accountInvitePath(completion.target.provider, email);
|
|
223
|
+
} catch {
|
|
224
|
+
return "no_link";
|
|
225
|
+
}
|
|
226
|
+
const declared = declaredCopy(completion.config);
|
|
227
|
+
const lang = locale ?? declared?.defaultLocale ?? "en";
|
|
228
|
+
const subject = declared
|
|
229
|
+
? resolveCopy(declared.copy.subject, lang, declared.defaultLocale)
|
|
230
|
+
: fill(INVITE_EMAIL_DEFAULTS.subject, completion.title);
|
|
231
|
+
const body = declared
|
|
232
|
+
? resolveCopy(declared.copy.body, lang, declared.defaultLocale)
|
|
233
|
+
: fill(INVITE_EMAIL_DEFAULTS.body, completion.title);
|
|
234
|
+
const cta = declared?.copy.cta
|
|
235
|
+
? resolveCopy(declared.copy.cta, lang, declared.defaultLocale)
|
|
236
|
+
: INVITE_EMAIL_DEFAULTS.cta;
|
|
237
|
+
|
|
238
|
+
const { sendDeliverableEmail } = await import("@working-theory/email");
|
|
239
|
+
const result = await sendDeliverableEmail({ to: email, fromAddress, subject, body, cta, downloadLink });
|
|
240
|
+
return result.ok ? "sent" : "send_failed";
|
|
241
|
+
}
|
|
242
|
+
}
|