create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Not marked oss: false (D-48/#5366 escalation) — tests the
|
|
2
|
+
// always-shipping project-listings anonymous surface; see
|
|
3
|
+
// routes/project-listings/me.ts's header comment.
|
|
1
4
|
/**
|
|
2
5
|
* @file site-key.test.ts
|
|
3
6
|
*
|
|
@@ -28,6 +31,13 @@ jest.mock("../../../lib/prisma.js");
|
|
|
28
31
|
|
|
29
32
|
jest.mock("@working-theory/email", () => ({
|
|
30
33
|
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
34
|
+
// #5406 — `start` now reaches the resume email. Declared here for the reason
|
|
35
|
+
// docs/lessons/project-listings/04 records: a mock factory that omits an
|
|
36
|
+
// export the code under test calls leaves it `undefined` at the callsite.
|
|
37
|
+
// These suites configure no `resumeEmail`, so the send short-circuits before
|
|
38
|
+
// this is reached; it is declared so a future fixture that DOES configure
|
|
39
|
+
// one fails on its assertion rather than on a missing mock.
|
|
40
|
+
sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
|
|
31
41
|
}));
|
|
32
42
|
|
|
33
43
|
jest.mock("@working-theory/license", () => ({
|
|
@@ -109,6 +119,17 @@ function installStore(): void {
|
|
|
109
119
|
},
|
|
110
120
|
);
|
|
111
121
|
|
|
122
|
+
// #5275 — the grant write `start` makes after the row. Append-only; it only
|
|
123
|
+
// needs to resolve here (drafts.test.ts asserts on what it is called with).
|
|
124
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
125
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// #5275 — `start` writes the row and its grant in one interactive
|
|
129
|
+
// transaction; the mock hands the same store-backed client through.
|
|
130
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
131
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
|
|
132
|
+
);
|
|
112
133
|
(prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
|
|
113
134
|
const now = new Date();
|
|
114
135
|
const row: Row = {
|
|
@@ -125,6 +146,13 @@ function installStore(): void {
|
|
|
125
146
|
anythingElse: null,
|
|
126
147
|
address: null,
|
|
127
148
|
questions: null,
|
|
149
|
+
// #5407 — a row written before the columns existed, and a freshly
|
|
150
|
+
// started draft on a site that asks for one name line: parts null,
|
|
151
|
+
// no phone, no verification stamp.
|
|
152
|
+
founderFirstName: null,
|
|
153
|
+
founderLastName: null,
|
|
154
|
+
founderPhone: null,
|
|
155
|
+
emailVerifiedAt: null,
|
|
128
156
|
founderCompany: null,
|
|
129
157
|
leadId: null,
|
|
130
158
|
headline: null,
|
|
@@ -175,6 +203,13 @@ function seedActiveListing(siteId: string, slug: string): void {
|
|
|
175
203
|
address: "1 Founder Way",
|
|
176
204
|
founderName: "Some Founder",
|
|
177
205
|
founderEmail: "founder@example.com",
|
|
206
|
+
// #5407 — a row written before the columns existed, and a freshly
|
|
207
|
+
// started draft on a site that asks for one name line: parts null,
|
|
208
|
+
// no phone, no verification stamp.
|
|
209
|
+
founderFirstName: null,
|
|
210
|
+
founderLastName: null,
|
|
211
|
+
founderPhone: null,
|
|
212
|
+
emailVerifiedAt: null,
|
|
178
213
|
founderCompany: null,
|
|
179
214
|
questions: null,
|
|
180
215
|
logoMarkUrl: null,
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// Not marked oss: false (D-48/#5366 escalation) — tests the
|
|
2
|
+
// always-shipping project-listings anonymous surface; see
|
|
3
|
+
// routes/project-listings/me.ts's header comment.
|
|
4
|
+
/**
|
|
5
|
+
* @file structured-address.test.ts
|
|
6
|
+
*
|
|
7
|
+
* THE ACCEPTANCE GATE for the structured founder address (#5279).
|
|
8
|
+
*
|
|
9
|
+
* The application collected a whole business address in ONE free-text box.
|
|
10
|
+
* It now renders a fieldset, and the payload field `address` became a UNION:
|
|
11
|
+
* the legacy display STRING or the structured PARTS. The row keeps `address`
|
|
12
|
+
* as the single-line DISPLAY string and gains six nullable part columns
|
|
13
|
+
* beside it, which the server writes — and composes the display line from —
|
|
14
|
+
* in `draftUpdateData`.
|
|
15
|
+
*
|
|
16
|
+
* Two properties carry this change, and both look fine when broken, which is
|
|
17
|
+
* why they each get a test that fails when they are:
|
|
18
|
+
*
|
|
19
|
+
* 1. The parts arrive as PARTS. A patch carrying the structured object must
|
|
20
|
+
* land six DISTINCT column values, not one concatenated string in
|
|
21
|
+
* `address` with the columns left null. A service that quietly stringified
|
|
22
|
+
* the object would produce a row that reads correctly on the listing page
|
|
23
|
+
* and has silently thrown away the split the fieldset exists to capture.
|
|
24
|
+
*
|
|
25
|
+
* 2. A legacy free-text address round-trips CHARACTER-FOR-CHARACTER with its
|
|
26
|
+
* parts null. Nothing may ever split an existing `address` into parts:
|
|
27
|
+
* address parsing guesses, and a wrong guess silently corrupts the party
|
|
28
|
+
* identification the listing's privacy notice points at (#5036 D6), with
|
|
29
|
+
* nothing on the row to mark it as invented. A null part is honest; a
|
|
30
|
+
* guessed one is not.
|
|
31
|
+
*
|
|
32
|
+
* Same harness as `configured-application.test.ts` and `drafts.test.ts`: real
|
|
33
|
+
* routes, real services, mocked `lib/prisma.js` over a small in-memory row
|
|
34
|
+
* store, `routeLead` running for real against a per-test temp path.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
|
|
38
|
+
jest.mock("../../../lib/prisma.js");
|
|
39
|
+
|
|
40
|
+
jest.mock("@working-theory/email", () => ({
|
|
41
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
42
|
+
// #5406 — `start` now reaches the resume email. Declared here for the reason
|
|
43
|
+
// docs/lessons/project-listings/04 records: a mock factory that omits an
|
|
44
|
+
// export the code under test calls leaves it `undefined` at the callsite.
|
|
45
|
+
// These suites configure no `resumeEmail`, so the send short-circuits before
|
|
46
|
+
// this is reached; it is declared so a future fixture that DOES configure
|
|
47
|
+
// one fails on its assertion rather than on a missing mock.
|
|
48
|
+
sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
jest.mock("@working-theory/license", () => ({
|
|
52
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
53
|
+
tier: "free",
|
|
54
|
+
plan: null,
|
|
55
|
+
features: [],
|
|
56
|
+
expiresAt: null,
|
|
57
|
+
revoked: false,
|
|
58
|
+
}),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import Fastify from "fastify";
|
|
62
|
+
import type { FastifyInstance } from "fastify";
|
|
63
|
+
|
|
64
|
+
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
65
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
66
|
+
import { patchProjectListingDraftRoute } from "../patch-draft.js";
|
|
67
|
+
import { startProjectListingRoute } from "../start.js";
|
|
68
|
+
import { submitProjectListingRoute } from "../submit.js";
|
|
69
|
+
|
|
70
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const SITE_ID = "site-1";
|
|
73
|
+
const SITE_KEY = "pk-site-1";
|
|
74
|
+
|
|
75
|
+
const opt = (value: string, label: string) => ({ value, label });
|
|
76
|
+
|
|
77
|
+
const VALID_QUESTIONS = {
|
|
78
|
+
q2: {
|
|
79
|
+
prompt: "Why are you here?",
|
|
80
|
+
options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
|
|
81
|
+
},
|
|
82
|
+
q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
83
|
+
q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
84
|
+
q5: {
|
|
85
|
+
prompt: "Urgency?",
|
|
86
|
+
options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
|
|
87
|
+
},
|
|
88
|
+
q6: {
|
|
89
|
+
prompt: "Pain?",
|
|
90
|
+
options: [
|
|
91
|
+
opt("low", "Low"),
|
|
92
|
+
opt("medium", "Medium"),
|
|
93
|
+
opt("high", "High"),
|
|
94
|
+
opt("critical", "Critical"),
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
q7: { prompt: "Anything else?" },
|
|
98
|
+
extras: [],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const START_PAYLOAD = {
|
|
102
|
+
siteKey: SITE_KEY,
|
|
103
|
+
founderName: "Ada Founder",
|
|
104
|
+
founderEmail: "ada@example.com",
|
|
105
|
+
consent: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Everything the ten steps after step one collect, minus the address. */
|
|
109
|
+
const APPLICATION_WITHOUT_ADDRESS = {
|
|
110
|
+
name: "My Project",
|
|
111
|
+
headline: "Ten words or fewer for the tile title here",
|
|
112
|
+
oneLiner: "A short sentence describing what the project does for its people.",
|
|
113
|
+
problem: "The problem is real and this describes it.",
|
|
114
|
+
product: "wireframes",
|
|
115
|
+
commitment: "part-time",
|
|
116
|
+
teamComposition: "co-founders",
|
|
117
|
+
faqs: [
|
|
118
|
+
{ question: "Q one?", answer: "A one." },
|
|
119
|
+
{ question: "Q two?", answer: "A two." },
|
|
120
|
+
{ question: "Q three?", answer: "A three." },
|
|
121
|
+
],
|
|
122
|
+
questions: VALID_QUESTIONS,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** What `AddressFieldset` posts — six controls under six fixed keys. */
|
|
126
|
+
const ADDRESS_PARTS = {
|
|
127
|
+
addressLine1: "221B Baker Street",
|
|
128
|
+
addressLine2: "Flat B",
|
|
129
|
+
city: "London",
|
|
130
|
+
region: "Greater London",
|
|
131
|
+
postalCode: "NW1 6XE",
|
|
132
|
+
country: "GB",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The display line the SERVER must compose from `ADDRESS_PARTS` — the parts
|
|
137
|
+
* in reading order, joined with ", ". Written out literally rather than by
|
|
138
|
+
* calling `composeAddressLine`, so a change to the joiner fails this test
|
|
139
|
+
* instead of travelling through it.
|
|
140
|
+
*/
|
|
141
|
+
const COMPOSED_ADDRESS_LINE =
|
|
142
|
+
"221B Baker Street, Flat B, London, Greater London, NW1 6XE, GB";
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* An address a founder typed into the ONE free-text box, before #5279. It is
|
|
146
|
+
* not in the composed order, its parts are not separable by any rule, and
|
|
147
|
+
* nothing may ever try: it is here to be returned unchanged.
|
|
148
|
+
*/
|
|
149
|
+
const LEGACY_ADDRESS_LINE =
|
|
150
|
+
"221B Baker Street, Marylebone, London NW1 6XE, United Kingdom";
|
|
151
|
+
|
|
152
|
+
// ─── The in-memory row store the Prisma mock is backed by ───────────────────
|
|
153
|
+
|
|
154
|
+
type Row = Record<string, unknown>;
|
|
155
|
+
|
|
156
|
+
let rows: Row[];
|
|
157
|
+
let nextId: number;
|
|
158
|
+
|
|
159
|
+
function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
160
|
+
return Object.entries(where).every(([key, value]) => {
|
|
161
|
+
if (key === "OR") {
|
|
162
|
+
return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
|
|
163
|
+
}
|
|
164
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) {
|
|
165
|
+
const clause = value as Record<string, unknown>;
|
|
166
|
+
if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
|
|
167
|
+
if ("not" in clause) return row[key] !== clause["not"];
|
|
168
|
+
if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
|
|
169
|
+
if ("startsWith" in clause) {
|
|
170
|
+
return (
|
|
171
|
+
typeof row[key] === "string" &&
|
|
172
|
+
(row[key] as string).startsWith(clause["startsWith"] as string)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return row[key] === value;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function installStore(): void {
|
|
181
|
+
rows = [];
|
|
182
|
+
nextId = 1;
|
|
183
|
+
|
|
184
|
+
(prisma.lead.create as jest.Mock).mockImplementation(() =>
|
|
185
|
+
Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
|
|
189
|
+
({ where }: { where: { siteId?: string; publicKey?: string } }) => {
|
|
190
|
+
if (where.publicKey !== undefined) {
|
|
191
|
+
return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
|
|
192
|
+
}
|
|
193
|
+
// No config row — the platform defaults, which collect `address`.
|
|
194
|
+
return Promise.resolve(null);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// #5275 — the grant write `start` makes after the row. Append-only; it only
|
|
199
|
+
// needs to resolve here (drafts.test.ts asserts on what it is called with).
|
|
200
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
201
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// #5275 — `start` writes the row and its grant in one interactive
|
|
205
|
+
// transaction; the mock hands the same store-backed client through.
|
|
206
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
207
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
(prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
|
|
211
|
+
const now = new Date();
|
|
212
|
+
const row: Row = {
|
|
213
|
+
id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
|
|
214
|
+
slug: null,
|
|
215
|
+
name: null,
|
|
216
|
+
oneLiner: null,
|
|
217
|
+
problem: null,
|
|
218
|
+
url: null,
|
|
219
|
+
product: null,
|
|
220
|
+
commitment: null,
|
|
221
|
+
teamComposition: null,
|
|
222
|
+
faqs: null,
|
|
223
|
+
anythingElse: null,
|
|
224
|
+
address: null,
|
|
225
|
+
// #5279 — a freshly-started draft has no parts, exactly like every row
|
|
226
|
+
// written before the columns existed.
|
|
227
|
+
addressLine1: null,
|
|
228
|
+
addressLine2: null,
|
|
229
|
+
addressCity: null,
|
|
230
|
+
addressRegion: null,
|
|
231
|
+
addressPostalCode: null,
|
|
232
|
+
addressCountry: null,
|
|
233
|
+
questions: null,
|
|
234
|
+
// #5407 — a row written before the columns existed, and a freshly
|
|
235
|
+
// started draft on a site that asks for one name line: parts null,
|
|
236
|
+
// no phone, no verification stamp.
|
|
237
|
+
founderFirstName: null,
|
|
238
|
+
founderLastName: null,
|
|
239
|
+
founderPhone: null,
|
|
240
|
+
emailVerifiedAt: null,
|
|
241
|
+
founderCompany: null,
|
|
242
|
+
leadId: null,
|
|
243
|
+
headline: null,
|
|
244
|
+
logoMarkUrl: null,
|
|
245
|
+
logoWordmarkUrl: null,
|
|
246
|
+
featured: false,
|
|
247
|
+
decidedAt: null,
|
|
248
|
+
decidedBy: null,
|
|
249
|
+
issueUrl: null,
|
|
250
|
+
experimentKey: null,
|
|
251
|
+
windowEndsAt: null,
|
|
252
|
+
createdAt: now,
|
|
253
|
+
updatedAt: now,
|
|
254
|
+
...data,
|
|
255
|
+
};
|
|
256
|
+
rows.push(row);
|
|
257
|
+
return Promise.resolve(row);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
(prisma.projectListing.findFirst as jest.Mock).mockImplementation(
|
|
261
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
262
|
+
Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
266
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
267
|
+
Promise.resolve(rows.filter((row) => matches(row, where))),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
(prisma.projectListing.update as jest.Mock).mockImplementation(
|
|
271
|
+
({ where, data }: { where: Record<string, unknown>; data: Row }) => {
|
|
272
|
+
const row = rows.find((r) => matches(r, where));
|
|
273
|
+
Object.assign(row!, data, { updatedAt: new Date() });
|
|
274
|
+
return Promise.resolve(row);
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
(prisma.projectListing.updateMany as jest.Mock).mockImplementation(
|
|
279
|
+
({ where, data }: { where: Record<string, unknown>; data: Row }) => {
|
|
280
|
+
const matched = rows.filter((r) => matches(r, where));
|
|
281
|
+
matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
|
|
282
|
+
return Promise.resolve({ count: matched.length });
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
290
|
+
|
|
291
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
292
|
+
const app = Fastify();
|
|
293
|
+
await app.register(startProjectListingRoute);
|
|
294
|
+
await app.register(patchProjectListingDraftRoute);
|
|
295
|
+
await app.register(submitProjectListingRoute);
|
|
296
|
+
await app.ready();
|
|
297
|
+
return app;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function startDraft(
|
|
301
|
+
app: FastifyInstance,
|
|
302
|
+
): Promise<{ id: string; resumeToken: string }> {
|
|
303
|
+
const res = await app.inject({
|
|
304
|
+
method: "POST",
|
|
305
|
+
url: "/v1/project-listings",
|
|
306
|
+
payload: START_PAYLOAD,
|
|
307
|
+
});
|
|
308
|
+
expect(res.statusCode).toBe(201);
|
|
309
|
+
return (res.json() as { data: { id: string; resumeToken: string } }).data;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const patch = (
|
|
313
|
+
app: FastifyInstance,
|
|
314
|
+
draft: { id: string; resumeToken: string },
|
|
315
|
+
payload: Record<string, unknown>,
|
|
316
|
+
) =>
|
|
317
|
+
app.inject({
|
|
318
|
+
method: "PATCH",
|
|
319
|
+
url: `/v1/project-listings/drafts/${draft.id}`,
|
|
320
|
+
headers: bearer(draft.resumeToken),
|
|
321
|
+
payload,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const submit = (app: FastifyInstance, draft: { id: string; resumeToken: string }) =>
|
|
325
|
+
app.inject({
|
|
326
|
+
method: "POST",
|
|
327
|
+
url: `/v1/project-listings/drafts/${draft.id}/submit`,
|
|
328
|
+
headers: bearer(draft.resumeToken),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const storedRow = (id: string): Row => {
|
|
332
|
+
const row = rows.find((r) => r["id"] === id);
|
|
333
|
+
expect(row).toBeDefined();
|
|
334
|
+
return row!;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const draftBody = (res: { json: () => unknown }) =>
|
|
338
|
+
(res.json() as { data: Record<string, unknown> }).data;
|
|
339
|
+
|
|
340
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
describe("the structured founder address (#5279)", () => {
|
|
343
|
+
setupLeadFileEnv("project-listing-structured-address");
|
|
344
|
+
let app: FastifyInstance;
|
|
345
|
+
|
|
346
|
+
beforeEach(async () => {
|
|
347
|
+
jest.clearAllMocks();
|
|
348
|
+
installStore();
|
|
349
|
+
app = await buildApp();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
afterEach(async () => {
|
|
353
|
+
await app.close();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("the parts arrive as PARTS, and the server composes the display line", () => {
|
|
357
|
+
it("stores six DISTINCT column values, not one concatenated string", async () => {
|
|
358
|
+
const draft = await startDraft(app);
|
|
359
|
+
|
|
360
|
+
const res = await patch(app, draft, { address: ADDRESS_PARTS });
|
|
361
|
+
|
|
362
|
+
expect(res.statusCode).toBe(200);
|
|
363
|
+
|
|
364
|
+
// The column-level half. Each part is in its OWN column — a service that
|
|
365
|
+
// stringified the object would leave every one of these null and this
|
|
366
|
+
// test is the thing that notices.
|
|
367
|
+
const row = storedRow(draft.id);
|
|
368
|
+
expect(row["addressLine1"]).toBe("221B Baker Street");
|
|
369
|
+
expect(row["addressLine2"]).toBe("Flat B");
|
|
370
|
+
expect(row["addressCity"]).toBe("London");
|
|
371
|
+
expect(row["addressRegion"]).toBe("Greater London");
|
|
372
|
+
expect(row["addressPostalCode"]).toBe("NW1 6XE");
|
|
373
|
+
expect(row["addressCountry"]).toBe("GB");
|
|
374
|
+
|
|
375
|
+
// The display half. `address` is DERIVED here and only here — the parts
|
|
376
|
+
// in reading order, joined with ", ".
|
|
377
|
+
expect(row["address"]).toBe(COMPOSED_ADDRESS_LINE);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("answers the save with the composed line AND the parts, separably", async () => {
|
|
381
|
+
const draft = await startDraft(app);
|
|
382
|
+
|
|
383
|
+
const body = draftBody(await patch(app, draft, { address: ADDRESS_PARTS }));
|
|
384
|
+
|
|
385
|
+
expect(body["address"]).toBe(COMPOSED_ADDRESS_LINE);
|
|
386
|
+
expect(body["addressParts"]).toEqual(ADDRESS_PARTS);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("omits the blank addressLine2 from both the columns and the composed line", async () => {
|
|
390
|
+
const draft = await startDraft(app);
|
|
391
|
+
const { addressLine2: _line2, ...withoutLine2 } = ADDRESS_PARTS;
|
|
392
|
+
|
|
393
|
+
const body = draftBody(await patch(app, draft, { address: withoutLine2 }));
|
|
394
|
+
|
|
395
|
+
expect(storedRow(draft.id)["addressLine2"]).toBeNull();
|
|
396
|
+
expect(body["address"]).toBe("221B Baker Street, London, Greater London, NW1 6XE, GB");
|
|
397
|
+
expect(body["addressParts"]).toEqual(withoutLine2);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("a composed address submits, and reaches the row as the display line", async () => {
|
|
401
|
+
const draft = await startDraft(app);
|
|
402
|
+
await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
|
|
403
|
+
|
|
404
|
+
const res = await submit(app, draft);
|
|
405
|
+
|
|
406
|
+
expect(res.statusCode).toBe(200);
|
|
407
|
+
expect(storedRow(draft.id)["address"]).toBe(COMPOSED_ADDRESS_LINE);
|
|
408
|
+
expect(storedRow(draft.id)["addressCountry"]).toBe("GB");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("refuses parts that are not a whole address, rather than storing half of one", async () => {
|
|
412
|
+
const draft = await startDraft(app);
|
|
413
|
+
const { city: _city, ...incomplete } = ADDRESS_PARTS;
|
|
414
|
+
|
|
415
|
+
const res = await patch(app, draft, { address: incomplete });
|
|
416
|
+
|
|
417
|
+
expect(res.statusCode).toBe(400);
|
|
418
|
+
expect(storedRow(draft.id)["addressLine1"]).toBeNull();
|
|
419
|
+
expect(storedRow(draft.id)["address"]).toBeNull();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe("a legacy free-text address round-trips unchanged, with its parts null", () => {
|
|
424
|
+
// The load-bearing guard. Nothing may split an existing `address` into
|
|
425
|
+
// parts — a wrong split silently corrupts the party identification the
|
|
426
|
+
// listing's privacy notice points at, and a null part is honest where a
|
|
427
|
+
// guessed one is not.
|
|
428
|
+
it("comes back CHARACTER-FOR-CHARACTER from a save, with addressParts null", async () => {
|
|
429
|
+
const draft = await startDraft(app);
|
|
430
|
+
|
|
431
|
+
const body = draftBody(await patch(app, draft, { address: LEGACY_ADDRESS_LINE }));
|
|
432
|
+
|
|
433
|
+
expect(body["address"]).toBe(LEGACY_ADDRESS_LINE);
|
|
434
|
+
expect(body["addressParts"]).toBeNull();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("leaves EVERY part column untouched — a plain string writes none of them", async () => {
|
|
438
|
+
const draft = await startDraft(app);
|
|
439
|
+
|
|
440
|
+
await patch(app, draft, { address: LEGACY_ADDRESS_LINE });
|
|
441
|
+
|
|
442
|
+
const row = storedRow(draft.id);
|
|
443
|
+
expect(row["address"]).toBe(LEGACY_ADDRESS_LINE);
|
|
444
|
+
for (const column of [
|
|
445
|
+
"addressLine1",
|
|
446
|
+
"addressLine2",
|
|
447
|
+
"addressCity",
|
|
448
|
+
"addressRegion",
|
|
449
|
+
"addressPostalCode",
|
|
450
|
+
"addressCountry",
|
|
451
|
+
]) {
|
|
452
|
+
expect(row[column]).toBeNull();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("submits exactly as it did before #5279, and the stored line is unchanged", async () => {
|
|
457
|
+
const draft = await startDraft(app);
|
|
458
|
+
await patch(app, draft, {
|
|
459
|
+
...APPLICATION_WITHOUT_ADDRESS,
|
|
460
|
+
address: LEGACY_ADDRESS_LINE,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const res = await submit(app, draft);
|
|
464
|
+
|
|
465
|
+
expect(res.statusCode).toBe(200);
|
|
466
|
+
expect(storedRow(draft.id)["address"]).toBe(LEGACY_ADDRESS_LINE);
|
|
467
|
+
expect(storedRow(draft.id)["addressLine1"]).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// A row written before #5279 holds a line and six nulls. Overwriting the
|
|
471
|
+
// line with a NEW line must not start inventing parts for it either.
|
|
472
|
+
it("re-patching a legacy line with another legacy line still writes no parts", async () => {
|
|
473
|
+
const draft = await startDraft(app);
|
|
474
|
+
await patch(app, draft, { address: LEGACY_ADDRESS_LINE });
|
|
475
|
+
|
|
476
|
+
await patch(app, draft, { address: "1 Example Street, Exampleton, EX1 2AM" });
|
|
477
|
+
|
|
478
|
+
const row = storedRow(draft.id);
|
|
479
|
+
expect(row["address"]).toBe("1 Example Street, Exampleton, EX1 2AM");
|
|
480
|
+
expect(row["addressCity"]).toBeNull();
|
|
481
|
+
expect(row["addressCountry"]).toBeNull();
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* POST /v1/project-listings — anonymous start (leadsScope)
|
|
7
7
|
* PATCH /v1/project-listings/drafts/:id — anonymous save-as-you-go, token-gated
|
|
8
8
|
* POST /v1/project-listings/drafts/:id/submit — anonymous submit, token-gated
|
|
9
|
+
* POST /v1/project-listings/drafts/:id/verify-email — anonymous, token- AND
|
|
10
|
+
* nonce-gated (#5407)
|
|
9
11
|
* GET /v1/project-listings/public — anonymous page-render read (leadsScope)
|
|
12
|
+
* GET /v1/project-listings/me — the founder's OWN listings (authScope)
|
|
10
13
|
* GET /v1/project-listings — tenant-scoped dashboard list
|
|
11
14
|
* GET /v1/project-listings/:id — tenant-scoped single read
|
|
12
15
|
* PATCH /v1/project-listings/:id — tenant-scoped decision + featured flag
|
|
@@ -19,6 +22,13 @@
|
|
|
19
22
|
* anonymous token-gated PATCH and the operator's tenant-guarded PATCH apart
|
|
20
23
|
* at the router — they differ in segment count, so neither shadows the other.
|
|
21
24
|
*
|
|
25
|
+
* `/me` (#5295) sits between the two scopes: `authScope` only, no tenant guard.
|
|
26
|
+
* A founder is not a workspace member, so the guard's default-deny answers 403
|
|
27
|
+
* for their own submission — and the fix is this ADDITIVE route, joined on the
|
|
28
|
+
* owner link, rather than a relaxed `PROJECT_LISTING_ROLES`. Its static `/me`
|
|
29
|
+
* segment takes router precedence over the parametric `/:id` below, exactly as
|
|
30
|
+
* the already-shipped static `/public` does.
|
|
31
|
+
*
|
|
22
32
|
* API<->MCP parity: no MCP twin. The anonymous set mirrors the leads routes'
|
|
23
33
|
* own identity-blind posture (an applying founder has no account, and the
|
|
24
34
|
* resume token is a per-draft capability rather than an identity); the
|
|
@@ -27,15 +37,30 @@
|
|
|
27
37
|
*/
|
|
28
38
|
import type { FastifyPluginAsync } from "fastify";
|
|
29
39
|
|
|
40
|
+
import { loadSlice } from "../../lib/slice-load.js";
|
|
41
|
+
|
|
42
|
+
import { projectListingMeRoute as meRoute } from "./me.js";
|
|
30
43
|
import { patchProjectListingDraftRoute } from "./patch-draft.js";
|
|
31
44
|
import { publicProjectListingsRoute } from "./public.js";
|
|
32
45
|
import { startProjectListingRoute } from "./start.js";
|
|
33
46
|
import { submitProjectListingRoute } from "./submit.js";
|
|
47
|
+
import { verifyProjectListingDraftEmailRoute } from "./verify-email.js";
|
|
34
48
|
|
|
35
49
|
export const projectListingStartRoute = startProjectListingRoute;
|
|
36
50
|
export const projectListingDraftPatchRoute = patchProjectListingDraftRoute;
|
|
37
51
|
export const projectListingSubmitRoute = submitProjectListingRoute;
|
|
38
52
|
export const projectListingPublicRoute = publicProjectListingsRoute;
|
|
53
|
+
/**
|
|
54
|
+
* The resume link's verification write (#5407). Static, not a slice: it is
|
|
55
|
+
* part of the free anonymous surface, exactly like the three routes above.
|
|
56
|
+
*/
|
|
57
|
+
export const projectListingVerifyEmailRoute = verifyProjectListingDraftEmailRoute;
|
|
58
|
+
/**
|
|
59
|
+
* The founder's own listings read (#5295). Static, not a slice: it depends on
|
|
60
|
+
* the owner link alone, so it is registered — and works — on a build with no
|
|
61
|
+
* multi-tenant module, the same as `flowCheckoutRoutes`.
|
|
62
|
+
*/
|
|
63
|
+
export const projectListingMeRoute = meRoute;
|
|
39
64
|
|
|
40
65
|
/**
|
|
41
66
|
* Tenant-scoped handlers, as { specifier, exportName }, reached at REGISTRATION
|
|
@@ -47,50 +72,67 @@ export const projectListingPublicRoute = publicProjectListingsRoute;
|
|
|
47
72
|
* purpose, which is why the boundary is drawn here at the registration
|
|
48
73
|
* call-site rather than by splitting the directory.
|
|
49
74
|
*
|
|
50
|
-
* The specifier is
|
|
51
|
-
*
|
|
52
|
-
*
|
|
75
|
+
* The specifier is kept off the `import()` call site because `tsc` resolves a
|
|
76
|
+
* string-literal `import("...")` EXPRESSION whether or not the branch runs, and
|
|
77
|
+
* would report TS2307 on the stripped tree. It is held in a module-level
|
|
78
|
+
* constant rather than read out of the table per iteration (#5304): the table
|
|
79
|
+
* form was invisible to the deploy-time file tracer too, so these files were
|
|
80
|
+
* compiled and never shipped, and the constant form is the one shape `tsc`
|
|
81
|
+
* refuses to resolve and `@vercel/nft` still traces. `specifier` stays on each
|
|
82
|
+
* row for the drift message and reads the same constant as `load`. The plugin body
|
|
53
83
|
* only executes when `server.ts` registers it, which it does inside
|
|
54
84
|
* `isModuleEnabled('multi-tenant')` — so nothing here runs on an OSS build.
|
|
55
85
|
*
|
|
56
86
|
* The export name is unchanged so `server.ts` needs no edit: it still reaches
|
|
57
87
|
* `projectListingTenantRoutes` from this module.
|
|
58
88
|
*/
|
|
89
|
+
const LIST_HANDLER = "./list.js";
|
|
90
|
+
const GET_HANDLER = "./get.js";
|
|
91
|
+
const PATCH_HANDLER = "./patch.js";
|
|
92
|
+
const PATCH_COPY_HANDLER = "./patch-copy.js";
|
|
93
|
+
|
|
59
94
|
const TENANT_HANDLERS: ReadonlyArray<{
|
|
60
95
|
method: "get" | "patch";
|
|
61
96
|
url: string;
|
|
62
97
|
specifier: string;
|
|
63
98
|
exportName: string;
|
|
99
|
+
load: () => Promise<unknown>;
|
|
64
100
|
}> = [
|
|
65
101
|
{
|
|
66
102
|
method: "get",
|
|
67
103
|
url: "/v1/project-listings",
|
|
68
|
-
specifier:
|
|
104
|
+
specifier: LIST_HANDLER,
|
|
69
105
|
exportName: "listProjectListingsHandler",
|
|
106
|
+
load: () => import(LIST_HANDLER),
|
|
70
107
|
},
|
|
71
108
|
{
|
|
72
109
|
method: "get",
|
|
73
110
|
url: "/v1/project-listings/:id",
|
|
74
|
-
specifier:
|
|
111
|
+
specifier: GET_HANDLER,
|
|
75
112
|
exportName: "getProjectListingHandler",
|
|
113
|
+
load: () => import(GET_HANDLER),
|
|
76
114
|
},
|
|
77
115
|
{
|
|
78
116
|
method: "patch",
|
|
79
117
|
url: "/v1/project-listings/:id",
|
|
80
|
-
specifier:
|
|
118
|
+
specifier: PATCH_HANDLER,
|
|
81
119
|
exportName: "patchProjectListingHandler",
|
|
120
|
+
load: () => import(PATCH_HANDLER),
|
|
82
121
|
},
|
|
83
122
|
{
|
|
84
123
|
method: "patch",
|
|
85
124
|
url: "/v1/project-listings/:id/copy",
|
|
86
|
-
specifier:
|
|
125
|
+
specifier: PATCH_COPY_HANDLER,
|
|
87
126
|
exportName: "patchProjectListingCopyHandler",
|
|
127
|
+
load: () => import(PATCH_COPY_HANDLER),
|
|
88
128
|
},
|
|
89
129
|
];
|
|
90
130
|
|
|
91
|
-
export const projectListingTenantRoutes: FastifyPluginAsync = async (
|
|
92
|
-
|
|
93
|
-
|
|
131
|
+
export const projectListingTenantRoutes: FastifyPluginAsync = async (
|
|
132
|
+
fastify,
|
|
133
|
+
) => {
|
|
134
|
+
for (const { method, url, specifier, exportName, load } of TENANT_HANDLERS) {
|
|
135
|
+
const mod = (await loadSlice(specifier, load)) as Record<
|
|
94
136
|
string,
|
|
95
137
|
Parameters<typeof fastify.get>[1] | undefined
|
|
96
138
|
>;
|