create-ailk 0.1.0 → 0.1.1
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 +295 -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 +22 -14
- 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 +75 -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 +231 -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 +547 -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 +742 -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-key.test.ts +364 -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 +105 -0
- package/templates/apps/api/src/routes/project-listings/list.ts +47 -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 +98 -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 +293 -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__/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__/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/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 +1278 -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/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 +490 -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 +1124 -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 +115 -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 +84 -27
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -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 +118 -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 +16 -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 +24 -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 +382 -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 +476 -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/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/scripts/db-generate-locked.sh +0 -0
- 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,1124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file project-listings.ts
|
|
3
|
+
* @description The founder's-application-row service (#4998, reshaped by
|
|
4
|
+
* #5036) — writes, reads, and transitions `ProjectListing` (ADR 0009 D3
|
|
5
|
+
* service-layer convention, mirroring `lead-routing.ts` and
|
|
6
|
+
* `waitlist-dashboard.ts`).
|
|
7
|
+
*
|
|
8
|
+
* The single anonymous create became THREE operations when the application
|
|
9
|
+
* became a ten-step form that saves as it goes (#5036 D1):
|
|
10
|
+
*
|
|
11
|
+
* start name + email + consent -> a `draft` row and a resume token
|
|
12
|
+
* patch merges a partial payload; asserts no completeness
|
|
13
|
+
* submit validates the WHOLE application, `draft` -> `requested`
|
|
14
|
+
*
|
|
15
|
+
* Validation therefore moved from write-time to submit-time, and that is why
|
|
16
|
+
* every application field is nullable on the row: a half-filled draft is a
|
|
17
|
+
* legitimate row, and only `submit` asserts it is finished.
|
|
18
|
+
*
|
|
19
|
+
* `submitProjectListingDraft` calls `routeLead` (kind `submit`, source
|
|
20
|
+
* `projects-apply`) for the Lead write — never a duplicate Lead-shaped insert.
|
|
21
|
+
* The Lead is written at SUBMIT rather than at start: the founder's contact
|
|
22
|
+
* details are already captured on the listing row itself at step one, so a
|
|
23
|
+
* Lead at step one adds no capture, and what it DOES add is an operator
|
|
24
|
+
* notification email per abandoned first step on an anonymous route (PR #5042
|
|
25
|
+
* security review, finding 4). Every tenant read/write
|
|
26
|
+
* is scoped by `siteId` (`request.tenant.workspaceId`) — there is no
|
|
27
|
+
* client-supplied site id on any tenant route. `waitlistCount` joins THROUGH
|
|
28
|
+
* `experimentId` (`WaitlistExperiment.id` -> `.key` -> `WaitlistScore.experimentKey`,
|
|
29
|
+
* scoped by `siteId`), 0 when a listing carries no `experimentKey`.
|
|
30
|
+
*
|
|
31
|
+
* PII boundary: this module never logs `founderEmail`, `founderName`, or
|
|
32
|
+
* `questions` — the same discipline `lead-routing.ts` documents. The resume
|
|
33
|
+
* token is never logged either, at any level: it is the ONLY credential on
|
|
34
|
+
* the anonymous draft surface.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
38
|
+
|
|
39
|
+
import type { Prisma, Role } from "@working-theory/database";
|
|
40
|
+
import { logger } from "@working-theory/observability";
|
|
41
|
+
import {
|
|
42
|
+
clampWindowDays,
|
|
43
|
+
buildProjectListingDraftPatchSchema,
|
|
44
|
+
buildProjectListingSubmitSchema,
|
|
45
|
+
PROJECT_LISTING_CONCURRENT_LIMIT,
|
|
46
|
+
PROJECT_LISTING_NON_TERMINAL_STATUSES,
|
|
47
|
+
} from "@working-theory/validation";
|
|
48
|
+
import type {
|
|
49
|
+
ProjectListing,
|
|
50
|
+
ProjectListingDraftPatchInput,
|
|
51
|
+
ProjectListingFaqs,
|
|
52
|
+
ProjectListingPublic,
|
|
53
|
+
ProjectListingQuestions,
|
|
54
|
+
ProjectListingStartInput,
|
|
55
|
+
} from "@working-theory/validation";
|
|
56
|
+
|
|
57
|
+
import { prisma } from "../lib/prisma.js";
|
|
58
|
+
|
|
59
|
+
import { routeLead, type SourceMeta } from "./lead-routing.js";
|
|
60
|
+
import { listingConfigFor } from "./listing-config.js";
|
|
61
|
+
|
|
62
|
+
// ─── Role floor ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The membership role this floor is expressed in (#5182).
|
|
66
|
+
*
|
|
67
|
+
* Previously `import type { MembershipRole } from "./tenant-context.js"` — a
|
|
68
|
+
* multi-tenant SLICE file the OSS strip removes, which makes a type-only import
|
|
69
|
+
* of it a TS2307 on the public tree exactly as a value import would be (spec
|
|
70
|
+
* D1). `Role` is the Prisma enum `Membership.role` is actually declared as
|
|
71
|
+
* (`database/auth/schema.prisma:25-33`), so it is the same three values from
|
|
72
|
+
* the non-slice home they originate in rather than a restatement — and this
|
|
73
|
+
* file already imports from `@working-theory/database` two lines up.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/** Reads/writes require `owner` or `admin` — the same floor as the waitlist dashboard. */
|
|
77
|
+
export const PROJECT_LISTING_ROLES: ReadonlySet<Role> = new Set<Role>([
|
|
78
|
+
"owner",
|
|
79
|
+
"admin",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// ─── The computed lifecycle state ───────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
type ProjectListingStatus = ProjectListing["status"];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a row's LIFECYCLE status from its STORED status (#5033 D3).
|
|
88
|
+
*
|
|
89
|
+
* `closed` is the one state nothing writes. An `active` listing whose window
|
|
90
|
+
* end has passed reads `closed`, here, on every read, with no job having run.
|
|
91
|
+
* Storing it instead would need a scheduler whose failure leaves expired
|
|
92
|
+
* listings publicly open — a silent, wrong-way failure. This makes that
|
|
93
|
+
* unrepresentable.
|
|
94
|
+
*
|
|
95
|
+
* A FEATURED row is exempt (#5036 D6b). The operator's own products become
|
|
96
|
+
* listing rows carrying `featured: true`, and such a row is not a time-boxed
|
|
97
|
+
* validation experiment: it has no meaningful window and must not vanish from
|
|
98
|
+
* the shelf because a date passed. Its `windowEndsAt` may be null, and it
|
|
99
|
+
* reads `active` either way.
|
|
100
|
+
*
|
|
101
|
+
* A null window on a NON-featured `active` row is an ERROR, not a licence to
|
|
102
|
+
* stay open forever: every real listing is time-boxed by design, and the whole
|
|
103
|
+
* point of computing `closed` rather than storing it is that an expired
|
|
104
|
+
* listing cannot linger. It is logged and read `closed` — failing toward the
|
|
105
|
+
* closed shelf rather than toward a publicly-open listing nothing bounds.
|
|
106
|
+
* `draft`, `requested` and `rejected` rows legitimately carry no window and
|
|
107
|
+
* are not touched by any of this.
|
|
108
|
+
*/
|
|
109
|
+
export function computeListingStatus(
|
|
110
|
+
storedStatus: string,
|
|
111
|
+
windowEndsAt: Date | null,
|
|
112
|
+
now: Date,
|
|
113
|
+
featured: boolean,
|
|
114
|
+
): ProjectListingStatus {
|
|
115
|
+
if (storedStatus !== "active") return storedStatus as ProjectListingStatus;
|
|
116
|
+
if (featured) return "active";
|
|
117
|
+
if (windowEndsAt === null) {
|
|
118
|
+
logger.error(
|
|
119
|
+
{ kind: "active_listing_without_window" },
|
|
120
|
+
"[project-listings] a non-featured active listing carries no window end; reading it closed",
|
|
121
|
+
);
|
|
122
|
+
return "closed";
|
|
123
|
+
}
|
|
124
|
+
return windowEndsAt.getTime() <= now.getTime() ? "closed" : "active";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Slug derivation (#5033 D2) ─────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** The `name`-derived slug's length cap. */
|
|
130
|
+
const SLUG_MAX_LENGTH = 48;
|
|
131
|
+
|
|
132
|
+
/** The slug a name with no alphanumeric content falls back to before suffixing. */
|
|
133
|
+
const SLUG_FALLBACK = "listing";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Derive a slug from a listing `name`: lowercase, every non-alphanumeric run
|
|
137
|
+
* to a single hyphen, trimmed of leading/trailing hyphens, truncated to 48.
|
|
138
|
+
* A name carrying no alphanumerics at all (the payload allows it — `name` is
|
|
139
|
+
* only bounded by length) falls back to `listing`, which the site-scoped
|
|
140
|
+
* suffixing below then disambiguates like any other collision.
|
|
141
|
+
*/
|
|
142
|
+
export function deriveListingSlug(name: string): string {
|
|
143
|
+
const base = name
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
146
|
+
.replace(/^-+|-+$/g, "")
|
|
147
|
+
.slice(0, SLUG_MAX_LENGTH)
|
|
148
|
+
.replace(/-+$/, "");
|
|
149
|
+
return base.length > 0 ? base : SLUG_FALLBACK;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The most same-prefix slugs one create will read before giving up.
|
|
154
|
+
*
|
|
155
|
+
* The collision read is a prefix scan, and `base` derives from a fully
|
|
156
|
+
* caller-controlled `name` on an ANONYMOUS route — a one-character name makes
|
|
157
|
+
* the prefix match most of the site's slugs, and every accepted application
|
|
158
|
+
* adds another row to that family. Unbounded, the cost of a create would grow
|
|
159
|
+
* with the number of listings an attacker had already filed. Bounded, a
|
|
160
|
+
* pathological family degrades to the same `slug_taken` 409 a genuine race
|
|
161
|
+
* already produces, rather than to an ever-slower table scan.
|
|
162
|
+
*/
|
|
163
|
+
const SLUG_COLLISION_SCAN_LIMIT = 200;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The first free slug for `base` on this site. Uniqueness is already scoped
|
|
167
|
+
* `@@unique([siteId, slug])`, so the suffix is too: a second "Atlas" on this
|
|
168
|
+
* site becomes `atlas-2` even if `atlas` is taken on another.
|
|
169
|
+
*
|
|
170
|
+
* Two concurrent creates of the same name can still both resolve the same
|
|
171
|
+
* free slug and race to the insert; the loser hits the unique constraint and
|
|
172
|
+
* is reported as `slug_taken`, exactly as it was when the client supplied the
|
|
173
|
+
* slug itself.
|
|
174
|
+
*/
|
|
175
|
+
async function firstFreeSlug(siteId: string, base: string): Promise<string> {
|
|
176
|
+
const rows = await prisma.projectListing.findMany({
|
|
177
|
+
where: { siteId, slug: { startsWith: base } },
|
|
178
|
+
select: { slug: true },
|
|
179
|
+
take: SLUG_COLLISION_SCAN_LIMIT,
|
|
180
|
+
});
|
|
181
|
+
const taken = new Set(rows.map((r) => r.slug));
|
|
182
|
+
if (!taken.has(base)) return base;
|
|
183
|
+
|
|
184
|
+
// Bounded by `taken.size + 1` iterations — one of the first `n` candidates
|
|
185
|
+
// is necessarily free.
|
|
186
|
+
let suffix = 2;
|
|
187
|
+
while (taken.has(`${base}-${suffix}`)) suffix += 1;
|
|
188
|
+
return `${base}-${suffix}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
const PROJECT_LISTING_SELECT = {
|
|
194
|
+
id: true,
|
|
195
|
+
siteId: true,
|
|
196
|
+
leadId: true,
|
|
197
|
+
slug: true,
|
|
198
|
+
name: true,
|
|
199
|
+
oneLiner: true,
|
|
200
|
+
problem: true,
|
|
201
|
+
url: true,
|
|
202
|
+
product: true,
|
|
203
|
+
commitment: true,
|
|
204
|
+
teamComposition: true,
|
|
205
|
+
faqs: true,
|
|
206
|
+
anythingElse: true,
|
|
207
|
+
address: true,
|
|
208
|
+
questions: true,
|
|
209
|
+
founderName: true,
|
|
210
|
+
founderEmail: true,
|
|
211
|
+
founderCompany: true,
|
|
212
|
+
headline: true,
|
|
213
|
+
logoMarkUrl: true,
|
|
214
|
+
logoWordmarkUrl: true,
|
|
215
|
+
featured: true,
|
|
216
|
+
status: true,
|
|
217
|
+
consentAt: true,
|
|
218
|
+
decidedAt: true,
|
|
219
|
+
decidedBy: true,
|
|
220
|
+
issueUrl: true,
|
|
221
|
+
experimentKey: true,
|
|
222
|
+
windowEndsAt: true,
|
|
223
|
+
createdAt: true,
|
|
224
|
+
updatedAt: true,
|
|
225
|
+
} as const satisfies Prisma.ProjectListingSelect;
|
|
226
|
+
|
|
227
|
+
type ProjectListingRow = Prisma.ProjectListingGetPayload<{
|
|
228
|
+
select: typeof PROJECT_LISTING_SELECT;
|
|
229
|
+
}>;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `now` is passed rather than read inside so one read renders every row of a
|
|
233
|
+
* list against a single instant. `featuredEnabled` is the site's config
|
|
234
|
+
* switch (#5033 D6): where a site has not enabled a featured shelf, the flag
|
|
235
|
+
* never surfaces. The tenant dashboard passes `true` — an operator manages
|
|
236
|
+
* the flag and must see what they set.
|
|
237
|
+
*/
|
|
238
|
+
function toResponseRow(
|
|
239
|
+
row: ProjectListingRow,
|
|
240
|
+
waitlistCount: number,
|
|
241
|
+
now: Date,
|
|
242
|
+
featuredEnabled: boolean,
|
|
243
|
+
): ProjectListing {
|
|
244
|
+
return {
|
|
245
|
+
id: row.id,
|
|
246
|
+
siteId: row.siteId,
|
|
247
|
+
leadId: row.leadId,
|
|
248
|
+
slug: row.slug,
|
|
249
|
+
name: row.name,
|
|
250
|
+
headline: row.headline,
|
|
251
|
+
oneLiner: row.oneLiner,
|
|
252
|
+
problem: row.problem,
|
|
253
|
+
url: row.url,
|
|
254
|
+
product: row.product as ProjectListing["product"],
|
|
255
|
+
commitment: row.commitment as ProjectListing["commitment"],
|
|
256
|
+
teamComposition: row.teamComposition as ProjectListing["teamComposition"],
|
|
257
|
+
faqs: row.faqs as unknown as ProjectListingFaqs | null,
|
|
258
|
+
anythingElse: row.anythingElse,
|
|
259
|
+
address: row.address,
|
|
260
|
+
founderName: row.founderName,
|
|
261
|
+
founderEmail: row.founderEmail,
|
|
262
|
+
founderCompany: row.founderCompany,
|
|
263
|
+
questions: row.questions as unknown as ProjectListingQuestions | null,
|
|
264
|
+
logoMarkUrl: row.logoMarkUrl,
|
|
265
|
+
logoWordmarkUrl: row.logoWordmarkUrl,
|
|
266
|
+
featured: featuredEnabled && row.featured,
|
|
267
|
+
// The row's OWN flag decides the closed exemption (#5036 D6b), not the
|
|
268
|
+
// site's `featuredEnabled` switch: that switch governs whether the flag
|
|
269
|
+
// SURFACES on a read, not whether the listing is time-boxed.
|
|
270
|
+
status: computeListingStatus(row.status, row.windowEndsAt, now, row.featured),
|
|
271
|
+
consentAt: row.consentAt.toISOString(),
|
|
272
|
+
decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
|
|
273
|
+
decidedBy: row.decidedBy,
|
|
274
|
+
issueUrl: row.issueUrl,
|
|
275
|
+
experimentKey: row.experimentKey,
|
|
276
|
+
windowEndsAt: row.windowEndsAt ? row.windowEndsAt.toISOString() : null,
|
|
277
|
+
waitlistCount,
|
|
278
|
+
createdAt: row.createdAt.toISOString(),
|
|
279
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `waitlistCount` per row, keyed by `experimentKey`.
|
|
285
|
+
*
|
|
286
|
+
* Since #5036 D6d the listing stores the experiment's KEY rather than its id,
|
|
287
|
+
* and that key is the same string `WaitlistScore.experimentKey` holds — so the
|
|
288
|
+
* `WaitlistExperiment` read that existed purely to translate one into the
|
|
289
|
+
* other is gone. One `WaitlistScore.count` per DISTINCT key remains, bounded
|
|
290
|
+
* by the small number of distinct experiments a dashboard page carries rather
|
|
291
|
+
* than by row count (`waitlist-dashboard.ts`'s "one read across every returned
|
|
292
|
+
* row" discipline).
|
|
293
|
+
*/
|
|
294
|
+
async function attachWaitlistCounts(
|
|
295
|
+
siteId: string,
|
|
296
|
+
rows: readonly ProjectListingRow[],
|
|
297
|
+
): Promise<Map<string, number>> {
|
|
298
|
+
const experimentKeys = [
|
|
299
|
+
...new Set(rows.map((r) => r.experimentKey).filter((v): v is string => v != null)),
|
|
300
|
+
];
|
|
301
|
+
if (experimentKeys.length === 0) return new Map();
|
|
302
|
+
|
|
303
|
+
const counts = new Map<string, number>();
|
|
304
|
+
await Promise.all(
|
|
305
|
+
experimentKeys.map(async (experimentKey) => {
|
|
306
|
+
const count = await prisma.waitlistScore.count({
|
|
307
|
+
where: { siteId, experimentKey },
|
|
308
|
+
});
|
|
309
|
+
counts.set(experimentKey, count);
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
return counts;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function waitlistCountFor(row: ProjectListingRow, counts: Map<string, number>): number {
|
|
316
|
+
return row.experimentKey ? (counts.get(row.experimentKey) ?? 0) : 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── The founder's key (#5036 D7) ──────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Normalize the email a founder is counted under.
|
|
323
|
+
*
|
|
324
|
+
* Trim and lowercase, so `Ada@Example.com ` and `ada@example.com` are one
|
|
325
|
+
* founder rather than two. Postgres text equality is case-sensitive, so
|
|
326
|
+
* without this the concurrent-listing limit is evaded by holding down shift.
|
|
327
|
+
*
|
|
328
|
+
* Deliberately NOT stripping plus-tags: `ada+two@example.com` still counts as
|
|
329
|
+
* a different founder. Plus-addressing is a legitimate, widely-used feature
|
|
330
|
+
* and treating it as evasion would refuse real applicants; closing that gap
|
|
331
|
+
* properly needs a verified email, which this anonymous surface does not have.
|
|
332
|
+
*/
|
|
333
|
+
function normalizeFounderEmail(email: string): string {
|
|
334
|
+
return email.trim().toLowerCase();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* How long an unsubmitted draft occupies one of a founder's two slots
|
|
339
|
+
* (#5036 D7).
|
|
340
|
+
*
|
|
341
|
+
* Drafts MUST count, or the limit is evaded by never submitting. But
|
|
342
|
+
* `founderEmail` is unverified on an anonymous surface, so anyone can open a
|
|
343
|
+
* draft in a stranger's name — and a draft that counted forever would let two
|
|
344
|
+
* anonymous requests lock a named founder out of ever applying, with no
|
|
345
|
+
* operator remedy (there is no route that deletes a listing; PR #5042 security
|
|
346
|
+
* review, finding 1).
|
|
347
|
+
*
|
|
348
|
+
* Bounding the window keeps D7's intent — an application in progress holds a
|
|
349
|
+
* slot, so not submitting evades nothing — while making that lockout expire on
|
|
350
|
+
* its own. Fourteen days is far longer than a ten-step form takes and far
|
|
351
|
+
* shorter than "forever".
|
|
352
|
+
*
|
|
353
|
+
* This bounds the QUOTA impact, not retention: an abandoned draft still holds
|
|
354
|
+
* the founder's name, email and address in the table. A retention sweep is a
|
|
355
|
+
* separate unit and is not in this change.
|
|
356
|
+
*/
|
|
357
|
+
const DRAFT_HOLDS_A_SLOT_DAYS = 14;
|
|
358
|
+
|
|
359
|
+
// ─── The resume token (#5036 D1) ───────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The token's entropy, in bytes. 256 bits — the same floor the repo's other
|
|
363
|
+
* capability tokens carry, and far beyond guessing at any request rate the
|
|
364
|
+
* `leadsScope` rate limiter permits.
|
|
365
|
+
*/
|
|
366
|
+
const RESUME_TOKEN_BYTES = 32;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Mint an opaque resume token. Returned to the caller EXACTLY once, at
|
|
370
|
+
* `start`: only its digest is stored, so the server cannot hand it back a
|
|
371
|
+
* second time and an attacker who reads the table still cannot present one.
|
|
372
|
+
*/
|
|
373
|
+
function mintResumeToken(): string {
|
|
374
|
+
return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What the column holds. A plain sha-256 with no salt and no stretching is
|
|
379
|
+
* correct here and a KDF would not be: the input is 256 bits of CSPRNG output,
|
|
380
|
+
* not a human-chosen secret, so there is no dictionary to precompute and
|
|
381
|
+
* nothing for a work factor to slow down.
|
|
382
|
+
*/
|
|
383
|
+
function hashResumeToken(token: string): string {
|
|
384
|
+
return createHash("sha256").update(token).digest("hex");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Resolve the ONE draft a token grants access to (#5036 D1).
|
|
389
|
+
*
|
|
390
|
+
* Both the id and the token's digest are in the `where`, so a valid token
|
|
391
|
+
* presented against a different listing's id resolves to nothing: a token is
|
|
392
|
+
* bound to one listing and grants access to no other. `status: "draft"` is in
|
|
393
|
+
* there too — `submit` clears the digest, so a token that leaks after
|
|
394
|
+
* submission is inert twice over.
|
|
395
|
+
*
|
|
396
|
+
* The comparison is an equality test on a 256-bit digest inside Postgres.
|
|
397
|
+
* There is no application-level secret compare to make constant-time, and no
|
|
398
|
+
* practical timing signal in matching digests of that width.
|
|
399
|
+
*/
|
|
400
|
+
async function findDraftByToken(
|
|
401
|
+
id: string,
|
|
402
|
+
resumeToken: string,
|
|
403
|
+
): Promise<ProjectListingRow | null> {
|
|
404
|
+
return prisma.projectListing.findFirst({
|
|
405
|
+
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
406
|
+
select: PROJECT_LISTING_SELECT,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── start (POST, anonymous) ────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* `start` has no failure mode of its own to report. It reads the site's config
|
|
414
|
+
* (which falls back to the platform defaults rather than erroring) and writes
|
|
415
|
+
* one row; a Prisma failure is a 500 through the framework, as everywhere else.
|
|
416
|
+
* The Lead write — the one step that used to give this operation a
|
|
417
|
+
* `persist_failed`/`config_missing` surface — moved to `submit`.
|
|
418
|
+
*/
|
|
419
|
+
export type StartProjectListingDraftResult = {
|
|
420
|
+
ok: true;
|
|
421
|
+
id: string;
|
|
422
|
+
resumeToken: string;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Create a `draft` from step one — the founder's name, email and consent.
|
|
427
|
+
*
|
|
428
|
+
* `siteId` is a PARAMETER, not a field of `input` (#5048 D2). The payload
|
|
429
|
+
* names the site by its public key and the route resolves it through
|
|
430
|
+
* `siteIdForPublicKey` before calling here, so no request body carries a
|
|
431
|
+
* tenant identifier and nothing a caller sends can choose which site an
|
|
432
|
+
* application is filed against.
|
|
433
|
+
*
|
|
434
|
+
* The site's own terms are read SERVER-SIDE and stamped now, so a listing is
|
|
435
|
+
* granted the terms that were in force when it applied; nothing in `input`
|
|
436
|
+
* can reach them. The applicant's *requested* window is clamped here, not
|
|
437
|
+
* rejected, because a founder who could post their own bound could post an
|
|
438
|
+
* unbounded one.
|
|
439
|
+
*
|
|
440
|
+
* No slug yet: it is derived from `name` at SUBMIT (#5033 D2), which is the
|
|
441
|
+
* first moment a name exists.
|
|
442
|
+
*/
|
|
443
|
+
export async function startProjectListingDraft(
|
|
444
|
+
siteId: string,
|
|
445
|
+
input: ProjectListingStartInput,
|
|
446
|
+
): Promise<StartProjectListingDraftResult> {
|
|
447
|
+
const { founderName, requestedWindowDays } = input;
|
|
448
|
+
const founderEmail = normalizeFounderEmail(input.founderEmail);
|
|
449
|
+
|
|
450
|
+
const listingConfig = await listingConfigFor(siteId);
|
|
451
|
+
const windowDays = clampWindowDays(requestedWindowDays, listingConfig);
|
|
452
|
+
|
|
453
|
+
const resumeToken = mintResumeToken();
|
|
454
|
+
const row = await prisma.projectListing.create({
|
|
455
|
+
data: {
|
|
456
|
+
siteId,
|
|
457
|
+
founderName,
|
|
458
|
+
founderEmail,
|
|
459
|
+
status: "draft",
|
|
460
|
+
consentAt: new Date(),
|
|
461
|
+
resumeTokenHash: hashResumeToken(resumeToken),
|
|
462
|
+
windowDays,
|
|
463
|
+
},
|
|
464
|
+
select: { id: true },
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
return { ok: true, id: row.id, resumeToken };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
|
|
471
|
+
|
|
472
|
+
export type PatchProjectListingDraftResult =
|
|
473
|
+
| { ok: true; value: ProjectListing }
|
|
474
|
+
| {
|
|
475
|
+
ok: false;
|
|
476
|
+
error:
|
|
477
|
+
| { kind: "not_found"; message: string }
|
|
478
|
+
| { kind: "invalid_input"; message: string };
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Translate a validated partial application into a Prisma update.
|
|
483
|
+
*
|
|
484
|
+
* Only the keys the caller actually sent are written: an absent key is
|
|
485
|
+
* `undefined`, which Prisma omits from the UPDATE, so patching one field
|
|
486
|
+
* cannot blank the nine the founder filled in on earlier steps.
|
|
487
|
+
*/
|
|
488
|
+
function draftUpdateData(
|
|
489
|
+
patch: ProjectListingDraftPatchInput,
|
|
490
|
+
): Prisma.ProjectListingUpdateInput {
|
|
491
|
+
return {
|
|
492
|
+
...(patch.name !== undefined && { name: patch.name }),
|
|
493
|
+
...(patch.headline !== undefined && { headline: patch.headline }),
|
|
494
|
+
...(patch.oneLiner !== undefined && { oneLiner: patch.oneLiner }),
|
|
495
|
+
...(patch.problem !== undefined && { problem: patch.problem }),
|
|
496
|
+
...(patch.url !== undefined && { url: patch.url }),
|
|
497
|
+
...(patch.product !== undefined && { product: patch.product }),
|
|
498
|
+
...(patch.commitment !== undefined && { commitment: patch.commitment }),
|
|
499
|
+
...(patch.teamComposition !== undefined && { teamComposition: patch.teamComposition }),
|
|
500
|
+
...(patch.faqs !== undefined && { faqs: patch.faqs as unknown as Prisma.InputJsonValue }),
|
|
501
|
+
...(patch.anythingElse !== undefined && { anythingElse: patch.anythingElse }),
|
|
502
|
+
...(patch.address !== undefined && { address: patch.address }),
|
|
503
|
+
...(patch.founderCompany !== undefined && { founderCompany: patch.founderCompany }),
|
|
504
|
+
...(patch.questions !== undefined && {
|
|
505
|
+
questions: patch.questions as unknown as Prisma.InputJsonValue,
|
|
506
|
+
}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Merge a partial application into a draft and hand back the merged row.
|
|
512
|
+
*
|
|
513
|
+
* Enforces NO completeness — that is `submit`'s job alone, and the split is
|
|
514
|
+
* the whole point of the lifecycle. Returning the merged row is what makes a
|
|
515
|
+
* step's save verifiable by the client that wrote it.
|
|
516
|
+
*/
|
|
517
|
+
export async function patchProjectListingDraft(
|
|
518
|
+
id: string,
|
|
519
|
+
resumeToken: string,
|
|
520
|
+
body: unknown,
|
|
521
|
+
): Promise<PatchProjectListingDraftResult> {
|
|
522
|
+
// The word caps are the SITE's (#5048 D6), so the body cannot be validated
|
|
523
|
+
// until the draft's site is known. This read resolves that and nothing
|
|
524
|
+
// else — it is not the write's guard, which stays the compare-and-swap
|
|
525
|
+
// below. A caller holding no valid token never gets past it, so a bad token
|
|
526
|
+
// still answers `not_found` before any body is inspected.
|
|
527
|
+
const owner = await prisma.projectListing.findFirst({
|
|
528
|
+
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
529
|
+
select: { siteId: true },
|
|
530
|
+
});
|
|
531
|
+
if (!owner) {
|
|
532
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const listingConfig = await listingConfigFor(owner.siteId);
|
|
536
|
+
const parsed = buildProjectListingDraftPatchSchema(
|
|
537
|
+
listingConfig.wordCaps,
|
|
538
|
+
listingConfig.collectedFields,
|
|
539
|
+
).safeParse(body);
|
|
540
|
+
if (!parsed.success) {
|
|
541
|
+
return {
|
|
542
|
+
ok: false,
|
|
543
|
+
error: {
|
|
544
|
+
kind: "invalid_input",
|
|
545
|
+
message: parsed.error.issues[0]?.message ?? "invalid input",
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const patch: ProjectListingDraftPatchInput = parsed.data;
|
|
550
|
+
|
|
551
|
+
// A GUARDED `updateMany`, not a read-then-write: the token check and the
|
|
552
|
+
// write are one statement, so a concurrent `submit` cannot flip the row to
|
|
553
|
+
// `requested` in between and let this patch land on an application the
|
|
554
|
+
// operator is already reviewing (PR #5042 security review, finding 3). The
|
|
555
|
+
// same compare-and-swap shape `submit` and `decideListing` already use.
|
|
556
|
+
const updated = await prisma.projectListing.updateMany({
|
|
557
|
+
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
558
|
+
data: draftUpdateData(patch),
|
|
559
|
+
});
|
|
560
|
+
if (updated.count === 0) {
|
|
561
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const row = await prisma.projectListing.findFirst({
|
|
565
|
+
where: { id },
|
|
566
|
+
select: PROJECT_LISTING_SELECT,
|
|
567
|
+
});
|
|
568
|
+
if (!row) {
|
|
569
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { ok: true, value: toResponseRow(row, 0, new Date(), true) };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ─── submit (POST, anonymous, token-gated) ──────────────────────────────────
|
|
576
|
+
|
|
577
|
+
export type SubmitProjectListingDraftResult =
|
|
578
|
+
| { ok: true; id: string; slug: string; listing: ProjectListing }
|
|
579
|
+
| {
|
|
580
|
+
ok: false;
|
|
581
|
+
error:
|
|
582
|
+
| { kind: "not_found"; message: string }
|
|
583
|
+
| { kind: "incomplete"; message: string; missingFields: string[] }
|
|
584
|
+
| { kind: "listing_limit_reached"; message: string }
|
|
585
|
+
| { kind: "slug_taken"; message: string };
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The application, assembled from the stored row for `submit` to validate.
|
|
590
|
+
*
|
|
591
|
+
* Narrowed to the site's COLLECTED fields (#5048 D5). The built submit schema
|
|
592
|
+
* is `.strict()` over exactly that set, so a stored value for a field the site
|
|
593
|
+
* does not collect would otherwise fail as an unrecognised key — which is the
|
|
594
|
+
* right shape for a REQUEST body and the wrong one for a row that may carry a
|
|
595
|
+
* value written before the site narrowed its step set.
|
|
596
|
+
*/
|
|
597
|
+
function applicationFromRow(
|
|
598
|
+
row: ProjectListingRow,
|
|
599
|
+
collectedFields: readonly string[],
|
|
600
|
+
): Record<string, unknown> {
|
|
601
|
+
const collected = new Set(collectedFields);
|
|
602
|
+
const all: Record<string, unknown> = {
|
|
603
|
+
name: row.name,
|
|
604
|
+
headline: row.headline,
|
|
605
|
+
oneLiner: row.oneLiner,
|
|
606
|
+
problem: row.problem,
|
|
607
|
+
...(row.url !== null && { url: row.url }),
|
|
608
|
+
product: row.product,
|
|
609
|
+
commitment: row.commitment,
|
|
610
|
+
teamComposition: row.teamComposition,
|
|
611
|
+
faqs: row.faqs,
|
|
612
|
+
...(row.anythingElse !== null && { anythingElse: row.anythingElse }),
|
|
613
|
+
address: row.address,
|
|
614
|
+
...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
|
|
615
|
+
questions: row.questions,
|
|
616
|
+
};
|
|
617
|
+
return Object.fromEntries(
|
|
618
|
+
Object.entries(all).filter(([key]) => collected.has(key)),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Count the founder's OTHER non-terminal listings on this site (#5036 D7).
|
|
624
|
+
*
|
|
625
|
+
* Drafts count. A limit drafts escaped would be evaded by never submitting,
|
|
626
|
+
* which is exactly the state a ten-step form leaves a founder in by default.
|
|
627
|
+
*
|
|
628
|
+
* The stored-status filter is a first pass, not the answer: `closed` is
|
|
629
|
+
* computed, so an `active` row whose window has elapsed is terminal and must
|
|
630
|
+
* not count. `computeListingStatus` is the one place that knows this, and
|
|
631
|
+
* consulting it here is the same discipline the CSV sweep learned in #5033.
|
|
632
|
+
*/
|
|
633
|
+
async function countOtherNonTerminalListings(
|
|
634
|
+
siteId: string,
|
|
635
|
+
founderEmail: string,
|
|
636
|
+
exceptId: string,
|
|
637
|
+
): Promise<number> {
|
|
638
|
+
const now = new Date();
|
|
639
|
+
const draftCutoff = new Date(now.getTime() - DRAFT_HOLDS_A_SLOT_DAYS * 24 * 60 * 60 * 1000);
|
|
640
|
+
|
|
641
|
+
const rows = await prisma.projectListing.findMany({
|
|
642
|
+
where: {
|
|
643
|
+
siteId,
|
|
644
|
+
founderEmail,
|
|
645
|
+
id: { not: exceptId },
|
|
646
|
+
OR: [
|
|
647
|
+
// Every non-terminal state except `draft`, which the clause below
|
|
648
|
+
// handles on its own terms. Derived from the shared constant rather
|
|
649
|
+
// than typed out, so a fifth non-terminal state is counted the day it
|
|
650
|
+
// is added.
|
|
651
|
+
{
|
|
652
|
+
status: {
|
|
653
|
+
in: PROJECT_LISTING_NON_TERMINAL_STATUSES.filter((s) => s !== "draft"),
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
// A draft holds a slot only while it is live — see
|
|
657
|
+
// `DRAFT_HOLDS_A_SLOT_DAYS`. It still counts, so not submitting evades
|
|
658
|
+
// nothing; it just stops counting once the application is plainly
|
|
659
|
+
// abandoned, which is what keeps an unverified email from locking a
|
|
660
|
+
// named founder out permanently.
|
|
661
|
+
{ status: "draft", createdAt: { gt: draftCutoff } },
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
select: { status: true, windowEndsAt: true, featured: true },
|
|
665
|
+
});
|
|
666
|
+
return rows.filter(
|
|
667
|
+
(r) => computeListingStatus(r.status, r.windowEndsAt, now, r.featured) !== "closed",
|
|
668
|
+
).length;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Validate the whole application and move the row `draft` -> `requested`.
|
|
673
|
+
*
|
|
674
|
+
* Three things happen here and nowhere else: completeness is asserted (an
|
|
675
|
+
* incomplete draft comes back with the offending fields NAMED, so the form can
|
|
676
|
+
* send the founder to the step that owns each one); the concurrent-listing
|
|
677
|
+
* limit binds; and the slug is derived, which is the first moment a `name`
|
|
678
|
+
* exists to derive it from.
|
|
679
|
+
*
|
|
680
|
+
* The transition write is a GUARDED `updateMany` on `status: "draft"`, so two
|
|
681
|
+
* concurrent submits of the same draft cannot both succeed — the loser reads
|
|
682
|
+
* as `not_found`, the same answer a spent token gets. The token's digest is
|
|
683
|
+
* cleared in that same write: a token that leaks after submission grants
|
|
684
|
+
* nothing.
|
|
685
|
+
*
|
|
686
|
+
* The Lead write and the Slack post are both side effects of that committed
|
|
687
|
+
* transition and both fail soft, in that order — never before it, or a submit
|
|
688
|
+
* that lost the race would still have put an application in the operator's
|
|
689
|
+
* pipeline with no listing behind it.
|
|
690
|
+
*/
|
|
691
|
+
export async function submitProjectListingDraft(
|
|
692
|
+
id: string,
|
|
693
|
+
resumeToken: string,
|
|
694
|
+
sourceMeta: SourceMeta,
|
|
695
|
+
): Promise<SubmitProjectListingDraftResult> {
|
|
696
|
+
const draft = await findDraftByToken(id, resumeToken);
|
|
697
|
+
if (!draft) {
|
|
698
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// #5048 D5 — completeness derives from the site's CONFIGURED collected-field
|
|
702
|
+
// set, never from a fixed schema. Steps are individually optional, so a
|
|
703
|
+
// field whose step the site never rendered was never asked for; requiring it
|
|
704
|
+
// would refuse a valid application naming a question the founder never saw.
|
|
705
|
+
//
|
|
706
|
+
// The set comes from the site's own server-side `SiteListingConfig`. It is
|
|
707
|
+
// never read from the request: a caller that could post its own required-
|
|
708
|
+
// field set could post an empty one and submit a blank listing.
|
|
709
|
+
const listingConfig = await listingConfigFor(draft.siteId);
|
|
710
|
+
const submitSchema = buildProjectListingSubmitSchema(
|
|
711
|
+
listingConfig.wordCaps,
|
|
712
|
+
listingConfig.collectedFields,
|
|
713
|
+
);
|
|
714
|
+
const parsed = submitSchema.safeParse(
|
|
715
|
+
applicationFromRow(draft, listingConfig.collectedFields),
|
|
716
|
+
);
|
|
717
|
+
if (!parsed.success) {
|
|
718
|
+
const missingFields = [
|
|
719
|
+
...new Set(parsed.error.issues.map((issue) => String(issue.path[0]))),
|
|
720
|
+
].sort();
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
error: {
|
|
724
|
+
kind: "incomplete",
|
|
725
|
+
message: `application is incomplete: ${missingFields.join(", ")}`,
|
|
726
|
+
missingFields,
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const concurrent = await countOtherNonTerminalListings(
|
|
732
|
+
draft.siteId,
|
|
733
|
+
draft.founderEmail,
|
|
734
|
+
draft.id,
|
|
735
|
+
);
|
|
736
|
+
if (concurrent >= PROJECT_LISTING_CONCURRENT_LIMIT) {
|
|
737
|
+
return {
|
|
738
|
+
ok: false,
|
|
739
|
+
error: {
|
|
740
|
+
kind: "listing_limit_reached",
|
|
741
|
+
message: `a founder may hold at most ${PROJECT_LISTING_CONCURRENT_LIMIT} listings at once`,
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// `name` may be absent from the picked schema if a site does not collect it
|
|
747
|
+
// (#5048 D5), so the slug derives from the ROW rather than from the parsed
|
|
748
|
+
// payload. A site that collects no name has none to derive from, and the
|
|
749
|
+
// founder's own name is the only other stable string on the row.
|
|
750
|
+
const base = deriveListingSlug(draft.name ?? draft.founderName);
|
|
751
|
+
const slug = await firstFreeSlug(draft.siteId, base);
|
|
752
|
+
|
|
753
|
+
const transition = async (candidate: string): Promise<number | "taken"> => {
|
|
754
|
+
try {
|
|
755
|
+
const updated = await prisma.projectListing.updateMany({
|
|
756
|
+
where: { id, status: "draft" },
|
|
757
|
+
data: { status: "requested", slug: candidate, resumeTokenHash: null },
|
|
758
|
+
});
|
|
759
|
+
return updated.count;
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (isUniqueViolation(err)) return "taken";
|
|
762
|
+
throw err;
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
let count = await transition(slug);
|
|
767
|
+
if (count === "taken") {
|
|
768
|
+
// `firstFreeSlug` samples at most 200 same-prefix rows, so a family larger
|
|
769
|
+
// than that can hand back a slug that is in fact taken — and a plain retry
|
|
770
|
+
// resamples and fails the same way, which would let a squatted prefix wall
|
|
771
|
+
// off every later applicant whose name derives to it (PR #5042 security
|
|
772
|
+
// review, finding 2). One random suffix ends that: an ugly slug beats an
|
|
773
|
+
// application that cannot be submitted at all.
|
|
774
|
+
count = await transition(`${base}-${randomBytes(3).toString("hex")}`);
|
|
775
|
+
}
|
|
776
|
+
if (count === "taken") {
|
|
777
|
+
return {
|
|
778
|
+
ok: false,
|
|
779
|
+
error: { kind: "slug_taken", message: "slug already in use for this site" },
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (count === 0) {
|
|
783
|
+
// A concurrent submit won the race and already spent this draft.
|
|
784
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// The Lead write reuses routeLead — never a duplicate persistence path — and
|
|
788
|
+
// happens at SUBMIT rather than at start, so an anonymous stranger cannot
|
|
789
|
+
// cause an operator notification by opening a draft they never finish.
|
|
790
|
+
//
|
|
791
|
+
// It runs AFTER the transition, and fails soft. Before the transition it
|
|
792
|
+
// would write a Lead (and mail the operator) for every submit that then lost
|
|
793
|
+
// the race or hit a taken slug — an application in the operator's pipeline
|
|
794
|
+
// with no listing behind it, and two Leads for the one submit that did land.
|
|
795
|
+
// After it, the worst case is a `requested` listing whose `leadId` is null,
|
|
796
|
+
// which the model allows and the weekly CSV sweep already skips explicitly.
|
|
797
|
+
// The submit itself has landed either way, and that is what the founder
|
|
798
|
+
// asked for; reporting it as a failure would be the wrong answer.
|
|
799
|
+
const leadResult = await routeLead(
|
|
800
|
+
{
|
|
801
|
+
kind: "submit",
|
|
802
|
+
siteId: draft.siteId,
|
|
803
|
+
source: "projects-apply",
|
|
804
|
+
email: draft.founderEmail,
|
|
805
|
+
payload: {
|
|
806
|
+
name: parsed.data.name,
|
|
807
|
+
oneLiner: parsed.data.oneLiner,
|
|
808
|
+
problem: parsed.data.problem,
|
|
809
|
+
product: parsed.data.product,
|
|
810
|
+
founderName: draft.founderName,
|
|
811
|
+
questions: parsed.data.questions,
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
sourceMeta,
|
|
815
|
+
);
|
|
816
|
+
if (leadResult.ok) {
|
|
817
|
+
await prisma.projectListing.update({
|
|
818
|
+
where: { id },
|
|
819
|
+
data: { leadId: leadResult.id },
|
|
820
|
+
});
|
|
821
|
+
} else {
|
|
822
|
+
logger.error(
|
|
823
|
+
{ listingId: id, kind: leadResult.error.kind },
|
|
824
|
+
"[project-listings] the Lead write failed after a submitted listing already landed",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const row = await prisma.projectListing.findFirst({
|
|
829
|
+
where: { id },
|
|
830
|
+
select: PROJECT_LISTING_SELECT,
|
|
831
|
+
});
|
|
832
|
+
if (!row) {
|
|
833
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
ok: true,
|
|
838
|
+
id,
|
|
839
|
+
slug: row.slug!,
|
|
840
|
+
listing: toResponseRow(row, 0, new Date(), true),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function isUniqueViolation(err: unknown): boolean {
|
|
845
|
+
return (
|
|
846
|
+
err != null &&
|
|
847
|
+
typeof err === "object" &&
|
|
848
|
+
"code" in err &&
|
|
849
|
+
(err as { code: string }).code === "P2002"
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ─── List + get (tenant scope) ──────────────────────────────────────────────
|
|
854
|
+
|
|
855
|
+
export async function listProjectListings(siteId: string): Promise<ProjectListing[]> {
|
|
856
|
+
const rows = await prisma.projectListing.findMany({
|
|
857
|
+
where: { siteId },
|
|
858
|
+
orderBy: { createdAt: "desc" },
|
|
859
|
+
select: PROJECT_LISTING_SELECT,
|
|
860
|
+
});
|
|
861
|
+
const counts = await attachWaitlistCounts(siteId, rows);
|
|
862
|
+
const now = new Date();
|
|
863
|
+
return rows.map((row) => toResponseRow(row, waitlistCountFor(row, counts), now, true));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export type GetProjectListingResult =
|
|
867
|
+
| { ok: true; value: ProjectListing }
|
|
868
|
+
| { ok: false; error: { kind: "not_found"; message: string } };
|
|
869
|
+
|
|
870
|
+
export async function getProjectListing(siteId: string, id: string): Promise<GetProjectListingResult> {
|
|
871
|
+
const row = await prisma.projectListing.findFirst({
|
|
872
|
+
where: { id, siteId },
|
|
873
|
+
select: PROJECT_LISTING_SELECT,
|
|
874
|
+
});
|
|
875
|
+
if (!row) {
|
|
876
|
+
return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
|
|
877
|
+
}
|
|
878
|
+
const counts = await attachWaitlistCounts(siteId, [row]);
|
|
879
|
+
return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ─── Patch (tenant scope; the featured flag) ───────────────────────────────
|
|
883
|
+
|
|
884
|
+
export type SetListingFeaturedResult =
|
|
885
|
+
| { ok: true; value: ProjectListing }
|
|
886
|
+
| { ok: false; error: { kind: "not_found"; message: string } };
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Set the operator's `featured` flag (#5033 D6).
|
|
890
|
+
*
|
|
891
|
+
* This is the only non-status write the PATCH route makes. STATUS
|
|
892
|
+
* transitions do not come through here at all — since the lifecycle
|
|
893
|
+
* collapsed to five states with `closed` computed, `requested -> active |
|
|
894
|
+
* rejected` is the only transition there is, and `decideListing` owns it for
|
|
895
|
+
* both the PATCH route and the Slack button. The old transition table (which
|
|
896
|
+
* also carried `approved -> live` and `live -> closed`) has no states left to
|
|
897
|
+
* describe.
|
|
898
|
+
*/
|
|
899
|
+
export async function setListingFeatured(
|
|
900
|
+
siteId: string,
|
|
901
|
+
id: string,
|
|
902
|
+
featured: boolean,
|
|
903
|
+
): Promise<SetListingFeaturedResult> {
|
|
904
|
+
const updated = await prisma.projectListing.updateMany({
|
|
905
|
+
where: { id, siteId },
|
|
906
|
+
data: { featured },
|
|
907
|
+
});
|
|
908
|
+
if (updated.count === 0) {
|
|
909
|
+
return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const row = await prisma.projectListing.findFirst({
|
|
913
|
+
where: { id, siteId },
|
|
914
|
+
select: PROJECT_LISTING_SELECT,
|
|
915
|
+
});
|
|
916
|
+
if (!row) {
|
|
917
|
+
return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const counts = await attachWaitlistCounts(siteId, [row]);
|
|
921
|
+
return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ─── Patch (tenant scope; the operator's copy edit) ─────────────────────────
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The five PUBLISHED copy fields an operator copy edit may rewrite.
|
|
928
|
+
*
|
|
929
|
+
* The set is deliberately narrow. `questions` is IN because its prompts and
|
|
930
|
+
* option labels render on the public page, which is precisely where abusive
|
|
931
|
+
* language would land. `name` is OUT because the slug is derived from it at
|
|
932
|
+
* submit and stored, so editing it would desync the public URL from the name.
|
|
933
|
+
* `url`, `product`, `commitment`, `teamComposition`, `address` and
|
|
934
|
+
* `founderCompany` are OUT because they are facts rather than copy — a wrong
|
|
935
|
+
* fact is a question to the founder or a reject, not an edit. `anythingElse`
|
|
936
|
+
* is OUT because it is a note to the reviewer and never renders.
|
|
937
|
+
*
|
|
938
|
+
* The request body is derived from the SAME package schema these keys name
|
|
939
|
+
* (`projectListingApplicationFieldsSchema`) at the route boundary, so every
|
|
940
|
+
* bound the application itself carries — the word caps, the fixed FAQ count,
|
|
941
|
+
* the question refinements — holds here for free.
|
|
942
|
+
*/
|
|
943
|
+
export type ProjectListingCopyEdit = Pick<
|
|
944
|
+
ProjectListingDraftPatchInput,
|
|
945
|
+
"headline" | "oneLiner" | "problem" | "faqs" | "questions"
|
|
946
|
+
>;
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* The WHERE terms that admit a copy edit: `requested`, or `active` as the row
|
|
950
|
+
* actually READS.
|
|
951
|
+
*
|
|
952
|
+
* `draft` is the applicant's own, held through the resume token, and stays
|
|
953
|
+
* theirs. `rejected` is terminal. `closed` is the awkward one, because nothing
|
|
954
|
+
* stores it: `computeListingStatus` derives it from a stored `active` whose
|
|
955
|
+
* window has elapsed, so scoping on the stored string alone would leave every
|
|
956
|
+
* closed listing editable. These three terms are that function's three
|
|
957
|
+
* accepting branches, transcribed — a featured row (window-exempt, #5036 D6b),
|
|
958
|
+
* or a window still in force. A non-featured row with a null or elapsed window
|
|
959
|
+
* matches none of them and is refused, exactly as it reads.
|
|
960
|
+
*
|
|
961
|
+
* `now` is a parameter so the one instant the caller renders the response
|
|
962
|
+
* against is the same instant the write is guarded by.
|
|
963
|
+
*
|
|
964
|
+
* This lives in the WHERE clause rather than in a read-then-write check, so a
|
|
965
|
+
* concurrent decision cannot slip the row out of the editable set between the
|
|
966
|
+
* check and the write — the same compare-and-swap discipline
|
|
967
|
+
* `patchProjectListingDraft` and `decideListing` use.
|
|
968
|
+
*/
|
|
969
|
+
function copyEditableWhere(now: Date): Prisma.ProjectListingWhereInput[] {
|
|
970
|
+
return [
|
|
971
|
+
{ status: "requested" },
|
|
972
|
+
{ status: "active", featured: true },
|
|
973
|
+
{ status: "active", windowEndsAt: { gt: now } },
|
|
974
|
+
];
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export type PatchListingCopyResult =
|
|
978
|
+
| { ok: true; value: ProjectListing }
|
|
979
|
+
| { ok: false; error: { kind: "not_found"; message: string } };
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Rewrite a listing's published copy, preserving what the applicant submitted.
|
|
983
|
+
*
|
|
984
|
+
* The third review outcome beside approve and reject: a listing whose copy
|
|
985
|
+
* needs a trim was previously publishable only exactly as written, because the
|
|
986
|
+
* legal transitions out of `requested` are `active` and `rejected`, there is no
|
|
987
|
+
* route back to `draft`, and `patchProjectListingDraft` is scoped to the
|
|
988
|
+
* applicant's own resume token.
|
|
989
|
+
*
|
|
990
|
+
* `submittedCopy` is WRITE-ONCE, on the first edit only. The read below is what
|
|
991
|
+
* resolves the pre-edit copy; it is NOT the write's guard, which stays the
|
|
992
|
+
* status-scoped `updateMany` — the same compare-and-swap shape
|
|
993
|
+
* `patchProjectListingDraft` and `decideListing` use. Because that read is one
|
|
994
|
+
* consistent snapshot, a row it saw with a null `submittedCopy` also carries
|
|
995
|
+
* the copy as the applicant submitted it, so two concurrent first edits write
|
|
996
|
+
* the same snapshot value rather than racing to a different one. Nothing
|
|
997
|
+
* clears the column, so the submitted copy stays recoverable for the life of
|
|
998
|
+
* the row.
|
|
999
|
+
*
|
|
1000
|
+
* DELIBERATELY silent on the notification surfaces, unlike a decision. No
|
|
1001
|
+
* founder email is sent, and the application's Slack card is not re-rendered,
|
|
1002
|
+
* so a card posted before an edit keeps showing the pre-edit copy. Both are the
|
|
1003
|
+
* decision path's concerns (`sendListingDecisionEmail`, `postListingApplication`)
|
|
1004
|
+
* and neither is touched here. Worth revisiting if operators start editing
|
|
1005
|
+
* before deciding often enough that the card misleads.
|
|
1006
|
+
*
|
|
1007
|
+
* The three audit columns are WRITE-ONLY at present: nothing reads them back,
|
|
1008
|
+
* because the tenant read's response contract is `.strict()` and surfacing them
|
|
1009
|
+
* would mean widening it. "Recoverable" therefore means recoverable from the
|
|
1010
|
+
* row, not through an API.
|
|
1011
|
+
*
|
|
1012
|
+
* PII boundary: as everywhere else in this module, nothing here is logged.
|
|
1013
|
+
*/
|
|
1014
|
+
export async function patchListingCopy(
|
|
1015
|
+
siteId: string,
|
|
1016
|
+
id: string,
|
|
1017
|
+
copy: ProjectListingCopyEdit,
|
|
1018
|
+
editedBy: string,
|
|
1019
|
+
): Promise<PatchListingCopyResult> {
|
|
1020
|
+
const before = await prisma.projectListing.findFirst({
|
|
1021
|
+
where: { id, siteId },
|
|
1022
|
+
select: {
|
|
1023
|
+
submittedCopy: true,
|
|
1024
|
+
headline: true,
|
|
1025
|
+
oneLiner: true,
|
|
1026
|
+
problem: true,
|
|
1027
|
+
faqs: true,
|
|
1028
|
+
questions: true,
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
if (!before) {
|
|
1032
|
+
return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const now = new Date();
|
|
1036
|
+
const updated = await prisma.projectListing.updateMany({
|
|
1037
|
+
where: { id, siteId, OR: copyEditableWhere(now) },
|
|
1038
|
+
data: {
|
|
1039
|
+
// Only the keys the operator actually sent are written; an absent key is
|
|
1040
|
+
// `undefined`, which Prisma omits from the UPDATE, so trimming the
|
|
1041
|
+
// headline cannot blank the FAQs.
|
|
1042
|
+
...(copy.headline !== undefined && { headline: copy.headline }),
|
|
1043
|
+
...(copy.oneLiner !== undefined && { oneLiner: copy.oneLiner }),
|
|
1044
|
+
...(copy.problem !== undefined && { problem: copy.problem }),
|
|
1045
|
+
...(copy.faqs !== undefined && { faqs: copy.faqs as unknown as Prisma.InputJsonValue }),
|
|
1046
|
+
...(copy.questions !== undefined && {
|
|
1047
|
+
questions: copy.questions as unknown as Prisma.InputJsonValue,
|
|
1048
|
+
}),
|
|
1049
|
+
...(before.submittedCopy === null && {
|
|
1050
|
+
submittedCopy: {
|
|
1051
|
+
headline: before.headline,
|
|
1052
|
+
oneLiner: before.oneLiner,
|
|
1053
|
+
problem: before.problem,
|
|
1054
|
+
faqs: before.faqs,
|
|
1055
|
+
questions: before.questions,
|
|
1056
|
+
} as unknown as Prisma.InputJsonValue,
|
|
1057
|
+
}),
|
|
1058
|
+
copyEditedAt: now,
|
|
1059
|
+
copyEditedBy: editedBy,
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
if (updated.count === 0) {
|
|
1063
|
+
return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const row = await prisma.projectListing.findFirst({
|
|
1067
|
+
where: { id, siteId },
|
|
1068
|
+
select: PROJECT_LISTING_SELECT,
|
|
1069
|
+
});
|
|
1070
|
+
if (!row) {
|
|
1071
|
+
return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const counts = await attachWaitlistCounts(siteId, [row]);
|
|
1075
|
+
return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), now, true) };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ─── Public read (anonymous) ────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* The page-render read (#4998 AC4, reshaped by #5033): every `active` row for
|
|
1082
|
+
* the site, the THREE newest by `decidedAt` first, then the rest by
|
|
1083
|
+
* `waitlistCount` desc, then `decidedAt` desc. `founderEmail` never leaves
|
|
1084
|
+
* this function.
|
|
1085
|
+
*
|
|
1086
|
+
* Rows whose window has elapsed are returned and read `closed` — computed,
|
|
1087
|
+
* per row, against one instant, with no job having run. They are not filtered
|
|
1088
|
+
* out: a consumer partitions its shelves on the status it is handed, and a
|
|
1089
|
+
* listing that quietly vanished at midnight would be a worse answer than one
|
|
1090
|
+
* that says it is closed.
|
|
1091
|
+
*/
|
|
1092
|
+
export async function publicProjectListings(siteId: string): Promise<ProjectListingPublic[]> {
|
|
1093
|
+
const listingConfig = await listingConfigFor(siteId);
|
|
1094
|
+
const rows = await prisma.projectListing.findMany({
|
|
1095
|
+
where: { siteId, status: "active" },
|
|
1096
|
+
select: PROJECT_LISTING_SELECT,
|
|
1097
|
+
});
|
|
1098
|
+
const counts = await attachWaitlistCounts(siteId, rows);
|
|
1099
|
+
const withCounts = rows.map((row) => ({ row, waitlistCount: waitlistCountFor(row, counts) }));
|
|
1100
|
+
|
|
1101
|
+
const decidedAtMs = (row: ProjectListingRow): number => row.decidedAt?.getTime() ?? -Infinity;
|
|
1102
|
+
|
|
1103
|
+
const newestFirst = [...withCounts].sort((a, b) => decidedAtMs(b.row) - decidedAtMs(a.row));
|
|
1104
|
+
const top3 = newestFirst.slice(0, 3);
|
|
1105
|
+
const top3Ids = new Set(top3.map((t) => t.row.id));
|
|
1106
|
+
|
|
1107
|
+
const rest = withCounts
|
|
1108
|
+
.filter((t) => !top3Ids.has(t.row.id))
|
|
1109
|
+
.sort((a, b) => {
|
|
1110
|
+
if (b.waitlistCount !== a.waitlistCount) return b.waitlistCount - a.waitlistCount;
|
|
1111
|
+
return decidedAtMs(b.row) - decidedAtMs(a.row);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const now = new Date();
|
|
1115
|
+
return [...top3, ...rest].map(({ row, waitlistCount }) => {
|
|
1116
|
+
const { founderEmail: _founderEmail, ...publicRow } = toResponseRow(
|
|
1117
|
+
row,
|
|
1118
|
+
waitlistCount,
|
|
1119
|
+
now,
|
|
1120
|
+
listingConfig.featuredEnabled,
|
|
1121
|
+
);
|
|
1122
|
+
return publicRow;
|
|
1123
|
+
});
|
|
1124
|
+
}
|