create-ailk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -3
- package/component-catalog.md +456 -0
- package/dist/cli.js +92 -16
- package/dist/component-catalog.d.ts +11 -0
- package/dist/component-catalog.js +39 -0
- package/dist/copy.d.ts +7 -0
- package/dist/copy.js +24 -0
- package/dist/derive-architecture.d.ts +13 -0
- package/dist/derive-architecture.js +238 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +19 -2
- package/dist/lib/extract-module-bundle.d.ts +16 -0
- package/dist/lib/extract-module-bundle.js +184 -0
- package/dist/lib/fetch-module.d.ts +55 -0
- package/dist/lib/fetch-module.js +125 -0
- package/dist/lib/module-license-gate.d.ts +48 -0
- package/dist/lib/module-license-gate.js +67 -0
- package/dist/lib/module-tier.d.ts +35 -0
- package/dist/lib/module-tier.js +37 -0
- package/dist/module-architecture.d.ts +175 -0
- package/dist/module-architecture.js +663 -0
- package/dist/parse-args.d.ts +47 -1
- package/dist/parse-args.js +143 -2
- package/dist/programmatic.d.ts +160 -3
- package/dist/programmatic.js +196 -7
- package/dist/surfaces.d.ts +211 -0
- package/dist/surfaces.js +532 -0
- package/dist/sync-routes.d.ts +92 -0
- package/dist/sync-routes.js +350 -0
- package/package.json +12 -5
- package/templates/.claude/rules/architecture.md +6 -2
- package/templates/.claude/skills/README.md +9 -9
- package/templates/.claude/skills/add-schema/SKILL.md +14 -7
- package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
- package/templates/.claude/skills/provision-config/SKILL.md +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
- package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
- package/templates/.env.example +126 -2
- package/templates/CLAUDE.md +11 -5
- package/templates/README.md +1 -1
- package/templates/apps/api/.env.example +38 -0
- package/templates/apps/api/CLAUDE.md +78 -1
- package/templates/apps/api/LICENSE +201 -0
- package/templates/apps/api/api/index.ts +6 -141
- package/templates/apps/api/jest.config.cjs +16 -0
- package/templates/apps/api/package.json +22 -6
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
- package/templates/apps/api/src/__tests__/server.test.ts +46 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
- package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
- package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
- package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
- package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
- package/templates/apps/api/src/bin/seed-presets.ts +58 -0
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
- package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
- package/templates/apps/api/src/cli.ts +5 -11
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/config/modules.ts +43 -0
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
- package/templates/apps/api/src/lib/site.ts +16 -0
- package/templates/apps/api/src/lib/stripe.ts +22 -0
- package/templates/apps/api/src/lib/tenant-db.ts +225 -0
- package/templates/apps/api/src/lib/ws-token.ts +91 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
- package/templates/apps/api/src/middleware/auth.ts +43 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
- package/templates/apps/api/src/middleware/tenant.ts +99 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
- package/templates/apps/api/src/openapi/spec.ts +312 -3
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
- package/templates/apps/api/src/routes/aeo/index.ts +107 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
- package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
- package/templates/apps/api/src/routes/billing/index.ts +36 -0
- package/templates/apps/api/src/routes/billing/portal.ts +182 -0
- package/templates/apps/api/src/routes/billing/read.ts +75 -0
- package/templates/apps/api/src/routes/billing/usage.ts +153 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
- package/templates/apps/api/src/routes/content/create.ts +1 -0
- package/templates/apps/api/src/routes/content/delete.ts +1 -0
- package/templates/apps/api/src/routes/content/index.ts +42 -6
- package/templates/apps/api/src/routes/content/update.ts +1 -0
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
- package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
- package/templates/apps/api/src/routes/flows/README.md +147 -0
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
- package/templates/apps/api/src/routes/flows/index.ts +202 -0
- package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
- package/templates/apps/api/src/routes/leads/index.ts +43 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
- package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
- package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
- package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
- package/templates/apps/api/src/routes/schedule/index.ts +249 -0
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
- package/templates/apps/api/src/routes/slack/actions.ts +177 -0
- package/templates/apps/api/src/routes/slack/index.ts +38 -0
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
- package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
- package/templates/apps/api/src/routes/webhooks/README.md +35 -12
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
- package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
- package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
- package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
- package/templates/apps/api/src/server.ts +307 -7
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
- package/templates/apps/api/src/services/abandon-email.ts +307 -0
- package/templates/apps/api/src/services/aeo-score.ts +288 -0
- package/templates/apps/api/src/services/answer-distributions.ts +138 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
- package/templates/apps/api/src/services/entitlements.ts +102 -0
- package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
- package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
- package/templates/apps/api/src/services/flow-engine.ts +1296 -0
- package/templates/apps/api/src/services/lead-promotion.ts +176 -0
- package/templates/apps/api/src/services/lead-routing.ts +3 -3
- package/templates/apps/api/src/services/listing-config.ts +102 -0
- package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/playbook-compile.ts +398 -0
- package/templates/apps/api/src/services/playbook-render.ts +263 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
- package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
- package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
- package/templates/apps/api/src/services/project-listings.ts +1472 -0
- package/templates/apps/api/src/services/recommender-capture.ts +835 -0
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
- package/templates/apps/api/src/services/scheduling/index.ts +63 -0
- package/templates/apps/api/src/services/scheduling/types.ts +88 -0
- package/templates/apps/api/src/services/slack-notify.ts +240 -0
- package/templates/apps/api/src/services/tenant-context.ts +451 -0
- package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
- package/templates/apps/api/src/services/usage-credits.ts +340 -0
- package/templates/apps/api/src/services/usage-metering.ts +699 -0
- package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
- package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
- package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
- package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
- package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
- package/templates/apps/api/src/vercel-handler.ts +165 -0
- package/templates/apps/api/tsconfig.json +0 -12
- package/templates/apps/mcp/CLAUDE.md +19 -9
- package/templates/apps/mcp/LICENSE +201 -0
- package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
- package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
- package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
- package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
- package/templates/apps/mcp/package.json +6 -4
- package/templates/apps/mcp/src/config/modules.ts +37 -0
- package/templates/apps/mcp/src/server.ts +109 -18
- package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
- package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
- package/templates/apps/mcp/src/tools/create_page.ts +1 -0
- package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
- package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
- package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
- package/templates/apps/mcp/src/tools/index.ts +25 -0
- package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
- package/templates/apps/mcp/src/tools/purchase.ts +3 -0
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
- package/templates/apps/mcp/src/tools/update_page.ts +1 -0
- package/templates/apps/mcp/tsconfig.json +1 -9
- package/templates/apps/web/.env.example +44 -0
- package/templates/apps/web/CLAUDE.md +28 -0
- package/templates/apps/web/LICENSE +201 -0
- package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
- package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
- package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
- package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
- package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
- package/templates/apps/web/__tests__/setup.ts +22 -32
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
- package/templates/apps/web/app/[locale]/layout.tsx +127 -16
- package/templates/apps/web/app/[locale]/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
- package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
- package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
- package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -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 +11 -1
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/globals.css +35 -0
- package/templates/apps/web/app/llms.txt/route.ts +2 -1
- package/templates/apps/web/app/og/route.tsx +10 -2
- package/templates/apps/web/auth.ts +8 -0
- package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
- package/templates/apps/web/content/en/blog/README.txt +25 -0
- package/templates/apps/web/content/en/docs/index.mdx +28 -0
- package/templates/apps/web/jest.config.cjs +90 -27
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
- package/templates/apps/web/lib/account-config.ts +13 -0
- package/templates/apps/web/lib/server-api.ts +85 -0
- package/templates/apps/web/lib/site-brand.tsx +121 -0
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/messages/en.json +275 -7
- package/templates/apps/web/middleware.ts +11 -3
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/apps/web/package.json +17 -7
- package/templates/apps/web/postcss.config.mjs +23 -0
- package/templates/apps/web/public/.well-known/security.txt +3 -3
- package/templates/apps/web/src/lib/routes.ts +15 -2
- package/templates/apps/web/tsconfig.json +1 -16
- package/templates/content/_site.mdx +36 -25
- package/templates/content/en/AboutPage/about.mdx +24 -14
- package/templates/content/en/ContactPage/contact.mdx +23 -14
- package/templates/content/en/FAQPage/faq.mdx +10 -392
- package/templates/content/en/HomePage/home.mdx +14 -285
- package/templates/database/CHANGELOG.md +443 -0
- package/templates/database/LICENSE +201 -0
- package/templates/database/README.md +3 -1
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
- package/templates/database/__tests__/flows-presets.test.ts +173 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
- package/templates/database/__tests__/seed.test.ts +88 -0
- package/templates/database/__tests__/user-cascade.test.ts +19 -4
- package/templates/database/auth/schema.prisma +521 -0
- package/templates/database/config/schema.prisma +234 -0
- package/templates/database/content/schema.prisma +263 -0
- package/templates/database/flows/listing-diagnostic.ts +182 -0
- package/templates/database/flows/presets.ts +315 -0
- package/templates/database/inbox/__tests__/schema.test.ts +4 -0
- package/templates/database/inbox/schema.prisma +574 -3
- package/templates/database/jest.config.cjs +8 -0
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/ops/schema.prisma +27 -0
- package/templates/database/package.json +42 -4
- package/templates/database/prisma.config.ts +1 -1
- package/templates/database/schema.prisma +19 -435
- package/templates/database/seed-test.ts +14 -4
- package/templates/database/seed.ts +148 -0
- package/templates/database/tenancy.ts +45 -0
- package/templates/database/tsconfig.flows.json +15 -0
- package/templates/database/tsconfig.json +7 -1
- package/templates/database/tsconfig.tenancy.json +14 -0
- package/templates/package.json +30 -55
- package/templates/pnpm-workspace.yaml +0 -3
- package/templates/project.yaml +1 -1
- package/templates/tsconfig.json +1 -37
- package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
- package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
- package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
- package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
- package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
- package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
- package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
- package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
- package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
- package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
- package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
- package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
- package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
- package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
- package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
- package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
- package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
- package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
- package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
- package/templates/apps/web/__tests__/page.test.tsx +0 -230
- package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
- package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
- package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
- package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
- package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
- package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
- package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
- package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
- package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
- package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
- package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
- package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
- package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
- package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
- package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
- package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
- package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
- package/templates/apps/web/content/en/blog/index.mdx +0 -15
- package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
- package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
- package/templates/content/.gitkeep +0 -0
- package/templates/content/CLAUDE.md +0 -85
- package/templates/content/ar/HomePage/home.mdx +0 -123
- package/templates/content/ar/_site.mdx +0 -35
- package/templates/content/de/_site.mdx +0 -29
- package/templates/content/en/.gitkeep +0 -0
- package/templates/content/en/AboutPage/about.schema.json +0 -82
- package/templates/content/en/BookCall/book-call.mdx +0 -19
- package/templates/content/en/BookCall/book-call.schema.json +0 -121
- package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
- package/templates/content/en/CareersList/careers.mdx +0 -24
- package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
- package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
- package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
- package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
- package/templates/content/en/ClientsList/clients.mdx +0 -38
- package/templates/content/en/ContactPage/contact.schema.json +0 -100
- package/templates/content/en/ContactPoint/primary.mdx +0 -14
- package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
- package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
- package/templates/content/en/CoursesList/courses.mdx +0 -22
- package/templates/content/en/DownloadsList/downloads.mdx +0 -22
- package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
- package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
- package/templates/content/en/EventsList/events.mdx +0 -26
- package/templates/content/en/FAQPage/faq.schema.json +0 -231
- package/templates/content/en/GlossaryList/glossary.mdx +0 -19
- package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
- package/templates/content/en/HomePage/home.schema.json +0 -88
- package/templates/content/en/HomePage/launch-service.mdx +0 -475
- package/templates/content/en/HomePage/launch-service.schema.json +0 -138
- package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
- package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
- package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
- package/templates/content/en/Integration/github-actions.mdx +0 -31
- package/templates/content/en/Integration/openai-api.mdx +0 -26
- package/templates/content/en/Integration/vercel.mdx +0 -32
- package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
- package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
- package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
- package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
- package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
- package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
- package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
- package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
- package/templates/content/en/LandingPage/developers.mdx +0 -19
- package/templates/content/en/LandingPage/developers.schema.json +0 -109
- package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
- package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
- package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
- package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
- package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
- package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
- package/templates/content/en/LandingPage/platform.mdx +0 -369
- package/templates/content/en/LandingPage/platform.schema.json +0 -166
- package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
- package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
- package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
- package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
- package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
- package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
- package/templates/content/en/Organization/site-owner.mdx +0 -16
- package/templates/content/en/Partner/prisma.mdx +0 -31
- package/templates/content/en/Partner/supabase.mdx +0 -30
- package/templates/content/en/Partner/vercel.mdx +0 -28
- package/templates/content/en/PartnersList/partners.mdx +0 -22
- package/templates/content/en/Person/founder.mdx +0 -18
- package/templates/content/en/Pricing/pricing.mdx +0 -538
- package/templates/content/en/Pricing/pricing.schema.json +0 -209
- package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
- package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
- package/templates/content/en/Product/ailk.mdx +0 -19
- package/templates/content/en/Product/ailk.schema.json +0 -94
- package/templates/content/en/Product/api-launch-kit.mdx +0 -26
- package/templates/content/en/Product/launch-kit.mdx +0 -28
- package/templates/content/en/ProductsList/products.mdx +0 -26
- package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
- package/templates/content/en/ProjectsList/projects.mdx +0 -25
- package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
- package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
- package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
- package/templates/content/en/ResourcesHub/resources.mdx +0 -22
- package/templates/content/en/Service/consulting.mdx +0 -22
- package/templates/content/en/Service/implementation.mdx +0 -30
- package/templates/content/en/Service/launch-service.mdx +0 -232
- package/templates/content/en/Service/launch.mdx +0 -228
- package/templates/content/en/ServicesList/services.mdx +0 -84
- package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
- package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
- package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
- package/templates/content/en/TeamList/team.mdx +0 -63
- package/templates/content/en/TeamMember/founder.mdx +0 -72
- package/templates/content/en/TermsPage/terms.mdx +0 -19
- package/templates/content/en/TermsPage/terms.schema.json +0 -99
- package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
- package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
- package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
- package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
- package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
- package/templates/content/en-XA/AboutPage/about.mdx +0 -8
- package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
- package/templates/content/en-XA/HomePage/home.mdx +0 -8
- package/templates/content/es/_site.mdx +0 -30
- package/templates/content/fr/_site.mdx +0 -30
- package/templates/content/ja/_site.mdx +0 -30
- package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
- package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
- package/templates/content/pt-BR/HomePage/home.mdx +0 -23
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file recommender-capture.ts
|
|
3
|
+
* @description The recommender's "email moment" service (#4837 — Epic #4760
|
|
4
|
+
* C1). One write path for everything the PRD's purchase CTA requires (L111):
|
|
5
|
+
* the FlowSession + Lead at the moment the visitor chooses save or checkout,
|
|
6
|
+
* the plan minted as a DeliverableGrant through the EXISTING T+0 path
|
|
7
|
+
* (`fulfillDeliverable`, #3961 D5), and — for checkout — the `FlowCheckout`
|
|
8
|
+
* buyer record at a SERVER-recomputed price (D6: MAJOR units × 100).
|
|
9
|
+
*
|
|
10
|
+
* Four exports (spec §6):
|
|
11
|
+
* - `captureRecommenderChoice` — resolve the served config + register,
|
|
12
|
+
* recompute `recommend()` exactly as the browser ran it (D3: no flags),
|
|
13
|
+
* create-or-resume a completed session (D1), `routeLead` (D2/D5),
|
|
14
|
+
* T+0 grant (opt-in, fail-soft).
|
|
15
|
+
* - `recordCheckout` — `price()` over the SERVED register, refuse the
|
|
16
|
+
* unpriceable cases (D7), write the row.
|
|
17
|
+
* - `attachStripeSession` — the stamp the route child (#4841) calls after the
|
|
18
|
+
* Stripe call (the session id and, since #4912, the hosted-page URL a
|
|
19
|
+
* replay is served from).
|
|
20
|
+
* - `expireFlowCheckout` — the row half of #4912's attach-miss compensation.
|
|
21
|
+
* - `findRegisterForSlug` — the slug → register read (the
|
|
22
|
+
* `flow-aggregate.ts:135–175` shape, as a named export) the route child's
|
|
23
|
+
* `stripeConfigRef` gate calls.
|
|
24
|
+
*
|
|
25
|
+
* The config read mirrors `serveFlow` (`flow-engine.ts:228–262`) — one
|
|
26
|
+
* `flowDefinition.findUnique` + `flowConfigSchema.safeParse` — rather than
|
|
27
|
+
* calling it, because `serveFlow` returns the parsed config only and
|
|
28
|
+
* `FlowSession.flowId` needs the definition's id (`resolveSession`'s own
|
|
29
|
+
* create shape, `flow-engine.ts:629–650`). Still one definition read.
|
|
30
|
+
*
|
|
31
|
+
* Lives in `apps/api/src/services/` (ADR 0009 D3): it composes the `inbox`
|
|
32
|
+
* rows (FlowDefinition / FlowSession / WaitlistExperiment / FlowCheckout)
|
|
33
|
+
* with `routeLead` (Lead) and `fulfillDeliverable` (DeliverableGrant).
|
|
34
|
+
*
|
|
35
|
+
* #4912 (REL-013.2 H2) hardens three things here: resume ownership (a session
|
|
36
|
+
* carrying a Lead is resumable only by the email stamped on it, and its Lead is
|
|
37
|
+
* reused rather than duplicated), `/checkout` idempotency (a server-derived
|
|
38
|
+
* fingerprint under `@@unique([siteId, idempotencyKey])`, replayed within the
|
|
39
|
+
* TTL and reconciled past it), and the attach-miss row compensation.
|
|
40
|
+
*
|
|
41
|
+
* PII boundary: every log line carries only
|
|
42
|
+
* `{ siteId, flowSlug, sessionId, leadId, choice, outcome }` (+ a terse `err`
|
|
43
|
+
* message on a caught rejection) — never the email, the answers, or the
|
|
44
|
+
* payload (the `flow-engine.ts` / `deliverable-fulfillment.ts` boundary).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
|
|
49
|
+
import type { Prisma } from "@working-theory/database";
|
|
50
|
+
import { logger } from "@working-theory/observability";
|
|
51
|
+
import {
|
|
52
|
+
flowConfigSchema,
|
|
53
|
+
price,
|
|
54
|
+
recommend,
|
|
55
|
+
waitlistExperimentConfigSchema,
|
|
56
|
+
WaitlistPricingError,
|
|
57
|
+
type FlowConfig,
|
|
58
|
+
type RecommendRegister,
|
|
59
|
+
type WaitlistAnswers,
|
|
60
|
+
type WaitlistBillingPeriod,
|
|
61
|
+
type WaitlistCreditStop,
|
|
62
|
+
type WaitlistExperimentConfig,
|
|
63
|
+
type WaitlistPriceQuote,
|
|
64
|
+
type WaitlistRecommendation,
|
|
65
|
+
} from "@working-theory/validation";
|
|
66
|
+
|
|
67
|
+
import { prisma } from "../lib/prisma.js";
|
|
68
|
+
|
|
69
|
+
import { MAX_AGGREGATION_ROWS } from "./answer-distributions.js";
|
|
70
|
+
import { fulfillDeliverable } from "./deliverable-fulfillment.js";
|
|
71
|
+
import { routeLead, type SourceMeta } from "./lead-routing.js";
|
|
72
|
+
|
|
73
|
+
// ─── Types (spec §6) ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export type RecommenderChoice = "save" | "checkout";
|
|
76
|
+
|
|
77
|
+
/** The permalink's answer map (`flow-permalink.ts:38`) — answers only, never identity. */
|
|
78
|
+
export type RecommenderAnswers = Record<string, string | string[]>;
|
|
79
|
+
|
|
80
|
+
export type CaptureRecommenderChoiceInput = {
|
|
81
|
+
siteId: string;
|
|
82
|
+
flowSlug: string;
|
|
83
|
+
answers: RecommenderAnswers;
|
|
84
|
+
email: string;
|
|
85
|
+
locale?: string;
|
|
86
|
+
/** The visitor's own walk session to resume (D1); absent on a cold load. */
|
|
87
|
+
sessionId?: string;
|
|
88
|
+
choice: RecommenderChoice;
|
|
89
|
+
sourceMeta: SourceMeta;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type RecommenderCaptureErrorKind =
|
|
93
|
+
| "flows_not_configured"
|
|
94
|
+
| "flow_not_found"
|
|
95
|
+
| "not_recommending"
|
|
96
|
+
| "invalid_input"
|
|
97
|
+
| "lead_failed";
|
|
98
|
+
|
|
99
|
+
export type RecommenderCaptureError = {
|
|
100
|
+
kind: RecommenderCaptureErrorKind;
|
|
101
|
+
message: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type CaptureRecommenderChoiceResult =
|
|
105
|
+
| {
|
|
106
|
+
ok: true;
|
|
107
|
+
sessionId: string;
|
|
108
|
+
leadId: string;
|
|
109
|
+
recommendation: WaitlistRecommendation;
|
|
110
|
+
config: FlowConfig;
|
|
111
|
+
register: WaitlistExperimentConfig | undefined;
|
|
112
|
+
}
|
|
113
|
+
| { ok: false; error: RecommenderCaptureError };
|
|
114
|
+
|
|
115
|
+
export type RecordCheckoutInput = {
|
|
116
|
+
siteId: string;
|
|
117
|
+
flowSlug: string;
|
|
118
|
+
experimentKey: string;
|
|
119
|
+
sessionId: string;
|
|
120
|
+
leadId: string;
|
|
121
|
+
/** The visitor's own email — hashed into the idempotency key (D5), never stored or logged in the clear. */
|
|
122
|
+
email: string;
|
|
123
|
+
offerKey: string;
|
|
124
|
+
seats: number;
|
|
125
|
+
period: WaitlistBillingPeriod;
|
|
126
|
+
creditStop: WaitlistCreditStop;
|
|
127
|
+
/** The answers-only payload string the recommendation was rendered from (#4770). */
|
|
128
|
+
permalink: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type RecordCheckoutErrorKind =
|
|
132
|
+
| "no_register"
|
|
133
|
+
| "session_mismatch"
|
|
134
|
+
| "unknown_offer"
|
|
135
|
+
| "no_seat_rate"
|
|
136
|
+
| "no_rate_table"
|
|
137
|
+
| "invalid_seats"
|
|
138
|
+
| "unknown_credit_stop"
|
|
139
|
+
| "custom_stop"
|
|
140
|
+
| "no_currency";
|
|
141
|
+
|
|
142
|
+
type RecordCheckoutQuoted = {
|
|
143
|
+
flowCheckoutId: string;
|
|
144
|
+
amountMinor: number;
|
|
145
|
+
currency: string;
|
|
146
|
+
quote: WaitlistPriceQuote;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* #4912 FR5 — DISCRIMINATED on `replayed`, so a replay can never be handed to
|
|
151
|
+
* the caller with a null session or URL. The route branches on this one field;
|
|
152
|
+
* modelling the two Stripe columns as `string | null` on a single arm would
|
|
153
|
+
* make the impossible state representable and let a mis-narrowed branch open a
|
|
154
|
+
* SECOND Stripe session for a request the service already replayed.
|
|
155
|
+
*/
|
|
156
|
+
export type RecordCheckoutResult =
|
|
157
|
+
| ({ ok: true; replayed: false } & RecordCheckoutQuoted)
|
|
158
|
+
| ({ ok: true; replayed: true; stripeSessionId: string; checkoutUrl: string } & RecordCheckoutQuoted)
|
|
159
|
+
| { ok: false; error: { kind: RecordCheckoutErrorKind; message: string } };
|
|
160
|
+
|
|
161
|
+
// ─── Row shapes ──────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const FLOW_DEFINITION_SELECT = {
|
|
164
|
+
id: true,
|
|
165
|
+
config: true,
|
|
166
|
+
} as const satisfies Prisma.FlowDefinitionSelect;
|
|
167
|
+
type FlowDefinitionRow = { id: string; config: unknown };
|
|
168
|
+
|
|
169
|
+
const EXPERIMENT_CONFIG_SELECT = {
|
|
170
|
+
id: true,
|
|
171
|
+
config: true,
|
|
172
|
+
} as const satisfies Prisma.WaitlistExperimentSelect;
|
|
173
|
+
type ExperimentConfigRow = { id: string; config: unknown };
|
|
174
|
+
|
|
175
|
+
const SESSION_RESUME_SELECT = {
|
|
176
|
+
id: true,
|
|
177
|
+
siteId: true,
|
|
178
|
+
flowSlug: true,
|
|
179
|
+
// #4912 FR3 — resume ownership is decided on these two columns; without them
|
|
180
|
+
// the resume path could not tell a visitor's own second call from a caller
|
|
181
|
+
// naming somebody else's session id (#4859).
|
|
182
|
+
leadId: true,
|
|
183
|
+
email: true,
|
|
184
|
+
} as const satisfies Prisma.FlowSessionSelect;
|
|
185
|
+
const SESSION_OWNER_SELECT = { siteId: true, flowSlug: true, leadId: true } as const;
|
|
186
|
+
type SessionOwnerRow = { siteId: string; flowSlug: string; leadId: string | null };
|
|
187
|
+
type SessionResumeRow = {
|
|
188
|
+
id: string;
|
|
189
|
+
siteId: string;
|
|
190
|
+
flowSlug: string;
|
|
191
|
+
leadId: string | null;
|
|
192
|
+
email: string | null;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** #4912 FR5 — the columns a keyed lookup needs to decide replay vs reconcile. */
|
|
196
|
+
const CHECKOUT_KEY_SELECT = {
|
|
197
|
+
id: true,
|
|
198
|
+
status: true,
|
|
199
|
+
stripeSessionId: true,
|
|
200
|
+
checkoutUrl: true,
|
|
201
|
+
createdAt: true,
|
|
202
|
+
} as const satisfies Prisma.FlowCheckoutSelect;
|
|
203
|
+
type CheckoutKeyRow = {
|
|
204
|
+
id: string;
|
|
205
|
+
status: string;
|
|
206
|
+
stripeSessionId: string | null;
|
|
207
|
+
checkoutUrl: string | null;
|
|
208
|
+
createdAt: Date;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ─── PII-safe logging ───────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
type CaptureOutcome =
|
|
214
|
+
| "captured"
|
|
215
|
+
| "lead_failed"
|
|
216
|
+
| "grant_failed"
|
|
217
|
+
| "checkout_recorded";
|
|
218
|
+
|
|
219
|
+
type LogCtx = {
|
|
220
|
+
siteId: string;
|
|
221
|
+
flowSlug: string;
|
|
222
|
+
sessionId?: string;
|
|
223
|
+
leadId?: string;
|
|
224
|
+
choice?: RecommenderChoice;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
function logOutcome(ctx: LogCtx, outcome: CaptureOutcome, err?: unknown): void {
|
|
228
|
+
const line = { ...ctx, outcome, ...(err !== undefined && { err: errorName(err) }) };
|
|
229
|
+
if (outcome === "lead_failed" || outcome === "grant_failed") {
|
|
230
|
+
logger.warn(line, `[recommender-capture] ${outcome}`);
|
|
231
|
+
} else {
|
|
232
|
+
logger.info(line, `[recommender-capture] ${outcome}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The error's NAME, never its message — an email-provider rejection can embed
|
|
238
|
+
* the recipient address in `message`, and this module's log lines carry no
|
|
239
|
+
* PII (spec §5.2; PR #4851 security review, condition (c)).
|
|
240
|
+
*/
|
|
241
|
+
function errorName(err: unknown): string {
|
|
242
|
+
return err instanceof Error && err.name ? err.name : "UnknownError";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The zod `flowCheckoutInputSchema` ceiling, enforced here too: `amountMinor` is INT4 (PR #4851 security review, condition (b)). */
|
|
246
|
+
export const MAX_CHECKOUT_SEATS = 999;
|
|
247
|
+
|
|
248
|
+
// ─── DB-required guard (mirrors flow-engine.ts's private `requireDb`) ────────
|
|
249
|
+
|
|
250
|
+
function requireDb(): RecommenderCaptureError | null {
|
|
251
|
+
if (!process.env.DATABASE_URL) {
|
|
252
|
+
return { kind: "flows_not_configured", message: "Flows are not configured." };
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── findRegisterForSlug (FR7) ───────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve a flow slug to its backing `WaitlistExperiment` register for one
|
|
261
|
+
* site — the `flow-aggregate.ts:135–175` read as a named export: one
|
|
262
|
+
* `siteId`-scoped `findMany`, oldest first, matched on `waitlists[].flowSlug`.
|
|
263
|
+
* A corrupt row is skipped and logged, never thrown; more than one match
|
|
264
|
+
* resolves to the oldest (the aggregate service's own documented posture).
|
|
265
|
+
*/
|
|
266
|
+
export async function findRegisterForSlug(
|
|
267
|
+
siteId: string,
|
|
268
|
+
slug: string,
|
|
269
|
+
): Promise<WaitlistExperimentConfig | undefined> {
|
|
270
|
+
const rows: ExperimentConfigRow[] = await prisma.waitlistExperiment.findMany({
|
|
271
|
+
where: { siteId },
|
|
272
|
+
select: EXPERIMENT_CONFIG_SELECT,
|
|
273
|
+
orderBy: { createdAt: "asc" },
|
|
274
|
+
take: MAX_AGGREGATION_ROWS,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
let match: WaitlistExperimentConfig | undefined;
|
|
278
|
+
for (const row of rows) {
|
|
279
|
+
const parsed = waitlistExperimentConfigSchema.safeParse(row.config);
|
|
280
|
+
if (!parsed.success) {
|
|
281
|
+
logger.warn(
|
|
282
|
+
{ siteId, flowSlug: slug, outcome: "register_parse_skipped" },
|
|
283
|
+
"[recommender-capture] experiment config failed to parse — skipped",
|
|
284
|
+
);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (parsed.data.waitlists.some((w) => w.flowSlug === slug)) {
|
|
288
|
+
match = parsed.data;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return match;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* FR3 — key the permalink's flat answers by the segmentation step each
|
|
299
|
+
* question `name` belongs to (`{ [stepKey]: { [name]: value } }`, the
|
|
300
|
+
* `FlowSession.state` shape `flow-engine.ts:730–733` writes). An answer no
|
|
301
|
+
* segmentation step declares is dropped — it is not part of the config.
|
|
302
|
+
*/
|
|
303
|
+
function stateFromAnswers(
|
|
304
|
+
config: FlowConfig,
|
|
305
|
+
answers: RecommenderAnswers,
|
|
306
|
+
): Record<string, Record<string, string | string[]>> {
|
|
307
|
+
const state: Record<string, Record<string, string | string[]>> = {};
|
|
308
|
+
for (const step of config.steps) {
|
|
309
|
+
if (step.type !== "segmentation") continue;
|
|
310
|
+
for (const question of step.questions) {
|
|
311
|
+
const value = answers[question.name];
|
|
312
|
+
if (value === undefined) continue;
|
|
313
|
+
(state[step.key] ??= {})[question.name] = value;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return state;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** D3 — the identical `recommend()` register `ResultsClient.tsx:47–56` builds. */
|
|
320
|
+
function registerFromConfig(config: FlowConfig): RecommendRegister {
|
|
321
|
+
return {
|
|
322
|
+
key: config.experiment?.key ?? config.slug,
|
|
323
|
+
offers: config.offers,
|
|
324
|
+
pairings: config.pairings,
|
|
325
|
+
rateTable: config.rateTable,
|
|
326
|
+
scoring: config.scoring,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* #4912 D3 — resume ownership.
|
|
332
|
+
*
|
|
333
|
+
* A walk session that carries no `leadId` is nobody's yet: the first email to
|
|
334
|
+
* name it takes it, which is the cold-load path this service has always had.
|
|
335
|
+
* Once a Lead is stamped on it the session belongs to that email, and only
|
|
336
|
+
* that email may resume it — a caller naming somebody else's high-entropy id
|
|
337
|
+
* gets a fresh session instead of overwriting theirs (#4859). Compared
|
|
338
|
+
* case-insensitively after trimming, the same normalization the Lead's own
|
|
339
|
+
* email validation applies.
|
|
340
|
+
*/
|
|
341
|
+
function resumeIsOwned(row: SessionResumeRow, email: string): boolean {
|
|
342
|
+
if (row.leadId === null) return true;
|
|
343
|
+
if (row.email === null) return false;
|
|
344
|
+
return row.email.trim().toLowerCase() === email.trim().toLowerCase();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─── captureRecommenderChoice (FR2–FR5) ──────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
export async function captureRecommenderChoice(
|
|
350
|
+
input: CaptureRecommenderChoiceInput,
|
|
351
|
+
): Promise<CaptureRecommenderChoiceResult> {
|
|
352
|
+
const dbError = requireDb();
|
|
353
|
+
if (dbError) return { ok: false, error: dbError };
|
|
354
|
+
|
|
355
|
+
const { siteId, flowSlug, answers, email, choice, sourceMeta } = input;
|
|
356
|
+
const logCtx: LogCtx = { siteId, flowSlug, choice };
|
|
357
|
+
|
|
358
|
+
// The served config — one definition read + the same parse `serveFlow` does.
|
|
359
|
+
const definition: FlowDefinitionRow | null = await prisma.flowDefinition.findUnique({
|
|
360
|
+
where: { siteId_slug: { siteId, slug: flowSlug } },
|
|
361
|
+
select: FLOW_DEFINITION_SELECT,
|
|
362
|
+
});
|
|
363
|
+
if (!definition) {
|
|
364
|
+
return { ok: false, error: { kind: "flow_not_found", message: "Flow not found." } };
|
|
365
|
+
}
|
|
366
|
+
const parsedConfig = flowConfigSchema.safeParse(definition.config);
|
|
367
|
+
if (!parsedConfig.success) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
error: { kind: "invalid_input", message: "Stored flow config failed validation." },
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const config = parsedConfig.data;
|
|
374
|
+
if (config.mode !== "recommending" || config.offers === undefined) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
error: { kind: "not_recommending", message: "Flow is not a recommending flow." },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (!config.experiment) {
|
|
381
|
+
// A compiled recommending flow always carries its binding
|
|
382
|
+
// (`compileWaitlistFlow` sets it unconditionally); its absence is a
|
|
383
|
+
// register-integrity fault, not a branch to invent (spec §5.3).
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
error: { kind: "invalid_input", message: "Flow carries no experiment binding." },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const register = await findRegisterForSlug(siteId, flowSlug);
|
|
391
|
+
|
|
392
|
+
// D3 — the browser's exact call: no flags.
|
|
393
|
+
const recommendation = recommend(registerFromConfig(config), answers as WaitlistAnswers);
|
|
394
|
+
|
|
395
|
+
const state = stateFromAnswers(config, answers);
|
|
396
|
+
const locale = input.locale ?? config.defaultLocale;
|
|
397
|
+
const now = new Date();
|
|
398
|
+
|
|
399
|
+
// D1 — resume the visitor's own walk session, else create.
|
|
400
|
+
// #4912 FR3 — and only when the session is theirs to resume.
|
|
401
|
+
let sessionId: string | undefined;
|
|
402
|
+
let ownedLeadId: string | undefined;
|
|
403
|
+
if (input.sessionId) {
|
|
404
|
+
const existing: SessionResumeRow | null = await prisma.flowSession.findUnique({
|
|
405
|
+
where: { id: input.sessionId },
|
|
406
|
+
select: SESSION_RESUME_SELECT,
|
|
407
|
+
});
|
|
408
|
+
if (
|
|
409
|
+
existing &&
|
|
410
|
+
existing.siteId === siteId &&
|
|
411
|
+
existing.flowSlug === flowSlug &&
|
|
412
|
+
resumeIsOwned(existing, email)
|
|
413
|
+
) {
|
|
414
|
+
await prisma.flowSession.update({
|
|
415
|
+
where: { id: existing.id },
|
|
416
|
+
data: {
|
|
417
|
+
email,
|
|
418
|
+
state: state as unknown as Prisma.InputJsonValue,
|
|
419
|
+
locale,
|
|
420
|
+
completedAt: now,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
sessionId = existing.id;
|
|
424
|
+
if (existing.leadId !== null) ownedLeadId = existing.leadId;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (sessionId === undefined) {
|
|
428
|
+
const created = (await prisma.flowSession.create({
|
|
429
|
+
data: {
|
|
430
|
+
siteId,
|
|
431
|
+
flowId: definition.id,
|
|
432
|
+
flowSlug,
|
|
433
|
+
configSnapshot: config as unknown as Prisma.InputJsonValue,
|
|
434
|
+
state: state as unknown as Prisma.InputJsonValue,
|
|
435
|
+
email,
|
|
436
|
+
locale,
|
|
437
|
+
completedAt: now,
|
|
438
|
+
},
|
|
439
|
+
select: { id: true },
|
|
440
|
+
})) as { id: string };
|
|
441
|
+
sessionId = created.id;
|
|
442
|
+
}
|
|
443
|
+
logCtx.sessionId = sessionId;
|
|
444
|
+
|
|
445
|
+
// #4912 FR4 — the visitor's own resubmission of a session that already
|
|
446
|
+
// carries its Lead. `routeLead` writes a new Lead on every call, so calling
|
|
447
|
+
// it again is exactly the duplicate #4860 reports; the T+0 grant was minted
|
|
448
|
+
// with that Lead too, so it is not re-minted either. The session's answers,
|
|
449
|
+
// locale and completion stamp were refreshed by the resume update above.
|
|
450
|
+
if (ownedLeadId !== undefined) {
|
|
451
|
+
logCtx.leadId = ownedLeadId;
|
|
452
|
+
logOutcome(logCtx, "captured");
|
|
453
|
+
return { ok: true, sessionId, leadId: ownedLeadId, recommendation, config, register };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// FR4 / D2 — the flow-engine completion payload shape + the recommender block.
|
|
457
|
+
const payload: Record<string, unknown> = {};
|
|
458
|
+
for (const stepData of Object.values(state)) Object.assign(payload, stepData);
|
|
459
|
+
payload["flowSlug"] = flowSlug;
|
|
460
|
+
payload["locale"] = locale;
|
|
461
|
+
payload["experimentKey"] = config.experiment.key;
|
|
462
|
+
payload["waitlistKey"] = config.experiment.waitlist;
|
|
463
|
+
payload["recommender"] = {
|
|
464
|
+
choice,
|
|
465
|
+
fits: recommendation.fits.map((f) => f.offerKey),
|
|
466
|
+
...(recommendation.pairing !== undefined && { pairing: recommendation.pairing.offers }),
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const leadResult = await routeLead(
|
|
470
|
+
{ kind: "submit", siteId, source: `flow:${flowSlug}`, email, payload },
|
|
471
|
+
sourceMeta,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
if (!leadResult.ok) {
|
|
475
|
+
// D5 — the purchase path needs a Lead; the session stays the record
|
|
476
|
+
// (the engine's own fallback, `flow-engine.ts:925–939`). Idempotent — the
|
|
477
|
+
// create/resume above already set `completedAt` — so no branch on which
|
|
478
|
+
// path we took.
|
|
479
|
+
await prisma.flowSession.update({
|
|
480
|
+
where: { id: sessionId },
|
|
481
|
+
data: { completedAt: now },
|
|
482
|
+
});
|
|
483
|
+
logOutcome(logCtx, "lead_failed");
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
error: { kind: "lead_failed", message: "Lead could not be recorded." },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const leadId = leadResult.id;
|
|
491
|
+
logCtx.leadId = leadId;
|
|
492
|
+
await prisma.flowSession.update({ where: { id: sessionId }, data: { leadId } });
|
|
493
|
+
|
|
494
|
+
// FR5 — the T+0 grant: opt-in on the block, fail-soft on any rejection.
|
|
495
|
+
if (config.deliverables) {
|
|
496
|
+
try {
|
|
497
|
+
await fulfillDeliverable({
|
|
498
|
+
siteId,
|
|
499
|
+
flowSlug,
|
|
500
|
+
configSnapshot: config,
|
|
501
|
+
leadId,
|
|
502
|
+
sessionId,
|
|
503
|
+
email,
|
|
504
|
+
locale,
|
|
505
|
+
});
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logOutcome(logCtx, "grant_failed", err);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
logOutcome(logCtx, "captured");
|
|
512
|
+
return { ok: true, sessionId, leadId, recommendation, config, register };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Checkout idempotency (#4912 FR5/FR6, D5/D8) ─────────────────────────────
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The window in which a repeat `/checkout` submission is still the SAME
|
|
519
|
+
* attempt — and, with the same value, the age past which a `created` row that
|
|
520
|
+
* never took a Stripe session id is dead (#4912 D8). Thirty minutes covers a
|
|
521
|
+
* double click, a retried request and a back-button resubmit, and sits well
|
|
522
|
+
* inside Stripe's own 24-hour session expiry, so a replayed row's session is
|
|
523
|
+
* never already gone.
|
|
524
|
+
*/
|
|
525
|
+
export const FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS = 30 * 60_000;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The request's fingerprint (#4912 D5) — a SHA-256 over the fields that make
|
|
529
|
+
* two submissions the same purchase, JSON-encoded in a fixed order.
|
|
530
|
+
*
|
|
531
|
+
* ENCODED, never delimiter-joined: a joined key forges, since `["a","b"]` and
|
|
532
|
+
* `["a:b"]` collide under any separator. `creditStop` is projected field by
|
|
533
|
+
* field (its schema is `{kind}` or `{kind, index}`) so JSON key order cannot
|
|
534
|
+
* vary between two otherwise-identical requests.
|
|
535
|
+
*
|
|
536
|
+
* The email is hashed here and never leaves this function in the clear: the
|
|
537
|
+
* digest is what reaches the database, and no log line carries it.
|
|
538
|
+
*/
|
|
539
|
+
function checkoutIdempotencyKey(
|
|
540
|
+
input: RecordCheckoutInput,
|
|
541
|
+
priced: { amountMinor: number; currency: string },
|
|
542
|
+
): string {
|
|
543
|
+
const stop = input.creditStop;
|
|
544
|
+
return createHash("sha256")
|
|
545
|
+
.update(
|
|
546
|
+
JSON.stringify([
|
|
547
|
+
input.siteId,
|
|
548
|
+
input.flowSlug,
|
|
549
|
+
input.sessionId,
|
|
550
|
+
input.email.trim().toLowerCase(),
|
|
551
|
+
input.offerKey,
|
|
552
|
+
input.seats,
|
|
553
|
+
input.period,
|
|
554
|
+
stop.kind,
|
|
555
|
+
stop.kind === "tier" ? stop.index : null,
|
|
556
|
+
input.permalink,
|
|
557
|
+
// The SERVER's resolved price is part of what makes two submissions the
|
|
558
|
+
// same purchase: a replay hands back a Stripe session opened at the
|
|
559
|
+
// stored amount, so a register edit between the two must mint a new
|
|
560
|
+
// session rather than replay the pre-edit price (PRD L104 in effect,
|
|
561
|
+
// not only in letter).
|
|
562
|
+
priced.amountMinor,
|
|
563
|
+
priced.currency,
|
|
564
|
+
]),
|
|
565
|
+
)
|
|
566
|
+
.digest("hex");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* What a keyed row means for the attempt that just hashed to its key.
|
|
571
|
+
*
|
|
572
|
+
* - `replayable` — a live `created` row inside the TTL whose Stripe session
|
|
573
|
+
* and hosted-page URL are both stamped. This is the double-submit AC-3
|
|
574
|
+
* names: hand back what the first attempt got.
|
|
575
|
+
* - `in_flight` — a `created` row inside the TTL with no session stamped yet.
|
|
576
|
+
* Its own request is between `create` and `attachStripeSession`; touching it
|
|
577
|
+
* would strand a session the #4838 webhook could then never join, because
|
|
578
|
+
* that webhook's update is guarded on `status: "created"`.
|
|
579
|
+
* - `dead` — past the TTL, or no longer `created` (the webhook completed or
|
|
580
|
+
* expired it). Its key is not this attempt's to collide with.
|
|
581
|
+
*/
|
|
582
|
+
function classifyKeyedRow(row: CheckoutKeyRow): "replayable" | "in_flight" | "dead" {
|
|
583
|
+
const fresh = Date.now() - row.createdAt.getTime() <= FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS;
|
|
584
|
+
if (!fresh || row.status !== "created") return "dead";
|
|
585
|
+
return row.stripeSessionId !== null && row.checkoutUrl !== null ? "replayable" : "in_flight";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* A Prisma unique-constraint violation, recognised structurally rather than by
|
|
590
|
+
* importing the runtime error class — the `waitlist-followup.ts:429` and
|
|
591
|
+
* `tenant-context.ts:448` idiom.
|
|
592
|
+
*/
|
|
593
|
+
function isUniqueViolation(err: unknown): boolean {
|
|
594
|
+
return (
|
|
595
|
+
typeof err === "object" && err !== null && (err as { code?: unknown }).code === "P2002"
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* A stale keyed row is logged with the row-id prefix and an outcome, and
|
|
601
|
+
* nothing else (#4912 AC-4) — no email, no permalink, no URL.
|
|
602
|
+
*/
|
|
603
|
+
function logCheckoutReconciled(flowCheckoutId: string): void {
|
|
604
|
+
logger.warn(
|
|
605
|
+
{ flowCheckoutIdPrefix: flowCheckoutId.substring(0, 8), outcome: "checkout_reconciled" },
|
|
606
|
+
"[recommender-capture] stale checkout row reconciled",
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ─── recordCheckout (FR6, D6/D7) ─────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
export async function recordCheckout(input: RecordCheckoutInput): Promise<RecordCheckoutResult> {
|
|
613
|
+
const { siteId, flowSlug, offerKey, seats, period, creditStop } = input;
|
|
614
|
+
const fail = (kind: RecordCheckoutErrorKind, message: string): RecordCheckoutResult => ({
|
|
615
|
+
ok: false,
|
|
616
|
+
error: { kind, message },
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// The register is resolved HERE, from the DB, never taken from a caller —
|
|
620
|
+
// so no route can price against a client-derived register (PR #4851
|
|
621
|
+
// security review, condition (a)).
|
|
622
|
+
const register = await findRegisterForSlug(siteId, flowSlug);
|
|
623
|
+
if (!register) return fail("no_register", "No register names this flow.");
|
|
624
|
+
|
|
625
|
+
if (!Number.isInteger(seats) || seats < 1 || seats > MAX_CHECKOUT_SEATS) {
|
|
626
|
+
return fail("invalid_seats", `seats must be an integer between 1 and ${MAX_CHECKOUT_SEATS}.`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The session + lead ids must be THIS site's, and the lead must be the one
|
|
630
|
+
// stamped on that session — never ids a caller threaded through (condition (d)).
|
|
631
|
+
const session: SessionOwnerRow | null = await prisma.flowSession.findUnique({
|
|
632
|
+
where: { id: input.sessionId },
|
|
633
|
+
select: SESSION_OWNER_SELECT,
|
|
634
|
+
});
|
|
635
|
+
if (
|
|
636
|
+
!session ||
|
|
637
|
+
session.siteId !== siteId ||
|
|
638
|
+
session.flowSlug !== flowSlug ||
|
|
639
|
+
session.leadId !== input.leadId
|
|
640
|
+
) {
|
|
641
|
+
return fail("session_mismatch", "The session does not belong to this site, flow and lead.");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const rateTable = register.rateTable;
|
|
645
|
+
if (!rateTable) return fail("no_rate_table", "The register carries no rateTable.");
|
|
646
|
+
const currency = rateTable.currency;
|
|
647
|
+
if (!currency) return fail("no_currency", "The register's rateTable declares no currency.");
|
|
648
|
+
if (creditStop.kind === "custom") {
|
|
649
|
+
return fail("custom_stop", "A custom credit stop carries no total to charge.");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let quote: WaitlistPriceQuote;
|
|
653
|
+
try {
|
|
654
|
+
quote = price(register, offerKey, seats, period, creditStop);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
if (err instanceof WaitlistPricingError) return fail(err.code, err.message);
|
|
657
|
+
throw err;
|
|
658
|
+
}
|
|
659
|
+
if (quote.kind !== "quoted") {
|
|
660
|
+
// Unreachable — the `custom` stop was refused above — but it is the TS
|
|
661
|
+
// narrowing that lets `quote.total` be read below; not a second policy check.
|
|
662
|
+
return fail("custom_stop", "A custom credit stop carries no total to charge.");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// D6 — the register is MAJOR units; Stripe and the row take MINOR units.
|
|
666
|
+
const amountMinor = Math.round(quote.total * 100);
|
|
667
|
+
|
|
668
|
+
// #4912 FR5 — the keyed lookup runs AFTER the gates and the price, so a
|
|
669
|
+
// replay is still a fully validated request and the amount is still the
|
|
670
|
+
// server's own; only the row write and the Stripe call are skipped.
|
|
671
|
+
const priced = { amountMinor, currency };
|
|
672
|
+
const idempotencyKey = checkoutIdempotencyKey(input, priced);
|
|
673
|
+
const existing: CheckoutKeyRow | null = await prisma.flowCheckout.findUnique({
|
|
674
|
+
where: { siteId_idempotencyKey: { siteId, idempotencyKey } },
|
|
675
|
+
select: CHECKOUT_KEY_SELECT,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// `true` once this attempt must write an UNKEYED row: the key belongs to an
|
|
679
|
+
// attempt that is still in flight, and stealing it would kill a row whose own
|
|
680
|
+
// Stripe session is about to be stamped on it (see `keyOwner` below).
|
|
681
|
+
let keyHeldByAnother = false;
|
|
682
|
+
|
|
683
|
+
if (existing) {
|
|
684
|
+
const state = classifyKeyedRow(existing);
|
|
685
|
+
if (state === "replayable") {
|
|
686
|
+
return {
|
|
687
|
+
ok: true,
|
|
688
|
+
replayed: true,
|
|
689
|
+
flowCheckoutId: existing.id,
|
|
690
|
+
amountMinor,
|
|
691
|
+
currency,
|
|
692
|
+
quote,
|
|
693
|
+
stripeSessionId: existing.stripeSessionId as string,
|
|
694
|
+
checkoutUrl: existing.checkoutUrl as string,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
if (state === "in_flight") {
|
|
698
|
+
// The keyed row was created moments ago and has not been stamped yet —
|
|
699
|
+
// an identical request is between its own `create` and its Stripe call.
|
|
700
|
+
// Releasing or expiring that row here would strand its session and leave
|
|
701
|
+
// a payment the #4838 webhook could never join (its update is guarded on
|
|
702
|
+
// `status: "created"`), so it is left completely alone and THIS attempt
|
|
703
|
+
// takes a row of its own, unkeyed.
|
|
704
|
+
keyHeldByAnother = true;
|
|
705
|
+
} else {
|
|
706
|
+
// #4912 FR6 — the keyed row is past the TTL, or the webhook already
|
|
707
|
+
// completed/expired it. Either way it is not this attempt's, so its key
|
|
708
|
+
// is released. A row that never took a Stripe session id can never be
|
|
709
|
+
// joined by the webhook, so it is also marked expired and its stored URL
|
|
710
|
+
// dropped; a row that HAS one is otherwise left untouched, because the
|
|
711
|
+
// webhook may still legitimately stamp a payment onto it.
|
|
712
|
+
const unjoinable = existing.status === "created" && existing.stripeSessionId === null;
|
|
713
|
+
await prisma.flowCheckout.updateMany({
|
|
714
|
+
where: { id: existing.id, siteId },
|
|
715
|
+
data: unjoinable
|
|
716
|
+
? { status: "expired", idempotencyKey: null, checkoutUrl: null }
|
|
717
|
+
: { idempotencyKey: null },
|
|
718
|
+
});
|
|
719
|
+
logCheckoutReconciled(existing.id);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const writeRow = async (key: string | null): Promise<{ id: string }> =>
|
|
724
|
+
(await prisma.flowCheckout.create({
|
|
725
|
+
data: {
|
|
726
|
+
siteId: input.siteId,
|
|
727
|
+
flowSlug: input.flowSlug,
|
|
728
|
+
experimentKey: input.experimentKey,
|
|
729
|
+
sessionId: input.sessionId,
|
|
730
|
+
leadId: input.leadId,
|
|
731
|
+
offerKey,
|
|
732
|
+
seats,
|
|
733
|
+
period,
|
|
734
|
+
creditStop: creditStop as unknown as Prisma.InputJsonValue,
|
|
735
|
+
amountMinor,
|
|
736
|
+
currency,
|
|
737
|
+
quote: quote as unknown as Prisma.InputJsonValue,
|
|
738
|
+
permalink: input.permalink,
|
|
739
|
+
stripeSessionId: null,
|
|
740
|
+
checkoutUrl: null,
|
|
741
|
+
idempotencyKey: key,
|
|
742
|
+
status: "created",
|
|
743
|
+
},
|
|
744
|
+
select: { id: true },
|
|
745
|
+
})) as { id: string };
|
|
746
|
+
|
|
747
|
+
let row: { id: string };
|
|
748
|
+
if (keyHeldByAnother) {
|
|
749
|
+
row = await writeRow(null);
|
|
750
|
+
} else {
|
|
751
|
+
try {
|
|
752
|
+
row = await writeRow(idempotencyKey);
|
|
753
|
+
} catch (err) {
|
|
754
|
+
// Two identical requests cleared the lookup above before either write
|
|
755
|
+
// landed, and the unique caught the loser. The `waitlist-followup.ts`
|
|
756
|
+
// idiom: recognise the violation, do not let it surface as a 500 on the
|
|
757
|
+
// payment path. Re-read once — the winner may already be replayable —
|
|
758
|
+
// and otherwise take an unkeyed row, exactly as the in-flight case does.
|
|
759
|
+
if (!isUniqueViolation(err)) throw err;
|
|
760
|
+
const winner: CheckoutKeyRow | null = await prisma.flowCheckout.findUnique({
|
|
761
|
+
where: { siteId_idempotencyKey: { siteId, idempotencyKey } },
|
|
762
|
+
select: CHECKOUT_KEY_SELECT,
|
|
763
|
+
});
|
|
764
|
+
if (winner && classifyKeyedRow(winner) === "replayable") {
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
replayed: true,
|
|
768
|
+
flowCheckoutId: winner.id,
|
|
769
|
+
amountMinor,
|
|
770
|
+
currency,
|
|
771
|
+
quote,
|
|
772
|
+
stripeSessionId: winner.stripeSessionId as string,
|
|
773
|
+
checkoutUrl: winner.checkoutUrl as string,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
row = await writeRow(null);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
logOutcome(
|
|
781
|
+
{ siteId: input.siteId, flowSlug: input.flowSlug, sessionId: input.sessionId, leadId: input.leadId, choice: "checkout" },
|
|
782
|
+
"checkout_recorded",
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
return { ok: true, replayed: false, flowCheckoutId: row.id, amountMinor, currency, quote };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ─── attachStripeSession ─────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The one-column stamp the route child calls after the Stripe call (#4841).
|
|
792
|
+
* Tenant-scoped: the update matches on `siteId` too, so a row id alone can
|
|
793
|
+
* never reach another site's record (PR #4851 security review, condition (e)).
|
|
794
|
+
* Returns the matched-row count so the caller can refuse to hand out a
|
|
795
|
+
* session whose row the #4838 webhook could never join (`count !== 1`).
|
|
796
|
+
*/
|
|
797
|
+
export async function attachStripeSession(
|
|
798
|
+
siteId: string,
|
|
799
|
+
flowCheckoutId: string,
|
|
800
|
+
stripeSessionId: string,
|
|
801
|
+
checkoutUrl: string,
|
|
802
|
+
): Promise<{ count: number }> {
|
|
803
|
+
const result = await prisma.flowCheckout.updateMany({
|
|
804
|
+
// #4912 — `status: "created"` is the third leg, matching the #4838
|
|
805
|
+
// webhook's own join. A row something else already finished or expired must
|
|
806
|
+
// NOT take this stamp: handing the visitor a URL for a row the webhook can
|
|
807
|
+
// no longer update would produce a real payment nobody records. A miss
|
|
808
|
+
// routes into the caller's attach-miss compensation, which expires the
|
|
809
|
+
// session instead of returning it.
|
|
810
|
+
where: { id: flowCheckoutId, siteId, status: "created" },
|
|
811
|
+
data: { stripeSessionId, checkoutUrl },
|
|
812
|
+
});
|
|
813
|
+
return { count: result.count };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The row half of the attach-miss compensation (#4912 FR7). The Stripe session
|
|
818
|
+
* is being expired, so the row is dead: it is marked `expired` and its
|
|
819
|
+
* idempotency key released, so the visitor's own retry writes a fresh row
|
|
820
|
+
* instead of colliding on the unique. Tenant-scoped like the stamp above.
|
|
821
|
+
*/
|
|
822
|
+
export async function expireFlowCheckout(
|
|
823
|
+
siteId: string,
|
|
824
|
+
flowCheckoutId: string,
|
|
825
|
+
): Promise<{ count: number }> {
|
|
826
|
+
const result = await prisma.flowCheckout.updateMany({
|
|
827
|
+
// Guarded on `status: "created"` like the stamp above: a row the webhook
|
|
828
|
+
// already completed must never be downgraded to `expired` by a compensation
|
|
829
|
+
// for a DIFFERENT attempt. A zero count means the row was no longer this
|
|
830
|
+
// attempt's to kill — the caller reports that rather than claiming it acted.
|
|
831
|
+
where: { id: flowCheckoutId, siteId, status: "created" },
|
|
832
|
+
data: { status: "expired", idempotencyKey: null, checkoutUrl: null },
|
|
833
|
+
});
|
|
834
|
+
return { count: result.count };
|
|
835
|
+
}
|