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,1211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file person.ts
|
|
3
|
-
* @description Person — one client of one workspace (#852, spec
|
|
4
|
-
* docs/specs/852-person-the-per-workspace-client-spec.md D3, D5, D6). This
|
|
5
|
-
* file is the ONLY writer of `Person`:
|
|
6
|
-
*
|
|
7
|
-
* - `findOrCreatePerson` — the one creator, called by lead capture
|
|
8
|
-
* (`lead-routing.ts`), the hub's own Stripe checkout webhook (through
|
|
9
|
-
* `recordCheckoutPerson` below), appointment creation
|
|
10
|
-
* (`scheduling/appointments.ts`) and the Admin inbox accept
|
|
11
|
-
* (`inbox-messages.ts`).
|
|
12
|
-
* - `stampPersonUser` — links a Person to a User, only on a verified-email
|
|
13
|
-
* match, never overwriting a link.
|
|
14
|
-
* - `recordCheckoutPerson` — the webhook's step: the Person of a completed
|
|
15
|
-
* hub checkout, with its live-mode customer id.
|
|
16
|
-
* `resolveCheckoutPerson` is the same step answering that Person too, for
|
|
17
|
-
* the checkout's terms grant (#8765 D14); the webhook calls it, and
|
|
18
|
-
* `recordCheckoutPerson` stays as its outcome-only form for the #852
|
|
19
|
-
* suites that pin that outcome.
|
|
20
|
-
* - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
|
|
21
|
-
* linked to the caller, else the one whose email is the caller's
|
|
22
|
-
* verified email, linked on first read by `stampPersonUser`'s rule. That
|
|
23
|
-
* first link — and no other — releases the Person's held checkout plan
|
|
24
|
-
* (#8793 D2).
|
|
25
|
-
* - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
|
|
26
|
-
* in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
|
|
27
|
-
* only while its `userId` is the caller.
|
|
28
|
-
* - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
|
|
29
|
-
* the Admin's plan route and `releaseHeldCheckoutPlan`.
|
|
30
|
-
* - `releaseHeldCheckoutPlan` and `checkoutPlanKey` — the checkout plan
|
|
31
|
-
* rule (#7877 D10, #8793 D1–D4). A spoke's checkout lead never writes a
|
|
32
|
-
* plan: it holds it (`lead-routing.ts`), because its email is whatever
|
|
33
|
-
* the buyer typed at Stripe Checkout. The hold is released once, at the
|
|
34
|
-
* Person's first member-path link to the user whose VERIFIED email is the
|
|
35
|
-
* Person's own, into a blank plan only, and only when every held purchase
|
|
36
|
-
* under that email names the same plan key.
|
|
37
|
-
* - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
|
|
38
|
-
* fields of an uncurated Person (D4); `updatePersonContact` (the
|
|
39
|
-
* operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
|
|
40
|
-
* client's save) overwrite and stamp who confirmed them (D9, D11).
|
|
41
|
-
*
|
|
42
|
-
* ## Match order (D3) — fixed, and there is no merge
|
|
43
|
-
*
|
|
44
|
-
* 1. Normalize the email (trim ASCII whitespace, lowercase ASCII letters);
|
|
45
|
-
* not storable → `invalid_email`, before any read.
|
|
46
|
-
* 2. A customer id given → the oldest Person of this workspace carrying it.
|
|
47
|
-
* A hit is returned with NO write — its email is never rewritten.
|
|
48
|
-
* 3. The Person of this workspace with this email. A hit gains the customer
|
|
49
|
-
* id only when it has none AND is linked to no user (first writer wins,
|
|
50
|
-
* else `billing: "kept"`), and a name only when it has none. The email is
|
|
51
|
-
* whatever the caller typed, never verified here, so it never pins a
|
|
52
|
-
* customer on a Person whose `userId` the verified-email stamp set
|
|
53
|
-
* (SEC-7894-6, decided on #7961 2026-10-09).
|
|
54
|
-
* 4. No hit → `createMany({ skipDuplicates })` — Postgres INSERT … ON
|
|
55
|
-
* CONFLICT DO NOTHING — then a re-read that finds the winner of any
|
|
56
|
-
* concurrent create. The duplicate is absorbed IN THE DATABASE, never as
|
|
57
|
-
* a caught P2002: the appointment path calls this inside an interactive
|
|
58
|
-
* transaction, where a raised unique violation aborts the transaction
|
|
59
|
-
* (C4). One implementation serves both.
|
|
60
|
-
*
|
|
61
|
-
* ## Why public, and what it may import (D3)
|
|
62
|
-
*
|
|
63
|
-
* The base-tree `lead-routing.ts` imports this file, so it ships in every
|
|
64
|
-
* build and must never reach the paid import closure: it imports
|
|
65
|
-
* `lib/prisma.js`, `verified-email.js`, `planOfferingSchema` from the
|
|
66
|
-
* base-tree kernel `@working-theory/validation` and types only — never
|
|
67
|
-
* `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
|
|
68
|
-
* find-or-create shape it mirrors) or `lib/tenant-db.ts`.
|
|
69
|
-
* `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
|
|
70
|
-
* file and `lead-routing.ts` in `MUST_SHIP`.
|
|
71
|
-
*
|
|
72
|
-
* ## Tenancy
|
|
73
|
-
*
|
|
74
|
-
* Every `Person` statement is scoped by a server-resolved `workspaceId`;
|
|
75
|
-
* there is no `Person` list read here (the reads are
|
|
76
|
-
* `tenantDb(tenant).people`). The one list read is `releaseHeldCheckoutPlan`'s
|
|
77
|
-
* (#8793 D3): the newest 51 `spokeCheckout` leads of a Person just linked to
|
|
78
|
-
* the caller, by `(workspaceId, personId)`, the workspace the member read
|
|
79
|
-
* resolved.
|
|
80
|
-
* `recordCheckoutPerson`'s two other reads are by a server-minted
|
|
81
|
-
* `FlowCheckout` id from a signature-verified webhook, matched on that
|
|
82
|
-
* session's own `stripeSessionId`, then that row's lead scoped by the row's
|
|
83
|
-
* own `workspaceId`. `resolveMemberPersonId` reads by `(workspaceId, userId)`
|
|
84
|
-
* and by `(workspaceId, email)`, and `resolveCallerBillingCustomerId` by
|
|
85
|
-
* `(id, workspaceId, userId)`, the workspace server-resolved and the user
|
|
86
|
-
* the authenticated caller. `architecture.yaml` names this file as an
|
|
87
|
-
* exclusion with that reason.
|
|
88
|
-
*
|
|
89
|
-
* ## Errors and logs
|
|
90
|
-
*
|
|
91
|
-
* A store error is caught and returned as a bare kind; no Prisma message is
|
|
92
|
-
* returned or logged (SEC-006). These functions log nothing: each caller
|
|
93
|
-
* logs the outcome kind, never an email, a name or a customer id. The
|
|
94
|
-
* exceptions are `resolveMemberPersonId`, whose `null` hides a refused stamp,
|
|
95
|
-
* and `resolveCallerBillingCustomerId`, whose `null` hides a failed read: each
|
|
96
|
-
* logs that outcome's kind through the logger its caller passes.
|
|
97
|
-
*/
|
|
98
|
-
|
|
99
|
-
import type { Prisma } from "@working-theory/database";
|
|
100
|
-
import type { Id } from "@working-theory/ids";
|
|
101
|
-
import { planOfferingSchema, type PersonContactPatch } from "@working-theory/validation";
|
|
102
|
-
|
|
103
|
-
import { prisma } from "../lib/prisma.js";
|
|
104
|
-
|
|
105
|
-
import { findVerifiedEmail } from "./verified-email.js";
|
|
106
|
-
|
|
107
|
-
// ─── Normalization ────────────────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
/** The ASCII whitespace the backfill's btrim set names: SP, HT, LF, CR, FF, VT. */
|
|
110
|
-
const EDGE_WHITESPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g;
|
|
111
|
-
/** Printable ASCII, one `@`, a non-empty side each — the `people_email_normalized` CHECK. */
|
|
112
|
-
const STORABLE_EMAIL = /^[!-?A-~]+@[!-?A-~]+$/;
|
|
113
|
-
const MAX_EMAIL_LENGTH = 254;
|
|
114
|
-
const MAX_NAME_CODE_POINTS = 200;
|
|
115
|
-
const MAX_CUSTOMER_ID_LENGTH = 255;
|
|
116
|
-
/** C0 and C1 controls and the bidi controls, removed from a name (SEC-852-I). */
|
|
117
|
-
// eslint-disable-next-line no-control-regex -- the control-character range is exactly what a stored name must not carry.
|
|
118
|
-
const NAME_STRIP = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Trim ASCII whitespace, then lowercase ASCII letters only — the same value
|
|
122
|
-
* Postgres computes as `lower(btrim(x, E' \t\n\r\f\x0B') COLLATE "C")`.
|
|
123
|
-
*/
|
|
124
|
-
export function normalizePersonEmail(raw: string): string {
|
|
125
|
-
return raw
|
|
126
|
-
.replace(EDGE_WHITESPACE, "")
|
|
127
|
-
.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Printable ASCII, one `@` with a non-empty side each, at most 254
|
|
132
|
-
* characters — the `people_email_normalized` CHECK's regex. ASCII only, so
|
|
133
|
-
* the TypeScript length and the Postgres length agree.
|
|
134
|
-
*/
|
|
135
|
-
export function isStorablePersonEmail(email: string): boolean {
|
|
136
|
-
return email.length <= MAX_EMAIL_LENGTH && STORABLE_EMAIL.test(email);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* A name as stored: controls and bidi controls removed, trimmed of spaces, cut
|
|
141
|
-
* to 200 code points; empty → `null`.
|
|
142
|
-
*/
|
|
143
|
-
export function normalizePersonName(raw: unknown): string | null {
|
|
144
|
-
if (typeof raw !== "string") return null;
|
|
145
|
-
// Spaces only, as the backfill's `btrim(x)` trims, so a live-written
|
|
146
|
-
// name and a backfilled one agree.
|
|
147
|
-
const cleaned = Array.from(raw.replace(NAME_STRIP, "").replace(/^ +| +$/g, ""))
|
|
148
|
-
.slice(0, MAX_NAME_CODE_POINTS)
|
|
149
|
-
.join("");
|
|
150
|
-
return cleaned === "" ? null : cleaned;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ─── Contact normalization (#8435 D3) ────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
export type PersonAddress = {
|
|
156
|
-
addressLine1: string;
|
|
157
|
-
addressLine2: string | null;
|
|
158
|
-
city: string;
|
|
159
|
-
region: string;
|
|
160
|
-
postalCode: string;
|
|
161
|
-
country: string;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
export type PersonContact = {
|
|
165
|
-
givenName: string | null;
|
|
166
|
-
familyName: string | null;
|
|
167
|
-
telephone: string | null;
|
|
168
|
-
address: PersonAddress | null;
|
|
169
|
-
companyName: string | null;
|
|
170
|
-
jobTitle: string | null;
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* What a capture offers, as untrusted `unknown` values: the magnet's tagged
|
|
175
|
-
* answers or a caller's explicit contact (`RouteLeadOptions.contact`).
|
|
176
|
-
*/
|
|
177
|
-
export type LeadContact = {
|
|
178
|
-
name?: unknown;
|
|
179
|
-
givenName?: unknown;
|
|
180
|
-
familyName?: unknown;
|
|
181
|
-
phone?: unknown;
|
|
182
|
-
address?: unknown;
|
|
183
|
-
company?: unknown;
|
|
184
|
-
jobTitle?: unknown;
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
/** The widths of the person-level text columns, in code points. */
|
|
188
|
-
const CONTACT_MAX = {
|
|
189
|
-
givenName: 100,
|
|
190
|
-
familyName: 100,
|
|
191
|
-
companyName: 200,
|
|
192
|
-
jobTitle: 100,
|
|
193
|
-
} as const;
|
|
194
|
-
/** The address parts' widths — `ADDRESS_PART_MAX_LENGTHS` in `packages/validation`. */
|
|
195
|
-
const ADDRESS_MAX = {
|
|
196
|
-
addressLine1: 100,
|
|
197
|
-
addressLine2: 60,
|
|
198
|
-
city: 60,
|
|
199
|
-
region: 40,
|
|
200
|
-
postalCode: 20,
|
|
201
|
-
} as const;
|
|
202
|
-
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
203
|
-
const SHORT_REGION = /^[A-Za-z0-9]{1,3}$/;
|
|
204
|
-
const ALPHA2 = /^[A-Z]{2}$/;
|
|
205
|
-
/**
|
|
206
|
-
* The code-point ranges `CONTACT_STRIP` removes (SEC-8435-L): `NAME_STRIP`'s
|
|
207
|
-
* C0/C1 and bidi controls plus the zero-width characters U+200B–U+200D and
|
|
208
|
-
* U+FEFF and the separators U+2028/U+2029. Numbers, not a regex literal, so no
|
|
209
|
-
* tool can rewrite a separator into a line break inside it. The same set
|
|
210
|
-
* `packages/validation`'s `people-contact.ts` strips on the wire.
|
|
211
|
-
*/
|
|
212
|
-
const CONTACT_STRIPPED: ReadonlyArray<readonly [number, number]> = [
|
|
213
|
-
[0x0000, 0x001f],
|
|
214
|
-
[0x007f, 0x009f],
|
|
215
|
-
[0x200b, 0x200f],
|
|
216
|
-
[0x2028, 0x2029],
|
|
217
|
-
[0x202a, 0x202e],
|
|
218
|
-
[0x2066, 0x2069],
|
|
219
|
-
[0xfeff, 0xfeff],
|
|
220
|
-
];
|
|
221
|
-
|
|
222
|
-
function stripContactChars(raw: string): string {
|
|
223
|
-
let out = "";
|
|
224
|
-
for (const ch of raw) {
|
|
225
|
-
const cp = ch.codePointAt(0) ?? 0;
|
|
226
|
-
if (!CONTACT_STRIPPED.some(([lo, hi]) => cp >= lo && cp <= hi)) out += ch;
|
|
227
|
-
}
|
|
228
|
-
return out;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const cutToCodePoints = (value: string, max: number): string =>
|
|
232
|
-
Array.from(value).slice(0, max).join("");
|
|
233
|
-
|
|
234
|
-
/** Stripped, trimmed, cut to `max` code points; empty or not a string → `null`. */
|
|
235
|
-
function normalizeContactText(raw: unknown, max: number): string | null {
|
|
236
|
-
if (typeof raw !== "string") return null;
|
|
237
|
-
const cleaned = cutToCodePoints(stripContactChars(raw).trim(), max);
|
|
238
|
-
return cleaned === "" ? null : cleaned;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Strip spaces, `( ) . -` (and the invisible characters), then the result
|
|
243
|
-
* must be an E.164 number — else `null`.
|
|
244
|
-
*/
|
|
245
|
-
export function normalizeTelephone(raw: unknown): string | null {
|
|
246
|
-
if (typeof raw !== "string") return null;
|
|
247
|
-
const compact = stripContactChars(raw)
|
|
248
|
-
.replace(/[ ().-]/g, "")
|
|
249
|
-
.trim();
|
|
250
|
-
return E164.test(compact) ? compact : null;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* An address as stored: whole or not at all. Every required part must be a
|
|
255
|
-
* non-empty string within its width after stripping; a short region is
|
|
256
|
-
* upper-cased (C3); the country must be an upper-case alpha-2 code.
|
|
257
|
-
*/
|
|
258
|
-
function normalizeAddress(raw: unknown): PersonAddress | null {
|
|
259
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
260
|
-
return null;
|
|
261
|
-
const value = raw as Record<string, unknown>;
|
|
262
|
-
const part = (key: keyof typeof ADDRESS_MAX): string | null => {
|
|
263
|
-
const field = value[key];
|
|
264
|
-
if (typeof field !== "string") return null;
|
|
265
|
-
const cleaned = stripContactChars(field).trim();
|
|
266
|
-
return cleaned === "" || Array.from(cleaned).length > ADDRESS_MAX[key]
|
|
267
|
-
? null
|
|
268
|
-
: cleaned;
|
|
269
|
-
};
|
|
270
|
-
const addressLine1 = part("addressLine1");
|
|
271
|
-
const city = part("city");
|
|
272
|
-
const region = part("region");
|
|
273
|
-
const postalCode = part("postalCode");
|
|
274
|
-
const country = value["country"];
|
|
275
|
-
if (
|
|
276
|
-
addressLine1 === null ||
|
|
277
|
-
city === null ||
|
|
278
|
-
region === null ||
|
|
279
|
-
postalCode === null ||
|
|
280
|
-
typeof country !== "string" ||
|
|
281
|
-
!ALPHA2.test(country)
|
|
282
|
-
) {
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
const line2Raw = value["addressLine2"];
|
|
286
|
-
const line2 = part("addressLine2");
|
|
287
|
-
// An over-long or non-string second line makes the address unusable; an
|
|
288
|
-
// absent or blank one is simply null.
|
|
289
|
-
if (line2 === null && line2Raw !== undefined && line2Raw !== null) {
|
|
290
|
-
if (
|
|
291
|
-
typeof line2Raw !== "string" ||
|
|
292
|
-
stripContactChars(line2Raw).trim() !== ""
|
|
293
|
-
)
|
|
294
|
-
return null;
|
|
295
|
-
}
|
|
296
|
-
return {
|
|
297
|
-
addressLine1,
|
|
298
|
-
addressLine2: line2,
|
|
299
|
-
city,
|
|
300
|
-
region: SHORT_REGION.test(region) ? region.toUpperCase() : region,
|
|
301
|
-
postalCode,
|
|
302
|
-
country,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Capture-side: every usable field, each normalized; unusable ones dropped. */
|
|
307
|
-
export function normalizeCapturedContact(
|
|
308
|
-
raw: LeadContact,
|
|
309
|
-
): Partial<PersonContact> {
|
|
310
|
-
const out: Partial<PersonContact> = {};
|
|
311
|
-
const givenName = normalizeContactText(raw.givenName, CONTACT_MAX.givenName);
|
|
312
|
-
if (givenName !== null) out.givenName = givenName;
|
|
313
|
-
const familyName = normalizeContactText(
|
|
314
|
-
raw.familyName,
|
|
315
|
-
CONTACT_MAX.familyName,
|
|
316
|
-
);
|
|
317
|
-
if (familyName !== null) out.familyName = familyName;
|
|
318
|
-
const telephone = normalizeTelephone(raw.phone);
|
|
319
|
-
if (telephone !== null) out.telephone = telephone;
|
|
320
|
-
const address = normalizeAddress(raw.address);
|
|
321
|
-
if (address !== null) out.address = address;
|
|
322
|
-
const companyName = normalizeContactText(
|
|
323
|
-
raw.company,
|
|
324
|
-
CONTACT_MAX.companyName,
|
|
325
|
-
);
|
|
326
|
-
if (companyName !== null) out.companyName = companyName;
|
|
327
|
-
const jobTitle = normalizeContactText(raw.jobTitle, CONTACT_MAX.jobTitle);
|
|
328
|
-
if (jobTitle !== null) out.jobTitle = jobTitle;
|
|
329
|
-
return out;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ─── The contact columns, as rows and as the wire's shape ───────────────────
|
|
333
|
-
|
|
334
|
-
/** The thirteen contact columns a read names (`select`). */
|
|
335
|
-
export const PERSON_CONTACT_SELECT = {
|
|
336
|
-
givenName: true,
|
|
337
|
-
familyName: true,
|
|
338
|
-
telephone: true,
|
|
339
|
-
addressLine1: true,
|
|
340
|
-
addressLine2: true,
|
|
341
|
-
addressCity: true,
|
|
342
|
-
addressRegion: true,
|
|
343
|
-
addressPostalCode: true,
|
|
344
|
-
addressCountry: true,
|
|
345
|
-
companyName: true,
|
|
346
|
-
jobTitle: true,
|
|
347
|
-
contactConfirmedAt: true,
|
|
348
|
-
contactConfirmedBy: true,
|
|
349
|
-
} as const;
|
|
350
|
-
|
|
351
|
-
export type PersonContactRow = {
|
|
352
|
-
givenName: string | null;
|
|
353
|
-
familyName: string | null;
|
|
354
|
-
telephone: string | null;
|
|
355
|
-
addressLine1: string | null;
|
|
356
|
-
addressLine2: string | null;
|
|
357
|
-
addressCity: string | null;
|
|
358
|
-
addressRegion: string | null;
|
|
359
|
-
addressPostalCode: string | null;
|
|
360
|
-
addressCountry: string | null;
|
|
361
|
-
companyName: string | null;
|
|
362
|
-
jobTitle: string | null;
|
|
363
|
-
contactConfirmedAt: Date | null;
|
|
364
|
-
contactConfirmedBy: string | null;
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
/** The wire's contact: every key present, `null` where blank, the address whole or `null`. */
|
|
368
|
-
export function personContactFromRow(row: PersonContactRow): PersonContact {
|
|
369
|
-
// `?? null` so a row that does not carry a column reads as blank, never as
|
|
370
|
-
// `undefined` on the wire.
|
|
371
|
-
const line1 = row.addressLine1 ?? null;
|
|
372
|
-
const city = row.addressCity ?? null;
|
|
373
|
-
const region = row.addressRegion ?? null;
|
|
374
|
-
const postalCode = row.addressPostalCode ?? null;
|
|
375
|
-
const country = row.addressCountry ?? null;
|
|
376
|
-
const address: PersonAddress | null =
|
|
377
|
-
line1 !== null && city !== null && region !== null && postalCode !== null && country !== null
|
|
378
|
-
? {
|
|
379
|
-
addressLine1: line1,
|
|
380
|
-
addressLine2: row.addressLine2 ?? null,
|
|
381
|
-
city,
|
|
382
|
-
region,
|
|
383
|
-
postalCode,
|
|
384
|
-
country,
|
|
385
|
-
}
|
|
386
|
-
: null;
|
|
387
|
-
return {
|
|
388
|
-
givenName: row.givenName ?? null,
|
|
389
|
-
familyName: row.familyName ?? null,
|
|
390
|
-
telephone: row.telephone ?? null,
|
|
391
|
-
address,
|
|
392
|
-
companyName: row.companyName ?? null,
|
|
393
|
-
jobTitle: row.jobTitle ?? null,
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Who last saved the contact by hand, and when — `null` while only captures have written it. */
|
|
398
|
-
export function contactConfirmedFromRow(
|
|
399
|
-
row: Pick<PersonContactRow, "contactConfirmedAt" | "contactConfirmedBy">,
|
|
400
|
-
): { by: "operator" | "client"; at: string } | null {
|
|
401
|
-
if (row.contactConfirmedAt === null || row.contactConfirmedAt === undefined)
|
|
402
|
-
return null;
|
|
403
|
-
if (
|
|
404
|
-
row.contactConfirmedBy !== "operator" &&
|
|
405
|
-
row.contactConfirmedBy !== "client"
|
|
406
|
-
)
|
|
407
|
-
return null;
|
|
408
|
-
return {
|
|
409
|
-
by: row.contactConfirmedBy,
|
|
410
|
-
at: row.contactConfirmedAt.toISOString(),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
type AddressColumns = {
|
|
415
|
-
addressLine1: string | null;
|
|
416
|
-
addressLine2: string | null;
|
|
417
|
-
addressCity: string | null;
|
|
418
|
-
addressRegion: string | null;
|
|
419
|
-
addressPostalCode: string | null;
|
|
420
|
-
addressCountry: string | null;
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
/** The six address columns for an address — all six `null` for `null`. */
|
|
424
|
-
function addressColumns(address: PersonAddress | null): AddressColumns {
|
|
425
|
-
return {
|
|
426
|
-
addressLine1: address?.addressLine1 ?? null,
|
|
427
|
-
addressLine2: address?.addressLine2 ?? null,
|
|
428
|
-
addressCity: address?.city ?? null,
|
|
429
|
-
addressRegion: address?.region ?? null,
|
|
430
|
-
addressPostalCode: address?.postalCode ?? null,
|
|
431
|
-
addressCountry: address?.country ?? null,
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** The scalar contact fields, each its own column of the same name. */
|
|
436
|
-
const SCALAR_CONTACT_FIELDS = [
|
|
437
|
-
"givenName",
|
|
438
|
-
"familyName",
|
|
439
|
-
"telephone",
|
|
440
|
-
"companyName",
|
|
441
|
-
"jobTitle",
|
|
442
|
-
] as const;
|
|
443
|
-
|
|
444
|
-
/** Every supplied contact field as insert columns. */
|
|
445
|
-
function contactInsertColumns(
|
|
446
|
-
contact: Partial<PersonContact>,
|
|
447
|
-
): Record<string, string | null> {
|
|
448
|
-
const columns: Record<string, string | null> = {};
|
|
449
|
-
for (const field of SCALAR_CONTACT_FIELDS) {
|
|
450
|
-
const value = contact[field];
|
|
451
|
-
if (value !== undefined && value !== null) columns[field] = value;
|
|
452
|
-
}
|
|
453
|
-
if (contact.address) Object.assign(columns, addressColumns(contact.address));
|
|
454
|
-
return columns;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** `"Ada" + "Lovelace"` → `"Ada Lovelace"`, `"Ada"` alone → `"Ada"`, neither → `null`. */
|
|
458
|
-
function joinedName(contact: Partial<PersonContact>): string | null {
|
|
459
|
-
return normalizePersonName(
|
|
460
|
-
[contact.givenName, contact.familyName].filter((part) => part).join(" "),
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function usableCustomerId(raw: string | null | undefined): string | null {
|
|
465
|
-
return typeof raw === "string" &&
|
|
466
|
-
raw.length > 0 &&
|
|
467
|
-
raw.length <= MAX_CUSTOMER_ID_LENGTH
|
|
468
|
-
? raw
|
|
469
|
-
: null;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/** A Stripe customer id: `cus_` and letters or digits only, no separator. */
|
|
473
|
-
const STRIPE_CUSTOMER_ID = /^cus_[A-Za-z0-9]+$/;
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* The Stripe Dashboard link to one customer (#9349 DD5). Pure: the caller
|
|
477
|
-
* reads the mode. `null` unless `customerId` is a well-formed `cus_` id of
|
|
478
|
-
* at most 255 characters, so a stored value can never add a path segment,
|
|
479
|
-
* a query or a fragment to the fixed Dashboard host.
|
|
480
|
-
*/
|
|
481
|
-
export function stripeCustomerDashboardUrl(
|
|
482
|
-
customerId: string,
|
|
483
|
-
mode: "test" | "live",
|
|
484
|
-
): string | null {
|
|
485
|
-
if (
|
|
486
|
-
customerId.length > MAX_CUSTOMER_ID_LENGTH ||
|
|
487
|
-
!STRIPE_CUSTOMER_ID.test(customerId)
|
|
488
|
-
) {
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
const segment = mode === "test" ? "test/customers" : "customers";
|
|
492
|
-
return `https://dashboard.stripe.com/${segment}/${customerId}`;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// ─── findOrCreatePerson ───────────────────────────────────────────────────────
|
|
496
|
-
|
|
497
|
-
/** The one delegate this file writes through — the shared client or a transaction's. */
|
|
498
|
-
export type PersonDb = Pick<Prisma.TransactionClient, "person">;
|
|
499
|
-
|
|
500
|
-
export type FindOrCreatePersonResult =
|
|
501
|
-
| {
|
|
502
|
-
ok: true;
|
|
503
|
-
value: {
|
|
504
|
-
personId: Id<"person">;
|
|
505
|
-
created: boolean;
|
|
506
|
-
billing: "written" | "kept" | "unchanged";
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
| { ok: false; error: { kind: "invalid_email" | "internal_error" } };
|
|
510
|
-
|
|
511
|
-
type MatchedPerson = {
|
|
512
|
-
id: string;
|
|
513
|
-
name: string | null;
|
|
514
|
-
userId: string | null;
|
|
515
|
-
billingCustomerId: string | null;
|
|
516
|
-
// #8435 D4.4 — the blank-sentinels the contact fill reads. A value reading
|
|
517
|
-
// `undefined` (a test double that does not carry the key) is "not blank",
|
|
518
|
-
// and an undefined `contactConfirmedAt` is "confirmed": no fill runs.
|
|
519
|
-
givenName?: string | null;
|
|
520
|
-
familyName?: string | null;
|
|
521
|
-
telephone?: string | null;
|
|
522
|
-
addressLine1?: string | null;
|
|
523
|
-
companyName?: string | null;
|
|
524
|
-
jobTitle?: string | null;
|
|
525
|
-
contactConfirmedAt?: Date | null;
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
const MATCH_SELECT = {
|
|
529
|
-
id: true,
|
|
530
|
-
name: true,
|
|
531
|
-
userId: true,
|
|
532
|
-
billingCustomerId: true,
|
|
533
|
-
givenName: true,
|
|
534
|
-
familyName: true,
|
|
535
|
-
telephone: true,
|
|
536
|
-
addressLine1: true,
|
|
537
|
-
companyName: true,
|
|
538
|
-
jobTitle: true,
|
|
539
|
-
contactConfirmedAt: true,
|
|
540
|
-
} as const;
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Step 3's writes on an email match: the customer id only when the Person
|
|
544
|
-
* has none and is linked to no user, the name only when it has none — each
|
|
545
|
-
* one guarded `updateMany`, so a concurrent writer's value, or a stamp that
|
|
546
|
-
* lands between the read and the write, is never overwritten or bypassed.
|
|
547
|
-
*/
|
|
548
|
-
async function enrichMatched(
|
|
549
|
-
db: PersonDb,
|
|
550
|
-
workspaceId: string,
|
|
551
|
-
person: MatchedPerson,
|
|
552
|
-
name: string | null,
|
|
553
|
-
customerId: string | null,
|
|
554
|
-
): Promise<"written" | "kept" | "unchanged"> {
|
|
555
|
-
let billing: "written" | "kept" | "unchanged" = "unchanged";
|
|
556
|
-
if (customerId !== null) {
|
|
557
|
-
if (person.billingCustomerId !== null || person.userId !== null) {
|
|
558
|
-
billing = "kept";
|
|
559
|
-
} else {
|
|
560
|
-
const written = await db.person.updateMany({
|
|
561
|
-
where: { id: person.id, workspaceId, userId: null, billingCustomerId: null },
|
|
562
|
-
data: { billingProvider: "stripe", billingCustomerId: customerId },
|
|
563
|
-
});
|
|
564
|
-
billing = written.count === 1 ? "written" : "kept";
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
if (name !== null && person.name === null) {
|
|
568
|
-
await db.person.updateMany({
|
|
569
|
-
where: { id: person.id, workspaceId, name: null },
|
|
570
|
-
data: { name },
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
return billing;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* The contact fill on an email match (#8435 D4.3): for each supplied field
|
|
578
|
-
* whose stored value is `null`, one guarded `updateMany`; the address is one
|
|
579
|
-
* statement guarded on `addressLine1: null` writing all six. It runs ONLY
|
|
580
|
-
* while the Person is uncurated — linked to no user and never saved by hand
|
|
581
|
-
* (`contactConfirmedAt` null) — and every guard repeats that condition, so a
|
|
582
|
-
* link or a save that lands between the read and the write still wins
|
|
583
|
-
* (SEC-8435-A). A stored value, whoever wrote it, is never overwritten.
|
|
584
|
-
* Best-effort: a failed write is swallowed, never refusing the capture.
|
|
585
|
-
*/
|
|
586
|
-
async function fillBlankContact(
|
|
587
|
-
db: PersonDb,
|
|
588
|
-
workspaceId: string,
|
|
589
|
-
person: MatchedPerson,
|
|
590
|
-
contact: Partial<PersonContact>,
|
|
591
|
-
plainName: string | null,
|
|
592
|
-
): Promise<void> {
|
|
593
|
-
if (person.userId !== null || person.contactConfirmedAt !== null) return;
|
|
594
|
-
const uncurated = { id: person.id, workspaceId, userId: null, contactConfirmedAt: null };
|
|
595
|
-
try {
|
|
596
|
-
for (const field of SCALAR_CONTACT_FIELDS) {
|
|
597
|
-
const value = contact[field];
|
|
598
|
-
if (value !== undefined && value !== null && person[field] === null) {
|
|
599
|
-
await db.person.updateMany({
|
|
600
|
-
where: { ...uncurated, [field]: null } as Prisma.PersonWhereInput,
|
|
601
|
-
data: { [field]: value } as Prisma.PersonUpdateManyMutationInput,
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
if (contact.address && person.addressLine1 === null) {
|
|
606
|
-
await db.person.updateMany({
|
|
607
|
-
where: { ...uncurated, addressLine1: null },
|
|
608
|
-
data: addressColumns(contact.address),
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
// The display name from given + family, only when nothing named it.
|
|
612
|
-
if (plainName === null && person.name === null) {
|
|
613
|
-
const derived = joinedName(contact);
|
|
614
|
-
if (derived !== null) {
|
|
615
|
-
await db.person.updateMany({ where: { ...uncurated, name: null }, data: { name: derived } });
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
} catch {
|
|
619
|
-
// A contact fill never changes the capture's outcome.
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Find this workspace's Person for an email, or create it.
|
|
625
|
-
*
|
|
626
|
-
* @param workspaceId the SERVER-RESOLVED workspace id — never a request value.
|
|
627
|
-
* @param input.email the raw address; normalized here.
|
|
628
|
-
* @param input.name an optional display name, from anonymous text.
|
|
629
|
-
* @param input.billingCustomerId an optional provider customer id.
|
|
630
|
-
* @param input.contact optional, already-normalized contact fields
|
|
631
|
-
* (`normalizeCapturedContact`): a created Person carries them all; a matched
|
|
632
|
-
* uncurated Person gains only the ones it has blank (#8435 D4).
|
|
633
|
-
* @param db the shared client by default; the appointment path passes its
|
|
634
|
-
* transaction client so the step runs on the lock's own connection.
|
|
635
|
-
*/
|
|
636
|
-
export async function findOrCreatePerson(
|
|
637
|
-
workspaceId: string,
|
|
638
|
-
input: {
|
|
639
|
-
email: string;
|
|
640
|
-
name?: string | null;
|
|
641
|
-
billingCustomerId?: string | null;
|
|
642
|
-
contact?: Partial<PersonContact>;
|
|
643
|
-
},
|
|
644
|
-
db: PersonDb = prisma,
|
|
645
|
-
): Promise<FindOrCreatePersonResult> {
|
|
646
|
-
const email =
|
|
647
|
-
typeof input.email === "string" ? normalizePersonEmail(input.email) : "";
|
|
648
|
-
if (!isStorablePersonEmail(email)) {
|
|
649
|
-
return { ok: false, error: { kind: "invalid_email" } };
|
|
650
|
-
}
|
|
651
|
-
const name = normalizePersonName(input.name);
|
|
652
|
-
const customerId = usableCustomerId(input.billingCustomerId);
|
|
653
|
-
const contact = input.contact ?? {};
|
|
654
|
-
const hasContact = Object.keys(contact).length > 0;
|
|
655
|
-
|
|
656
|
-
try {
|
|
657
|
-
// Step 2 — a customer id names its Person; nothing is written.
|
|
658
|
-
if (customerId !== null) {
|
|
659
|
-
const byCustomer = await db.person.findFirst({
|
|
660
|
-
where: { workspaceId, billingCustomerId: customerId },
|
|
661
|
-
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
662
|
-
select: { id: true },
|
|
663
|
-
});
|
|
664
|
-
if (byCustomer) {
|
|
665
|
-
return {
|
|
666
|
-
ok: true,
|
|
667
|
-
value: {
|
|
668
|
-
personId: byCustomer.id as Id<"person">,
|
|
669
|
-
created: false,
|
|
670
|
-
billing: "unchanged",
|
|
671
|
-
},
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// Step 3 — the email's Person.
|
|
677
|
-
const byEmail = await db.person.findFirst({
|
|
678
|
-
where: { workspaceId, email },
|
|
679
|
-
select: MATCH_SELECT,
|
|
680
|
-
});
|
|
681
|
-
if (byEmail) {
|
|
682
|
-
const billing = await enrichMatched(db, workspaceId, byEmail, name, customerId);
|
|
683
|
-
if (hasContact) await fillBlankContact(db, workspaceId, byEmail, contact, name);
|
|
684
|
-
return {
|
|
685
|
-
ok: true,
|
|
686
|
-
value: { personId: byEmail.id as Id<"person">, created: false, billing },
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Step 4 — insert, absorbing a concurrent duplicate in the database.
|
|
691
|
-
const inserted = await db.person.createMany({
|
|
692
|
-
data: [
|
|
693
|
-
{
|
|
694
|
-
workspaceId,
|
|
695
|
-
email,
|
|
696
|
-
// A supplied name wins; else given + family joined (#8435 D4.2).
|
|
697
|
-
name: name ?? joinedName(contact),
|
|
698
|
-
...(customerId !== null && {
|
|
699
|
-
billingProvider: "stripe",
|
|
700
|
-
billingCustomerId: customerId,
|
|
701
|
-
}),
|
|
702
|
-
...(hasContact && contactInsertColumns(contact)),
|
|
703
|
-
},
|
|
704
|
-
],
|
|
705
|
-
skipDuplicates: true,
|
|
706
|
-
});
|
|
707
|
-
const winner = await db.person.findFirst({
|
|
708
|
-
where: { workspaceId, email },
|
|
709
|
-
select: MATCH_SELECT,
|
|
710
|
-
});
|
|
711
|
-
if (!winner) return { ok: false, error: { kind: "internal_error" } };
|
|
712
|
-
if (inserted.count === 1) {
|
|
713
|
-
return {
|
|
714
|
-
ok: true,
|
|
715
|
-
value: {
|
|
716
|
-
personId: winner.id as Id<"person">,
|
|
717
|
-
created: true,
|
|
718
|
-
billing: customerId !== null ? "written" : "unchanged",
|
|
719
|
-
},
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
// A concurrent create won the race: treat its row as an email match.
|
|
723
|
-
const billing = await enrichMatched(db, workspaceId, winner, name, customerId);
|
|
724
|
-
if (hasContact) await fillBlankContact(db, workspaceId, winner, contact, name);
|
|
725
|
-
return {
|
|
726
|
-
ok: true,
|
|
727
|
-
value: { personId: winner.id as Id<"person">, created: false, billing },
|
|
728
|
-
};
|
|
729
|
-
} catch {
|
|
730
|
-
return { ok: false, error: { kind: "internal_error" } };
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ─── stampPersonUser (D5) ─────────────────────────────────────────────────────
|
|
735
|
-
|
|
736
|
-
export type StampPersonUserOutcome =
|
|
737
|
-
| "stamped"
|
|
738
|
-
| "already_linked"
|
|
739
|
-
| "user_unverified"
|
|
740
|
-
| "email_mismatch"
|
|
741
|
-
| "linked_to_other_user"
|
|
742
|
-
| "user_on_other_person"
|
|
743
|
-
| "not_found"
|
|
744
|
-
| "internal_error";
|
|
745
|
-
|
|
746
|
-
function isUniqueViolation(err: unknown): boolean {
|
|
747
|
-
return (
|
|
748
|
-
typeof err === "object" &&
|
|
749
|
-
err !== null &&
|
|
750
|
-
(err as { code?: unknown }).code === "P2002"
|
|
751
|
-
);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* Link a Person to a User — only when the User's VERIFIED email normalizes
|
|
756
|
-
* to the Person's (`verified-email.ts`'s rule), and never over an existing
|
|
757
|
-
* link. Creating a Person never calls this: no verified email is needed to
|
|
758
|
-
* exist. Runs outside any transaction, so catching a P2002 is safe here.
|
|
759
|
-
*/
|
|
760
|
-
export async function stampPersonUser(
|
|
761
|
-
workspaceId: string,
|
|
762
|
-
personId: string,
|
|
763
|
-
userId: string,
|
|
764
|
-
): Promise<StampPersonUserOutcome> {
|
|
765
|
-
try {
|
|
766
|
-
const person = await prisma.person.findFirst({
|
|
767
|
-
where: { id: personId, workspaceId },
|
|
768
|
-
select: { email: true, userId: true },
|
|
769
|
-
});
|
|
770
|
-
if (!person) return "not_found";
|
|
771
|
-
if (person.userId === userId) return "already_linked";
|
|
772
|
-
if (person.userId !== null) return "linked_to_other_user";
|
|
773
|
-
|
|
774
|
-
const verified = await findVerifiedEmail(userId);
|
|
775
|
-
if (!verified.ok) return "user_unverified";
|
|
776
|
-
if (normalizePersonEmail(verified.value.email) !== person.email) {
|
|
777
|
-
return "email_mismatch";
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
let count: number;
|
|
781
|
-
try {
|
|
782
|
-
({ count } = await prisma.person.updateMany({
|
|
783
|
-
where: { id: personId, workspaceId, userId: null },
|
|
784
|
-
data: { userId },
|
|
785
|
-
}));
|
|
786
|
-
} catch (err) {
|
|
787
|
-
if (isUniqueViolation(err)) return "user_on_other_person";
|
|
788
|
-
throw err;
|
|
789
|
-
}
|
|
790
|
-
if (count === 1) return "stamped";
|
|
791
|
-
|
|
792
|
-
const reread = await prisma.person.findFirst({
|
|
793
|
-
where: { id: personId, workspaceId },
|
|
794
|
-
select: { userId: true },
|
|
795
|
-
});
|
|
796
|
-
if (!reread) return "not_found";
|
|
797
|
-
return reread.userId === userId ? "already_linked" : "linked_to_other_user";
|
|
798
|
-
} catch {
|
|
799
|
-
return "internal_error";
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// ─── recordCheckoutPerson (D6) ────────────────────────────────────────────────
|
|
804
|
-
|
|
805
|
-
export type CheckoutPersonOutcome =
|
|
806
|
-
| "person_written"
|
|
807
|
-
| "person_kept"
|
|
808
|
-
| "person_unchanged"
|
|
809
|
-
| "person_skipped"
|
|
810
|
-
| "person_failed";
|
|
811
|
-
|
|
812
|
-
/** `recordCheckoutPerson`'s outcome plus the Person it found or created (#8765 D14). */
|
|
813
|
-
export type CheckoutPersonResult = {
|
|
814
|
-
outcome: CheckoutPersonOutcome;
|
|
815
|
-
/** The checkout's Person, or `null` when the step skipped or failed. */
|
|
816
|
-
person: { workspaceId: string; personId: string } | null;
|
|
817
|
-
};
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* The hub webhook's Person step for a paid `checkout.session.completed` of
|
|
821
|
-
* its OWN site carrying `metadata.flowCheckoutId`.
|
|
822
|
-
*
|
|
823
|
-
* Reads the checkout row THIS session completed (`stripeSessionId` must be
|
|
824
|
-
* this session's id and the row `completed`), so a session that merely names
|
|
825
|
-
* another checkout's id matches nothing (SEC-852-H); then that row's lead,
|
|
826
|
-
* scoped by the row's workspace. The customer id's one source is
|
|
827
|
-
* `session.customer`, and it is passed only for a LIVE-mode event: a
|
|
828
|
-
* test-mode checkout typed with someone else's email cannot pin a customer
|
|
829
|
-
* id on their Person (SEC-852-A). Never throws.
|
|
830
|
-
*/
|
|
831
|
-
export async function recordCheckoutPerson(input: {
|
|
832
|
-
flowCheckoutId: string;
|
|
833
|
-
stripeSessionId: string | null | undefined;
|
|
834
|
-
customer: unknown;
|
|
835
|
-
livemode: boolean;
|
|
836
|
-
}): Promise<CheckoutPersonOutcome> {
|
|
837
|
-
return (await resolveCheckoutPerson(input)).outcome;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/**
|
|
841
|
-
* `recordCheckoutPerson`, also answering the Person it found or created —
|
|
842
|
-
* the subject of the checkout's terms grant (#8765 D14), which reads the
|
|
843
|
-
* flow checkout's Person from here and never creates one itself.
|
|
844
|
-
*/
|
|
845
|
-
export async function resolveCheckoutPerson(input: {
|
|
846
|
-
flowCheckoutId: string;
|
|
847
|
-
stripeSessionId: string | null | undefined;
|
|
848
|
-
customer: unknown;
|
|
849
|
-
livemode: boolean;
|
|
850
|
-
}): Promise<CheckoutPersonResult> {
|
|
851
|
-
const skipped = { outcome: "person_skipped", person: null } as const;
|
|
852
|
-
if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
|
|
853
|
-
return skipped;
|
|
854
|
-
}
|
|
855
|
-
try {
|
|
856
|
-
const checkout = await prisma.flowCheckout.findFirst({
|
|
857
|
-
where: {
|
|
858
|
-
id: input.flowCheckoutId,
|
|
859
|
-
stripeSessionId: input.stripeSessionId,
|
|
860
|
-
status: "completed",
|
|
861
|
-
},
|
|
862
|
-
select: { leadId: true, workspaceId: true },
|
|
863
|
-
});
|
|
864
|
-
if (!checkout) return skipped;
|
|
865
|
-
|
|
866
|
-
const lead = await prisma.lead.findFirst({
|
|
867
|
-
where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
|
|
868
|
-
select: { email: true },
|
|
869
|
-
});
|
|
870
|
-
if (!lead) return skipped;
|
|
871
|
-
|
|
872
|
-
const customerId =
|
|
873
|
-
input.livemode === true && typeof input.customer === "string"
|
|
874
|
-
? input.customer
|
|
875
|
-
: undefined;
|
|
876
|
-
const result = await findOrCreatePerson(checkout.workspaceId, {
|
|
877
|
-
email: lead.email,
|
|
878
|
-
...(customerId !== undefined && { billingCustomerId: customerId }),
|
|
879
|
-
});
|
|
880
|
-
if (!result.ok) {
|
|
881
|
-
return result.error.kind === "invalid_email"
|
|
882
|
-
? skipped
|
|
883
|
-
: { outcome: "person_failed", person: null };
|
|
884
|
-
}
|
|
885
|
-
const person = {
|
|
886
|
-
workspaceId: checkout.workspaceId,
|
|
887
|
-
personId: result.value.personId,
|
|
888
|
-
};
|
|
889
|
-
switch (result.value.billing) {
|
|
890
|
-
case "written":
|
|
891
|
-
return { outcome: "person_written", person };
|
|
892
|
-
case "kept":
|
|
893
|
-
return { outcome: "person_kept", person };
|
|
894
|
-
default:
|
|
895
|
-
return { outcome: "person_unchanged", person };
|
|
896
|
-
}
|
|
897
|
-
} catch {
|
|
898
|
-
return { outcome: "person_failed", person: null };
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// ─── The member area (#7877 D8) ───────────────────────────────────────────────
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* The Person a member read answers for: this workspace's Person linked to
|
|
906
|
-
* the caller, else the one whose email is the caller's VERIFIED email —
|
|
907
|
-
* linked now through `stampPersonUser` (its rule, unchanged) and the answer
|
|
908
|
-
* only on `stamped` or `already_linked`. `null` is "not a client here";
|
|
909
|
-
* every other stamp outcome, and a store error, is `null` too, logged by its
|
|
910
|
-
* kind only. Never throws.
|
|
911
|
-
*
|
|
912
|
-
* A fresh `stamped` link — and only that — releases the Person's held
|
|
913
|
-
* checkout plan (#8793 D2, `releaseHeldCheckoutPlan`): the caller has just
|
|
914
|
-
* proven they own the email the purchases were made under. A Person linked
|
|
915
|
-
* earlier, a refused stamp and every other caller of `stampPersonUser` (the
|
|
916
|
-
* Inbox accept above all) release nothing. The release never changes the
|
|
917
|
-
* answer; an `ambiguous` or `internal_error` release is logged by its kind
|
|
918
|
-
* only, as `{ event: "checkout-plan-release", outcome }`.
|
|
919
|
-
*
|
|
920
|
-
* @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
|
|
921
|
-
* @param userId the authenticated caller.
|
|
922
|
-
* @param verifiedEmail the caller's verified address, read by the caller.
|
|
923
|
-
*/
|
|
924
|
-
export async function resolveMemberPersonId(
|
|
925
|
-
workspaceId: string,
|
|
926
|
-
userId: string,
|
|
927
|
-
verifiedEmail: string,
|
|
928
|
-
log?: { warn: (obj: object, msg: string) => void },
|
|
929
|
-
): Promise<string | null> {
|
|
930
|
-
let byEmail: { id: string } | null;
|
|
931
|
-
try {
|
|
932
|
-
const linked = await prisma.person.findFirst({
|
|
933
|
-
where: { workspaceId, userId },
|
|
934
|
-
select: { id: true },
|
|
935
|
-
});
|
|
936
|
-
if (linked) return linked.id;
|
|
937
|
-
|
|
938
|
-
byEmail = await prisma.person.findFirst({
|
|
939
|
-
where: { workspaceId, email: normalizePersonEmail(verifiedEmail) },
|
|
940
|
-
select: { id: true },
|
|
941
|
-
});
|
|
942
|
-
} catch {
|
|
943
|
-
// A store error is "not a client here" for this read: never thrown.
|
|
944
|
-
log?.warn({ event: "member-person-stamp", outcome: "internal_error" }, "[person] member read failed");
|
|
945
|
-
return null;
|
|
946
|
-
}
|
|
947
|
-
if (!byEmail) return null;
|
|
948
|
-
|
|
949
|
-
const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
|
|
950
|
-
if (outcome === "stamped") {
|
|
951
|
-
const release = await releaseHeldCheckoutPlan(
|
|
952
|
-
workspaceId,
|
|
953
|
-
byEmail.id,
|
|
954
|
-
userId,
|
|
955
|
-
normalizePersonEmail(verifiedEmail),
|
|
956
|
-
);
|
|
957
|
-
if (release === "ambiguous" || release === "internal_error") {
|
|
958
|
-
log?.warn({ event: "checkout-plan-release", outcome: release }, "[person] held checkout plan not released");
|
|
959
|
-
}
|
|
960
|
-
return byEmail.id;
|
|
961
|
-
}
|
|
962
|
-
if (outcome === "already_linked") return byEmail.id;
|
|
963
|
-
log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
|
|
964
|
-
return null;
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
/**
|
|
968
|
-
* The customer id the hub vouches for to a spoke's buy route (#7961, spec
|
|
969
|
-
* 7894 D13): the `billingCustomerId` of the Person `resolveMemberPersonId`
|
|
970
|
-
* answers for (workspace, caller), read only while that Person's `userId` is
|
|
971
|
-
* the caller — never a Person matched by email alone (SEC-7894-6). `null` is
|
|
972
|
-
* "no customer to reuse"; a store error is `null` too, logged by its kind
|
|
973
|
-
* only. Never throws.
|
|
974
|
-
*
|
|
975
|
-
* @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
|
|
976
|
-
* @param userId the authenticated caller.
|
|
977
|
-
* @param verifiedEmail the caller's verified address, read by the caller.
|
|
978
|
-
*/
|
|
979
|
-
export async function resolveCallerBillingCustomerId(
|
|
980
|
-
workspaceId: string,
|
|
981
|
-
userId: string,
|
|
982
|
-
verifiedEmail: string,
|
|
983
|
-
log?: { warn: (obj: object, msg: string) => void },
|
|
984
|
-
): Promise<string | null> {
|
|
985
|
-
const personId = await resolveMemberPersonId(workspaceId, userId, verifiedEmail, log);
|
|
986
|
-
if (personId === null) return null;
|
|
987
|
-
try {
|
|
988
|
-
const person = await prisma.person.findFirst({
|
|
989
|
-
where: { id: personId, workspaceId, userId },
|
|
990
|
-
select: { billingCustomerId: true },
|
|
991
|
-
});
|
|
992
|
-
return person?.billingCustomerId ?? null;
|
|
993
|
-
} catch {
|
|
994
|
-
log?.warn({ event: "buyer-customer-read", outcome: "internal_error" }, "[person] buyer read failed");
|
|
995
|
-
return null;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
|
|
1000
|
-
|
|
1001
|
-
/**
|
|
1002
|
-
* Set — or, with `null`, clear — a Person's plan: the ONE writer of
|
|
1003
|
-
* `Person.planOffering`, called by the Admin's plan route and the held
|
|
1004
|
-
* checkout plan's release (#8793). One `updateMany` scoped by the
|
|
1005
|
-
* server-resolved workspace; no row → `not_found`. Never throws.
|
|
1006
|
-
*
|
|
1007
|
-
* With a `guard` (#8793 D5, the release's own write) the statement also
|
|
1008
|
-
* requires a blank plan and the Person still linked to `guard.linkedUserId`,
|
|
1009
|
-
* so a plan already set — or one the operator PUTs at the same moment — wins
|
|
1010
|
-
* and the answer is `not_found`. Without one it is the operator's overwrite.
|
|
1011
|
-
*/
|
|
1012
|
-
export async function setPersonPlanOffering(
|
|
1013
|
-
workspaceId: string,
|
|
1014
|
-
personId: string,
|
|
1015
|
-
planOffering: string | null,
|
|
1016
|
-
guard?: { linkedUserId: string },
|
|
1017
|
-
): Promise<SetPlanOfferingOutcome> {
|
|
1018
|
-
try {
|
|
1019
|
-
const { count } = await prisma.person.updateMany({
|
|
1020
|
-
where: {
|
|
1021
|
-
id: personId,
|
|
1022
|
-
workspaceId,
|
|
1023
|
-
...(guard !== undefined && { planOffering: null, userId: guard.linkedUserId }),
|
|
1024
|
-
},
|
|
1025
|
-
data: { planOffering },
|
|
1026
|
-
});
|
|
1027
|
-
return count === 0 ? "not_found" : "set";
|
|
1028
|
-
} catch {
|
|
1029
|
-
return "internal_error";
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// ─── The held checkout plan (#8793 D3, D4) ───────────────────────────────────
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* A spoke checkout's plan key: the offering minus a leading `<site>_` (when
|
|
1037
|
-
* the site is a string), answered only when `planOfferingSchema` — the key
|
|
1038
|
-
* the operator's PUT accepts — admits it; else `null`. Shared by the
|
|
1039
|
-
* checkout's hold (`lead-routing.ts`) and the release below.
|
|
1040
|
-
*/
|
|
1041
|
-
export function checkoutPlanKey(offering: unknown, site: unknown): string | null {
|
|
1042
|
-
if (typeof offering !== "string") return null;
|
|
1043
|
-
const prefix = typeof site === "string" ? `${site}_` : null;
|
|
1044
|
-
const key = prefix !== null && offering.startsWith(prefix) ? offering.slice(prefix.length) : offering;
|
|
1045
|
-
return planOfferingSchema.safeParse(key).success ? key : null;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
export type HeldPlanOutcome = "set" | "none" | "ambiguous" | "kept" | "internal_error";
|
|
1049
|
-
|
|
1050
|
-
/** The most held purchases a release weighs; one more is `ambiguous`. */
|
|
1051
|
-
const HELD_PURCHASES_READ = 50;
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* Release a Person's held checkout plan (#8793 D3) — called only by
|
|
1055
|
-
* `resolveMemberPersonId` on a fresh `stamped` link, so `verifiedEmail` (the
|
|
1056
|
-
* caller's, normalized) is the Person's own. The Person's marked spoke
|
|
1057
|
-
* checkout leads (`Lead.spokeCheckout`, newest 51) under that email are taken
|
|
1058
|
-
* as a SET: one distinct plan key is written to a blank plan through the
|
|
1059
|
-
* guarded `setPersonPlanOffering` (`set`, or `kept` when the guard matched
|
|
1060
|
-
* nothing — a plan was already there, or the Person is no longer linked to
|
|
1061
|
-
* `userId`); no key is `none`; more than one key, or more than 50 leads, is
|
|
1062
|
-
* `ambiguous` and writes nothing — the operator sets the plan. So a
|
|
1063
|
-
* stranger's purchase under the owner's email can deny the automatic plan,
|
|
1064
|
-
* never choose it (SEC-8793-A). A store error is `internal_error`. Never
|
|
1065
|
-
* throws, and logs nothing: the caller logs the kind.
|
|
1066
|
-
*/
|
|
1067
|
-
export async function releaseHeldCheckoutPlan(
|
|
1068
|
-
workspaceId: string,
|
|
1069
|
-
personId: string,
|
|
1070
|
-
userId: string,
|
|
1071
|
-
verifiedEmail: string,
|
|
1072
|
-
): Promise<HeldPlanOutcome> {
|
|
1073
|
-
try {
|
|
1074
|
-
const rows = await prisma.lead.findMany({
|
|
1075
|
-
where: { workspaceId, personId, spokeCheckout: true },
|
|
1076
|
-
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
1077
|
-
take: HELD_PURCHASES_READ + 1,
|
|
1078
|
-
select: { email: true, payload: true },
|
|
1079
|
-
});
|
|
1080
|
-
if (rows.length > HELD_PURCHASES_READ) return "ambiguous";
|
|
1081
|
-
|
|
1082
|
-
const keys = new Set<string>();
|
|
1083
|
-
for (const row of rows) {
|
|
1084
|
-
// The purchase read's own rule: a purchase is its Person's only under
|
|
1085
|
-
// the Person's own email.
|
|
1086
|
-
if (normalizePersonEmail(row.email) !== verifiedEmail) continue;
|
|
1087
|
-
const payload = (row.payload ?? {}) as Record<string, unknown>;
|
|
1088
|
-
const key = checkoutPlanKey(payload["offering"], payload["site"]);
|
|
1089
|
-
if (key !== null) keys.add(key);
|
|
1090
|
-
}
|
|
1091
|
-
if (keys.size > 1) return "ambiguous";
|
|
1092
|
-
const [key] = keys;
|
|
1093
|
-
if (key === undefined) return "none";
|
|
1094
|
-
|
|
1095
|
-
const written = await setPersonPlanOffering(workspaceId, personId, key, { linkedUserId: userId });
|
|
1096
|
-
if (written === "set") return "set";
|
|
1097
|
-
return written === "not_found" ? "kept" : "internal_error";
|
|
1098
|
-
} catch {
|
|
1099
|
-
return "internal_error";
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
|
|
1104
|
-
|
|
1105
|
-
export type SavedContact = {
|
|
1106
|
-
contact: PersonContact;
|
|
1107
|
-
confirmed: { by: "operator" | "client"; at: string } | null;
|
|
1108
|
-
};
|
|
1109
|
-
|
|
1110
|
-
export type SaveContactOutcome =
|
|
1111
|
-
| { kind: "saved"; value: SavedContact }
|
|
1112
|
-
| { kind: "not_found" | "internal_error" };
|
|
1113
|
-
|
|
1114
|
-
/** The columns a patch writes: only the keys present; the address key writes (or clears) all six. */
|
|
1115
|
-
function patchColumns(patch: PersonContactPatch): Record<string, string | null> {
|
|
1116
|
-
const columns: Record<string, string | null> = {};
|
|
1117
|
-
for (const field of SCALAR_CONTACT_FIELDS) {
|
|
1118
|
-
const value = patch[field];
|
|
1119
|
-
if (value !== undefined) columns[field] = value;
|
|
1120
|
-
}
|
|
1121
|
-
if (patch.address !== undefined) Object.assign(columns, addressColumns(patch.address));
|
|
1122
|
-
return columns;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
async function readSavedContact(workspaceId: string, personId: string): Promise<SaveContactOutcome> {
|
|
1126
|
-
const row = await prisma.person.findFirst({
|
|
1127
|
-
where: { id: personId, workspaceId },
|
|
1128
|
-
select: PERSON_CONTACT_SELECT,
|
|
1129
|
-
});
|
|
1130
|
-
if (!row) return { kind: "not_found" };
|
|
1131
|
-
return { kind: "saved", value: { contact: personContactFromRow(row), confirmed: contactConfirmedFromRow(row) } };
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* The operator's save (#8435 D9): one `updateMany` scoped by the
|
|
1136
|
-
* server-resolved workspace, writing only the keys present plus the
|
|
1137
|
-
* confirmation stamp (`operator`). No row → `not_found`. Never throws.
|
|
1138
|
-
*/
|
|
1139
|
-
export async function updatePersonContact(
|
|
1140
|
-
workspaceId: string,
|
|
1141
|
-
personId: string,
|
|
1142
|
-
patch: PersonContactPatch,
|
|
1143
|
-
): Promise<SaveContactOutcome> {
|
|
1144
|
-
try {
|
|
1145
|
-
const { count } = await prisma.person.updateMany({
|
|
1146
|
-
where: { id: personId, workspaceId },
|
|
1147
|
-
data: {
|
|
1148
|
-
...patchColumns(patch),
|
|
1149
|
-
contactConfirmedAt: new Date(),
|
|
1150
|
-
contactConfirmedBy: "operator",
|
|
1151
|
-
} as Prisma.PersonUpdateManyMutationInput,
|
|
1152
|
-
});
|
|
1153
|
-
if (count === 0) return { kind: "not_found" };
|
|
1154
|
-
return await readSavedContact(workspaceId, personId);
|
|
1155
|
-
} catch {
|
|
1156
|
-
return { kind: "internal_error" };
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
/**
|
|
1161
|
-
* The Person a client's first contact save belongs to (#8435 D11): the
|
|
1162
|
-
* caller's VERIFIED email's Person in this workspace, found or created, and
|
|
1163
|
-
* linked to the caller through `stampPersonUser`'s own rule. Anything but a
|
|
1164
|
-
* stamped or already-linked outcome is `unavailable` — nothing is written for
|
|
1165
|
-
* the contact. Never throws.
|
|
1166
|
-
*/
|
|
1167
|
-
export async function ensureMemberPerson(
|
|
1168
|
-
workspaceId: string,
|
|
1169
|
-
userId: string,
|
|
1170
|
-
): Promise<{ ok: true; personId: string } | { ok: false }> {
|
|
1171
|
-
try {
|
|
1172
|
-
const verified = await findVerifiedEmail(userId);
|
|
1173
|
-
if (!verified.ok) return { ok: false };
|
|
1174
|
-
const person = await findOrCreatePerson(workspaceId, { email: verified.value.email });
|
|
1175
|
-
if (!person.ok) return { ok: false };
|
|
1176
|
-
const outcome = await stampPersonUser(workspaceId, person.value.personId, userId);
|
|
1177
|
-
if (outcome !== "stamped" && outcome !== "already_linked") return { ok: false };
|
|
1178
|
-
return { ok: true, personId: person.value.personId };
|
|
1179
|
-
} catch {
|
|
1180
|
-
return { ok: false };
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* The client's save (#8435 D11): ONE `updateMany` guarded on `{ id,
|
|
1186
|
-
* workspaceId, userId }` — only the caller's own linked Person is ever
|
|
1187
|
-
* written — replacing all six contact keys with the body and stamping
|
|
1188
|
-
* `client`, so no value the client never saw survives it (SEC-8435-B). No
|
|
1189
|
-
* row → `not_found`. Never throws.
|
|
1190
|
-
*/
|
|
1191
|
-
export async function setOwnPersonContact(
|
|
1192
|
-
workspaceId: string,
|
|
1193
|
-
personId: string,
|
|
1194
|
-
userId: string,
|
|
1195
|
-
contact: PersonContactPatch,
|
|
1196
|
-
): Promise<SaveContactOutcome> {
|
|
1197
|
-
try {
|
|
1198
|
-
const { count } = await prisma.person.updateMany({
|
|
1199
|
-
where: { id: personId, workspaceId, userId },
|
|
1200
|
-
data: {
|
|
1201
|
-
...patchColumns(contact),
|
|
1202
|
-
contactConfirmedAt: new Date(),
|
|
1203
|
-
contactConfirmedBy: "client",
|
|
1204
|
-
} as Prisma.PersonUpdateManyMutationInput,
|
|
1205
|
-
});
|
|
1206
|
-
if (count === 0) return { kind: "not_found" };
|
|
1207
|
-
return await readSavedContact(workspaceId, personId);
|
|
1208
|
-
} catch {
|
|
1209
|
-
return { kind: "internal_error" };
|
|
1210
|
-
}
|
|
1211
|
-
}
|