create-ailk 0.4.0 → 0.6.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 +55 -8
- package/component-catalog.json +12248 -0
- package/component-catalog.md +193 -68
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +60 -8
- package/dist/module-architecture.js +587 -242
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +209 -36
- package/dist/template-manifest.d.ts +88 -3
- package/dist/template-manifest.js +281 -29
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +2 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
- package/site-templates/minimalism-site/ailk-template.json +22 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +102 -12
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +29 -2
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +7 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +521 -55
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +300 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +160 -0
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +284 -0
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +58 -25
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +8 -2
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +61 -37
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +9 -7
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +74 -8
- package/templates/apps/web/CLAUDE.md +46 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +308 -65
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +15 -19
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
- package/templates/apps/web/messages/en.json +216 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +8 -3
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lead-promotion.test.ts
|
|
3
|
+
* @description Unit tests for the lead-promotion service (ADR 0009 D3
|
|
4
|
+
* service-layer pattern; spec docs/specs/3586-lead-user-promotion-spec.md §5).
|
|
5
|
+
*
|
|
6
|
+
* External dependency boundary mocked:
|
|
7
|
+
* - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
|
|
8
|
+
* manual mock (`jest.mock("../../lib/prisma.js")`).
|
|
9
|
+
*
|
|
10
|
+
* `prisma.lead.findMany` is given a REALISTIC filtering implementation (over
|
|
11
|
+
* an in-memory fixture "table") rather than a static `mockResolvedValue`, so
|
|
12
|
+
* the tenant-safety test (AC-4) actually exercises what the service passes to
|
|
13
|
+
* the DB — a bug that widened the `siteId` match set would surface the
|
|
14
|
+
* cross-tenant lead here, not just in an argument-shape assertion.
|
|
15
|
+
*
|
|
16
|
+
* tenant-context.ts's `listTenancy` runs REAL — it is a pure read over the
|
|
17
|
+
* mocked `prisma.membership.findMany`, so exercising it here also covers the
|
|
18
|
+
* D3/D4 "resolve via the sole tenant-context assembler" contract rather than
|
|
19
|
+
* re-stubbing it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
23
|
+
jest.mock("../../lib/prisma.js");
|
|
24
|
+
|
|
25
|
+
import { prisma } from "../../lib/prisma.js";
|
|
26
|
+
import { promoteLeadsForVerifiedUser } from "../lead-promotion";
|
|
27
|
+
import type { PromoteLeadsResult } from "../lead-promotion";
|
|
28
|
+
|
|
29
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
|
|
32
|
+
const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
|
|
33
|
+
const OWN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
|
|
34
|
+
// OTHER_WORKSPACE_ID belongs to a different organization than OWN_ORG_ID —
|
|
35
|
+
// the tenant-safety fixture AC-4 relies on (never a workspace of OWN_ORG_ID).
|
|
36
|
+
const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
|
|
37
|
+
const USER_EMAIL = "verified-user@example.com";
|
|
38
|
+
|
|
39
|
+
type FakeLeadRow = {
|
|
40
|
+
id: string;
|
|
41
|
+
email: string;
|
|
42
|
+
siteId: string;
|
|
43
|
+
source: string;
|
|
44
|
+
promotedUserId: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Shapes prisma.membership.findMany's mocked return — what listTenancy reads. */
|
|
48
|
+
function ownMembership() {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
role: "owner",
|
|
52
|
+
organization: {
|
|
53
|
+
id: OWN_ORG_ID,
|
|
54
|
+
name: "Acme",
|
|
55
|
+
slug: "acme",
|
|
56
|
+
workspaces: [
|
|
57
|
+
{ id: OWN_WORKSPACE_ID, name: "Acme HQ", slug: "acme-hq" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function verifiedUser(overrides?: Partial<{ emailVerified: boolean }>) {
|
|
65
|
+
return {
|
|
66
|
+
id: USER_ID,
|
|
67
|
+
email: USER_EMAIL,
|
|
68
|
+
emailVerified: overrides?.emailVerified ?? true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wires prisma.lead.findMany / updateMany to a realistic in-memory "table"
|
|
74
|
+
* filtered by the (where) args the service passes — the DB-fidelity fixture
|
|
75
|
+
* AC-4 relies on: a lead outside `siteId.in` is genuinely never returned,
|
|
76
|
+
* exactly as a real WHERE clause would exclude it.
|
|
77
|
+
*/
|
|
78
|
+
function seedLeadTable(rows: FakeLeadRow[]) {
|
|
79
|
+
const table = rows.map((r) => ({ ...r }));
|
|
80
|
+
|
|
81
|
+
(prisma.lead.findMany as jest.Mock).mockImplementation(
|
|
82
|
+
async (args: {
|
|
83
|
+
where: {
|
|
84
|
+
email: string;
|
|
85
|
+
siteId: { in: string[] };
|
|
86
|
+
promotedUserId: null;
|
|
87
|
+
};
|
|
88
|
+
select: { id: true; siteId: true; source: true };
|
|
89
|
+
}) => {
|
|
90
|
+
const { email, siteId } = args.where;
|
|
91
|
+
return table
|
|
92
|
+
.filter(
|
|
93
|
+
(row) =>
|
|
94
|
+
row.email === email &&
|
|
95
|
+
siteId.in.includes(row.siteId) &&
|
|
96
|
+
row.promotedUserId === null,
|
|
97
|
+
)
|
|
98
|
+
.map((row) => ({ id: row.id, siteId: row.siteId, source: row.source }));
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
(prisma.lead.updateMany as jest.Mock).mockImplementation(
|
|
103
|
+
async (args: { where: { id: { in: string[] } }; data: { promotedUserId: string; promotedAt: Date } }) => {
|
|
104
|
+
let count = 0;
|
|
105
|
+
for (const row of table) {
|
|
106
|
+
if (args.where.id.in.includes(row.id)) {
|
|
107
|
+
row.promotedUserId = args.data.promotedUserId;
|
|
108
|
+
count += 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { count };
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return table;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("promoteLeadsForVerifiedUser", () => {
|
|
119
|
+
// This service's workspace resolution is itself module-conditional
|
|
120
|
+
// (isModuleEnabled('multi-tenant') — see lead-promotion.ts); every test
|
|
121
|
+
// below exercises that path, so it needs the module explicitly enabled
|
|
122
|
+
// now that AILK_ENABLED_MODULES unset means no paid modules (D-44, #5368).
|
|
123
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
jest.clearAllMocks();
|
|
127
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterAll(() => {
|
|
131
|
+
if (priorEnabledModules === undefined) {
|
|
132
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
133
|
+
} else {
|
|
134
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── AC-3: verified user + matching lead in the user's own workspace ──────
|
|
139
|
+
it("AC-3: matches an un-promoted lead in the user's own workspace and marks it promoted", async () => {
|
|
140
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
141
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
142
|
+
ownMembership(),
|
|
143
|
+
);
|
|
144
|
+
seedLeadTable([
|
|
145
|
+
{
|
|
146
|
+
id: "lead_00000000-0000-7000-8000-0000000000d1",
|
|
147
|
+
email: USER_EMAIL,
|
|
148
|
+
siteId: OWN_WORKSPACE_ID,
|
|
149
|
+
source: "landing-page-1",
|
|
150
|
+
promotedUserId: null,
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
155
|
+
|
|
156
|
+
expect(result).toEqual<PromoteLeadsResult>({
|
|
157
|
+
ok: true,
|
|
158
|
+
count: 1,
|
|
159
|
+
promoted: [
|
|
160
|
+
{
|
|
161
|
+
leadId: "lead_00000000-0000-7000-8000-0000000000d1",
|
|
162
|
+
siteId: OWN_WORKSPACE_ID,
|
|
163
|
+
source: "landing-page-1",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
expect(prisma.lead.findMany).toHaveBeenCalledWith({
|
|
168
|
+
where: {
|
|
169
|
+
email: USER_EMAIL,
|
|
170
|
+
siteId: { in: [OWN_WORKSPACE_ID] },
|
|
171
|
+
promotedUserId: null,
|
|
172
|
+
},
|
|
173
|
+
select: { id: true, siteId: true, source: true },
|
|
174
|
+
});
|
|
175
|
+
expect(prisma.lead.updateMany).toHaveBeenCalledWith({
|
|
176
|
+
where: { id: { in: ["lead_00000000-0000-7000-8000-0000000000d1"] } },
|
|
177
|
+
data: {
|
|
178
|
+
promotedUserId: USER_ID,
|
|
179
|
+
promotedAt: expect.any(Date),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── AC-4 (tenant-safety — load-bearing): cross-tenant siteId not promoted ─
|
|
185
|
+
it("AC-4: a lead whose siteId belongs to a DIFFERENT organization's workspace is not promoted", async () => {
|
|
186
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
187
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
188
|
+
ownMembership(), // the user belongs ONLY to OWN_ORG_ID / OWN_WORKSPACE_ID
|
|
189
|
+
);
|
|
190
|
+
const table = seedLeadTable([
|
|
191
|
+
{
|
|
192
|
+
id: "lead_cross_tenant",
|
|
193
|
+
email: USER_EMAIL,
|
|
194
|
+
siteId: OTHER_WORKSPACE_ID, // a DIFFERENT organization's workspace
|
|
195
|
+
source: "contact",
|
|
196
|
+
promotedUserId: null,
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
201
|
+
|
|
202
|
+
expect(result).toEqual<PromoteLeadsResult>({
|
|
203
|
+
ok: true,
|
|
204
|
+
count: 0,
|
|
205
|
+
promoted: [],
|
|
206
|
+
});
|
|
207
|
+
expect(prisma.lead.updateMany).not.toHaveBeenCalled();
|
|
208
|
+
// The cross-tenant lead's promotedUserId is untouched.
|
|
209
|
+
expect(table.find((r) => r.id === "lead_cross_tenant")?.promotedUserId).toBeNull();
|
|
210
|
+
// The query itself never widens siteId.in to the foreign workspace.
|
|
211
|
+
expect(prisma.lead.findMany).toHaveBeenCalledWith({
|
|
212
|
+
where: {
|
|
213
|
+
email: USER_EMAIL,
|
|
214
|
+
siteId: { in: [OWN_WORKSPACE_ID] },
|
|
215
|
+
promotedUserId: null,
|
|
216
|
+
},
|
|
217
|
+
select: { id: true, siteId: true, source: true },
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── AC-5: unverified email → no-op, verified-email gate ───────────────────
|
|
222
|
+
it("AC-5: unverified email performs no lead update and returns count:0", async () => {
|
|
223
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
224
|
+
verifiedUser({ emailVerified: false }),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
228
|
+
|
|
229
|
+
expect(result).toEqual<PromoteLeadsResult>({
|
|
230
|
+
ok: true,
|
|
231
|
+
count: 0,
|
|
232
|
+
promoted: [],
|
|
233
|
+
});
|
|
234
|
+
expect(prisma.membership.findMany).not.toHaveBeenCalled();
|
|
235
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
236
|
+
expect(prisma.lead.updateMany).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── AC-6: idempotent — a second invocation promotes nothing ──────────────
|
|
240
|
+
it("AC-6: a second invocation (leads already promoted) promotes nothing", async () => {
|
|
241
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
242
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
243
|
+
ownMembership(),
|
|
244
|
+
);
|
|
245
|
+
seedLeadTable([
|
|
246
|
+
{
|
|
247
|
+
id: "lead_already_promoted",
|
|
248
|
+
email: USER_EMAIL,
|
|
249
|
+
siteId: OWN_WORKSPACE_ID,
|
|
250
|
+
source: "contact",
|
|
251
|
+
promotedUserId: USER_ID, // already promoted — excluded by the query
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
const first = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
256
|
+
expect(first).toEqual<PromoteLeadsResult>({
|
|
257
|
+
ok: true,
|
|
258
|
+
count: 0,
|
|
259
|
+
promoted: [],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const second = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
263
|
+
expect(second).toEqual<PromoteLeadsResult>({
|
|
264
|
+
ok: true,
|
|
265
|
+
count: 0,
|
|
266
|
+
promoted: [],
|
|
267
|
+
});
|
|
268
|
+
expect(prisma.lead.updateMany).not.toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Idempotent end-to-end: promote once, re-run promotes nothing more ────
|
|
272
|
+
it("a lead promoted on the first run is excluded on the second run", async () => {
|
|
273
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
274
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
275
|
+
ownMembership(),
|
|
276
|
+
);
|
|
277
|
+
seedLeadTable([
|
|
278
|
+
{
|
|
279
|
+
id: "lead_first_run",
|
|
280
|
+
email: USER_EMAIL,
|
|
281
|
+
siteId: OWN_WORKSPACE_ID,
|
|
282
|
+
source: "contact",
|
|
283
|
+
promotedUserId: null,
|
|
284
|
+
},
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const first = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
288
|
+
expect(first).toEqual<PromoteLeadsResult>({
|
|
289
|
+
ok: true,
|
|
290
|
+
count: 1,
|
|
291
|
+
promoted: [
|
|
292
|
+
{ leadId: "lead_first_run", siteId: OWN_WORKSPACE_ID, source: "contact" },
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const second = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
297
|
+
expect(second).toEqual<PromoteLeadsResult>({
|
|
298
|
+
ok: true,
|
|
299
|
+
count: 0,
|
|
300
|
+
promoted: [],
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ── No match → count:0 ─────────────────────────────────────────────────────
|
|
305
|
+
it("no matching lead → returns count:0 with no update", async () => {
|
|
306
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
307
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
308
|
+
ownMembership(),
|
|
309
|
+
);
|
|
310
|
+
seedLeadTable([]);
|
|
311
|
+
|
|
312
|
+
const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
313
|
+
|
|
314
|
+
expect(result).toEqual<PromoteLeadsResult>({
|
|
315
|
+
ok: true,
|
|
316
|
+
count: 0,
|
|
317
|
+
promoted: [],
|
|
318
|
+
});
|
|
319
|
+
expect(prisma.lead.updateMany).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── source preservation ────────────────────────────────────────────────────
|
|
323
|
+
it("preserves each lead's source in the result", async () => {
|
|
324
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
325
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
326
|
+
ownMembership(),
|
|
327
|
+
);
|
|
328
|
+
seedLeadTable([
|
|
329
|
+
{
|
|
330
|
+
id: "lead_a",
|
|
331
|
+
email: USER_EMAIL,
|
|
332
|
+
siteId: OWN_WORKSPACE_ID,
|
|
333
|
+
source: "contact",
|
|
334
|
+
promotedUserId: null,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: "lead_b",
|
|
338
|
+
email: USER_EMAIL,
|
|
339
|
+
siteId: OWN_WORKSPACE_ID,
|
|
340
|
+
source: "newsletter-signup",
|
|
341
|
+
promotedUserId: null,
|
|
342
|
+
},
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
const result = (await promoteLeadsForVerifiedUser({
|
|
346
|
+
userId: USER_ID,
|
|
347
|
+
})) as Extract<PromoteLeadsResult, { ok: true }>;
|
|
348
|
+
|
|
349
|
+
expect(result.ok).toBe(true);
|
|
350
|
+
expect(result.count).toBe(2);
|
|
351
|
+
expect(result.promoted).toEqual([
|
|
352
|
+
{ leadId: "lead_a", siteId: OWN_WORKSPACE_ID, source: "contact" },
|
|
353
|
+
{
|
|
354
|
+
leadId: "lead_b",
|
|
355
|
+
siteId: OWN_WORKSPACE_ID,
|
|
356
|
+
source: "newsletter-signup",
|
|
357
|
+
},
|
|
358
|
+
]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── No workspaces → no-op, never queries lead ─────────────────────────────
|
|
362
|
+
it("a user with no workspaces is a no-op — never queries inbox.Lead", async () => {
|
|
363
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
364
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
|
|
365
|
+
|
|
366
|
+
const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
367
|
+
|
|
368
|
+
expect(result).toEqual<PromoteLeadsResult>({
|
|
369
|
+
ok: true,
|
|
370
|
+
count: 0,
|
|
371
|
+
promoted: [],
|
|
372
|
+
});
|
|
373
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ── Unknown user → ok:false, user_not_found ───────────────────────────────
|
|
377
|
+
it("an unknown userId returns ok:false with kind user_not_found", async () => {
|
|
378
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
379
|
+
|
|
380
|
+
const result = (await promoteLeadsForVerifiedUser({
|
|
381
|
+
userId: "user_does-not-exist",
|
|
382
|
+
})) as Extract<PromoteLeadsResult, { ok: false }>;
|
|
383
|
+
|
|
384
|
+
expect(result.ok).toBe(false);
|
|
385
|
+
expect(result.error.kind).toBe("user_not_found");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── PII non-leak — the result never carries the user's raw email ─────────
|
|
389
|
+
it("PII non-leak — the result never carries the user's raw email field", async () => {
|
|
390
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(verifiedUser());
|
|
391
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
392
|
+
ownMembership(),
|
|
393
|
+
);
|
|
394
|
+
seedLeadTable([
|
|
395
|
+
{
|
|
396
|
+
id: "lead_00000000-0000-7000-8000-0000000000d1",
|
|
397
|
+
email: USER_EMAIL,
|
|
398
|
+
siteId: OWN_WORKSPACE_ID,
|
|
399
|
+
source: "contact",
|
|
400
|
+
promotedUserId: null,
|
|
401
|
+
},
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
const result = await promoteLeadsForVerifiedUser({ userId: USER_ID });
|
|
405
|
+
|
|
406
|
+
expect(JSON.stringify(result)).not.toContain(USER_EMAIL);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -392,4 +392,164 @@ describe("routeLead", () => {
|
|
|
392
392
|
expect(result.error.kind).toBe("invalid_input");
|
|
393
393
|
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
394
394
|
});
|
|
395
|
+
|
|
396
|
+
// ── #6203/#6254 — the workspaceId stamp is ALWAYS a copy of siteId ────────
|
|
397
|
+
//
|
|
398
|
+
// routeLead(kind: "submit") has FOUR callers in this repo, and as of
|
|
399
|
+
// #6254 ALL FOUR resolve `siteId` to a real `wksp_<uuidv7>` workspace id
|
|
400
|
+
// before calling in — the three that once passed an unresolved alias
|
|
401
|
+
// (flow-engine.ts, recommender-capture.ts, project-listings.ts's
|
|
402
|
+
// submitProjectListingDraft) now resolve it themselves
|
|
403
|
+
// (`resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`,
|
|
404
|
+
// `apps/api/src/lib/site.ts`). `workspaceId` is NOT NULL as of
|
|
405
|
+
// database/migrations/20260922120000_tenancy_fence, so `persistToDb` can
|
|
406
|
+
// no longer stamp `null` for an unresolved alias the way #6203 once did —
|
|
407
|
+
// it copies `siteId` unconditionally, and a caller that still somehow
|
|
408
|
+
// passed an unresolved alias hits the pre-existing
|
|
409
|
+
// `leads_workspace_id_format` CHECK loudly (via the AC-11 re-raise this
|
|
410
|
+
// file's other describe block proves) instead of landing a
|
|
411
|
+
// silently-unfenced row.
|
|
412
|
+
describe("the workspaceId stamp copies siteId unconditionally (#6203, #6254)", () => {
|
|
413
|
+
it("workspaceId is always the SAME value as siteId, whatever shape siteId carries", async () => {
|
|
414
|
+
await routeLead(leadInput, sourceMeta); // leadInput.siteId === "site-abc"
|
|
415
|
+
|
|
416
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
417
|
+
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
418
|
+
expect(data.siteId).toBe("site-abc");
|
|
419
|
+
expect(data.workspaceId).toBe("site-abc");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("a workspace-shaped siteId (the routes/leads/index.ts path) stamps workspaceId to the SAME value", async () => {
|
|
423
|
+
const resolvedInput = {
|
|
424
|
+
...leadInput,
|
|
425
|
+
siteId: "wksp_00000000-0000-7000-8000-000000000001",
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
await routeLead(resolvedInput, sourceMeta);
|
|
429
|
+
|
|
430
|
+
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
431
|
+
expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
432
|
+
expect(data.workspaceId).toBe("wksp_00000000-0000-7000-8000-000000000001");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// #6254 AC-11 (spec D6, SEC-02) — a constraint violation (the new
|
|
437
|
+
// `leads_workspace_site_agree` CHECK, or the pre-existing NOT NULL) must
|
|
438
|
+
// never be swallowed into the file fallback and a silent `ok:true`: it
|
|
439
|
+
// means OUR OWN derivation produced a row Postgres itself refuses, which
|
|
440
|
+
// is a bug to surface, not an outage to degrade through. A GENUINE
|
|
441
|
+
// infrastructure failure (the existing "DB down → file fallback" test
|
|
442
|
+
// above, a plain `Error("connection refused")` that carries none of the
|
|
443
|
+
// signals below) must still take the file fallback, unchanged.
|
|
444
|
+
describe("a constraint violation re-raises as a deliberate error, never a silent success (#6254 AC-11)", () => {
|
|
445
|
+
const listTmpDirFiles = () => fs.readdir(tmpDir);
|
|
446
|
+
|
|
447
|
+
it("a Prisma-mapped class-23 code (e.g. P2011, null constraint) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
|
|
448
|
+
const before = await listTmpDirFiles();
|
|
449
|
+
const constraintErr = Object.assign(
|
|
450
|
+
new Error("Null constraint violation on the field: (`workspace_id`)"),
|
|
451
|
+
{ code: "P2011" },
|
|
452
|
+
);
|
|
453
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
|
|
454
|
+
|
|
455
|
+
const result = (await routeLead(
|
|
456
|
+
leadInput,
|
|
457
|
+
sourceMeta,
|
|
458
|
+
)) as Extract<RouteLeadResult, { ok: false }>;
|
|
459
|
+
|
|
460
|
+
expect(result.ok).toBe(false);
|
|
461
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
462
|
+
// No file was written — the fallback was never attempted.
|
|
463
|
+
const after = await listTmpDirFiles();
|
|
464
|
+
expect(after).toEqual(before);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("a raw SQLSTATE the message carries (no Prisma-mapped code — the new CHECK constraint's own shape) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
|
|
468
|
+
const before = await listTmpDirFiles();
|
|
469
|
+
const checkErr = new Error(
|
|
470
|
+
'new row for relation "leads" violates check constraint "leads_workspace_site_agree"',
|
|
471
|
+
);
|
|
472
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
|
|
473
|
+
|
|
474
|
+
const result = (await routeLead(
|
|
475
|
+
leadInput,
|
|
476
|
+
sourceMeta,
|
|
477
|
+
)) as Extract<RouteLeadResult, { ok: false }>;
|
|
478
|
+
|
|
479
|
+
expect(result.ok).toBe(false);
|
|
480
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
481
|
+
const after = await listTmpDirFiles();
|
|
482
|
+
expect(after).toEqual(before);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
|
|
486
|
+
const before = await listTmpDirFiles();
|
|
487
|
+
const notNullErr = Object.assign(
|
|
488
|
+
new Error("null value in column \"workspace_id\" violates not-null constraint (23502)"),
|
|
489
|
+
{},
|
|
490
|
+
);
|
|
491
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
|
|
492
|
+
|
|
493
|
+
const result = (await routeLead(
|
|
494
|
+
leadInput,
|
|
495
|
+
sourceMeta,
|
|
496
|
+
)) as Extract<RouteLeadResult, { ok: false }>;
|
|
497
|
+
|
|
498
|
+
expect(result.ok).toBe(false);
|
|
499
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
500
|
+
const after = await listTmpDirFiles();
|
|
501
|
+
expect(after).toEqual(before);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("a genuine outage (no class-23 signal) still takes the file fallback — persisted:file, not persist_failed", async () => {
|
|
505
|
+
// Regression guard for the branch above: an ordinary connection error
|
|
506
|
+
// must not be misclassified as a constraint violation.
|
|
507
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
508
|
+
new Error("connection refused"),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const result = (await routeLead(
|
|
512
|
+
leadInput,
|
|
513
|
+
sourceMeta,
|
|
514
|
+
)) as Extract<RouteLeadResult, { ok: true }>;
|
|
515
|
+
|
|
516
|
+
expect(result.ok).toBe(true);
|
|
517
|
+
expect(result.persisted).toBe("file");
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// ─── #6257 — the fence: every fenced read carries workspaceId ───────────────
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Unit C2's contract for this service (#6257 AC-1/AC-2). `routeLead`'s one
|
|
526
|
+
* READ is the duplicate-subscriber lookup on the P2002 arm. `input.siteId` is
|
|
527
|
+
* the resolved workspace id at every caller (the same guarantee the three
|
|
528
|
+
* `create` sites above stamp on), so the lookup is scoped by it rather than
|
|
529
|
+
* resolving a second time.
|
|
530
|
+
*/
|
|
531
|
+
describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () => {
|
|
532
|
+
beforeEach(() => {
|
|
533
|
+
jest.clearAllMocks();
|
|
534
|
+
process.env.DATABASE_URL = "postgres://test";
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("scopes the existing-subscriber read by the caller's resolved workspace id", async () => {
|
|
538
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(
|
|
539
|
+
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
|
540
|
+
);
|
|
541
|
+
(prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "sub_1" });
|
|
542
|
+
|
|
543
|
+
await routeLead(
|
|
544
|
+
{ ...subscribeInput, email: "dupe@example.com" },
|
|
545
|
+
sourceMeta,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const { where } = (prisma.subscriber.findUnique as jest.Mock).mock.calls[0][0];
|
|
549
|
+
expect(where.workspaceId).toBe(subscribeInput.siteId);
|
|
550
|
+
expect(where.siteId_email).toEqual({
|
|
551
|
+
siteId: subscribeInput.siteId,
|
|
552
|
+
email: "dupe@example.com",
|
|
553
|
+
});
|
|
554
|
+
});
|
|
395
555
|
});
|