create-ailk 0.24.0 → 0.26.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 +53 -22
- package/component-catalog.json +3 -3
- package/component-catalog.md +3 -3
- package/dist/add-module.js +4 -2
- package/dist/add-route.d.ts +12 -1
- package/dist/add-route.js +31 -3
- package/dist/cli.js +12 -7
- package/dist/derive-architecture.d.ts +3 -4
- package/dist/derive-architecture.js +65 -29
- package/dist/lib/design-checks.d.ts +9 -4
- package/dist/lib/design-checks.js +13 -7
- package/dist/lib/extract-module-bundle.js +29 -2
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +8 -3
- package/dist/lib/import-graph.d.ts +1 -1
- package/dist/lib/import-graph.js +4 -4
- package/dist/lib/module-license-gate.d.ts +12 -1
- package/dist/lib/module-license-gate.js +36 -15
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.d.ts +20 -3
- package/dist/module-architecture.js +129 -552
- package/dist/paid-sections.d.ts +25 -1
- package/dist/paid-sections.js +88 -3
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +39 -2
- package/dist/template-record.d.ts +21 -3
- package/dist/template-record.js +38 -7
- package/dist/topology.d.ts +16 -3
- package/dist/topology.js +54 -23
- package/dist/update.d.ts +10 -1
- package/dist/update.js +715 -21
- package/package.json +3 -3
- package/release-scope.json +2 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/template-guard.sh +137 -0
- package/templates/.claude/rules/extending.md +106 -0
- package/templates/.claude/rules/loop-contract.md +2 -2
- package/templates/.claude/settings.json +11 -0
- package/templates/.env.example +20 -5
- package/templates/.github/workflows/ci.yml +7 -4
- package/templates/CLAUDE.md +11 -1
- package/templates/CONVENTIONS.md +8 -10
- package/templates/README.md +14 -13
- package/templates/apps/api/.env.example +23 -2
- package/templates/apps/api/CLAUDE.md +119 -83
- package/templates/apps/api/jest.config.cjs +15 -5
- package/templates/apps/api/package.json +16 -20
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
- package/templates/apps/api/src/index.ts +1 -1
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
- package/templates/apps/api/src/modules/commerce.ts +10 -0
- package/templates/apps/api/src/modules/content-write.ts +10 -0
- package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
- package/templates/apps/api/src/modules/scheduling.ts +10 -0
- package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
- package/templates/apps/api/src/modules/workspace.ts +10 -0
- package/templates/apps/api/src/server.ts +117 -1882
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/CLAUDE.md +90 -26
- package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
- package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
- package/templates/apps/mcp/jest.config.cjs +6 -1
- package/templates/apps/mcp/package.json +5 -13
- package/templates/apps/mcp/src/app.ts +8 -7
- package/templates/apps/mcp/src/index.ts +6 -34
- package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
- package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
- package/templates/apps/mcp/src/site.ts +12 -0
- package/templates/apps/web/.env.example +1 -1
- package/templates/apps/web/CLAUDE.md +31 -3
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
- package/templates/apps/web/app/[locale]/layout.tsx +33 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
- package/templates/apps/web/app/api/revalidate/route.ts +3 -3
- package/templates/apps/web/auth.ts +10 -6
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/auth-email.ts +103 -21
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +2 -2
- package/templates/apps/web/lib/release-scope.ts +47 -0
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
- package/templates/customizations.yaml +37 -0
- package/templates/database/prisma.config.ts +10 -14
- package/templates/database/seed-test.ts +2 -2
- package/templates/database/seed.ts +5 -5
- package/templates/package.json +12 -6
- package/templates/pnpm-workspace.yaml +0 -1
- package/templates/project.yaml +16 -8
- package/templates/tools/jest/reporters.cjs +65 -0
- package/templates/tools/lint-architecture.ts +553 -7
- package/templates/tsconfig.json +1 -2
- package/versions.json +49 -36
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
- package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
- package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
- package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
- package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
- package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
- package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
- package/templates/apps/api/src/bin/spokes.ts +0 -198
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
- package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
- package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
- package/templates/apps/api/src/config/index.ts +0 -596
- package/templates/apps/api/src/config/modules.ts +0 -83
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
- package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
- package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
- package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
- package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
- package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
- package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
- package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
- package/templates/apps/api/src/lib/agent-store.ts +0 -613
- package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
- package/templates/apps/api/src/lib/consent-store.ts +0 -363
- package/templates/apps/api/src/lib/content-adapter.ts +0 -112
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
- package/templates/apps/api/src/lib/error-serializer.ts +0 -164
- package/templates/apps/api/src/lib/errors.ts +0 -95
- package/templates/apps/api/src/lib/file-fallback.ts +0 -87
- package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
- package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
- package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
- package/templates/apps/api/src/lib/license-key-store.ts +0 -45
- package/templates/apps/api/src/lib/locales.ts +0 -201
- package/templates/apps/api/src/lib/outbound-http.ts +0 -127
- package/templates/apps/api/src/lib/prisma.ts +0 -21
- package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
- package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
- package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
- package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
- package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
- package/templates/apps/api/src/lib/site.ts +0 -257
- package/templates/apps/api/src/lib/slice-load.ts +0 -90
- package/templates/apps/api/src/lib/spoke-store.ts +0 -180
- package/templates/apps/api/src/lib/stripe.ts +0 -321
- package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
- package/templates/apps/api/src/middleware/auth.ts +0 -864
- package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
- package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
- package/templates/apps/api/src/openapi/index.ts +0 -18
- package/templates/apps/api/src/openapi/spec.ts +0 -2148
- package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
- package/templates/apps/api/src/routes/aeo/index.ts +0 -107
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
- package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
- package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
- package/templates/apps/api/src/routes/auth/user.ts +0 -305
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
- package/templates/apps/api/src/routes/checkout/index.ts +0 -34
- package/templates/apps/api/src/routes/checkout/session.ts +0 -171
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
- package/templates/apps/api/src/routes/consent/account.ts +0 -268
- package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
- package/templates/apps/api/src/routes/consent/index.ts +0 -26
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
- package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
- package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
- package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
- package/templates/apps/api/src/routes/content/get-page.ts +0 -183
- package/templates/apps/api/src/routes/content/index.ts +0 -49
- package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
- package/templates/apps/api/src/routes/content/list-types.ts +0 -19
- package/templates/apps/api/src/routes/content/offerings.ts +0 -184
- package/templates/apps/api/src/routes/content/pricing.ts +0 -176
- package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
- package/templates/apps/api/src/routes/content/search.ts +0 -100
- package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
- package/templates/apps/api/src/routes/leads/index.ts +0 -639
- package/templates/apps/api/src/routes/leads/list.ts +0 -104
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
- package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
- package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
- package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
- package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
- package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
- package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
- package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
- package/templates/apps/api/src/routes/requests/index.ts +0 -54
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
- package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
- package/templates/apps/api/src/routes/webhooks/README.md +0 -398
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
- package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
- package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
- package/templates/apps/api/src/services/abandon-email.ts +0 -336
- package/templates/apps/api/src/services/aeo-score.ts +0 -325
- package/templates/apps/api/src/services/answer-distributions.ts +0 -138
- package/templates/apps/api/src/services/audit-log.ts +0 -961
- package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
- package/templates/apps/api/src/services/consent.ts +0 -834
- package/templates/apps/api/src/services/default-tenant.ts +0 -416
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
- package/templates/apps/api/src/services/dunning-policy.ts +0 -294
- package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
- package/templates/apps/api/src/services/entitlements.ts +0 -338
- package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
- package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
- package/templates/apps/api/src/services/lead-promotion.ts +0 -160
- package/templates/apps/api/src/services/lead-routing.ts +0 -1372
- package/templates/apps/api/src/services/leads-inbox.ts +0 -312
- package/templates/apps/api/src/services/listing-config.ts +0 -178
- package/templates/apps/api/src/services/magnet-completion.ts +0 -242
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
- package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
- package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
- package/templates/apps/api/src/services/offerings/index.ts +0 -614
- package/templates/apps/api/src/services/offerings/source.ts +0 -140
- package/templates/apps/api/src/services/offerings/store.ts +0 -150
- package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
- package/templates/apps/api/src/services/person.ts +0 -1211
- package/templates/apps/api/src/services/plan-offerings.ts +0 -125
- package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
- package/templates/apps/api/src/services/project-listings.ts +0 -2085
- package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
- package/templates/apps/api/src/services/request-confirm.ts +0 -698
- package/templates/apps/api/src/services/revalidation.ts +0 -235
- package/templates/apps/api/src/services/slack-notify.ts +0 -291
- package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
- package/templates/apps/api/src/services/usage-credits.ts +0 -340
- package/templates/apps/api/src/services/verified-email.ts +0 -71
- package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
- package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
- package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
- package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
- package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
- package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
- package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
- package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
- package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
- package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
- package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
- package/templates/apps/mcp/__tests__/index.test.ts +0 -195
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
- package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
- package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
- package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
- package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
- package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
- package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
- package/templates/apps/mcp/__tests__/server.test.ts +0 -388
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
- package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
- package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
- package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
- package/templates/apps/mcp/src/abuse/index.ts +0 -349
- package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
- package/templates/apps/mcp/src/boot.ts +0 -167
- package/templates/apps/mcp/src/config/modules.ts +0 -43
- package/templates/apps/mcp/src/http-transport.ts +0 -564
- package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
- package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
- package/templates/apps/mcp/src/lib/errors.ts +0 -61
- package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
- package/templates/apps/mcp/src/lib/locales.ts +0 -171
- package/templates/apps/mcp/src/lib/prisma.ts +0 -39
- package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
- package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
- package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
- package/templates/apps/mcp/src/lib/site-config.ts +0 -53
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
- package/templates/apps/mcp/src/request-context.ts +0 -52
- package/templates/apps/mcp/src/server.ts +0 -343
- package/templates/apps/mcp/src/serverless-app.ts +0 -88
- package/templates/apps/mcp/src/tools/audit.ts +0 -127
- package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
- package/templates/apps/mcp/src/tools/create_project.ts +0 -134
- package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
- package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
- package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
- package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
- package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
- package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
- package/templates/apps/mcp/src/tools/index.ts +0 -138
- package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
- package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
- package/templates/apps/mcp/src/tools/proposals.ts +0 -27
- package/templates/apps/mcp/src/tools/purchase.ts +0 -205
- package/templates/apps/mcp/src/tools/read_content.ts +0 -81
- package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
- package/templates/apps/mcp/src/tools/search_content.ts +0 -63
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
- package/templates/apps/mcp/src/workspace-server.ts +0 -224
- package/templates/database/CHANGELOG.md +0 -981
- package/templates/database/LICENSE +0 -202
- package/templates/database/NOTICE +0 -2
- package/templates/database/README.md +0 -243
- package/templates/database/__tests__/agents.db.test.ts +0 -320
- package/templates/database/__tests__/appointment.db.test.ts +0 -106
- package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
- package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
- package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
- package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
- package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
- package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
- package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
- package/templates/database/__tests__/flows-presets.test.ts +0 -173
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
- package/templates/database/__tests__/member-area.db.test.ts +0 -257
- package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
- package/templates/database/__tests__/member-contract.db.test.ts +0 -413
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
- package/templates/database/__tests__/offerings-migration.test.ts +0 -197
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
- package/templates/database/__tests__/offerings.db.test.ts +0 -303
- package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
- package/templates/database/__tests__/person-contact.db.test.ts +0 -563
- package/templates/database/__tests__/person-notes.db.test.ts +0 -249
- package/templates/database/__tests__/person.db.test.ts +0 -586
- package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
- package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
- package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
- package/templates/database/__tests__/seed.test.ts +0 -272
- package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
- package/templates/database/__tests__/tenancy.test.ts +0 -141
- package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
- package/templates/database/__tests__/user-cascade.test.ts +0 -140
- package/templates/database/_gitignore +0 -8
- package/templates/database/auth/.gitkeep +0 -0
- package/templates/database/auth/schema.prisma +0 -641
- package/templates/database/config/.gitkeep +0 -0
- package/templates/database/config/schema.prisma +0 -441
- package/templates/database/content/.gitkeep +0 -0
- package/templates/database/content/schema.prisma +0 -339
- package/templates/database/eslint.config.mjs +0 -69
- package/templates/database/flows/listing-diagnostic.ts +0 -182
- package/templates/database/flows/presets.ts +0 -315
- package/templates/database/inbox/.gitkeep +0 -0
- package/templates/database/inbox/README.md +0 -3
- package/templates/database/inbox/__tests__/schema.test.ts +0 -44
- package/templates/database/inbox/schema.prisma +0 -1447
- package/templates/database/jest.config.cjs +0 -36
- package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
- package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
- package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
- package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
- package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
- package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
- package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
- package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
- package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
- package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
- package/templates/database/migrations/migration_lock.toml +0 -3
- package/templates/database/ops/.gitkeep +0 -0
- package/templates/database/ops/schema.prisma +0 -43
- package/templates/database/package.json +0 -89
- package/templates/database/schema.prisma +0 -71
- package/templates/database/scripts/db-generate-locked.sh +0 -80
- package/templates/database/scripts/person-backfill.sql +0 -132
- package/templates/database/tenancy.ts +0 -183
- package/templates/database/tsconfig.build.json +0 -16
- package/templates/database/tsconfig.flows.json +0 -15
- package/templates/database/tsconfig.json +0 -16
- package/templates/database/tsconfig.tenancy.json +0 -14
|
@@ -1,1372 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file lead-routing.ts
|
|
3
|
-
* @description Cross-namespace lead orchestration service (ADR 0009 D3).
|
|
4
|
-
*
|
|
5
|
-
* Pipeline: validate → delivery guard → enrich → persist (DB with file
|
|
6
|
-
* fallback) → email. Where a lead may go is `resolveLeadDelivery()`
|
|
7
|
-
* (`lib/lead-delivery.ts`, #6730 D1): with no database but the email vars set,
|
|
8
|
-
* the owner email IS the delivery; in production with neither, nothing is
|
|
9
|
-
* accepted. A lead is reported sent only for a durable row or an email the
|
|
10
|
-
* provider accepted — never for the non-durable file fallback in production.
|
|
11
|
-
* This is the first implementation of ADR 0009 D3's service-layer pattern.
|
|
12
|
-
* Future cross-namespace orchestrators in apps/api/src/services/ should mirror
|
|
13
|
-
* this shape: type-result returns, mocked-dep tests, non-transactional secondary
|
|
14
|
-
* effects, PII-safe logging.
|
|
15
|
-
*
|
|
16
|
-
* PII boundary: only { siteId, kind, ip, persisted, emailKind } appear in logs.
|
|
17
|
-
* Never log email, phone, name, or payload values.
|
|
18
|
-
*
|
|
19
|
-
* TENANCY (#6203). `input.siteId` is the caller's RESOLVED workspace id —
|
|
20
|
-
* `routes/leads/index.ts` resolves it server-side before calling `routeLead`
|
|
21
|
-
* and never a client-supplied value (see that file's header). `Lead` gets a
|
|
22
|
-
* `workspaceId` stamp of the same value, and since #6374 so do `Subscriber`
|
|
23
|
-
* and `Callback` — both gained a required `workspaceId` column in the fence
|
|
24
|
-
* fan-out, stamped from the same resolved id by the three `create` sites
|
|
25
|
-
* below. (This line previously said those two models had no such column;
|
|
26
|
-
* that stopped being true when the keystone migration landed, and the code
|
|
27
|
-
* under it already contradicted it.) The one READ in this file — the
|
|
28
|
-
* duplicate-subscriber lookup on the P2002 arm — is scoped by it too
|
|
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). The `email`, `local` and `none` deliveries never reach
|
|
52
|
-
* `persistToDb`.
|
|
53
|
-
*
|
|
54
|
-
* CONTACT AND LINKS (#8435 D5). A `submit` may carry `options.contact` (the
|
|
55
|
-
* magnet's tagged answers): `findOrCreatePerson` fills the Person's BLANK
|
|
56
|
-
* contact fields from it while the Person is uncurated, and the lead's own
|
|
57
|
-
* `payload` is unchanged. A `subscribe` and a `callback` with a storable email
|
|
58
|
-
* (a callback's is `payload.email`) now also resolve a Person and stamp its id
|
|
59
|
-
* as `personId`; a callback with no email makes no Person call and writes no
|
|
60
|
-
* `personId`. Every one of these steps is best-effort exactly like the lead's.
|
|
61
|
-
*
|
|
62
|
-
* CHECKOUT PLAN (#7877 D10, #8793 D1). A spoke's checkout fulfilment posts
|
|
63
|
-
* one `submit` with the Checkout Session id as its `Idempotency-Key`. A
|
|
64
|
-
* LIVE, paid checkout lead from a registered spoke's server — `viaSpoke`, a
|
|
65
|
-
* `cs_live_` key, `source: "web"`, `payload.page: "checkout"`, a string
|
|
66
|
-
* `payload.offering` and `payload.site`, and an `amountTotal` that is
|
|
67
|
-
* absent, `null` or a positive integer — names a plan: the offering minus a
|
|
68
|
-
* leading `<site>_` (`person.ts`'s `checkoutPlanKey`). Its email is what
|
|
69
|
-
* the buyer typed at Stripe Checkout, which nobody verified, so the lead
|
|
70
|
-
* never writes any Person's plan (SEC-8748-D). It HOLDS it: on the first
|
|
71
|
-
* write of a marked lead (below) with a Person, the step logs one bare
|
|
72
|
-
* `{ event: "checkout-plan", outcome, siteId }` line at `info` — `held` for
|
|
73
|
-
* a plan key, `skipped` for none — and makes no Person call; the marked lead
|
|
74
|
-
* itself is the hold. `person.ts`'s `releaseHeldCheckoutPlan` writes it
|
|
75
|
-
* later, once, at the Person's first member-path link to the user whose
|
|
76
|
-
* verified email is the Person's own. No spoke file changes. The step never
|
|
77
|
-
* throws, never changes `routeLead`'s result, and never logs the offering,
|
|
78
|
-
* the email or the key.
|
|
79
|
-
*
|
|
80
|
-
* THE OFFERINGS TWIN (#8932 D7). The per-Organization Stripe webhook
|
|
81
|
-
* (`routes/webhooks/stripe-offerings.ts`) is the predicate's second source:
|
|
82
|
-
* `options.offeringsCheckout`, which it sets only after W3–W6 (its own
|
|
83
|
-
* signature, the hub's session record for that Organization and workspace,
|
|
84
|
-
* the recorded Price paid) and no request can set, a key
|
|
85
|
-
* `"stripe-offerings " + <cs_live_ session id>` (a space no `Idempotency-Key`
|
|
86
|
-
* header can carry), `source: "web"`, `payload.page: "checkout"`, a
|
|
87
|
-
* `payload.offering` `parseLookupKey` admits and a `payload.site` equal to
|
|
88
|
-
* the lead's workspace. Its plan key is the FULL lookup key (no `<site>_`
|
|
89
|
-
* prefix can match a workspace id), and its consent grant's `sourceRef` is
|
|
90
|
-
* the bare session id. A test-mode session's key fails the twin, so its lead
|
|
91
|
-
* is unmarked (#8932 C9).
|
|
92
|
-
*
|
|
93
|
-
* SPOKE CHECKOUT MARKER (#8185 D2). The same predicate — or its Offerings
|
|
94
|
-
* twin (#8932 D7) — plus "not relayed for a visitor" (`SourceMeta.relayed`),
|
|
95
|
-
* is evaluated once BEFORE the lead's create and stored as
|
|
96
|
-
* `Lead.spokeCheckout` — the hub's conclusion at write
|
|
97
|
-
* time, which `GET /v1/member/purchases` serves as a purchase
|
|
98
|
-
* (`lib/member-store.ts`). Written only in the submit create below, never by
|
|
99
|
-
* an update; an unmarked create carries no `spokeCheckout` key at all and
|
|
100
|
-
* relies on the column default. The plan step above runs only for a marked
|
|
101
|
-
* lead, after the write, and holds (#8793 D1), so a request relayed for a
|
|
102
|
-
* visitor never reaches it.
|
|
103
|
-
*
|
|
104
|
-
* PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
|
|
105
|
-
* write of a lead marked `spokeCheckout` with a Person — so never on a
|
|
106
|
-
* replay, a test-mode or relayed checkout, or a lead with no Person — the
|
|
107
|
-
* buyer gets one outbound `member` `Interaction` on that Person
|
|
108
|
-
* (`createdBy: "system:checkout"`, subject "Your purchase", no link, payload
|
|
109
|
-
* or attachment) and, fired with `void`, one `sendPurchaseConfirmationEmail`.
|
|
110
|
-
* Both name the offering only when its stripped key is one
|
|
111
|
-
* `listPlanOfferings` lists for the workspace, else "your purchase" — never
|
|
112
|
-
* the spoke-supplied `payload.offering` (SEC-8757-G) — and both bodies are
|
|
113
|
-
* stripped of control and bidi characters. The email's "Open your account"
|
|
114
|
-
* link is `options.accountOrigin` (the route resolves it, as for the invite
|
|
115
|
-
* email) plus the hub sign-in path: a marked lead is always a registered
|
|
116
|
-
* spoke's. With no origin the email goes without the link. The email is
|
|
117
|
-
* capped per recipient: more than `PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY`
|
|
118
|
-
* marked leads of this workspace for the address (any case) in the last 24
|
|
119
|
-
* hours, this one included, skips it; and per workspace: more than
|
|
120
|
-
* `PURCHASE_EMAILS_PER_SITE_PER_HOUR` marked leads in the last hour skips
|
|
121
|
-
* it. The message is still written. Neither
|
|
122
|
-
* step ever throws or changes the lead's result, and their one log line,
|
|
123
|
-
* `{ event: "checkout-purchase", step, outcome }`, names no address or
|
|
124
|
-
* offering.
|
|
125
|
-
*
|
|
126
|
-
* CHECKOUT CONSENT GRANT (#8778). After the same first write of a marked
|
|
127
|
-
* lead with a Person, a lead whose `payload.termsAccepted` equals the current
|
|
128
|
-
* `account_terms@<version>` stamp (`currentAccountTermsStamp`) — the spoke's
|
|
129
|
-
* fulfilment sends the stamp of the terms the buyer ticked in Stripe's box,
|
|
130
|
-
* else `null` — records the buyer's checkout `ConsentGrant` through #8765's
|
|
131
|
-
* `recordCheckoutConsent`: one `account_terms` row for that Person, source
|
|
132
|
-
* `checkout`, `sourceRef` the Checkout Session id (the lead's
|
|
133
|
-
* `Idempotency-Key`), at most once per Person and session. A `null` or absent
|
|
134
|
-
* value writes nothing (`consent_absent`), as does any other value
|
|
135
|
-
* (`terms_unstamped`). A replay never reaches it, and the key is left out of
|
|
136
|
-
* the replay comparison (`comparablePayload`). Never throws and never
|
|
137
|
-
* changes the lead's result; its one log line,
|
|
138
|
-
* `{ event: "checkout-consent", outcome, siteId }` (plus `errName` on
|
|
139
|
-
* `failed`), names no address or Person.
|
|
140
|
-
*
|
|
141
|
-
* VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
|
|
142
|
-
* id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
|
|
143
|
-
* `x-ailk-consent-visitor` only when it is a v4 UUID) and the submit resolved
|
|
144
|
-
* a Person, `linkConsentVisitor` joins the two — source `magnet` for a
|
|
145
|
-
* `flow:` source, else `api`. A link never fails a lead and is never
|
|
146
|
-
* returned: `RouteLeadResult.personId` is internal, for the flow engine's
|
|
147
|
-
* consent write. A subscribe or callback resolves no Person, so it links
|
|
148
|
-
* nothing. The header is never echoed and never logged.
|
|
149
|
-
*/
|
|
150
|
-
|
|
151
|
-
import { randomUUID } from "node:crypto";
|
|
152
|
-
import { isDeepStrictEqual } from "node:util";
|
|
153
|
-
|
|
154
|
-
import type { Prisma } from "@working-theory/database";
|
|
155
|
-
import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-theory/email";
|
|
156
|
-
import { logger } from "@working-theory/observability";
|
|
157
|
-
import {
|
|
158
|
-
accountInvitePath,
|
|
159
|
-
isConsentLeadSource,
|
|
160
|
-
LEAD_SITE_MARKER_KEY,
|
|
161
|
-
leadInputSchema,
|
|
162
|
-
parseLookupKey,
|
|
163
|
-
PURCHASE_EMAIL_DEFAULTS,
|
|
164
|
-
PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
|
|
165
|
-
stripPlainText,
|
|
166
|
-
} from "@working-theory/validation";
|
|
167
|
-
|
|
168
|
-
import { writeLeadFallback } from "../lib/file-fallback.js";
|
|
169
|
-
import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
170
|
-
import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
|
|
171
|
-
import { prisma } from "../lib/prisma.js";
|
|
172
|
-
|
|
173
|
-
import {
|
|
174
|
-
currentAccountTermsStamp,
|
|
175
|
-
linkConsentVisitor,
|
|
176
|
-
recordCheckoutConsent,
|
|
177
|
-
type RecordCheckoutConsentOutcome,
|
|
178
|
-
} from "./consent.js";
|
|
179
|
-
import { enrichLead } from "./enrichment-shim.js";
|
|
180
|
-
import {
|
|
181
|
-
checkoutPlanKey,
|
|
182
|
-
findOrCreatePerson,
|
|
183
|
-
normalizeCapturedContact,
|
|
184
|
-
normalizeTelephone,
|
|
185
|
-
} from "./person.js";
|
|
186
|
-
import type { LeadContact, PersonContact } from "./person.js";
|
|
187
|
-
import { listPlanOfferings } from "./plan-offerings.js";
|
|
188
|
-
import { requestConfirmSupersedesNotification } from "./request-confirm.js";
|
|
189
|
-
|
|
190
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
export type LeadKind = "submit" | "subscribe" | "callback";
|
|
193
|
-
|
|
194
|
-
export type RouteLeadInput =
|
|
195
|
-
| {
|
|
196
|
-
kind: "submit";
|
|
197
|
-
siteId: string;
|
|
198
|
-
source: string;
|
|
199
|
-
email: string;
|
|
200
|
-
payload: Record<string, unknown>;
|
|
201
|
-
}
|
|
202
|
-
| { kind: "subscribe"; siteId: string; email: string; source: string }
|
|
203
|
-
| {
|
|
204
|
-
kind: "callback";
|
|
205
|
-
siteId: string;
|
|
206
|
-
name: string;
|
|
207
|
-
phone: string;
|
|
208
|
-
preferredTime?: string;
|
|
209
|
-
payload: Record<string, unknown>;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
export type SourceMeta = {
|
|
213
|
-
ip: string;
|
|
214
|
-
userAgent: string | undefined;
|
|
215
|
-
receivedAt: string; // ISO
|
|
216
|
-
/**
|
|
217
|
-
* The lead came from a registered spoke of a hub (#7532 D4). Absent reads as
|
|
218
|
-
* `false`. Never persisted — `captureRecordFor` copies fields by name.
|
|
219
|
-
*/
|
|
220
|
-
viaSpoke?: boolean;
|
|
221
|
-
/**
|
|
222
|
-
* The request carried `x-ailk-relayed`, i.e. it was forwarded for a
|
|
223
|
-
* visitor by a spoke's same-origin relay (#8185 D3). Absent reads as
|
|
224
|
-
* `false`. Never persisted. It can only REMOVE the spoke-checkout marker,
|
|
225
|
-
* never grant it.
|
|
226
|
-
*/
|
|
227
|
-
relayed?: boolean;
|
|
228
|
-
/**
|
|
229
|
-
* The cookie banner's visitor id from the request's `x-ailk-consent-visitor`
|
|
230
|
-
* header, set only when it is a v4 UUID (#8438 D11). Used to link the
|
|
231
|
-
* visitor to the lead's Person; never persisted, echoed or logged.
|
|
232
|
-
*/
|
|
233
|
-
consentVisitorId?: string;
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
/** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
|
|
237
|
-
export type Persisted = "db" | "file" | "email";
|
|
238
|
-
|
|
239
|
-
/** Per-call options carried beside the input (#7869 D2). */
|
|
240
|
-
export type RouteLeadOptions = {
|
|
241
|
-
/** The caller's validated `Idempotency-Key`; honoured for `submit` only. */
|
|
242
|
-
idempotencyKey?: string;
|
|
243
|
-
/**
|
|
244
|
-
* The contact the capture offers (#8435 D5) — the magnet's tagged answers —
|
|
245
|
-
* honoured for `submit` only and never persisted as such: the lead's
|
|
246
|
-
* `payload` stays the raw answers.
|
|
247
|
-
*/
|
|
248
|
-
contact?: LeadContact;
|
|
249
|
-
/**
|
|
250
|
-
* Where the buyer's account lives (#8757 D14): the origin the purchase
|
|
251
|
-
* email's "Open your account" link starts sign-in on, resolved by the route
|
|
252
|
-
* from the caller, never the body. Read for a marked checkout lead only.
|
|
253
|
-
*/
|
|
254
|
-
accountOrigin?: string;
|
|
255
|
-
/**
|
|
256
|
-
* Set only by the per-Organization Stripe webhook after W5 and W6 (#8932
|
|
257
|
-
* D7) — a signature from the Organization's own secret, the hub's own
|
|
258
|
-
* session record for that Organization and workspace, and the recorded
|
|
259
|
-
* Price paid; never from a request. Admits the Offerings twin of the
|
|
260
|
-
* checkout plan predicate (`offeringsCheckoutCandidate`).
|
|
261
|
-
*/
|
|
262
|
-
offeringsCheckout?: true;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
export type RouteLeadResult =
|
|
266
|
-
| {
|
|
267
|
-
ok: true;
|
|
268
|
-
/**
|
|
269
|
-
* The answer to a repeat of an already-stored idempotency key (#7869
|
|
270
|
-
* D4): nothing was written. Never on the wire — the route builds its
|
|
271
|
-
* 201 body from `id`, `persisted` and `email` only.
|
|
272
|
-
*/
|
|
273
|
-
replayed?: true;
|
|
274
|
-
id: string;
|
|
275
|
-
/**
|
|
276
|
-
* The Person a `submit` resolved (#8438 D11), `null`/absent when none.
|
|
277
|
-
* Internal only — the route builds its 201 body from `id`, `persisted`
|
|
278
|
-
* and `email`, so this never reaches a wire.
|
|
279
|
-
*/
|
|
280
|
-
personId?: string | null;
|
|
281
|
-
persisted: Persisted;
|
|
282
|
-
email:
|
|
283
|
-
| { ok: true; messageId: string }
|
|
284
|
-
| { ok: false; error: string }
|
|
285
|
-
| { skipped: true; reason: string };
|
|
286
|
-
}
|
|
287
|
-
| {
|
|
288
|
-
ok: false;
|
|
289
|
-
error: {
|
|
290
|
-
kind:
|
|
291
|
-
| "invalid_input"
|
|
292
|
-
| "persist_failed"
|
|
293
|
-
| "config_missing"
|
|
294
|
-
| "delivery_failed"
|
|
295
|
-
| "idempotency_conflict";
|
|
296
|
-
message: string;
|
|
297
|
-
};
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
301
|
-
|
|
302
|
-
function validateInput(
|
|
303
|
-
input: RouteLeadInput,
|
|
304
|
-
): { ok: true } | { ok: false; message: string } {
|
|
305
|
-
const result = leadInputSchema.safeParse(input);
|
|
306
|
-
if (!result.success) {
|
|
307
|
-
return {
|
|
308
|
-
ok: false,
|
|
309
|
-
message: result.error.issues[0]?.message ?? "invalid input",
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
return { ok: true };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ─── Idempotency key (#7869) ─────────────────────────────────────────────────
|
|
316
|
-
|
|
317
|
-
type SubmitInput = Extract<RouteLeadInput, { kind: "submit" }>;
|
|
318
|
-
type KeyedRow = { id: string; email: string; source: string; payload: unknown };
|
|
319
|
-
/** What a stored key means for this request (#7869 D4). */
|
|
320
|
-
type KeyedOutcome = { kind: "replay"; id: string } | { kind: "conflict" };
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* The one read of `Lead.idempotencyKey` (#7869 D3, reused by D5's re-read).
|
|
324
|
-
* The compound unique carries `workspaceId`, so the lookup can only ever
|
|
325
|
-
* reach this workspace's rows.
|
|
326
|
-
*/
|
|
327
|
-
async function findLeadByKey(
|
|
328
|
-
workspaceId: string,
|
|
329
|
-
idempotencyKey: string,
|
|
330
|
-
): Promise<KeyedRow | null> {
|
|
331
|
-
return (await prisma.lead.findUnique({
|
|
332
|
-
where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey } },
|
|
333
|
-
select: { id: true, email: true, source: true, payload: true },
|
|
334
|
-
})) as KeyedRow | null;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* A consent-bearing lead's `payload.consent` — a contact lead's (#8730 D3) or,
|
|
339
|
-
* since #9130, a job application's — is written by the route on every request,
|
|
340
|
-
* its `acceptedAt` that request's receive time, so it is left out of the replay
|
|
341
|
-
* comparison for every `isConsentLeadSource` source; every key the client sent
|
|
342
|
-
* still counts.
|
|
343
|
-
*
|
|
344
|
-
* A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
|
|
345
|
-
* updated between a stored attempt and Stripe's redelivery sends the key the
|
|
346
|
-
* stored body lacks, and a conflict there would fail every retry. A replay
|
|
347
|
-
* writes no grant, so the key decides nothing on one.
|
|
348
|
-
*/
|
|
349
|
-
function comparablePayload(payload: unknown, source: string): unknown {
|
|
350
|
-
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
351
|
-
return payload;
|
|
352
|
-
}
|
|
353
|
-
const record = payload as Record<string, unknown>;
|
|
354
|
-
if (isConsentLeadSource(source)) {
|
|
355
|
-
const { consent: _serverWritten, ...rest } = record;
|
|
356
|
-
return rest;
|
|
357
|
-
}
|
|
358
|
-
if (record["page"] === "checkout") {
|
|
359
|
-
const { termsAccepted: _addedByUpdate, ...rest } = record;
|
|
360
|
-
return rest;
|
|
361
|
-
}
|
|
362
|
-
return payload;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* A stored row is a replay only when it carries this request's exact `email`
|
|
367
|
-
* and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
|
|
368
|
-
* any other row is a conflict (#7869 D4). A consent-bearing lead's
|
|
369
|
-
* server-written `payload.consent` is not compared (#8730, #9130), nor a
|
|
370
|
-
* checkout lead's `payload.termsAccepted` (#8778).
|
|
371
|
-
*/
|
|
372
|
-
function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
|
|
373
|
-
return row.email === input.email &&
|
|
374
|
-
row.source === input.source &&
|
|
375
|
-
isDeepStrictEqual(
|
|
376
|
-
comparablePayload(row.payload, row.source),
|
|
377
|
-
comparablePayload(input.payload, input.source),
|
|
378
|
-
)
|
|
379
|
-
? { kind: "replay", id: row.id }
|
|
380
|
-
: { kind: "conflict" };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function keyedResult(outcome: KeyedOutcome): RouteLeadResult {
|
|
384
|
-
if (outcome.kind === "conflict") {
|
|
385
|
-
return {
|
|
386
|
-
ok: false,
|
|
387
|
-
error: { kind: "idempotency_conflict", message: "idempotency key reused" },
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
return {
|
|
391
|
-
ok: true,
|
|
392
|
-
replayed: true,
|
|
393
|
-
id: outcome.id,
|
|
394
|
-
persisted: "db",
|
|
395
|
-
email: { skipped: true, reason: "idempotent_replay" },
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** The structural `P2002` test `recommender-capture.ts` already uses. */
|
|
400
|
-
function isUniqueViolation(err: unknown): boolean {
|
|
401
|
-
return (
|
|
402
|
-
typeof err === "object" && err !== null && (err as { code?: unknown }).code === "P2002"
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// ─── DB persist ───────────────────────────────────────────────────────────────
|
|
407
|
-
|
|
408
|
-
type DbRow = { id: string };
|
|
409
|
-
type PersistResult =
|
|
410
|
-
| {
|
|
411
|
-
ok: true;
|
|
412
|
-
id: string;
|
|
413
|
-
source: "db";
|
|
414
|
-
skipEmail: boolean;
|
|
415
|
-
skipReason?: string;
|
|
416
|
-
/** The Person a `submit` resolved (#8438 D11). */
|
|
417
|
-
personId?: string | null;
|
|
418
|
-
}
|
|
419
|
-
// #7869 D5 — a keyed create lost a race to a row it now answers with.
|
|
420
|
-
| { ok: "keyed"; outcome: KeyedOutcome }
|
|
421
|
-
| { ok: false; error: string };
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* The Person of a submitted lead (#852 D6) — its id, or `null` when the email
|
|
425
|
-
* is not a storable address or the step failed. Never throws: a Person step
|
|
426
|
-
* must never refuse, lose or delay a lead. Logs the outcome kind and the
|
|
427
|
-
* site only.
|
|
428
|
-
*/
|
|
429
|
-
async function resolveCapturePersonId(
|
|
430
|
-
siteId: string,
|
|
431
|
-
capture: { email: string; name?: string | null; contact?: Partial<PersonContact> },
|
|
432
|
-
): Promise<string | null> {
|
|
433
|
-
try {
|
|
434
|
-
const person = await findOrCreatePerson(siteId, capture);
|
|
435
|
-
if (person.ok) return person.value.personId;
|
|
436
|
-
if (person.error.kind === "invalid_email") {
|
|
437
|
-
logger.warn(
|
|
438
|
-
{ event: "person-skipped", kind: person.error.kind, siteId },
|
|
439
|
-
"lead person skipped",
|
|
440
|
-
);
|
|
441
|
-
} else {
|
|
442
|
-
logger.error({ event: "person-failed", siteId }, "lead person failed");
|
|
443
|
-
}
|
|
444
|
-
} catch {
|
|
445
|
-
logger.error({ event: "person-failed", siteId }, "lead person failed");
|
|
446
|
-
}
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): Promise<string | null> {
|
|
451
|
-
const payloadName = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
|
|
452
|
-
if (contact === undefined) {
|
|
453
|
-
return resolveCapturePersonId(input.siteId, { email: input.email, name: payloadName });
|
|
454
|
-
}
|
|
455
|
-
// #8435 D5 — an explicit contact name wins over the payload's.
|
|
456
|
-
const name = typeof contact.name === "string" ? contact.name : payloadName;
|
|
457
|
-
return resolveCapturePersonId(input.siteId, {
|
|
458
|
-
email: input.email,
|
|
459
|
-
name,
|
|
460
|
-
contact: normalizeCapturedContact(contact),
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** A marked checkout lead's offering and site; `sessionId` is the Offerings twin's bare session id. */
|
|
465
|
-
type CheckoutCandidate = { offering: string; site: string; sessionId?: string };
|
|
466
|
-
|
|
467
|
-
/** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
|
|
468
|
-
const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
|
|
469
|
-
const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* The raw offering of a lead that qualifies for the checkout plan rule, or
|
|
473
|
-
* `null` when it does not (#7877 D10). Reads the lead's own fields only.
|
|
474
|
-
*/
|
|
475
|
-
function checkoutPlanCandidate(
|
|
476
|
-
input: SubmitInput,
|
|
477
|
-
idempotencyKey: string | undefined,
|
|
478
|
-
viaSpoke: boolean,
|
|
479
|
-
): CheckoutCandidate | null {
|
|
480
|
-
if (viaSpoke !== true) return null;
|
|
481
|
-
if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
|
|
482
|
-
return null;
|
|
483
|
-
}
|
|
484
|
-
const { payload } = input;
|
|
485
|
-
const amountTotal = payload["amountTotal"];
|
|
486
|
-
if (
|
|
487
|
-
amountTotal !== undefined &&
|
|
488
|
-
amountTotal !== null &&
|
|
489
|
-
!(typeof amountTotal === "number" && Number.isInteger(amountTotal) && amountTotal > 0)
|
|
490
|
-
) {
|
|
491
|
-
return null;
|
|
492
|
-
}
|
|
493
|
-
if (input.source !== "web" || payload["page"] !== "checkout") return null;
|
|
494
|
-
const offering = payload["offering"];
|
|
495
|
-
const site = payload[LEAD_SITE_MARKER_KEY];
|
|
496
|
-
if (typeof offering !== "string" || typeof site !== "string" || !SITE_SLUG.test(site)) {
|
|
497
|
-
return null;
|
|
498
|
-
}
|
|
499
|
-
return { offering, site };
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** The Offerings webhook's key: a space no `Idempotency-Key` header can carry, then a LIVE session id. */
|
|
503
|
-
const OFFERINGS_CHECKOUT_KEY = /^stripe-offerings (cs_live_[A-Za-z0-9]{1,250})$/;
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* The Offerings twin of `checkoutPlanCandidate` (#8932 D7): a lead the
|
|
507
|
-
* per-Organization Stripe webhook wrote after W5 and W6, for a LIVE session
|
|
508
|
-
* only (C9, SEC-7877-C) — a test-mode session's lead is written unmarked.
|
|
509
|
-
* Reads no amount: W6 already proved the session paid. `checkoutPlanCandidate`
|
|
510
|
-
* is not edited, so a spoke lead is marked exactly as before.
|
|
511
|
-
*/
|
|
512
|
-
function offeringsCheckoutCandidate(
|
|
513
|
-
input: SubmitInput,
|
|
514
|
-
idempotencyKey: string | undefined,
|
|
515
|
-
offeringsCheckout: boolean,
|
|
516
|
-
): CheckoutCandidate | null {
|
|
517
|
-
if (offeringsCheckout !== true || idempotencyKey === undefined) return null;
|
|
518
|
-
const sessionId = OFFERINGS_CHECKOUT_KEY.exec(idempotencyKey)?.[1];
|
|
519
|
-
if (sessionId === undefined) return null;
|
|
520
|
-
const { payload } = input;
|
|
521
|
-
if (input.source !== "web" || payload["page"] !== "checkout") return null;
|
|
522
|
-
const offering = payload["offering"];
|
|
523
|
-
if (typeof offering !== "string" || parseLookupKey(offering) === null) return null;
|
|
524
|
-
const site = payload[LEAD_SITE_MARKER_KEY];
|
|
525
|
-
if (typeof site !== "string" || site !== input.siteId) return null;
|
|
526
|
-
return { offering, site, sessionId };
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/** The candidate's offering minus a leading `<site>_` (#7877 D10). */
|
|
530
|
-
function stripSitePrefix(candidate: CheckoutCandidate): string {
|
|
531
|
-
const prefix = `${candidate.site}_`;
|
|
532
|
-
return candidate.offering.startsWith(prefix)
|
|
533
|
-
? candidate.offering.slice(prefix.length)
|
|
534
|
-
: candidate.offering;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Hold the plan of a marked checkout lead (#7877 D10, #8793 D1): no Person
|
|
539
|
-
* call — the marked lead itself is the hold, released only at the Person's
|
|
540
|
-
* verified first link (`person.ts`'s `releaseHeldCheckoutPlan`). Logs
|
|
541
|
-
* `held` for an offering that is a plan key, else `skipped`, and the site
|
|
542
|
-
* only.
|
|
543
|
-
*/
|
|
544
|
-
function applyCheckoutPlan(
|
|
545
|
-
input: SubmitInput,
|
|
546
|
-
candidate: CheckoutCandidate,
|
|
547
|
-
): void {
|
|
548
|
-
const outcome = checkoutPlanKey(candidate.offering, candidate.site) === null ? "skipped" : "held";
|
|
549
|
-
logger.info({ event: "checkout-plan", outcome, siteId: input.siteId }, "checkout plan");
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
|
|
553
|
-
|
|
554
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
555
|
-
const HOUR_MS = 60 * 60 * 1000;
|
|
556
|
-
/**
|
|
557
|
-
* A workspace's purchase emails per hour, whatever the address (SEC review of
|
|
558
|
-
* #8770, M1): a marked lead's inputs are all the spoke's own, so the
|
|
559
|
-
* per-recipient cap alone would let a spoke key mail any number of distinct
|
|
560
|
-
* addresses. The invite email's per-site cap
|
|
561
|
-
* (`INVITE_EMAILS_PER_SITE_PER_HOUR`) has the same value.
|
|
562
|
-
*/
|
|
563
|
-
const PURCHASE_EMAILS_PER_SITE_PER_HOUR = 50;
|
|
564
|
-
const GENERIC_PURCHASE_BODY = "Thanks for your purchase.";
|
|
565
|
-
/** How long the lead write waits on the offering's name before it says "your purchase". */
|
|
566
|
-
const OFFERING_NAME_BUDGET_MS = 1500;
|
|
567
|
-
/**
|
|
568
|
-
* Stripe's special-case currencies, whose minor units differ from the ISO
|
|
569
|
-
* decimals `Intl` formats with: the amount is left out rather than misstated.
|
|
570
|
-
*/
|
|
571
|
-
const AMOUNT_UNSTATED_CURRENCIES = new Set(["HUF", "ISK", "TWD", "UGX"]);
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* The purchase email's sender: every completion email's rule
|
|
575
|
-
* (`AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`; `||`, so
|
|
576
|
-
* "" names none), the same as `magnet-completion.ts`'s `inviteSenderAddress`,
|
|
577
|
-
* kept here so the lead path does not load that module's graph.
|
|
578
|
-
*/
|
|
579
|
-
function purchaseSenderAddress(): string | undefined {
|
|
580
|
-
return (
|
|
581
|
-
process.env.AILK_DELIVERABLE_EMAIL_FROM ||
|
|
582
|
-
process.env.AILK_LEAD_NOTIFICATION_FROM ||
|
|
583
|
-
undefined
|
|
584
|
-
);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
type PurchaseStep = "message" | "email";
|
|
588
|
-
|
|
589
|
-
function logPurchase(siteId: string, step: PurchaseStep, outcome: string): void {
|
|
590
|
-
const line = { event: "checkout-purchase", step, outcome, siteId };
|
|
591
|
-
if (outcome === "failed") logger.error(line, "checkout purchase step failed");
|
|
592
|
-
else logger.info(line, "checkout purchase step");
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* The offering's listed name, or `null` when its stripped key is not listed,
|
|
597
|
-
* the read fails, or it outlasts `OFFERING_NAME_BUDGET_MS`.
|
|
598
|
-
*/
|
|
599
|
-
async function listedOfferingName(siteId: string, offering: string): Promise<string | null> {
|
|
600
|
-
let timer: NodeJS.Timeout | undefined;
|
|
601
|
-
const budget = new Promise<null>((resolve) => {
|
|
602
|
-
timer = setTimeout(() => resolve(null), OFFERING_NAME_BUDGET_MS);
|
|
603
|
-
});
|
|
604
|
-
try {
|
|
605
|
-
const listed = await Promise.race([listPlanOfferings(siteId), budget]);
|
|
606
|
-
if (!Array.isArray(listed)) return null;
|
|
607
|
-
const name = listed.find((item) => item.key === offering)?.name;
|
|
608
|
-
const clean = name === undefined ? "" : stripPlainText(name).trim();
|
|
609
|
-
return clean === "" ? null : clean;
|
|
610
|
-
} catch {
|
|
611
|
-
return null;
|
|
612
|
-
} finally {
|
|
613
|
-
clearTimeout(timer);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/** `amountTotal` (minor units) in `currency`, formatted, or `null` when either is unusable. */
|
|
618
|
-
function formattedAmount(amountTotal: unknown, currency: unknown): string | null {
|
|
619
|
-
if (typeof amountTotal !== "number" || !Number.isInteger(amountTotal) || amountTotal <= 0) {
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) return null;
|
|
623
|
-
const code = currency.toUpperCase();
|
|
624
|
-
if (AMOUNT_UNSTATED_CURRENCIES.has(code)) return null;
|
|
625
|
-
try {
|
|
626
|
-
const format = new Intl.NumberFormat("en", { style: "currency", currency: code });
|
|
627
|
-
const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
|
|
628
|
-
return format.format(amountTotal / 10 ** digits);
|
|
629
|
-
} catch {
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/** The one plain-text body the email and the message share. */
|
|
635
|
-
function purchaseBody(name: string | null, amount: string | null): string {
|
|
636
|
-
const sentence =
|
|
637
|
-
name === null ? GENERIC_PURCHASE_BODY : PURCHASE_EMAIL_DEFAULTS.body.replaceAll("{offering}", name);
|
|
638
|
-
return stripPlainText(amount === null ? sentence : `${sentence} Total: ${amount}.`);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** The purchase email, capped per recipient (D14). Never throws; fired with `void`. */
|
|
642
|
-
async function sendPurchaseEmail(
|
|
643
|
-
input: SubmitInput,
|
|
644
|
-
body: string,
|
|
645
|
-
accountOrigin: string | undefined,
|
|
646
|
-
): Promise<void> {
|
|
647
|
-
let outcome: string;
|
|
648
|
-
try {
|
|
649
|
-
const recent = await prisma.lead.count({
|
|
650
|
-
where: {
|
|
651
|
-
workspaceId: input.siteId,
|
|
652
|
-
spokeCheckout: true,
|
|
653
|
-
email: { equals: input.email, mode: "insensitive" },
|
|
654
|
-
createdAt: { gte: new Date(Date.now() - DAY_MS) },
|
|
655
|
-
},
|
|
656
|
-
});
|
|
657
|
-
const siteHour = await prisma.lead.count({
|
|
658
|
-
where: {
|
|
659
|
-
workspaceId: input.siteId,
|
|
660
|
-
spokeCheckout: true,
|
|
661
|
-
createdAt: { gte: new Date(Date.now() - HOUR_MS) },
|
|
662
|
-
},
|
|
663
|
-
});
|
|
664
|
-
const fromAddress = purchaseSenderAddress();
|
|
665
|
-
if (recent > PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY) {
|
|
666
|
-
outcome = "capped_recipient";
|
|
667
|
-
} else if (siteHour > PURCHASE_EMAILS_PER_SITE_PER_HOUR) {
|
|
668
|
-
outcome = "capped_site";
|
|
669
|
-
} else if (!fromAddress) {
|
|
670
|
-
outcome = "sender_unconfigured";
|
|
671
|
-
} else {
|
|
672
|
-
let accountLink: string | undefined;
|
|
673
|
-
if (accountOrigin !== undefined) {
|
|
674
|
-
try {
|
|
675
|
-
accountLink = accountOrigin + accountInvitePath("hub", input.email);
|
|
676
|
-
} catch {
|
|
677
|
-
// An address the sign-in hint refuses: the email goes without the link.
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
const result = await sendPurchaseConfirmationEmail({
|
|
681
|
-
to: input.email,
|
|
682
|
-
fromAddress,
|
|
683
|
-
subject: PURCHASE_EMAIL_DEFAULTS.subject,
|
|
684
|
-
body,
|
|
685
|
-
cta: PURCHASE_EMAIL_DEFAULTS.cta,
|
|
686
|
-
...(accountLink !== undefined && { accountLink }),
|
|
687
|
-
});
|
|
688
|
-
outcome = result.ok ? "sent" : "failed";
|
|
689
|
-
}
|
|
690
|
-
} catch {
|
|
691
|
-
outcome = "failed";
|
|
692
|
-
}
|
|
693
|
-
logPurchase(input.siteId, "email", outcome);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* The buyer's Inbox message and purchase email for a marked checkout lead
|
|
698
|
-
* just written (D14). Never throws and never changes the lead's result.
|
|
699
|
-
*/
|
|
700
|
-
async function recordPurchase(
|
|
701
|
-
input: SubmitInput,
|
|
702
|
-
personId: string,
|
|
703
|
-
candidate: CheckoutCandidate,
|
|
704
|
-
accountOrigin: string | undefined,
|
|
705
|
-
): Promise<void> {
|
|
706
|
-
// One guard around the whole step: it runs inside `routeLead`'s persist
|
|
707
|
-
// `try`, where a throw after the row is written would be read as a failed
|
|
708
|
-
// write and the lead captured a second time.
|
|
709
|
-
let body: string;
|
|
710
|
-
try {
|
|
711
|
-
const name = await listedOfferingName(input.siteId, stripSitePrefix(candidate));
|
|
712
|
-
body = purchaseBody(name, formattedAmount(input.payload["amountTotal"], input.payload["currency"]));
|
|
713
|
-
} catch {
|
|
714
|
-
logPurchase(input.siteId, "message", "failed");
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
try {
|
|
718
|
-
await prisma.interaction.create({
|
|
719
|
-
data: {
|
|
720
|
-
workspaceId: input.siteId,
|
|
721
|
-
personId,
|
|
722
|
-
channel: "member",
|
|
723
|
-
direction: "outbound",
|
|
724
|
-
subject: PURCHASE_EMAIL_DEFAULTS.subject,
|
|
725
|
-
body,
|
|
726
|
-
createdBy: "system:checkout",
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
|
-
logPurchase(input.siteId, "message", "written");
|
|
730
|
-
} catch {
|
|
731
|
-
logPurchase(input.siteId, "message", "failed");
|
|
732
|
-
}
|
|
733
|
-
void sendPurchaseEmail(input, body, accountOrigin);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* The buyer's checkout consent grant for a marked checkout lead just written
|
|
738
|
-
* (#8778). Never throws and never changes the lead's result.
|
|
739
|
-
*/
|
|
740
|
-
async function recordSpokeCheckoutConsent(
|
|
741
|
-
input: SubmitInput,
|
|
742
|
-
personId: string,
|
|
743
|
-
sessionId: string,
|
|
744
|
-
): Promise<void> {
|
|
745
|
-
const accepted = input.payload["termsAccepted"];
|
|
746
|
-
let outcome:
|
|
747
|
-
| RecordCheckoutConsentOutcome
|
|
748
|
-
| "consent_absent"
|
|
749
|
-
| "terms_unstamped"
|
|
750
|
-
| "failed";
|
|
751
|
-
let errName: string | undefined;
|
|
752
|
-
try {
|
|
753
|
-
const expected = currentAccountTermsStamp();
|
|
754
|
-
if (accepted === undefined || accepted === null) {
|
|
755
|
-
outcome = "consent_absent";
|
|
756
|
-
} else if (expected === null || accepted !== expected.stamp) {
|
|
757
|
-
outcome = "terms_unstamped";
|
|
758
|
-
} else {
|
|
759
|
-
outcome = await recordCheckoutConsent({
|
|
760
|
-
workspaceId: input.siteId,
|
|
761
|
-
personId,
|
|
762
|
-
sessionId,
|
|
763
|
-
policyVersion: expected.policyVersion,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
} catch (err) {
|
|
767
|
-
// The error's class only (a Prisma error's message can carry values).
|
|
768
|
-
outcome = "failed";
|
|
769
|
-
errName = err instanceof Error ? err.name : "UnknownError";
|
|
770
|
-
}
|
|
771
|
-
const line = {
|
|
772
|
-
event: "checkout-consent",
|
|
773
|
-
outcome,
|
|
774
|
-
siteId: input.siteId,
|
|
775
|
-
...(errName !== undefined && { errName }),
|
|
776
|
-
};
|
|
777
|
-
if (outcome === "failed") logger.error(line, "checkout terms grant failed");
|
|
778
|
-
else logger.info(line, "checkout terms grant");
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async function persistToDb(
|
|
782
|
-
input: RouteLeadInput,
|
|
783
|
-
idempotencyKey?: string,
|
|
784
|
-
viaSpoke = false,
|
|
785
|
-
relayed = false,
|
|
786
|
-
contact?: LeadContact,
|
|
787
|
-
accountOrigin?: string,
|
|
788
|
-
offeringsCheckout = false,
|
|
789
|
-
): Promise<PersistResult> {
|
|
790
|
-
if (input.kind === "submit") {
|
|
791
|
-
const personId = await resolveLeadPersonId(input, contact);
|
|
792
|
-
// #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
|
|
793
|
-
// evaluated before the create so its result can be stored. A relayed
|
|
794
|
-
// request is never a spoke's server call (D3). #8932 D7 — or its
|
|
795
|
-
// Offerings twin, set only by the per-Organization Stripe webhook.
|
|
796
|
-
const candidate =
|
|
797
|
-
checkoutPlanCandidate(input, idempotencyKey, viaSpoke) ??
|
|
798
|
-
offeringsCheckoutCandidate(input, idempotencyKey, offeringsCheckout);
|
|
799
|
-
const spokeCheckout = candidate !== null && relayed !== true;
|
|
800
|
-
let row: DbRow;
|
|
801
|
-
try {
|
|
802
|
-
row = (await prisma.lead.create({
|
|
803
|
-
data: {
|
|
804
|
-
siteId: input.siteId,
|
|
805
|
-
// #6203/#6254 — the isolation key, equal to `siteId` by
|
|
806
|
-
// construction. `input.siteId` is the RESOLVED workspace id at
|
|
807
|
-
// EVERY `routeLead(kind: "submit")` caller now — the primary route
|
|
808
|
-
// (`routes/leads/index.ts`, never reads it from the body — see that
|
|
809
|
-
// file's header) and the three service-layer callers
|
|
810
|
-
// (`flow-engine.ts`'s `completeSubmission`,
|
|
811
|
-
// `recommender-capture.ts`'s `captureRecommenderChoice`,
|
|
812
|
-
// `project-listings.ts`'s `submitProjectListingDraft`) all resolve
|
|
813
|
-
// the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
|
|
814
|
-
// is NOT NULL as of
|
|
815
|
-
// database/migrations/20260922120000_tenancy_fence, so this can no
|
|
816
|
-
// longer stamp `null` for an unresolved alias the way it once did
|
|
817
|
-
// (spec D2's old rule) — a caller that somehow still passed one
|
|
818
|
-
// would instead hit the pre-existing `<table>_workspace_id_format`
|
|
819
|
-
// CHECK loudly, via the AC-11 re-raise below, rather than landing a
|
|
820
|
-
// silently-unfenced row.
|
|
821
|
-
workspaceId: input.siteId,
|
|
822
|
-
source: input.source,
|
|
823
|
-
email: input.email,
|
|
824
|
-
payload: input.payload as Prisma.InputJsonValue,
|
|
825
|
-
// #852 D6 — the Person `resolveLeadPersonId` found or created.
|
|
826
|
-
personId,
|
|
827
|
-
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
828
|
-
// #8185 D4 — only a marked create names the column.
|
|
829
|
-
...(spokeCheckout && { spokeCheckout: true }),
|
|
830
|
-
},
|
|
831
|
-
select: { id: true },
|
|
832
|
-
})) as DbRow;
|
|
833
|
-
} catch (err) {
|
|
834
|
-
// #7869 D5 — two keyed requests both missed the lookup and both
|
|
835
|
-
// created; the unique index refused the second. Answer with the
|
|
836
|
-
// winner. Anything else — no key, another error, a winner that cannot
|
|
837
|
-
// be read back — is rethrown untouched, so `routeLead`'s existing
|
|
838
|
-
// constraint and outage handling decide it exactly as before.
|
|
839
|
-
if (idempotencyKey === undefined || !isUniqueViolation(err)) throw err;
|
|
840
|
-
const winner = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
|
|
841
|
-
if (!winner) throw err;
|
|
842
|
-
return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
|
|
843
|
-
}
|
|
844
|
-
// #7877 D10 — only after THIS request's own write, so a replay holds nothing.
|
|
845
|
-
if (candidate !== null && personId !== null) {
|
|
846
|
-
// #8757 D14 — the marked lead only: live, unrelayed, this write.
|
|
847
|
-
if (spokeCheckout) {
|
|
848
|
-
// #8793 D1 — the plan is held, never written here.
|
|
849
|
-
applyCheckoutPlan(input, candidate);
|
|
850
|
-
await recordPurchase(input, personId, candidate, accountOrigin);
|
|
851
|
-
// #8778 — a marked lead's key is its live Checkout Session id;
|
|
852
|
-
// #8932 D7 — the Offerings twin's key is prefixed, so its bare id.
|
|
853
|
-
const sessionId = candidate.sessionId ?? idempotencyKey;
|
|
854
|
-
if (sessionId !== undefined) {
|
|
855
|
-
await recordSpokeCheckoutConsent(input, personId, sessionId);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
if (input.kind === "subscribe") {
|
|
863
|
-
// #8435 D5 — the subscriber's Person; `null` when the step skips or fails.
|
|
864
|
-
const personId = await resolveCapturePersonId(input.siteId, { email: input.email });
|
|
865
|
-
try {
|
|
866
|
-
const row = (await prisma.subscriber.create({
|
|
867
|
-
data: {
|
|
868
|
-
siteId: input.siteId,
|
|
869
|
-
// #6374 — the isolation key, equal to `siteId` by construction,
|
|
870
|
-
// exactly as `lead.create` above stamps it. `input.siteId` is the
|
|
871
|
-
// RESOLVED workspace id at every `routeLead` caller (see the
|
|
872
|
-
// `submit` branch's own note); a caller that somehow passed a raw
|
|
873
|
-
// alias would hit `subscribers_workspace_id_format` loudly rather
|
|
874
|
-
// than landing a silently-unfenced row. Never from a request body.
|
|
875
|
-
workspaceId: input.siteId,
|
|
876
|
-
email: input.email,
|
|
877
|
-
source: input.source,
|
|
878
|
-
personId,
|
|
879
|
-
},
|
|
880
|
-
select: { id: true },
|
|
881
|
-
})) as DbRow;
|
|
882
|
-
return { ok: true, id: row.id, source: "db", skipEmail: false };
|
|
883
|
-
} catch (err) {
|
|
884
|
-
// Handle unique constraint violation (duplicate subscriber)
|
|
885
|
-
if (
|
|
886
|
-
err != null &&
|
|
887
|
-
typeof err === "object" &&
|
|
888
|
-
"code" in err &&
|
|
889
|
-
(err as { code: string }).code === "P2002"
|
|
890
|
-
) {
|
|
891
|
-
// #6257 — the duplicate lookup is scoped by the same resolved
|
|
892
|
-
// workspace id the `create` above stamps (`input.siteId` is the
|
|
893
|
-
// RESOLVED id at every caller — see the `submit` branch's note), so a
|
|
894
|
-
// P2002 on another tenant's row can never resolve to its id.
|
|
895
|
-
const existing = (await prisma.subscriber.findUnique({
|
|
896
|
-
where: {
|
|
897
|
-
siteId_email: { siteId: input.siteId, email: input.email },
|
|
898
|
-
workspaceId: input.siteId,
|
|
899
|
-
},
|
|
900
|
-
select: { id: true },
|
|
901
|
-
})) as DbRow | null;
|
|
902
|
-
const existingId = existing?.id ?? "duplicate-no-id";
|
|
903
|
-
return {
|
|
904
|
-
ok: true,
|
|
905
|
-
id: existingId,
|
|
906
|
-
source: "db",
|
|
907
|
-
skipEmail: true,
|
|
908
|
-
skipReason: "duplicate",
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
throw err; // re-throw non-P2002 errors to trigger fallback
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// kind === "callback"
|
|
916
|
-
// #8435 D5 — a callback with a string `payload.email` resolves a Person; one
|
|
917
|
-
// without makes no call and writes no `personId`.
|
|
918
|
-
const callbackEmail = input.payload["email"];
|
|
919
|
-
const telephone = normalizeTelephone(input.phone);
|
|
920
|
-
const callbackPersonId =
|
|
921
|
-
typeof callbackEmail === "string"
|
|
922
|
-
? await resolveCapturePersonId(input.siteId, {
|
|
923
|
-
email: callbackEmail,
|
|
924
|
-
name: input.name,
|
|
925
|
-
contact: telephone === null ? {} : { telephone },
|
|
926
|
-
})
|
|
927
|
-
: undefined;
|
|
928
|
-
const row = (await prisma.callback.create({
|
|
929
|
-
data: {
|
|
930
|
-
siteId: input.siteId,
|
|
931
|
-
// #6374 — the isolation key, equal to `siteId` by construction; same
|
|
932
|
-
// resolution guarantee and same loud-failure posture as the two
|
|
933
|
-
// branches above. Never from a request body (AC-6).
|
|
934
|
-
workspaceId: input.siteId,
|
|
935
|
-
name: input.name,
|
|
936
|
-
phone: input.phone,
|
|
937
|
-
...(input.preferredTime !== undefined && {
|
|
938
|
-
preferredTime: input.preferredTime,
|
|
939
|
-
}),
|
|
940
|
-
payload: input.payload as Prisma.InputJsonValue,
|
|
941
|
-
...(callbackPersonId !== undefined && { personId: callbackPersonId }),
|
|
942
|
-
},
|
|
943
|
-
select: { id: true },
|
|
944
|
-
})) as DbRow;
|
|
945
|
-
return { ok: true, id: row.id, source: "db", skipEmail: false };
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// ─── Constraint-violation detection (#6254 AC-11) ──────────────────────────────
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* SQLSTATE class 23 — "integrity constraint violation" — covers a NOT NULL
|
|
952
|
-
* (23502), a CHECK (23514), a unique (23505) or a foreign-key (23503)
|
|
953
|
-
* violation. Two of Prisma's own mapped codes correspond to a class-23
|
|
954
|
-
* violation on a `.create()` call: P2002 (unique) and P2011 (null
|
|
955
|
-
* constraint) — both `PrismaClientKnownRequestError`s Prisma classifies
|
|
956
|
-
* from its OWN schema knowledge. The new `<table>_workspace_site_agree`
|
|
957
|
-
* CHECK (database/migrations/20260922120000_tenancy_fence) has no such
|
|
958
|
-
* schema-level representation — Prisma cannot classify a raw CHECK it does
|
|
959
|
-
* not know about, so the raw Postgres SQLSTATE is read defensively from
|
|
960
|
-
* wherever the error actually carries it: `error.meta.code` (some engine/
|
|
961
|
-
* adapter combinations thread it through there) or the error message text
|
|
962
|
-
* (every Postgres client library's message includes the SQLSTATE and/or the
|
|
963
|
-
* words "constraint"/"null value" verbatim).
|
|
964
|
-
*/
|
|
965
|
-
// P2000 (value too long) is deliberately excluded — a data-length validation
|
|
966
|
-
// error, not a SQLSTATE class-23 integrity-constraint violation (security
|
|
967
|
-
// review finding, PR #6360): it doesn't correspond to 23502/23503/23505/
|
|
968
|
-
// 23514, so treating it as one would take this path for a case AC-11 does
|
|
969
|
-
// not target.
|
|
970
|
-
const PRISMA_CLASS_23_CODES = new Set(["P2002", "P2003", "P2004", "P2011"]);
|
|
971
|
-
|
|
972
|
-
function isConstraintViolation(error: unknown): boolean {
|
|
973
|
-
if (error == null || typeof error !== "object") return false;
|
|
974
|
-
|
|
975
|
-
const code = (error as { code?: unknown }).code;
|
|
976
|
-
if (typeof code === "string" && PRISMA_CLASS_23_CODES.has(code)) return true;
|
|
977
|
-
|
|
978
|
-
const metaCode = (error as { meta?: { code?: unknown } }).meta?.code;
|
|
979
|
-
if (typeof metaCode === "string" && /^23\d{3}$/.test(metaCode)) return true;
|
|
980
|
-
|
|
981
|
-
const message = (error as { message?: unknown }).message;
|
|
982
|
-
if (typeof message === "string") {
|
|
983
|
-
// Labeled SQLSTATE only — "code: '23514'", "(SQLSTATE 23502)", etc. A
|
|
984
|
-
// bare `\b23\d{3}\b` anywhere in the message (security review finding,
|
|
985
|
-
// PR #6360) would also match a coincidental 5-digit number in a genuine
|
|
986
|
-
// outage's own text (a port, a byte count, a timeout in ms), stealing
|
|
987
|
-
// that case away from the file fallback it should still take.
|
|
988
|
-
if (/\b(?:sqlstate|code)[:\s]*['"]?23\d{3}['"]?/i.test(message)) return true;
|
|
989
|
-
if (/violates (check|not-null|unique|foreign key) constraint/i.test(message)) {
|
|
990
|
-
return true;
|
|
991
|
-
}
|
|
992
|
-
if (/null value in column/i.test(message)) return true;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
return false;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
// ─── Email ────────────────────────────────────────────────────────────────────
|
|
999
|
-
|
|
1000
|
-
type EmailResult =
|
|
1001
|
-
| { ok: true; messageId: string }
|
|
1002
|
-
| { ok: false; error: string }
|
|
1003
|
-
| { skipped: true; reason: string };
|
|
1004
|
-
|
|
1005
|
-
async function sendEmail(
|
|
1006
|
-
input: RouteLeadInput,
|
|
1007
|
-
id: string,
|
|
1008
|
-
persisted: Persisted,
|
|
1009
|
-
viaSpoke = false,
|
|
1010
|
-
): Promise<EmailResult> {
|
|
1011
|
-
// Request-and-confirm SUPERSEDES this notification for a callback (#5970
|
|
1012
|
-
// D6). AC-1 requires the tenant to receive exactly one email, and the
|
|
1013
|
-
// confirm message carries strictly more than this one does — the same
|
|
1014
|
-
// request details, plus the link that confirms a time and a price. Sending
|
|
1015
|
-
// both would be two; sending this one and not that one would lose the
|
|
1016
|
-
// feature. `submit` and `subscribe` are untouched.
|
|
1017
|
-
//
|
|
1018
|
-
// The predicate lives in request-confirm.ts and is read by both sides, so
|
|
1019
|
-
// the two cannot both fire. It does NOT by itself rule out zero — see that
|
|
1020
|
-
// function's header for what the coupling does and does not guarantee, and
|
|
1021
|
-
// for the `persisted` term below, which is what closed the one zero case
|
|
1022
|
-
// that was reachable in production. With the feature unconfigured — the
|
|
1023
|
-
// default, and what every existing leads suite runs under — this is false
|
|
1024
|
-
// and every byte below is reached as before.
|
|
1025
|
-
// `persisted === "db"` is load-bearing, not decoration (#5970 security
|
|
1026
|
-
// review round 3, M1). The confirm message can only supersede this one if it
|
|
1027
|
-
// is actually going to be sent, and it can only be sent against a ROW: the
|
|
1028
|
-
// credential is minted by an `updateMany` on `callbacks`. On the FILE
|
|
1029
|
-
// FALLBACK path — a DB outage, an unset DATABASE_URL, an exhausted pool —
|
|
1030
|
-
// there is no row, so suppressing here would have produced ZERO operator
|
|
1031
|
-
// emails for every callback captured during the outage, on the one surface
|
|
1032
|
-
// whose whole purpose is that an outage must not lose a lead.
|
|
1033
|
-
//
|
|
1034
|
-
// `!viaSpoke` (#7532 D4, SEC-7532-2): request-and-confirm mints a Payment
|
|
1035
|
-
// Link on the deployment's ONE Stripe account and mails the deployment's ONE
|
|
1036
|
-
// address, so a spoke's callback never takes it — the route does not send
|
|
1037
|
-
// the confirm link for a spoke, and this keeps the generic notification.
|
|
1038
|
-
if (
|
|
1039
|
-
input.kind === "callback" &&
|
|
1040
|
-
!viaSpoke &&
|
|
1041
|
-
persisted === "db" &&
|
|
1042
|
-
requestConfirmSupersedesNotification()
|
|
1043
|
-
) {
|
|
1044
|
-
return { skipped: true, reason: "request_confirm" };
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
const to = process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1048
|
-
const from = process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1049
|
-
|
|
1050
|
-
if (!to || !from) {
|
|
1051
|
-
return { skipped: true, reason: "env_unset" };
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
const leadForEmail = {
|
|
1055
|
-
id,
|
|
1056
|
-
siteId: input.siteId,
|
|
1057
|
-
source: input.kind === "callback" ? "callback" : input.source,
|
|
1058
|
-
email: input.kind === "callback" ? "" : input.email,
|
|
1059
|
-
// A callback's contact details are top-level fields, not payload keys
|
|
1060
|
-
// (#6730): with no database the email is the only record of the
|
|
1061
|
-
// request, so it must carry the name and number to call back.
|
|
1062
|
-
payload:
|
|
1063
|
-
input.kind === "subscribe"
|
|
1064
|
-
? {}
|
|
1065
|
-
: input.kind === "callback"
|
|
1066
|
-
? {
|
|
1067
|
-
...input.payload,
|
|
1068
|
-
name: input.name,
|
|
1069
|
-
phone: input.phone,
|
|
1070
|
-
...(input.preferredTime !== undefined && {
|
|
1071
|
-
preferredTime: input.preferredTime,
|
|
1072
|
-
}),
|
|
1073
|
-
}
|
|
1074
|
-
: input.payload,
|
|
1075
|
-
createdAt: new Date().toISOString(),
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
const emailResult = await sendLeadNotification({
|
|
1079
|
-
lead: leadForEmail,
|
|
1080
|
-
recipientEmail: to,
|
|
1081
|
-
fromAddress: from,
|
|
1082
|
-
// A callback has no visitor address to reply to; an empty Reply-To is
|
|
1083
|
-
// not a valid header, so reply to the owner instead.
|
|
1084
|
-
...(input.kind === "callback" && { replyToOverride: to }),
|
|
1085
|
-
});
|
|
1086
|
-
|
|
1087
|
-
if (emailResult.ok) {
|
|
1088
|
-
return { ok: true, messageId: emailResult.messageId };
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
const { kind, message } = emailResult.error;
|
|
1092
|
-
if (kind === "config_missing") {
|
|
1093
|
-
return { skipped: true, reason: "resend_key_unset" };
|
|
1094
|
-
}
|
|
1095
|
-
return { ok: false, error: `${kind}: ${message}` };
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// ─── Email delivery (#6730 D2) ────────────────────────────────────────────────
|
|
1099
|
-
|
|
1100
|
-
type EmailDelivery =
|
|
1101
|
-
| { ok: true; id: string; email: { ok: true; messageId: string } }
|
|
1102
|
-
| { ok: false; message: string };
|
|
1103
|
-
|
|
1104
|
-
/**
|
|
1105
|
-
* Deliver a lead as the owner email alone — success only when the provider
|
|
1106
|
-
* accepted it. The id matches the file-fallback id format
|
|
1107
|
-
* (`/^[a-z0-9_-]{8,64}$/i`).
|
|
1108
|
-
*/
|
|
1109
|
-
async function deliverByEmail(input: RouteLeadInput): Promise<EmailDelivery> {
|
|
1110
|
-
const id = `email-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
1111
|
-
const result = await sendEmail(input, id, "email");
|
|
1112
|
-
if ("ok" in result && result.ok) return { ok: true, id, email: result };
|
|
1113
|
-
return {
|
|
1114
|
-
ok: false,
|
|
1115
|
-
message: "error" in result ? result.error : `email skipped: ${result.reason}`,
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
// ─── Lead-capture record ─────────────────────────────────────────────────────
|
|
1120
|
-
|
|
1121
|
-
function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceMeta) {
|
|
1122
|
-
return {
|
|
1123
|
-
id,
|
|
1124
|
-
createdAt: sourceMeta.receivedAt,
|
|
1125
|
-
source: input.kind === "callback" ? "callback" : input.source,
|
|
1126
|
-
data:
|
|
1127
|
-
input.kind === "callback"
|
|
1128
|
-
? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
|
|
1129
|
-
: input.kind === "subscribe"
|
|
1130
|
-
? { email: input.email }
|
|
1131
|
-
: { email: input.email, ...(input.payload ?? {}) },
|
|
1132
|
-
metadata: {
|
|
1133
|
-
ip: sourceMeta.ip,
|
|
1134
|
-
userAgent: sourceMeta.userAgent,
|
|
1135
|
-
},
|
|
1136
|
-
};
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
1140
|
-
|
|
1141
|
-
export async function routeLead(
|
|
1142
|
-
input: RouteLeadInput,
|
|
1143
|
-
sourceMeta: SourceMeta,
|
|
1144
|
-
options: RouteLeadOptions = {},
|
|
1145
|
-
): Promise<RouteLeadResult> {
|
|
1146
|
-
// 1. Validate
|
|
1147
|
-
const validation = validateInput(input);
|
|
1148
|
-
if (!validation.ok) {
|
|
1149
|
-
return {
|
|
1150
|
-
ok: false,
|
|
1151
|
-
error: { kind: "invalid_input", message: validation.message },
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
// 2. Guard: where can this lead go (#6730 D1)? A kind the configured
|
|
1156
|
-
// delivery cannot take — anything in production with neither a database
|
|
1157
|
-
// nor the email vars, a newsletter signup with no database — is refused
|
|
1158
|
-
// before any write, so no visitor is told a message was sent when it
|
|
1159
|
-
// reached nobody.
|
|
1160
|
-
const delivery = resolveLeadDelivery();
|
|
1161
|
-
if (!acceptsKind(delivery, input.kind)) {
|
|
1162
|
-
return {
|
|
1163
|
-
ok: false,
|
|
1164
|
-
error: {
|
|
1165
|
-
kind: "config_missing",
|
|
1166
|
-
message: "Lead capture is not configured.",
|
|
1167
|
-
},
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
// 2a. Idempotency key (#7869 D3/D7) — submit, a key, and a database to
|
|
1172
|
-
// dedupe against. A stored key answers before any enrichment or write. A
|
|
1173
|
-
// failed read is not an answer: the create below meets the same database
|
|
1174
|
-
// and takes the existing outage path.
|
|
1175
|
-
const idempotencyKey =
|
|
1176
|
-
input.kind === "submit" && delivery === "inbox" ? options.idempotencyKey : undefined;
|
|
1177
|
-
if (idempotencyKey !== undefined && input.kind === "submit") {
|
|
1178
|
-
const stored = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
|
|
1179
|
-
if (stored) return keyedResult(classifyKeyedRow(stored, input));
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// 3. Enrich (reserved for Phase 3 — today returns lead unchanged).
|
|
1183
|
-
// Cast via unknown: enrichLead accepts CreateLead (B3 shim type) but the
|
|
1184
|
-
// pass-through contract means RouteLeadInput is safe here until Phase 3 wires
|
|
1185
|
-
// the real enrichment call with proper type alignment.
|
|
1186
|
-
await enrichLead(input as unknown as Parameters<typeof enrichLead>[0]);
|
|
1187
|
-
|
|
1188
|
-
// Email delivery — no database: the owner email is the delivery, so skip
|
|
1189
|
-
// the DB and the (non-durable) file fallback entirely.
|
|
1190
|
-
if (delivery === "email") {
|
|
1191
|
-
const emailed = await deliverByEmail(input);
|
|
1192
|
-
if (!emailed.ok) {
|
|
1193
|
-
return {
|
|
1194
|
-
ok: false,
|
|
1195
|
-
error: { kind: "delivery_failed", message: emailed.message },
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
|
|
1199
|
-
return {
|
|
1200
|
-
ok: true,
|
|
1201
|
-
id: emailed.id,
|
|
1202
|
-
persisted: "email",
|
|
1203
|
-
email: emailed.email,
|
|
1204
|
-
};
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// 4. Persist primary (DB), fallback to file on error
|
|
1208
|
-
let id: string;
|
|
1209
|
-
let persisted: Persisted;
|
|
1210
|
-
let skipEmail = false;
|
|
1211
|
-
let skipReason: string | undefined;
|
|
1212
|
-
let personId: string | null = null;
|
|
1213
|
-
|
|
1214
|
-
try {
|
|
1215
|
-
const dbResult = await persistToDb(
|
|
1216
|
-
input,
|
|
1217
|
-
idempotencyKey,
|
|
1218
|
-
sourceMeta.viaSpoke === true,
|
|
1219
|
-
sourceMeta.relayed === true,
|
|
1220
|
-
input.kind === "submit" ? options.contact : undefined,
|
|
1221
|
-
input.kind === "submit" ? options.accountOrigin : undefined,
|
|
1222
|
-
input.kind === "submit" && options.offeringsCheckout === true,
|
|
1223
|
-
);
|
|
1224
|
-
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
1225
|
-
// record, no notification.
|
|
1226
|
-
if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
|
|
1227
|
-
if (!dbResult.ok) {
|
|
1228
|
-
// persistToDb returned ok:false — defensive guard; current impl doesn't reach this
|
|
1229
|
-
return {
|
|
1230
|
-
ok: false,
|
|
1231
|
-
error: { kind: "persist_failed", message: dbResult.error },
|
|
1232
|
-
};
|
|
1233
|
-
}
|
|
1234
|
-
id = dbResult.id;
|
|
1235
|
-
persisted = "db";
|
|
1236
|
-
skipEmail = dbResult.skipEmail;
|
|
1237
|
-
skipReason = dbResult.skipReason;
|
|
1238
|
-
personId = dbResult.personId ?? null;
|
|
1239
|
-
// #8438 D11 — only after THIS request's own write (a keyed replay
|
|
1240
|
-
// returned above), and only when a Person was resolved.
|
|
1241
|
-
if (sourceMeta.consentVisitorId !== undefined && personId !== null) {
|
|
1242
|
-
await linkConsentVisitor({
|
|
1243
|
-
workspaceId: input.siteId,
|
|
1244
|
-
visitorId: sourceMeta.consentVisitorId,
|
|
1245
|
-
personId,
|
|
1246
|
-
source:
|
|
1247
|
-
input.kind === "submit" && input.source.startsWith("flow:")
|
|
1248
|
-
? "magnet"
|
|
1249
|
-
: "api",
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
} catch (dbErr) {
|
|
1253
|
-
// #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
|
|
1254
|
-
// derivation producing a row Postgres itself refuses (an unresolved
|
|
1255
|
-
// workspaceId hitting NOT NULL, or a site<->workspace disagreement
|
|
1256
|
-
// hitting the new equality CHECK) — a bug to surface, never an outage
|
|
1257
|
-
// to degrade through. Falling back here would return a silent 201 while
|
|
1258
|
-
// the Lead never lands in Postgres, invisible to the Inbox forever —
|
|
1259
|
-
// the exact silent data loss AC-7 exists to close, merely relocated
|
|
1260
|
-
// onto this never-gate surface.
|
|
1261
|
-
//
|
|
1262
|
-
// A DELIBERATE error response, not a bare re-raise (spec § 7's
|
|
1263
|
-
// api-versioning waiver is conditioned on exactly this: "a documented
|
|
1264
|
-
// error body, never a bare re-raise surfacing as an unhandled throw").
|
|
1265
|
-
// `persist_failed` already maps to a 503 with a documented body
|
|
1266
|
-
// (`routes/leads/index.ts`) — the SAME shape a genuine DB-outage
|
|
1267
|
-
// failure (both DB and file fail) already returns, so this adds no new
|
|
1268
|
-
// response shape, only a new path that reaches the existing one
|
|
1269
|
-
// WITHOUT first trying — and losing time to — a file write for an
|
|
1270
|
-
// error the file fallback can never fix.
|
|
1271
|
-
//
|
|
1272
|
-
// A GENUINE infrastructure failure (an unreachable DB, an exhausted
|
|
1273
|
-
// pool, an unset DATABASE_URL) is NOT a constraint violation and still
|
|
1274
|
-
// takes the file fallback below, unchanged.
|
|
1275
|
-
if (isConstraintViolation(dbErr)) {
|
|
1276
|
-
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
1277
|
-
return {
|
|
1278
|
-
ok: false,
|
|
1279
|
-
error: { kind: "persist_failed", message: `db: ${dbMsg}` },
|
|
1280
|
-
};
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
// In production the file fallback is not durable (a serverless instance
|
|
1284
|
-
// does not outlive the request), so reporting it as success would lose
|
|
1285
|
-
// the lead silently. Deliver by email when that is configured; otherwise
|
|
1286
|
-
// fail loudly (#6730 D2). A newsletter signup is never emailed — one
|
|
1287
|
-
// owner email per address is not a list (`acceptsKind`).
|
|
1288
|
-
if (process.env.NODE_ENV === "production") {
|
|
1289
|
-
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
1290
|
-
if (input.kind === "subscribe") {
|
|
1291
|
-
return {
|
|
1292
|
-
ok: false,
|
|
1293
|
-
error: { kind: "persist_failed", message: `db: ${dbMsg}` },
|
|
1294
|
-
};
|
|
1295
|
-
}
|
|
1296
|
-
const emailed = await deliverByEmail(input);
|
|
1297
|
-
if (!emailed.ok) {
|
|
1298
|
-
return {
|
|
1299
|
-
ok: false,
|
|
1300
|
-
error: {
|
|
1301
|
-
kind: "persist_failed",
|
|
1302
|
-
// Not the provider's text: it can echo an address, and this
|
|
1303
|
-
// message is logged (spec D5).
|
|
1304
|
-
message: `db: ${dbMsg}; email delivery failed`,
|
|
1305
|
-
},
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
|
|
1309
|
-
return {
|
|
1310
|
-
ok: true,
|
|
1311
|
-
id: emailed.id,
|
|
1312
|
-
persisted: "email",
|
|
1313
|
-
email: emailed.email,
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
// 5. File fallback (outside production only)
|
|
1318
|
-
// Include a UUID suffix so concurrent fallbacks produce unique IDs even
|
|
1319
|
-
// within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
|
|
1320
|
-
const fallbackLead = {
|
|
1321
|
-
id: `fallback-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
1322
|
-
siteId: input.siteId,
|
|
1323
|
-
source: input.kind === "callback" ? "callback" : input.source,
|
|
1324
|
-
email: input.kind === "callback" ? "" : input.email,
|
|
1325
|
-
payload: input.kind === "subscribe" ? {} : input.payload,
|
|
1326
|
-
createdAt: sourceMeta.receivedAt,
|
|
1327
|
-
};
|
|
1328
|
-
|
|
1329
|
-
let fallbackResult;
|
|
1330
|
-
try {
|
|
1331
|
-
fallbackResult = await writeLeadFallback(fallbackLead);
|
|
1332
|
-
} catch {
|
|
1333
|
-
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
1334
|
-
return {
|
|
1335
|
-
ok: false,
|
|
1336
|
-
error: {
|
|
1337
|
-
kind: "persist_failed",
|
|
1338
|
-
message: `db: ${dbMsg}; fallback threw`,
|
|
1339
|
-
},
|
|
1340
|
-
};
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
if (!fallbackResult.ok) {
|
|
1344
|
-
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
1345
|
-
return {
|
|
1346
|
-
ok: false,
|
|
1347
|
-
error: {
|
|
1348
|
-
kind: "persist_failed",
|
|
1349
|
-
message: `db: ${dbMsg}; fallback: ${fallbackResult.error.message}`,
|
|
1350
|
-
},
|
|
1351
|
-
};
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
id = fallbackLead.id;
|
|
1355
|
-
persisted = "file";
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// 6. Write to lead-capture JSONL (always-on canonical AX surface).
|
|
1359
|
-
// Best-effort — a write failure does NOT roll back persistence or block email.
|
|
1360
|
-
// The file is the agent-readable surface; DB is the transactional record.
|
|
1361
|
-
await appendLeadRecord(captureRecordFor(input, id!, sourceMeta));
|
|
1362
|
-
|
|
1363
|
-
// 7. Email notification (best-effort — never rolls back persistence)
|
|
1364
|
-
let emailResult: EmailResult;
|
|
1365
|
-
if (skipEmail) {
|
|
1366
|
-
emailResult = { skipped: true, reason: skipReason ?? "skipped" };
|
|
1367
|
-
} else {
|
|
1368
|
-
emailResult = await sendEmail(input, id!, persisted!, sourceMeta.viaSpoke);
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
return { ok: true, id: id!, personId, persisted, email: emailResult };
|
|
1372
|
-
}
|