create-ailk 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +87 -17
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +268 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +69 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe-backed Offerings catalog source (#8922, spec
|
|
3
|
+
* docs/specs/8922-the-may-sell-product-list-spec.md D2; contract
|
|
4
|
+
* docs/specs/8879-the-offerings-security-contract-key-spec.md C8, T1, T7) —
|
|
5
|
+
* the `OfferingsSourceFor` the routes pass to the read services: one
|
|
6
|
+
* Organization's active Products and Prices, read through its own
|
|
7
|
+
* connection's client (#8907 D12) and paged to the normalizer's caps.
|
|
8
|
+
*
|
|
9
|
+
* `stripeCatalogSourceFor` does no work when called, so it can never throw
|
|
10
|
+
* outside the services' failure memo (#8876 D8). It takes a bare
|
|
11
|
+
* Organization id and checks neither the tenant nor the claim — its callers
|
|
12
|
+
* do — so `architecture.yaml` confines it to the exact files that call it.
|
|
13
|
+
*
|
|
14
|
+
* BOUNDED (SEC-8922-3). Each list is paged by calls, not items: at most
|
|
15
|
+
* `OFFERINGS_CATALOG_PRODUCTS_MAX / 100` product calls and
|
|
16
|
+
* `OFFERINGS_CATALOG_PRICES_MAX / 100` price calls, stopping early on
|
|
17
|
+
* `has_more: false`, an empty page, a page whose last id repeats the cursor
|
|
18
|
+
* it was asked with, or a full count. The whole read is bounded by
|
|
19
|
+
* `OFFERINGS_CATALOG_READ_TIMEOUT_MS`; after it fails, no further call is
|
|
20
|
+
* started.
|
|
21
|
+
*
|
|
22
|
+
* A list cut off at its cap while Stripe still had more is logged as
|
|
23
|
+
* `catalog_truncated` (SEC-8922-7), with the Organization and nothing else.
|
|
24
|
+
*
|
|
25
|
+
* Nothing is filtered or cached here: the client lives only inside
|
|
26
|
+
* one read (C8), and every field is normalized once by `normalizeCatalog`
|
|
27
|
+
* (T1). A disconnected or unusable connection rejects, never reading as an
|
|
28
|
+
* empty catalog; a Stripe error rejects as itself, and the services turn it
|
|
29
|
+
* into `OfferingsSourceError` without its text.
|
|
30
|
+
*/
|
|
31
|
+
import { logger } from "@working-theory/observability";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
OFFERINGS_CATALOG_PRICES_MAX,
|
|
35
|
+
OFFERINGS_CATALOG_PRODUCTS_MAX,
|
|
36
|
+
type OfferingsCatalog,
|
|
37
|
+
type OfferingsCatalogSource,
|
|
38
|
+
} from "./catalog.js";
|
|
39
|
+
import { stripeClientForOrganization } from "./connection.js";
|
|
40
|
+
|
|
41
|
+
import type { OfferingsSourceFor } from "./index.js";
|
|
42
|
+
|
|
43
|
+
/** The longest one catalog read may take, connection lookup included. */
|
|
44
|
+
export const OFFERINGS_CATALOG_READ_TIMEOUT_MS = 20_000;
|
|
45
|
+
|
|
46
|
+
const PAGE_LIMIT = 100;
|
|
47
|
+
|
|
48
|
+
/** No usable connection, or the read timed out. The message is fixed; there is no cause. */
|
|
49
|
+
export class OfferingsCatalogUnavailableError extends Error {
|
|
50
|
+
constructor() {
|
|
51
|
+
super("The Offerings catalog source is unavailable");
|
|
52
|
+
this.name = "OfferingsCatalogUnavailableError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ListParams {
|
|
57
|
+
active: true;
|
|
58
|
+
limit: number;
|
|
59
|
+
starting_after?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ListPage<T> {
|
|
63
|
+
data: T[];
|
|
64
|
+
has_more: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One list paged to `maxItems`, by at most `maxItems / PAGE_LIMIT` calls.
|
|
69
|
+
* `truncated` when the bound stopped it while Stripe still had more.
|
|
70
|
+
*/
|
|
71
|
+
async function pageAll<T extends { id?: unknown }>(
|
|
72
|
+
list: (params: ListParams) => PromiseLike<ListPage<T>>,
|
|
73
|
+
maxItems: number,
|
|
74
|
+
aborted: () => boolean,
|
|
75
|
+
): Promise<{ items: T[]; truncated: boolean }> {
|
|
76
|
+
const items: T[] = [];
|
|
77
|
+
let after: string | undefined;
|
|
78
|
+
for (let call = 0; call < maxItems / PAGE_LIMIT && items.length < maxItems; call++) {
|
|
79
|
+
if (aborted()) throw new OfferingsCatalogUnavailableError();
|
|
80
|
+
const page = await list({
|
|
81
|
+
active: true,
|
|
82
|
+
limit: PAGE_LIMIT,
|
|
83
|
+
...(after !== undefined && { starting_after: after }),
|
|
84
|
+
});
|
|
85
|
+
items.push(...page.data);
|
|
86
|
+
const last = page.data.at(-1)?.id;
|
|
87
|
+
if (!page.has_more || typeof last !== "string" || last === after) return { items, truncated: false };
|
|
88
|
+
after = last;
|
|
89
|
+
}
|
|
90
|
+
return { items, truncated: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The Organization's catalog source. Builds nothing until it is read. */
|
|
94
|
+
export const stripeCatalogSourceFor: OfferingsSourceFor = (
|
|
95
|
+
organizationId: string,
|
|
96
|
+
): OfferingsCatalogSource => ({
|
|
97
|
+
async listActiveCatalog(): Promise<OfferingsCatalog> {
|
|
98
|
+
let aborted = false;
|
|
99
|
+
const isAborted = () => aborted;
|
|
100
|
+
const read = (async (): Promise<OfferingsCatalog> => {
|
|
101
|
+
const connection = await stripeClientForOrganization(organizationId);
|
|
102
|
+
if (connection.kind !== "ok") throw new OfferingsCatalogUnavailableError();
|
|
103
|
+
const { client } = connection;
|
|
104
|
+
const products = pageAll(
|
|
105
|
+
(params) => client.products.list(params),
|
|
106
|
+
OFFERINGS_CATALOG_PRODUCTS_MAX,
|
|
107
|
+
isAborted,
|
|
108
|
+
);
|
|
109
|
+
const prices = pageAll((params) => client.prices.list(params), OFFERINGS_CATALOG_PRICES_MAX, isAborted);
|
|
110
|
+
// Each loop is caught on its own, so a page that rejects after the
|
|
111
|
+
// other loop or the timeout has failed the read is never unhandled.
|
|
112
|
+
products.catch(() => undefined);
|
|
113
|
+
prices.catch(() => undefined);
|
|
114
|
+
const [productPages, pricePages] = await Promise.all([products, prices]);
|
|
115
|
+
if (productPages.truncated || pricePages.truncated) {
|
|
116
|
+
logger.warn(
|
|
117
|
+
{ organizationId, reason: "catalog_truncated" },
|
|
118
|
+
"[offerings] catalog over its read cap",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { products: productPages.items, prices: pricePages.items };
|
|
122
|
+
})();
|
|
123
|
+
read.catch(() => undefined);
|
|
124
|
+
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
127
|
+
timer = setTimeout(() => {
|
|
128
|
+
aborted = true;
|
|
129
|
+
reject(new OfferingsCatalogUnavailableError());
|
|
130
|
+
}, OFFERINGS_CATALOG_READ_TIMEOUT_MS);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
return await Promise.race([read, timeout]);
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
// A loop still running after the other one failed starts no more calls.
|
|
137
|
+
aborted = true;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Offerings sets and the snapshot (#8876, spec
|
|
3
|
+
* docs/specs/8876-the-offerings-data-model-typed-spec.md D6) — the one file
|
|
4
|
+
* naming the org-enabled set, the workspace selection and its snapshot, and,
|
|
5
|
+
* for this service, the workspace's Organization.
|
|
6
|
+
*
|
|
7
|
+
* Every argument is a server-resolved id, never a request value, except a
|
|
8
|
+
* set or selection handed to a writer, which its caller checked and the
|
|
9
|
+
* writer parses; and no function lists or enumerates. The selection read
|
|
10
|
+
* never names the snapshot, so checkout's allowlist (contract A4) can never
|
|
11
|
+
* read the display copy.
|
|
12
|
+
*
|
|
13
|
+
* This file writes the snapshot, only into a row that exists; for #8922, the
|
|
14
|
+
* org-enabled set; and, for #8926, the workspace selection and Recommended,
|
|
15
|
+
* creating its row.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
offeringsProductIdsSchema,
|
|
19
|
+
workspaceOfferingsSelectionSchema,
|
|
20
|
+
type OfferingsSnapshot,
|
|
21
|
+
} from "@working-theory/validation";
|
|
22
|
+
|
|
23
|
+
import { prisma } from "../../lib/prisma.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An Offerings result, or a set handed to a writer, failed its schema. The
|
|
27
|
+
* message is fixed; there is no cause. Re-exported by `index.ts`.
|
|
28
|
+
*/
|
|
29
|
+
export class OfferingsShapeError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("An Offerings result failed its schema");
|
|
32
|
+
this.name = "OfferingsShapeError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkspaceSelection {
|
|
37
|
+
shownProductIds: string[];
|
|
38
|
+
/** The shown Prices (#9070 D1); empty for a row saved per Product. */
|
|
39
|
+
shownPriceIds: string[];
|
|
40
|
+
recommendedPlanProductId: string | null;
|
|
41
|
+
recommendedAddonProductId: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorkspaceSnapshotRow {
|
|
45
|
+
snapshot: unknown;
|
|
46
|
+
snapshotAt: Date | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The workspace's own Organization, or `null` for no workspace. */
|
|
50
|
+
export async function organizationIdOfWorkspace(workspaceId: string): Promise<string | null> {
|
|
51
|
+
const row = await prisma.workspace.findUnique({
|
|
52
|
+
where: { id: workspaceId },
|
|
53
|
+
select: { organizationId: true },
|
|
54
|
+
});
|
|
55
|
+
return row?.organizationId ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The org-enabled set; no row is the empty set. */
|
|
59
|
+
export async function readEnabledProductIds(organizationId: string): Promise<string[]> {
|
|
60
|
+
const row = await prisma.organizationOfferings.findUnique({
|
|
61
|
+
where: { organizationId },
|
|
62
|
+
select: { enabledProductIds: true },
|
|
63
|
+
});
|
|
64
|
+
return row?.enabledProductIds ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Replace the org-enabled set (#8922 D4), creating the row on an
|
|
69
|
+
* Organization's first save. Every set is parsed first — at most 100
|
|
70
|
+
* distinct Stripe ids — so the bound holds for every writer.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeEnabledProductIds(organizationId: string, productIds: string[]): Promise<void> {
|
|
73
|
+
const parsed = offeringsProductIdsSchema.safeParse(productIds);
|
|
74
|
+
if (!parsed.success) throw new OfferingsShapeError();
|
|
75
|
+
await prisma.organizationOfferings.upsert({
|
|
76
|
+
where: { organizationId },
|
|
77
|
+
create: { organizationId, enabledProductIds: parsed.data },
|
|
78
|
+
update: { enabledProductIds: parsed.data },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The shown set and Recommended, or `null` for no row. Never the snapshot. */
|
|
83
|
+
export async function readWorkspaceSelection(workspaceId: string): Promise<WorkspaceSelection | null> {
|
|
84
|
+
return prisma.workspaceOfferings.findUnique({
|
|
85
|
+
where: { workspaceId },
|
|
86
|
+
select: {
|
|
87
|
+
shownProductIds: true,
|
|
88
|
+
shownPriceIds: true,
|
|
89
|
+
recommendedPlanProductId: true,
|
|
90
|
+
recommendedAddonProductId: true,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replace the workspace's selection (#8926 D5), creating the row on its first
|
|
97
|
+
* save. Every selection is parsed first — at most 100 distinct Product ids
|
|
98
|
+
* and 300 Price ids, Prices exactly when Products (#9070 D4), each
|
|
99
|
+
* Recommended shown — so the database's CHECKs never fire and no new save is
|
|
100
|
+
* at the per-Product grain. Never names the snapshot, so a save keeps the
|
|
101
|
+
* last good copy.
|
|
102
|
+
*/
|
|
103
|
+
export async function writeWorkspaceSelection(
|
|
104
|
+
workspaceId: string,
|
|
105
|
+
selection: WorkspaceSelection,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const parsed = workspaceOfferingsSelectionSchema.safeParse(selection);
|
|
108
|
+
if (!parsed.success) throw new OfferingsShapeError();
|
|
109
|
+
const { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId } =
|
|
110
|
+
parsed.data;
|
|
111
|
+
await prisma.workspaceOfferings.upsert({
|
|
112
|
+
where: { workspaceId },
|
|
113
|
+
create: { workspaceId, shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
|
|
114
|
+
update: { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The last-good snapshot and its time, or `null` for no row. */
|
|
119
|
+
export async function readWorkspaceSnapshot(workspaceId: string): Promise<WorkspaceSnapshotRow | null> {
|
|
120
|
+
return prisma.workspaceOfferings.findUnique({
|
|
121
|
+
where: { workspaceId },
|
|
122
|
+
select: { snapshot: true, snapshotAt: true },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write a parsed snapshot and its time together (the pairing CHECK). Creates
|
|
128
|
+
* no row, and never replaces a snapshot dated after `at` (unless it is dated
|
|
129
|
+
* more than `futureSkewMs` past it, which no reader serves): `false` when
|
|
130
|
+
* nothing was written.
|
|
131
|
+
*/
|
|
132
|
+
export async function writeWorkspaceSnapshot(
|
|
133
|
+
workspaceId: string,
|
|
134
|
+
snapshot: OfferingsSnapshot,
|
|
135
|
+
at: Date,
|
|
136
|
+
futureSkewMs: number,
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
const { count } = await prisma.workspaceOfferings.updateMany({
|
|
139
|
+
where: {
|
|
140
|
+
workspaceId,
|
|
141
|
+
OR: [
|
|
142
|
+
{ snapshotAt: null },
|
|
143
|
+
{ snapshotAt: { lte: at } },
|
|
144
|
+
{ snapshotAt: { gt: new Date(at.getTime() + futureSkewMs) } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
data: { snapshot, snapshotAt: at },
|
|
148
|
+
});
|
|
149
|
+
return count > 0;
|
|
150
|
+
}
|
|
@@ -13,14 +13,27 @@
|
|
|
13
13
|
* match, never overwriting a link.
|
|
14
14
|
* - `recordCheckoutPerson` — the webhook's step: the Person of a completed
|
|
15
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.
|
|
16
20
|
* - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
|
|
17
21
|
* linked to the caller, else the one whose email is the caller's
|
|
18
|
-
* verified email, linked on first read by `stampPersonUser`'s rule.
|
|
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).
|
|
19
25
|
* - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
|
|
20
26
|
* in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
|
|
21
27
|
* only while its `userId` is the caller.
|
|
22
28
|
* - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
|
|
23
|
-
* the Admin's plan route and
|
|
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.
|
|
24
37
|
* - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
|
|
25
38
|
* fields of an uncurated Person (D4); `updatePersonContact` (the
|
|
26
39
|
* operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
|
|
@@ -49,7 +62,8 @@
|
|
|
49
62
|
*
|
|
50
63
|
* The base-tree `lead-routing.ts` imports this file, so it ships in every
|
|
51
64
|
* build and must never reach the paid import closure: it imports
|
|
52
|
-
* `lib/prisma.js`, `verified-email.js`
|
|
65
|
+
* `lib/prisma.js`, `verified-email.js`, `planOfferingSchema` from the
|
|
66
|
+
* base-tree kernel `@working-theory/validation` and types only — never
|
|
53
67
|
* `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
|
|
54
68
|
* find-or-create shape it mirrors) or `lib/tenant-db.ts`.
|
|
55
69
|
* `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
|
|
@@ -58,7 +72,11 @@
|
|
|
58
72
|
* ## Tenancy
|
|
59
73
|
*
|
|
60
74
|
* Every `Person` statement is scoped by a server-resolved `workspaceId`;
|
|
61
|
-
* there is no list read here (the reads are
|
|
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.
|
|
62
80
|
* `recordCheckoutPerson`'s two other reads are by a server-minted
|
|
63
81
|
* `FlowCheckout` id from a signature-verified webhook, matched on that
|
|
64
82
|
* session's own `stripeSessionId`, then that row's lead scoped by the row's
|
|
@@ -80,7 +98,7 @@
|
|
|
80
98
|
|
|
81
99
|
import type { Prisma } from "@working-theory/database";
|
|
82
100
|
import type { Id } from "@working-theory/ids";
|
|
83
|
-
import type
|
|
101
|
+
import { planOfferingSchema, type PersonContactPatch } from "@working-theory/validation";
|
|
84
102
|
|
|
85
103
|
import { prisma } from "../lib/prisma.js";
|
|
86
104
|
|
|
@@ -768,6 +786,13 @@ export type CheckoutPersonOutcome =
|
|
|
768
786
|
| "person_skipped"
|
|
769
787
|
| "person_failed";
|
|
770
788
|
|
|
789
|
+
/** `recordCheckoutPerson`'s outcome plus the Person it found or created (#8765 D14). */
|
|
790
|
+
export type CheckoutPersonResult = {
|
|
791
|
+
outcome: CheckoutPersonOutcome;
|
|
792
|
+
/** The checkout's Person, or `null` when the step skipped or failed. */
|
|
793
|
+
person: { workspaceId: string; personId: string } | null;
|
|
794
|
+
};
|
|
795
|
+
|
|
771
796
|
/**
|
|
772
797
|
* The hub webhook's Person step for a paid `checkout.session.completed` of
|
|
773
798
|
* its OWN site carrying `metadata.flowCheckoutId`.
|
|
@@ -786,8 +811,23 @@ export async function recordCheckoutPerson(input: {
|
|
|
786
811
|
customer: unknown;
|
|
787
812
|
livemode: boolean;
|
|
788
813
|
}): Promise<CheckoutPersonOutcome> {
|
|
814
|
+
return (await resolveCheckoutPerson(input)).outcome;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* `recordCheckoutPerson`, also answering the Person it found or created —
|
|
819
|
+
* the subject of the checkout's terms grant (#8765 D14), which reads the
|
|
820
|
+
* flow checkout's Person from here and never creates one itself.
|
|
821
|
+
*/
|
|
822
|
+
export async function resolveCheckoutPerson(input: {
|
|
823
|
+
flowCheckoutId: string;
|
|
824
|
+
stripeSessionId: string | null | undefined;
|
|
825
|
+
customer: unknown;
|
|
826
|
+
livemode: boolean;
|
|
827
|
+
}): Promise<CheckoutPersonResult> {
|
|
828
|
+
const skipped = { outcome: "person_skipped", person: null } as const;
|
|
789
829
|
if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
|
|
790
|
-
return
|
|
830
|
+
return skipped;
|
|
791
831
|
}
|
|
792
832
|
try {
|
|
793
833
|
const checkout = await prisma.flowCheckout.findFirst({
|
|
@@ -798,13 +838,13 @@ export async function recordCheckoutPerson(input: {
|
|
|
798
838
|
},
|
|
799
839
|
select: { leadId: true, workspaceId: true },
|
|
800
840
|
});
|
|
801
|
-
if (!checkout) return
|
|
841
|
+
if (!checkout) return skipped;
|
|
802
842
|
|
|
803
843
|
const lead = await prisma.lead.findFirst({
|
|
804
844
|
where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
|
|
805
845
|
select: { email: true },
|
|
806
846
|
});
|
|
807
|
-
if (!lead) return
|
|
847
|
+
if (!lead) return skipped;
|
|
808
848
|
|
|
809
849
|
const customerId =
|
|
810
850
|
input.livemode === true && typeof input.customer === "string"
|
|
@@ -815,18 +855,24 @@ export async function recordCheckoutPerson(input: {
|
|
|
815
855
|
...(customerId !== undefined && { billingCustomerId: customerId }),
|
|
816
856
|
});
|
|
817
857
|
if (!result.ok) {
|
|
818
|
-
return result.error.kind === "invalid_email"
|
|
858
|
+
return result.error.kind === "invalid_email"
|
|
859
|
+
? skipped
|
|
860
|
+
: { outcome: "person_failed", person: null };
|
|
819
861
|
}
|
|
862
|
+
const person = {
|
|
863
|
+
workspaceId: checkout.workspaceId,
|
|
864
|
+
personId: result.value.personId,
|
|
865
|
+
};
|
|
820
866
|
switch (result.value.billing) {
|
|
821
867
|
case "written":
|
|
822
|
-
return "person_written";
|
|
868
|
+
return { outcome: "person_written", person };
|
|
823
869
|
case "kept":
|
|
824
|
-
return "person_kept";
|
|
870
|
+
return { outcome: "person_kept", person };
|
|
825
871
|
default:
|
|
826
|
-
return "person_unchanged";
|
|
872
|
+
return { outcome: "person_unchanged", person };
|
|
827
873
|
}
|
|
828
874
|
} catch {
|
|
829
|
-
return "person_failed";
|
|
875
|
+
return { outcome: "person_failed", person: null };
|
|
830
876
|
}
|
|
831
877
|
}
|
|
832
878
|
|
|
@@ -840,6 +886,14 @@ export async function recordCheckoutPerson(input: {
|
|
|
840
886
|
* every other stamp outcome, and a store error, is `null` too, logged by its
|
|
841
887
|
* kind only. Never throws.
|
|
842
888
|
*
|
|
889
|
+
* A fresh `stamped` link — and only that — releases the Person's held
|
|
890
|
+
* checkout plan (#8793 D2, `releaseHeldCheckoutPlan`): the caller has just
|
|
891
|
+
* proven they own the email the purchases were made under. A Person linked
|
|
892
|
+
* earlier, a refused stamp and every other caller of `stampPersonUser` (the
|
|
893
|
+
* Inbox accept above all) release nothing. The release never changes the
|
|
894
|
+
* answer; an `ambiguous` or `internal_error` release is logged by its kind
|
|
895
|
+
* only, as `{ event: "checkout-plan-release", outcome }`.
|
|
896
|
+
*
|
|
843
897
|
* @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
|
|
844
898
|
* @param userId the authenticated caller.
|
|
845
899
|
* @param verifiedEmail the caller's verified address, read by the caller.
|
|
@@ -870,7 +924,19 @@ export async function resolveMemberPersonId(
|
|
|
870
924
|
if (!byEmail) return null;
|
|
871
925
|
|
|
872
926
|
const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
|
|
873
|
-
if (outcome === "stamped"
|
|
927
|
+
if (outcome === "stamped") {
|
|
928
|
+
const release = await releaseHeldCheckoutPlan(
|
|
929
|
+
workspaceId,
|
|
930
|
+
byEmail.id,
|
|
931
|
+
userId,
|
|
932
|
+
normalizePersonEmail(verifiedEmail),
|
|
933
|
+
);
|
|
934
|
+
if (release === "ambiguous" || release === "internal_error") {
|
|
935
|
+
log?.warn({ event: "checkout-plan-release", outcome: release }, "[person] held checkout plan not released");
|
|
936
|
+
}
|
|
937
|
+
return byEmail.id;
|
|
938
|
+
}
|
|
939
|
+
if (outcome === "already_linked") return byEmail.id;
|
|
874
940
|
log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
|
|
875
941
|
return null;
|
|
876
942
|
}
|
|
@@ -911,17 +977,28 @@ export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
|
|
|
911
977
|
|
|
912
978
|
/**
|
|
913
979
|
* Set — or, with `null`, clear — a Person's plan: the ONE writer of
|
|
914
|
-
* `Person.planOffering
|
|
915
|
-
*
|
|
980
|
+
* `Person.planOffering`, called by the Admin's plan route and the held
|
|
981
|
+
* checkout plan's release (#8793). One `updateMany` scoped by the
|
|
982
|
+
* server-resolved workspace; no row → `not_found`. Never throws.
|
|
983
|
+
*
|
|
984
|
+
* With a `guard` (#8793 D5, the release's own write) the statement also
|
|
985
|
+
* requires a blank plan and the Person still linked to `guard.linkedUserId`,
|
|
986
|
+
* so a plan already set — or one the operator PUTs at the same moment — wins
|
|
987
|
+
* and the answer is `not_found`. Without one it is the operator's overwrite.
|
|
916
988
|
*/
|
|
917
989
|
export async function setPersonPlanOffering(
|
|
918
990
|
workspaceId: string,
|
|
919
991
|
personId: string,
|
|
920
992
|
planOffering: string | null,
|
|
993
|
+
guard?: { linkedUserId: string },
|
|
921
994
|
): Promise<SetPlanOfferingOutcome> {
|
|
922
995
|
try {
|
|
923
996
|
const { count } = await prisma.person.updateMany({
|
|
924
|
-
where: {
|
|
997
|
+
where: {
|
|
998
|
+
id: personId,
|
|
999
|
+
workspaceId,
|
|
1000
|
+
...(guard !== undefined && { planOffering: null, userId: guard.linkedUserId }),
|
|
1001
|
+
},
|
|
925
1002
|
data: { planOffering },
|
|
926
1003
|
});
|
|
927
1004
|
return count === 0 ? "not_found" : "set";
|
|
@@ -930,6 +1007,76 @@ export async function setPersonPlanOffering(
|
|
|
930
1007
|
}
|
|
931
1008
|
}
|
|
932
1009
|
|
|
1010
|
+
// ─── The held checkout plan (#8793 D3, D4) ───────────────────────────────────
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* A spoke checkout's plan key: the offering minus a leading `<site>_` (when
|
|
1014
|
+
* the site is a string), answered only when `planOfferingSchema` — the key
|
|
1015
|
+
* the operator's PUT accepts — admits it; else `null`. Shared by the
|
|
1016
|
+
* checkout's hold (`lead-routing.ts`) and the release below.
|
|
1017
|
+
*/
|
|
1018
|
+
export function checkoutPlanKey(offering: unknown, site: unknown): string | null {
|
|
1019
|
+
if (typeof offering !== "string") return null;
|
|
1020
|
+
const prefix = typeof site === "string" ? `${site}_` : null;
|
|
1021
|
+
const key = prefix !== null && offering.startsWith(prefix) ? offering.slice(prefix.length) : offering;
|
|
1022
|
+
return planOfferingSchema.safeParse(key).success ? key : null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export type HeldPlanOutcome = "set" | "none" | "ambiguous" | "kept" | "internal_error";
|
|
1026
|
+
|
|
1027
|
+
/** The most held purchases a release weighs; one more is `ambiguous`. */
|
|
1028
|
+
const HELD_PURCHASES_READ = 50;
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Release a Person's held checkout plan (#8793 D3) — called only by
|
|
1032
|
+
* `resolveMemberPersonId` on a fresh `stamped` link, so `verifiedEmail` (the
|
|
1033
|
+
* caller's, normalized) is the Person's own. The Person's marked spoke
|
|
1034
|
+
* checkout leads (`Lead.spokeCheckout`, newest 51) under that email are taken
|
|
1035
|
+
* as a SET: one distinct plan key is written to a blank plan through the
|
|
1036
|
+
* guarded `setPersonPlanOffering` (`set`, or `kept` when the guard matched
|
|
1037
|
+
* nothing — a plan was already there, or the Person is no longer linked to
|
|
1038
|
+
* `userId`); no key is `none`; more than one key, or more than 50 leads, is
|
|
1039
|
+
* `ambiguous` and writes nothing — the operator sets the plan. So a
|
|
1040
|
+
* stranger's purchase under the owner's email can deny the automatic plan,
|
|
1041
|
+
* never choose it (SEC-8793-A). A store error is `internal_error`. Never
|
|
1042
|
+
* throws, and logs nothing: the caller logs the kind.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function releaseHeldCheckoutPlan(
|
|
1045
|
+
workspaceId: string,
|
|
1046
|
+
personId: string,
|
|
1047
|
+
userId: string,
|
|
1048
|
+
verifiedEmail: string,
|
|
1049
|
+
): Promise<HeldPlanOutcome> {
|
|
1050
|
+
try {
|
|
1051
|
+
const rows = await prisma.lead.findMany({
|
|
1052
|
+
where: { workspaceId, personId, spokeCheckout: true },
|
|
1053
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
1054
|
+
take: HELD_PURCHASES_READ + 1,
|
|
1055
|
+
select: { email: true, payload: true },
|
|
1056
|
+
});
|
|
1057
|
+
if (rows.length > HELD_PURCHASES_READ) return "ambiguous";
|
|
1058
|
+
|
|
1059
|
+
const keys = new Set<string>();
|
|
1060
|
+
for (const row of rows) {
|
|
1061
|
+
// The purchase read's own rule: a purchase is its Person's only under
|
|
1062
|
+
// the Person's own email.
|
|
1063
|
+
if (normalizePersonEmail(row.email) !== verifiedEmail) continue;
|
|
1064
|
+
const payload = (row.payload ?? {}) as Record<string, unknown>;
|
|
1065
|
+
const key = checkoutPlanKey(payload["offering"], payload["site"]);
|
|
1066
|
+
if (key !== null) keys.add(key);
|
|
1067
|
+
}
|
|
1068
|
+
if (keys.size > 1) return "ambiguous";
|
|
1069
|
+
const [key] = keys;
|
|
1070
|
+
if (key === undefined) return "none";
|
|
1071
|
+
|
|
1072
|
+
const written = await setPersonPlanOffering(workspaceId, personId, key, { linkedUserId: userId });
|
|
1073
|
+
if (written === "set") return "set";
|
|
1074
|
+
return written === "not_found" ? "kept" : "internal_error";
|
|
1075
|
+
} catch {
|
|
1076
|
+
return "internal_error";
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
933
1080
|
// ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
|
|
934
1081
|
|
|
935
1082
|
export type SavedContact = {
|