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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file offerings-shown-price-ids-migration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* DB-free proof of the offerings_shown_price_ids migration's shape (#9070,
|
|
5
|
+
* spec docs/specs/9070-the-workspace-offerings-page-becomes-spec.md D1,
|
|
6
|
+
* AC-6). Reads the migration's own text and the config schema: it adds
|
|
7
|
+
* exactly one column to workspace_offerings, NOT NULL with the empty-array
|
|
8
|
+
* default, and exactly two CHECKs, each added NOT VALID then VALIDATEd — and
|
|
9
|
+
* creates, drops and updates nothing else, so no existing row is rewritten.
|
|
10
|
+
* The live-database half is `offerings.db.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const MIGRATION = join(
|
|
16
|
+
__dirname,
|
|
17
|
+
"..",
|
|
18
|
+
"migrations",
|
|
19
|
+
"20261023120000_offerings_shown_price_ids",
|
|
20
|
+
"migration.sql",
|
|
21
|
+
);
|
|
22
|
+
const SCHEMA = join(__dirname, "..", "config", "schema.prisma");
|
|
23
|
+
|
|
24
|
+
const CHECKS = [
|
|
25
|
+
"workspace_offerings_shown_price_ids_bound",
|
|
26
|
+
"workspace_offerings_shown_prices_need_products",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** The migration's statements, comments stripped, whitespace collapsed. */
|
|
30
|
+
function statements(): string[] {
|
|
31
|
+
const text = readFileSync(MIGRATION, "utf8")
|
|
32
|
+
.split("\n")
|
|
33
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
34
|
+
.join("\n");
|
|
35
|
+
return text
|
|
36
|
+
.split(";")
|
|
37
|
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("the offerings_shown_price_ids migration (#9070 D1, AC-6)", () => {
|
|
42
|
+
it("adds exactly one column, NOT NULL with the empty-array default", () => {
|
|
43
|
+
const adds = statements().filter((s) => / ADD COLUMN /.test(s));
|
|
44
|
+
expect(adds).toEqual([
|
|
45
|
+
`ALTER TABLE "workspace_offerings" ADD COLUMN "shown_price_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`,
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("adds exactly the two CHECKs, each NOT VALID then VALIDATEd", () => {
|
|
50
|
+
const added = statements()
|
|
51
|
+
.map((s) => /^ALTER TABLE "workspace_offerings" ADD CONSTRAINT "([a-z0-9_]+)" CHECK .* NOT VALID$/.exec(s)?.[1])
|
|
52
|
+
.filter((name): name is string => name !== undefined);
|
|
53
|
+
expect(added).toEqual(CHECKS);
|
|
54
|
+
const validated = statements()
|
|
55
|
+
.map((s) => /^ALTER TABLE "workspace_offerings" VALIDATE CONSTRAINT "([a-z0-9_]+)"$/.exec(s)?.[1])
|
|
56
|
+
.filter((name): name is string => name !== undefined);
|
|
57
|
+
expect(validated).toEqual(CHECKS);
|
|
58
|
+
expect(statements()).toHaveLength(1 + 2 * CHECKS.length);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("pins each CHECK's rule", () => {
|
|
62
|
+
const sql = statements().join("\n");
|
|
63
|
+
const rule = (name: string) =>
|
|
64
|
+
new RegExp(`ADD CONSTRAINT "${name}" CHECK (.*) NOT VALID`).exec(sql)?.[1];
|
|
65
|
+
expect(rule("workspace_offerings_shown_price_ids_bound")).toBe(
|
|
66
|
+
`(cardinality("shown_price_ids") <= 300 AND array_position("shown_price_ids", NULL) IS NULL)`,
|
|
67
|
+
);
|
|
68
|
+
expect(rule("workspace_offerings_shown_prices_need_products")).toBe(
|
|
69
|
+
`(cardinality("shown_price_ids") = 0 OR cardinality("shown_product_ids") > 0)`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("creates, drops and updates nothing, and alters no other table", () => {
|
|
74
|
+
for (const s of statements()) {
|
|
75
|
+
expect(s).not.toMatch(/^(UPDATE|INSERT|DELETE|DROP|TRUNCATE|CREATE)\b/);
|
|
76
|
+
expect(s).toMatch(/^ALTER TABLE "workspace_offerings" /);
|
|
77
|
+
expect(s).not.toMatch(/ DROP /);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("the model maps the column with the empty-list default", () => {
|
|
82
|
+
expect(readFileSync(SCHEMA, "utf8")).toMatch(
|
|
83
|
+
/^ {2}shownPriceIds +String\[\] +@default\(\[\]\) @map\("shown_price_ids"\)$/m,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file offerings.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the three Offerings tables' constraints (#8876, spec
|
|
5
|
+
* docs/specs/8876-the-offerings-data-model-typed-spec.md D1, D2, AC-1).
|
|
6
|
+
* Requires DATABASE_URL pointing at a Postgres with the migrations applied —
|
|
7
|
+
* SKIPS when DATABASE_URL is unset (the repo convention; the `apps/api Route
|
|
8
|
+
* Tests (Postgres)` workflow exercises it).
|
|
9
|
+
*
|
|
10
|
+
* Uses the `pg` driver directly, in ONE transaction that is rolled back, so no
|
|
11
|
+
* row outlives the suite. Each refusal runs in its own savepoint and names the
|
|
12
|
+
* constraint Postgres reports, so a refusal for the wrong reason fails.
|
|
13
|
+
*/
|
|
14
|
+
import { Client } from "pg";
|
|
15
|
+
|
|
16
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
17
|
+
|
|
18
|
+
const ORG = "org_01920000-8876-7000-8000-0000000000a1";
|
|
19
|
+
const ORG_2 = "org_01920000-8876-7000-8000-0000000000a2";
|
|
20
|
+
const WS = "wksp_01920000-8876-7000-8000-0000000000b1";
|
|
21
|
+
const WS_2 = "wksp_01920000-8876-7000-8000-0000000000b2";
|
|
22
|
+
/** The fixture's placeholder: the envelope shape, decrypting under no key. */
|
|
23
|
+
const PLACEHOLDER = "v1.Zml4dHVyZWl2ODg3.Zml4dHVyZS10YWctODg3Ng.Y2lwaGVydGV4dA";
|
|
24
|
+
/** The placeholder's `v1.<iv>.<tag>` head, to wrap a cleartext value in. */
|
|
25
|
+
const ENVELOPE_HEAD = PLACEHOLDER.split(".").slice(0, 3).join(".");
|
|
26
|
+
const TOKEN = "A".repeat(43);
|
|
27
|
+
const TOKEN_2 = "B".repeat(43);
|
|
28
|
+
|
|
29
|
+
describeDb("the Offerings tables (#8876 D1, D2, AC-1, live DB)", () => {
|
|
30
|
+
let db: Client;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
34
|
+
await db.connect();
|
|
35
|
+
await db.query("BEGIN");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await db.query("ROLLBACK");
|
|
40
|
+
await db.end();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Run `run` inside a savepoint; resolve to the violated constraint's name, or null. */
|
|
44
|
+
const violation = async (run: () => Promise<unknown>): Promise<string | null> => {
|
|
45
|
+
await db.query("SAVEPOINT attempt");
|
|
46
|
+
try {
|
|
47
|
+
await run();
|
|
48
|
+
await db.query("ROLLBACK TO SAVEPOINT attempt");
|
|
49
|
+
return null;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
await db.query("ROLLBACK TO SAVEPOINT attempt");
|
|
52
|
+
return (err as { constraint?: string }).constraint ?? String(err);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Insert one row of `table` from `cols` (plus `updated_at`). */
|
|
57
|
+
const insert = (table: string, cols: Record<string, unknown>) => {
|
|
58
|
+
const all: Record<string, unknown> = { updated_at: new Date(), ...cols };
|
|
59
|
+
const names = Object.keys(all);
|
|
60
|
+
return db.query(
|
|
61
|
+
`INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")})
|
|
62
|
+
VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
|
|
63
|
+
names.map((n) => all[n]),
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const connection = (cols: Record<string, unknown> = {}) =>
|
|
68
|
+
insert("stripe_connections", {
|
|
69
|
+
organization_id: ORG,
|
|
70
|
+
mode: "test",
|
|
71
|
+
key_last4: "4242",
|
|
72
|
+
credential_ciphertext: PLACEHOLDER,
|
|
73
|
+
webhook_token: TOKEN,
|
|
74
|
+
...cols,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const workspaceOfferings = (cols: Record<string, unknown> = {}) =>
|
|
78
|
+
insert("workspace_offerings", { workspace_id: WS, ...cols });
|
|
79
|
+
|
|
80
|
+
describe("stripe_connections", () => {
|
|
81
|
+
it("accepts the fixture's envelope-shaped placeholder and mints a prefixed id", async () => {
|
|
82
|
+
await db.query("SAVEPOINT ok");
|
|
83
|
+
const { rows } = await connection({ webhook_secret_ciphertext: PLACEHOLDER });
|
|
84
|
+
expect(rows[0].id).toMatch(/^stripecon_[0-9a-f]{8}-[0-9a-f]{4}-7/);
|
|
85
|
+
await db.query("ROLLBACK TO SAVEPOINT ok");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("accepts a live row with its go-live time", async () => {
|
|
89
|
+
expect(await violation(() => connection({ mode: "live", live_at: new Date() }))).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("refuses a second row for one Organization", async () => {
|
|
93
|
+
expect(
|
|
94
|
+
await violation(async () => {
|
|
95
|
+
await connection();
|
|
96
|
+
await connection({ webhook_token: TOKEN_2 });
|
|
97
|
+
}),
|
|
98
|
+
).toBe("stripe_connections_organization_id_key");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("refuses a second row with one webhook token", async () => {
|
|
102
|
+
expect(
|
|
103
|
+
await violation(async () => {
|
|
104
|
+
await connection();
|
|
105
|
+
await connection({ organization_id: ORG_2 });
|
|
106
|
+
}),
|
|
107
|
+
).toBe("stripe_connections_webhook_token_key");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it.each([
|
|
111
|
+
["a mode of prod", { mode: "prod" }, "stripe_connections_mode_domain"],
|
|
112
|
+
["a live row with no live_at", { mode: "live" }, "stripe_connections_live_at_pair"],
|
|
113
|
+
["a test row with a live_at", { live_at: new Date() }, "stripe_connections_live_at_pair"],
|
|
114
|
+
["a token of 42 characters", { webhook_token: "A".repeat(42) }, "stripe_connections_webhook_token_shape"],
|
|
115
|
+
["a token with a dot", { webhook_token: `${"A".repeat(42)}.` }, "stripe_connections_webhook_token_shape"],
|
|
116
|
+
["a last4 of three characters", { key_last4: "424" }, "stripe_connections_key_last4_shape"],
|
|
117
|
+
["a last4 with a symbol", { key_last4: "42_2" }, "stripe_connections_key_last4_shape"],
|
|
118
|
+
[
|
|
119
|
+
"a credential stored in clear",
|
|
120
|
+
{ credential_ciphertext: `rk_test_${"a1B2".repeat(24)}` },
|
|
121
|
+
"stripe_connections_credential_envelope",
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
"a live credential stored in clear",
|
|
125
|
+
{ credential_ciphertext: `sk_${"live"}_${"a1B2".repeat(24)}` },
|
|
126
|
+
"stripe_connections_credential_envelope",
|
|
127
|
+
],
|
|
128
|
+
[
|
|
129
|
+
"a webhook secret stored in clear",
|
|
130
|
+
{ webhook_secret_ciphertext: `whsec_${"a1B2".repeat(8)}` },
|
|
131
|
+
"stripe_connections_webhook_secret_envelope",
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
"a live key wrapped in the envelope's dots",
|
|
135
|
+
{ credential_ciphertext: `${ENVELOPE_HEAD}.rk_${"live"}_${"a1B2".repeat(24)}` },
|
|
136
|
+
"stripe_connections_credential_envelope",
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
"a test key wrapped in the envelope's dots",
|
|
140
|
+
{ credential_ciphertext: `${ENVELOPE_HEAD}.rk_test_${"a1B2".repeat(24)}` },
|
|
141
|
+
"stripe_connections_credential_envelope",
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
"an IV that is not 12 bytes",
|
|
145
|
+
{ credential_ciphertext: "v1.Zml4dHVyZQ.Zml4dHVyZS10YWctODg3Ng.Y2lwaGVydGV4dA" },
|
|
146
|
+
"stripe_connections_credential_envelope",
|
|
147
|
+
],
|
|
148
|
+
[
|
|
149
|
+
"a webhook secret wrapped in the envelope's dots",
|
|
150
|
+
{ webhook_secret_ciphertext: `${ENVELOPE_HEAD}.whsec_${"a1B2".repeat(8)}` },
|
|
151
|
+
"stripe_connections_webhook_secret_envelope",
|
|
152
|
+
],
|
|
153
|
+
["a malformed organization_id", { organization_id: "org_1" }, "stripe_connections_organization_id_format"],
|
|
154
|
+
[
|
|
155
|
+
"a workspace id as organization_id",
|
|
156
|
+
{ organization_id: WS },
|
|
157
|
+
"stripe_connections_organization_id_format",
|
|
158
|
+
],
|
|
159
|
+
])("refuses %s", async (_label, cols, constraint) => {
|
|
160
|
+
expect(await violation(() => connection(cols))).toBe(constraint);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("organization_offerings", () => {
|
|
165
|
+
const orgOfferings = (cols: Record<string, unknown> = {}) =>
|
|
166
|
+
insert("organization_offerings", { organization_id: ORG, ...cols });
|
|
167
|
+
|
|
168
|
+
it("defaults to the empty set and accepts 100 ids", async () => {
|
|
169
|
+
expect(await violation(() => orgOfferings())).toBeNull();
|
|
170
|
+
const hundred = Array.from({ length: 100 }, (_, i) => `prod_${i}`);
|
|
171
|
+
expect(await violation(() => orgOfferings({ enabled_product_ids: hundred }))).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it.each([
|
|
175
|
+
[
|
|
176
|
+
"more than 100 ids",
|
|
177
|
+
{ enabled_product_ids: Array.from({ length: 101 }, (_, i) => `prod_${i}`) },
|
|
178
|
+
"organization_offerings_enabled_product_ids_bound",
|
|
179
|
+
],
|
|
180
|
+
["a NULL set", { enabled_product_ids: null }, "organization_offerings_enabled_product_ids_bound"],
|
|
181
|
+
[
|
|
182
|
+
"a NULL element",
|
|
183
|
+
{ enabled_product_ids: ["prod_a", null] },
|
|
184
|
+
"organization_offerings_enabled_product_ids_bound",
|
|
185
|
+
],
|
|
186
|
+
["a malformed organization_id", { organization_id: "org_x" }, "organization_offerings_organization_id_format"],
|
|
187
|
+
])("refuses %s", async (_label, cols, constraint) => {
|
|
188
|
+
expect(await violation(() => orgOfferings(cols))).toBe(constraint);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("refuses a second row for one Organization", async () => {
|
|
192
|
+
expect(
|
|
193
|
+
await violation(async () => {
|
|
194
|
+
await orgOfferings();
|
|
195
|
+
await orgOfferings();
|
|
196
|
+
}),
|
|
197
|
+
).toBe("organization_offerings_organization_id_key");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("workspace_offerings", () => {
|
|
202
|
+
it("accepts a shown set with Recommended and a snapshot with its time", async () => {
|
|
203
|
+
expect(
|
|
204
|
+
await violation(() =>
|
|
205
|
+
workspaceOfferings({
|
|
206
|
+
shown_product_ids: ["prod_growth", "prod_seo"],
|
|
207
|
+
recommended_plan_product_id: "prod_growth",
|
|
208
|
+
recommended_addon_product_id: "prod_seo",
|
|
209
|
+
snapshot: JSON.stringify({ v: 1, offerings: [] }),
|
|
210
|
+
snapshot_at: new Date(),
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it.each([
|
|
217
|
+
[
|
|
218
|
+
"a Recommended plan not in the shown set",
|
|
219
|
+
{ shown_product_ids: ["prod_seo"], recommended_plan_product_id: "prod_growth" },
|
|
220
|
+
"workspace_offerings_recommended_plan_shown",
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
"a Recommended add-on not in the shown set",
|
|
224
|
+
{ shown_product_ids: ["prod_growth"], recommended_addon_product_id: "prod_seo" },
|
|
225
|
+
"workspace_offerings_recommended_addon_shown",
|
|
226
|
+
],
|
|
227
|
+
[
|
|
228
|
+
"more than 100 ids",
|
|
229
|
+
{ shown_product_ids: Array.from({ length: 101 }, (_, i) => `prod_${i}`) },
|
|
230
|
+
"workspace_offerings_shown_product_ids_bound",
|
|
231
|
+
],
|
|
232
|
+
["a NULL element", { shown_product_ids: [null] }, "workspace_offerings_shown_product_ids_bound"],
|
|
233
|
+
[
|
|
234
|
+
"a snapshot without snapshot_at",
|
|
235
|
+
{ snapshot: JSON.stringify({ v: 1, offerings: [] }) },
|
|
236
|
+
"workspace_offerings_snapshot_pair",
|
|
237
|
+
],
|
|
238
|
+
["a snapshot_at without a snapshot", { snapshot_at: new Date() }, "workspace_offerings_snapshot_pair"],
|
|
239
|
+
["a malformed workspace_id", { workspace_id: "wksp_1" }, "workspace_offerings_workspace_id_format"],
|
|
240
|
+
])("refuses %s", async (_label, cols, constraint) => {
|
|
241
|
+
expect(await violation(() => workspaceOfferings(cols))).toBe(constraint);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("shown_price_ids (#9070 D1, AC-6)", () => {
|
|
245
|
+
it("reads the empty set for a row inserted without it", async () => {
|
|
246
|
+
await db.query("SAVEPOINT attempt");
|
|
247
|
+
const { rows } = await db.query(
|
|
248
|
+
`INSERT INTO "workspace_offerings" ("workspace_id", "shown_product_ids", "updated_at")
|
|
249
|
+
VALUES ($1, $2, now()) RETURNING "shown_price_ids"`,
|
|
250
|
+
[WS, ["prod_growth"]],
|
|
251
|
+
);
|
|
252
|
+
await db.query("ROLLBACK TO SAVEPOINT attempt");
|
|
253
|
+
expect(rows[0].shown_price_ids).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("accepts 300 Price ids beside one Product", async () => {
|
|
257
|
+
expect(
|
|
258
|
+
await violation(() =>
|
|
259
|
+
workspaceOfferings({
|
|
260
|
+
shown_product_ids: ["prod_growth"],
|
|
261
|
+
shown_price_ids: Array.from({ length: 300 }, (_, i) => `price_${i}`),
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it.each([
|
|
268
|
+
[
|
|
269
|
+
"301 Price ids",
|
|
270
|
+
{ shown_product_ids: ["prod_growth"], shown_price_ids: Array.from({ length: 301 }, (_, i) => `price_${i}`) },
|
|
271
|
+
"workspace_offerings_shown_price_ids_bound",
|
|
272
|
+
],
|
|
273
|
+
[
|
|
274
|
+
"a NULL Price id",
|
|
275
|
+
{ shown_product_ids: ["prod_growth"], shown_price_ids: [null] },
|
|
276
|
+
"workspace_offerings_shown_price_ids_bound",
|
|
277
|
+
],
|
|
278
|
+
[
|
|
279
|
+
"a Price id with no shown Product",
|
|
280
|
+
{ shown_product_ids: [], shown_price_ids: ["price_growth_yearly"] },
|
|
281
|
+
"workspace_offerings_shown_prices_need_products",
|
|
282
|
+
],
|
|
283
|
+
])("refuses %s", async (_label, cols, constraint) => {
|
|
284
|
+
expect(await violation(() => workspaceOfferings(cols))).toBe(constraint);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("refuses a second row for one workspace and accepts another workspace's", async () => {
|
|
289
|
+
expect(
|
|
290
|
+
await violation(async () => {
|
|
291
|
+
await workspaceOfferings();
|
|
292
|
+
await workspaceOfferings();
|
|
293
|
+
}),
|
|
294
|
+
).toBe("workspace_offerings_workspace_id_key");
|
|
295
|
+
expect(
|
|
296
|
+
await violation(async () => {
|
|
297
|
+
await workspaceOfferings();
|
|
298
|
+
await workspaceOfferings({ workspace_id: WS_2 });
|
|
299
|
+
}),
|
|
300
|
+
).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -13,9 +13,14 @@
|
|
|
13
13
|
import {
|
|
14
14
|
CONTACT_FORM_FLOW_SLUG,
|
|
15
15
|
contactFormFlowConfig,
|
|
16
|
+
JOB_APPLICATION_FLOW_SLUG,
|
|
17
|
+
jobApplicationFlowConfig,
|
|
18
|
+
NEWSLETTER_FLOW_SLUG,
|
|
19
|
+
newsletterFlowConfig,
|
|
16
20
|
REQUEST_QUOTE_FLOW_SLUG,
|
|
17
21
|
REQUEST_QUOTE_FLOW_TITLE,
|
|
18
22
|
requestQuoteFlowConfig,
|
|
23
|
+
STANDARD_MAGNET_COMPLETIONS,
|
|
19
24
|
} from "@working-theory/validation";
|
|
20
25
|
|
|
21
26
|
import {
|
|
@@ -109,7 +114,7 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
|
|
|
109
114
|
|
|
110
115
|
await provisionDefaultWorkspace(prisma);
|
|
111
116
|
|
|
112
|
-
expect(flowDefinitionUpsert).toHaveBeenCalledTimes(
|
|
117
|
+
expect(flowDefinitionUpsert).toHaveBeenCalledTimes(4);
|
|
113
118
|
expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(1, {
|
|
114
119
|
where: {
|
|
115
120
|
siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: REQUEST_QUOTE_FLOW_SLUG },
|
|
@@ -123,6 +128,7 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
|
|
|
123
128
|
title: REQUEST_QUOTE_FLOW_TITLE,
|
|
124
129
|
status: "draft",
|
|
125
130
|
customersEnabled: true,
|
|
131
|
+
completion: STANDARD_MAGNET_COMPLETIONS[REQUEST_QUOTE_FLOW_SLUG],
|
|
126
132
|
},
|
|
127
133
|
// Empty: an existing (site_id, slug) row is never overwritten (D8, C6).
|
|
128
134
|
update: {},
|
|
@@ -160,6 +166,7 @@ describe("provisionDefaultWorkspace — the Contact form magnet (#8661 D3, AC-1)
|
|
|
160
166
|
title: "Contact form",
|
|
161
167
|
status: "live",
|
|
162
168
|
customersEnabled: false,
|
|
169
|
+
completion: STANDARD_MAGNET_COMPLETIONS[CONTACT_FORM_FLOW_SLUG],
|
|
163
170
|
landingPage: "/contact",
|
|
164
171
|
},
|
|
165
172
|
// Empty: a re-run never overwrites the owner's edits.
|
|
@@ -175,3 +182,59 @@ describe("provisionDefaultWorkspace — the Contact form magnet (#8661 D3, AC-1)
|
|
|
175
182
|
expect(flowDefinitionUpsert).not.toHaveBeenCalled();
|
|
176
183
|
});
|
|
177
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("provisionDefaultWorkspace — the Job application magnet (#8746 D3, AC-2)", () => {
|
|
187
|
+
it("upserts the default workspace's live Job application, insert-if-absent", async () => {
|
|
188
|
+
const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
|
|
189
|
+
|
|
190
|
+
await provisionDefaultWorkspace(prisma);
|
|
191
|
+
|
|
192
|
+
expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(3, {
|
|
193
|
+
where: {
|
|
194
|
+
siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: JOB_APPLICATION_FLOW_SLUG },
|
|
195
|
+
},
|
|
196
|
+
create: {
|
|
197
|
+
siteId: DEFAULT_WORKSPACE_ID,
|
|
198
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
199
|
+
slug: "job-application",
|
|
200
|
+
config: jobApplicationFlowConfig,
|
|
201
|
+
kind: "diagnostic",
|
|
202
|
+
title: "Job application",
|
|
203
|
+
status: "live",
|
|
204
|
+
customersEnabled: false,
|
|
205
|
+
completion: STANDARD_MAGNET_COMPLETIONS[JOB_APPLICATION_FLOW_SLUG],
|
|
206
|
+
landingPage: "/careers",
|
|
207
|
+
},
|
|
208
|
+
// Empty: a re-run never overwrites the owner's edits.
|
|
209
|
+
update: {},
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("provisionDefaultWorkspace — the newsletter magnet (#8847 D4, AC-5)", () => {
|
|
215
|
+
it("upserts the default workspace's live newsletter, insert-if-absent", async () => {
|
|
216
|
+
const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
|
|
217
|
+
|
|
218
|
+
await provisionDefaultWorkspace(prisma);
|
|
219
|
+
|
|
220
|
+
expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(4, {
|
|
221
|
+
where: {
|
|
222
|
+
siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: NEWSLETTER_FLOW_SLUG },
|
|
223
|
+
},
|
|
224
|
+
create: {
|
|
225
|
+
siteId: DEFAULT_WORKSPACE_ID,
|
|
226
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
227
|
+
slug: "newsletter",
|
|
228
|
+
config: newsletterFlowConfig,
|
|
229
|
+
kind: "newsletter",
|
|
230
|
+
title: "Newsletter",
|
|
231
|
+
status: "live",
|
|
232
|
+
customersEnabled: false,
|
|
233
|
+
completion: STANDARD_MAGNET_COMPLETIONS[NEWSLETTER_FLOW_SLUG],
|
|
234
|
+
landingPage: "/newsletter",
|
|
235
|
+
},
|
|
236
|
+
// Empty: a re-run never overwrites the owner's edits.
|
|
237
|
+
update: {},
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -55,6 +55,10 @@ model Organization {
|
|
|
55
55
|
tier String @default("free") // "free" | "pro" | "pro_plus" | "enterprise"
|
|
56
56
|
hasCarePlan Boolean @default(false) @map("has_care_plan")
|
|
57
57
|
seatCount Int @default(1) @map("seat_count")
|
|
58
|
+
// #9067 D13 — the one plan Checkout this Organization has open, recorded by
|
|
59
|
+
// POST /v1/billing/plan-checkout. The webhook binds billingCustomerId only
|
|
60
|
+
// for this session, into an empty column, and clears it. Server-side only.
|
|
61
|
+
planCheckoutSessionId String? @unique @map("plan_checkout_session_id")
|
|
58
62
|
|
|
59
63
|
// Usage-billing balance counters — the Company-counter live on-demand
|
|
60
64
|
// credit model (FEAT-047 P1.S2, #3710 — capture D6, NOT a CreditUsagePeriod
|
|
@@ -175,6 +175,97 @@ model SchedulingConnection {
|
|
|
175
175
|
@@map("scheduling_connections")
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/// StripeConnection — an Organization's own Stripe account, by a restricted
|
|
179
|
+
/// key (#8876 D1; contract docs/specs/8879-the-offerings-security-contract-key-spec.md
|
|
180
|
+
/// C1). One row per Organization on the SchedulingConnection shape: a plain
|
|
181
|
+
/// `organizationId @unique` scalar, no relation, no foreign key (ADR 0009
|
|
182
|
+
/// invariant 1). In hub-and-spoke every workspace reads its Organization's
|
|
183
|
+
/// one row; a single-tenant site's sentinel Organization holds its own — no
|
|
184
|
+
/// workspace-scoped key column exists anywhere. The key and the webhook
|
|
185
|
+
/// secret are `v1.<iv>.<tag>.<ct>` ciphertext (contract C2), written only by
|
|
186
|
+
/// apps/api's services/offerings/connection.ts; the migration's envelope
|
|
187
|
+
/// CHECKs refuse a value stored in clear. Every reader sees `mode` and
|
|
188
|
+
/// `keyLast4` only. `webhookToken` is a 256-bit routing id, not an
|
|
189
|
+
/// authenticator. Keep every `@map` in lockstep with the migration's column
|
|
190
|
+
/// names (see WorkspaceMeetings).
|
|
191
|
+
model StripeConnection {
|
|
192
|
+
id String @id @default(dbgenerated("('stripecon_'::text || (uuidv7())::text)")) @db.Text
|
|
193
|
+
organizationId String @unique @map("organization_id")
|
|
194
|
+
mode String @db.VarChar(8)
|
|
195
|
+
keyLast4 String @map("key_last4") @db.VarChar(4)
|
|
196
|
+
credentialCiphertext String @map("credential_ciphertext") @db.Text
|
|
197
|
+
webhookToken String @unique @map("webhook_token") @db.VarChar(64)
|
|
198
|
+
webhookSecretCiphertext String? @map("webhook_secret_ciphertext") @db.Text
|
|
199
|
+
liveAt DateTime? @map("live_at") @db.Timestamptz
|
|
200
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
201
|
+
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
202
|
+
|
|
203
|
+
@@map("stripe_connections")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// OrganizationOfferings — the Stripe Products an Organization has switched
|
|
207
|
+
/// on for its sites (#8876 D1, C2): the org-enabled set. Its own row, not a
|
|
208
|
+
/// column on the connection, because disconnecting deletes the connection and
|
|
209
|
+
/// keeps this set (contract C8). One row per Organization; no row is the
|
|
210
|
+
/// empty set.
|
|
211
|
+
model OrganizationOfferings {
|
|
212
|
+
id String @id @default(dbgenerated("('orgoffer_'::text || (uuidv7())::text)")) @db.Text
|
|
213
|
+
organizationId String @unique @map("organization_id")
|
|
214
|
+
enabledProductIds String[] @default([]) @map("enabled_product_ids")
|
|
215
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
216
|
+
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
217
|
+
|
|
218
|
+
@@map("organization_offerings")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// WorkspaceOfferings — what one workspace shows of its Organization's
|
|
222
|
+
/// enabled Products (#8876 D1): the shown set, Recommended per group, and the
|
|
223
|
+
/// last-good Stripe snapshot anonymous readers serve (contract T7). One row
|
|
224
|
+
/// per workspace, the WorkspaceMeetings shape. The two Recommended columns
|
|
225
|
+
/// must name a shown product (a CHECK), and `snapshot` and `snapshotAt` are
|
|
226
|
+
/// set together (a CHECK). `snapshot` is `offeringsSnapshotSchema`-shaped,
|
|
227
|
+
/// validated on read and write, never trusted. `shownPriceIds` (#9070 D1) is
|
|
228
|
+
/// the shown set at Price grain; empty means every Price of each shown
|
|
229
|
+
/// Product, the grain of a row saved before #9070.
|
|
230
|
+
model WorkspaceOfferings {
|
|
231
|
+
id String @id @default(dbgenerated("('wsoffer_'::text || (uuidv7())::text)")) @db.Text
|
|
232
|
+
workspaceId String @unique @map("workspace_id")
|
|
233
|
+
shownProductIds String[] @default([]) @map("shown_product_ids")
|
|
234
|
+
shownPriceIds String[] @default([]) @map("shown_price_ids")
|
|
235
|
+
recommendedPlanProductId String? @map("recommended_plan_product_id")
|
|
236
|
+
recommendedAddonProductId String? @map("recommended_addon_product_id")
|
|
237
|
+
snapshot Json?
|
|
238
|
+
snapshotAt DateTime? @map("snapshot_at") @db.Timestamptz
|
|
239
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
240
|
+
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
|
241
|
+
|
|
242
|
+
@@map("workspace_offerings")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// OfferingsCheckoutSession — the Offerings Checkout session record (#8930
|
|
246
|
+
/// D1; contract docs/specs/8879-the-offerings-security-contract-key-spec.md
|
|
247
|
+
/// A11, W5, A13). One row per Checkout Session the hub created, written
|
|
248
|
+
/// before the checkout route answers, so the per-Organization webhook and the
|
|
249
|
+
/// session read act only on a session this hub recorded — never on metadata
|
|
250
|
+
/// alone. Exactly A11's six columns beside the id and createdAt; no
|
|
251
|
+
/// updatedAt, because no path updates a record. Plain scalar keys, no
|
|
252
|
+
/// relation, no foreign key (ADR 0009 invariant 1); the migration's CHECKs
|
|
253
|
+
/// bound every column. Written and read only by apps/api's
|
|
254
|
+
/// services/offerings/checkout-session-record.ts. Keep every `@map` in
|
|
255
|
+
/// lockstep with the migration's column names (see WorkspaceMeetings).
|
|
256
|
+
model OfferingsCheckoutSession {
|
|
257
|
+
id String @id @default(dbgenerated("('ocsess_'::text || (uuidv7())::text)")) @db.Text
|
|
258
|
+
sessionId String @unique @map("session_id") @db.Text
|
|
259
|
+
workspaceId String @map("workspace_id")
|
|
260
|
+
organizationId String @map("organization_id")
|
|
261
|
+
priceId String @map("price_id") @db.VarChar(255)
|
|
262
|
+
lookupKey String @map("lookup_key") @db.VarChar(48)
|
|
263
|
+
mode String @db.VarChar(8)
|
|
264
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
265
|
+
|
|
266
|
+
@@map("offerings_checkout_sessions")
|
|
267
|
+
}
|
|
268
|
+
|
|
178
269
|
/// SiteListingConfig — per-SITE project-listings config blob (#5033).
|
|
179
270
|
///
|
|
180
271
|
/// The fourth site-config model, and the one keyed on `siteId` rather than
|
|
@@ -473,6 +473,18 @@ model FlowDefinition {
|
|
|
473
473
|
/// `request-quote` magnet is `draft` (never served anonymously) with
|
|
474
474
|
/// `customersEnabled = true`.
|
|
475
475
|
customersEnabled Boolean @default(false) @map("customers_enabled")
|
|
476
|
+
/// #8757 D1/D2 — what happens after someone submits the magnet: `lead`
|
|
477
|
+
/// (the thank-you only), `invite` (an account link with the email
|
|
478
|
+
/// prefilled) or `checkout` (continue to `checkoutOffering`). A column, on
|
|
479
|
+
/// the `customersEnabled` footing: no seed or config names it. Closed by
|
|
480
|
+
/// the `flow_definitions_completion_domain` CHECK; the standard defaults are
|
|
481
|
+
/// the migration's backfill by slug (`STANDARD_MAGNET_COMPLETIONS`).
|
|
482
|
+
completion String @default("lead") @db.VarChar(16)
|
|
483
|
+
/// #8757 D1/D2 — the offering key a `checkout` magnet continues to; NULL
|
|
484
|
+
/// exactly when `completion` is not `checkout`
|
|
485
|
+
/// (`flow_definitions_checkout_offering_pair`), in the spoke checkout slug
|
|
486
|
+
/// format (`flow_definitions_checkout_offering_format`).
|
|
487
|
+
checkoutOffering String? @map("checkout_offering") @db.VarChar(40)
|
|
476
488
|
createdAt DateTime @default(now()) @map("created_at")
|
|
477
489
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
478
490
|
|
|
@@ -1283,8 +1295,11 @@ model ProjectListing {
|
|
|
1283
1295
|
/// would prove nothing once that row is edited, and `statementHash` groups
|
|
1284
1296
|
/// identical wordings. `locale` is the locale the statement was actually
|
|
1285
1297
|
/// served in, not the one requested. `purposeId` is the site-config registry
|
|
1286
|
-
/// id — the join key across surfaces.
|
|
1287
|
-
/// apps/
|
|
1298
|
+
/// id — the join key across surfaces. Two writers: apps/api/src/services/consent.ts
|
|
1299
|
+
/// (every source), and the web sign-up hook apps/web/lib/sign-up-agreements.ts
|
|
1300
|
+
/// (#8765 D4), which writes only `sign_up` rows (subject `user`) of a user it
|
|
1301
|
+
/// creates in the same Better Auth transaction, from evidence the API built
|
|
1302
|
+
/// with consent.ts's own functions.
|
|
1288
1303
|
model ConsentGrant {
|
|
1289
1304
|
id String @id @default(dbgenerated("('cnsnt_'::text || (uuidv7())::text)")) @db.Text
|
|
1290
1305
|
siteId String @map("site_id")
|