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
|
@@ -460,10 +460,116 @@ model ProjectListing {
|
|
|
460
460
|
// #5036 D6 — a legal control, not a contact detail: visitors answering this
|
|
461
461
|
// founder's diagnostic are owed the identity and address of the party
|
|
462
462
|
// collecting their data. Carried on the PUBLIC read for that reason.
|
|
463
|
+
//
|
|
464
|
+
// #5279 — still the single-line DISPLAY string. Where the six part columns
|
|
465
|
+
// below are written, this is COMPOSED from them server-side; where they are
|
|
466
|
+
// not, it is the line the founder typed, verbatim.
|
|
463
467
|
address String?
|
|
468
|
+
// #5279 — the structured parts of the address above, beside it rather than
|
|
469
|
+
// instead of it. Nullable for the same reason as the #5036 D1 block above: a
|
|
470
|
+
// row written before #5279 has no parts, and none may ever be GUESSED from
|
|
471
|
+
// its `address` string. Parts compose into a line deterministically; a line
|
|
472
|
+
// does not decompose into parts, and a wrong split silently corrupts the
|
|
473
|
+
// party identification the listing's privacy notice points at — so the
|
|
474
|
+
// migration deliberately backfills nothing and a null part stays null for
|
|
475
|
+
// the life of the row. Composition runs one way only, in `draftUpdateData`
|
|
476
|
+
// (apps/api/src/services/project-listings.ts).
|
|
477
|
+
addressLine1 String? @map("address_line1")
|
|
478
|
+
addressLine2 String? @map("address_line2")
|
|
479
|
+
addressCity String? @map("address_city")
|
|
480
|
+
addressRegion String? @map("address_region")
|
|
481
|
+
addressPostalCode String? @map("address_postal_code")
|
|
482
|
+
addressCountry String? @map("address_country")
|
|
464
483
|
questions Json?
|
|
484
|
+
// #5285 — the answers to the site's OWN questions: one flat JSON object of
|
|
485
|
+
// `{ questionId: answer }` for everything a site asks that the PLATFORM does
|
|
486
|
+
// not read. The field contract (packages/validation/.../listing-
|
|
487
|
+
// application-contract.ts) names what the platform reads and therefore what
|
|
488
|
+
// earns a column; everything else was previously unaskable without a
|
|
489
|
+
// migration and a release, which is what this column removes.
|
|
490
|
+
//
|
|
491
|
+
// BOUNDED, not free-form, and that is the point: the write path is
|
|
492
|
+
// anonymous (a resume token and nothing else), so an unbounded JSON bag on
|
|
493
|
+
// it is a place for a stranger to park arbitrary text in the table. The
|
|
494
|
+
// server accepts only ids the SITE declared in its own `SiteListingConfig`,
|
|
495
|
+
// at that site's own count and length caps, and refuses the whole request
|
|
496
|
+
// otherwise — so what lands here is never larger than the questions the
|
|
497
|
+
// operator chose to ask. Null on every row written before #5285 and on
|
|
498
|
+
// every site that asks nothing of its own.
|
|
499
|
+
//
|
|
500
|
+
// Only the `public`-visibility subset rides the public read; an
|
|
501
|
+
// `operator-only` answer reaches the dashboard and the review payload and
|
|
502
|
+
// stops there.
|
|
503
|
+
siteAnswers Json? @map("site_answers")
|
|
504
|
+
// #5407 — still the single-line DISPLAY name, and still NOT NULL: `start`
|
|
505
|
+
// has always required one and every reader below expects one. Where the two
|
|
506
|
+
// part columns beside it are written, this is COMPOSED from them
|
|
507
|
+
// server-side (`startProjectListingDraft`); where they are not, it is the
|
|
508
|
+
// line the founder typed, verbatim.
|
|
509
|
+
//
|
|
510
|
+
// Read by: the submit-time slug fallback, the approve/reject email's
|
|
511
|
+
// greeting, the Slack review card's Founder row, the build issue, and the
|
|
512
|
+
// `routeLead` identity. None of them changes.
|
|
465
513
|
founderName String @map("founder_name")
|
|
514
|
+
// #5407 — the structured parts of the display name above, beside it rather
|
|
515
|
+
// than instead of it, exactly as #5279 put the six address parts beside
|
|
516
|
+
// `address`. Nullable for the #5036 D1 reason and one more: a row written
|
|
517
|
+
// before #5407 has no parts, and none may ever be GUESSED from its
|
|
518
|
+
// `founder_name`. "First Last" composes one way only; a name does not
|
|
519
|
+
// decompose (a two-word split mangles "van der Berg", "Maria del Carmen"
|
|
520
|
+
// and every mononym), so the migration backfills nothing and a null part
|
|
521
|
+
// stays null for the life of the row.
|
|
522
|
+
founderFirstName String? @map("founder_first_name")
|
|
523
|
+
founderLastName String? @map("founder_last_name")
|
|
466
524
|
founderEmail String @map("founder_email")
|
|
525
|
+
// #5407 — the founder's phone, collected on the default contact screen
|
|
526
|
+
// alongside the email (samhenry spec 316 D1). Nullable at rest for the
|
|
527
|
+
// #5036 D1 reason AND permanently: a row written before #5407 has none, and
|
|
528
|
+
// a site may declare the question optional through its own config.
|
|
529
|
+
//
|
|
530
|
+
// NEVER on the public read: `projectListingPublicSchema` omits it beside
|
|
531
|
+
// `founderEmail`. The address on this row is a legal control a visitor is
|
|
532
|
+
// owed; a phone number is a contact detail nobody outside the operator is.
|
|
533
|
+
founderPhone String? @map("founder_phone")
|
|
534
|
+
// #5407 — sha-256 of the EMAIL VERIFICATION NONCE, never the nonce itself.
|
|
535
|
+
//
|
|
536
|
+
// A second credential beside `resumeTokenHash`, and the reason
|
|
537
|
+
// `emailVerifiedAt` below means what its name says. The resume token is
|
|
538
|
+
// returned to WHOEVER POSTED `start`'s FORM, so presenting it proves only
|
|
539
|
+
// that you opened the draft. This nonce is minted at `start`, digested
|
|
540
|
+
// here, and handed to nothing except the resume-email builder
|
|
541
|
+
// (`services/project-listing-resume-email.ts`): it is never in a response
|
|
542
|
+
// body, never in a log, and never in the browser that filled the form. The
|
|
543
|
+
// only way to hold it is to have read mail sent to `founder_email`.
|
|
544
|
+
//
|
|
545
|
+
// Null on every row written before #5407, and on every row whose site has
|
|
546
|
+
// not switched the resume email on — there the nonce exists and is simply
|
|
547
|
+
// never delivered, so nothing can verify, which is the correct outcome.
|
|
548
|
+
// Cleared at `submit` beside `resumeTokenHash`, for the same reason: a
|
|
549
|
+
// credential that leaks after the draft stops being a draft is inert twice
|
|
550
|
+
// over.
|
|
551
|
+
emailVerifyNonceHash String? @unique @map("email_verify_nonce_hash")
|
|
552
|
+
// #5407 — when the founder proved control of `founder_email`, by opening
|
|
553
|
+
// the link mailed to it.
|
|
554
|
+
//
|
|
555
|
+
// WRITE-ONCE. `verifyProjectListingDraftEmail` sets it only where it is
|
|
556
|
+
// still null, so a founder who opens the link five times is stamped with
|
|
557
|
+
// the FIRST opening rather than the latest.
|
|
558
|
+
//
|
|
559
|
+
// WHAT IT PROVES, and its exact limit. The stamp requires the nonce above,
|
|
560
|
+
// which left this server only inside the email. So it says the holder can
|
|
561
|
+
// read mail sent to `founder_email` — which is the whole of what "email
|
|
562
|
+
// verified" claims. It says nothing about WHO they are: `owner_user_id`
|
|
563
|
+
// (#5295) is the column that carries an identity claim, and it still needs
|
|
564
|
+
// a signed-in user whose own address the auth system verified. A row can
|
|
565
|
+
// carry either, both, or neither.
|
|
566
|
+
//
|
|
567
|
+
// Permanently nullable, and NOT a backfill target. Every row written before
|
|
568
|
+
// #5407 predates the stamp; asserting verification on one would assert
|
|
569
|
+
// exactly the thing nobody proved. Null means "not verified", never
|
|
570
|
+
// "verification failed", and it is also what a site running no resume email
|
|
571
|
+
// reads forever.
|
|
572
|
+
emailVerifiedAt DateTime? @map("email_verified_at")
|
|
467
573
|
founderCompany String? @map("founder_company")
|
|
468
574
|
status String
|
|
469
575
|
// #5036 D1/D6 — when the founder ticked step one's consent box. Never null:
|
|
@@ -512,6 +618,24 @@ model ProjectListing {
|
|
|
512
618
|
// rolls back on a Slack failure).
|
|
513
619
|
slackChannel String? @map("slack_channel")
|
|
514
620
|
slackTs String? @map("slack_ts")
|
|
621
|
+
// Listing -> owner-user link (#5295). Plain scalars — no cross-namespace
|
|
622
|
+
// @relation into auth.User (ADR 0009 invariant #1), exactly as
|
|
623
|
+
// `Lead.promotedUserId` / `promotedAt` above. Set by
|
|
624
|
+
// apps/api/src/services/listing-promotion.ts once the reconciling user's own
|
|
625
|
+
// email is VERIFIED, that verified address matches `founderEmail`, and this
|
|
626
|
+
// row's `siteId` resolves to one of that user's own workspaces.
|
|
627
|
+
//
|
|
628
|
+
// `founderEmail` is an UNVERIFIED string typed into an anonymous form, so it
|
|
629
|
+
// can never by itself say who owns the row: two people who type the same
|
|
630
|
+
// address are two people. This column is the only ownership claim any read
|
|
631
|
+
// may join on, and nothing derives it from `founderEmail` at read time.
|
|
632
|
+
//
|
|
633
|
+
// Nullable PERMANENTLY, not pending a backfill. A listing submitted by
|
|
634
|
+
// someone who never signs in has no owner for the life of the row, and that
|
|
635
|
+
// is a legitimate resting state: a backfill from `founderEmail` would assert
|
|
636
|
+
// a link nobody proved, which is the bug this column exists to remove.
|
|
637
|
+
ownerUserId String? @map("owner_user_id")
|
|
638
|
+
ownerLinkedAt DateTime? @map("owner_linked_at")
|
|
515
639
|
createdAt DateTime @default(now()) @map("created_at")
|
|
516
640
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
517
641
|
|
|
@@ -521,5 +645,46 @@ model ProjectListing {
|
|
|
521
645
|
// rows on every submit, from an anonymous route. Indexed so a stranger
|
|
522
646
|
// cannot make that count a table scan.
|
|
523
647
|
@@index([siteId, founderEmail])
|
|
648
|
+
// The self-scoped read's whole WHERE clause (#5295) is `{ ownerUserId }` —
|
|
649
|
+
// the owner link IS the scope, so no site term joins it. Single-column by
|
|
650
|
+
// design: the promotion match (`founderEmail` + `siteId` + null owner) is
|
|
651
|
+
// already served by the (site_id, founder_email) index above.
|
|
652
|
+
@@index([ownerUserId])
|
|
524
653
|
@@map("project_listings")
|
|
525
654
|
}
|
|
655
|
+
|
|
656
|
+
/// ConsentGrant — one person's answer to one consent purpose, with the exact
|
|
657
|
+
/// wording they read (#5275). APPEND-ONLY: current state is the newest row
|
|
658
|
+
/// per (subject, purpose), so a later withdrawal never destroys the evidence
|
|
659
|
+
/// of the earlier grant (D9) — no `updatedAt`, and deliberately NO unique
|
|
660
|
+
/// constraint on subject + purpose. DECLINES ARE STORED as `granted: false`
|
|
661
|
+
/// (D8): an absent row cannot distinguish "they said no" from "we never
|
|
662
|
+
/// asked", and on an optional purpose that distinction is the only thing the
|
|
663
|
+
/// record is for. The subject is a type + id pair of plain scalars
|
|
664
|
+
/// (`project_listing` / `subscriber` / …) with NO cross-namespace @relation
|
|
665
|
+
/// (D10, ADR 0009 invariant 1) — which is what lets a new surface adopt
|
|
666
|
+
/// consent with a service call and no migration. `statement` is the resolved
|
|
667
|
+
/// text DENORMALIZED at write time (D7): a pointer into the site's config row
|
|
668
|
+
/// would prove nothing once that row is edited, and `statementHash` groups
|
|
669
|
+
/// identical wordings. `locale` is the locale the statement was actually
|
|
670
|
+
/// served in, not the one requested. `purposeId` is the site-config registry
|
|
671
|
+
/// id — the join key across surfaces. Written only by
|
|
672
|
+
/// apps/api/src/services/consent.ts.
|
|
673
|
+
model ConsentGrant {
|
|
674
|
+
id String @id @default(dbgenerated("('cnsnt_'::text || (uuidv7())::text)")) @db.Text
|
|
675
|
+
siteId String @map("site_id")
|
|
676
|
+
subjectType String @map("subject_type")
|
|
677
|
+
subjectId String @map("subject_id")
|
|
678
|
+
purposeId String @map("purpose_id")
|
|
679
|
+
granted Boolean
|
|
680
|
+
locale String
|
|
681
|
+
statement String @db.Text
|
|
682
|
+
statementHash String @map("statement_hash")
|
|
683
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
684
|
+
|
|
685
|
+
// "What did this subject agree to" — every row for one subject, newest last.
|
|
686
|
+
@@index([siteId, subjectType, subjectId])
|
|
687
|
+
// "Who agreed to this purpose" — the cross-surface count the id exists for (D1/D2).
|
|
688
|
+
@@index([siteId, purposeId])
|
|
689
|
+
@@map("consent_grants")
|
|
690
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- The founder's address, in structured parts, on `project_listings` (#5279).
|
|
2
|
+
--
|
|
3
|
+
-- The application collected a whole business address in ONE free-text box
|
|
4
|
+
-- (`address`, a single line). That line is a legal control rather than a
|
|
5
|
+
-- contact detail — visitors answering a founder's diagnostic are owed the
|
|
6
|
+
-- identity and address of the party collecting their data (#5036 D6) — and a
|
|
7
|
+
-- notice that has to name a country or a region cannot reliably find one
|
|
8
|
+
-- inside a line someone typed by hand.
|
|
9
|
+
--
|
|
10
|
+
-- These six columns carry the parts the fieldset now collects. They sit
|
|
11
|
+
-- BESIDE `address`, not instead of it: `address` remains the single-line
|
|
12
|
+
-- DISPLAY string, composed server-side from these columns when they are
|
|
13
|
+
-- written, and left exactly as the founder typed it when they are not.
|
|
14
|
+
--
|
|
15
|
+
-- Every column is nullable, and there is DELIBERATELY NO BACKFILL. Parts
|
|
16
|
+
-- compose into a line deterministically; a line does not decompose into
|
|
17
|
+
-- parts. Address parsing guesses, and a wrong guess — a region read as a
|
|
18
|
+
-- city, a house number read as a postcode — silently corrupts the party
|
|
19
|
+
-- identification a listing's privacy notice points at, with nothing on the
|
|
20
|
+
-- row to mark it as invented. A null part is honest; a guessed one is not.
|
|
21
|
+
-- So every existing row keeps its `address` string byte-for-byte and keeps
|
|
22
|
+
-- all six parts NULL, for the life of the row.
|
|
23
|
+
--
|
|
24
|
+
-- No index: every read of these columns is already keyed by the listing's own
|
|
25
|
+
-- primary key or by an existing (site_id, …) index, and no query filters on a
|
|
26
|
+
-- part.
|
|
27
|
+
ALTER TABLE "project_listings" ADD COLUMN "address_line1" TEXT;
|
|
28
|
+
ALTER TABLE "project_listings" ADD COLUMN "address_line2" TEXT;
|
|
29
|
+
ALTER TABLE "project_listings" ADD COLUMN "address_city" TEXT;
|
|
30
|
+
ALTER TABLE "project_listings" ADD COLUMN "address_region" TEXT;
|
|
31
|
+
ALTER TABLE "project_listings" ADD COLUMN "address_postal_code" TEXT;
|
|
32
|
+
ALTER TABLE "project_listings" ADD COLUMN "address_country" TEXT;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- #5275 — ConsentGrant: one person's answer to one consent purpose, with the
|
|
2
|
+
-- exact wording they read. Mirrors the shape of
|
|
3
|
+
-- 20260907180000_add_listing_csv_send (CreateTable + CreateIndex immediately
|
|
4
|
+
-- followed by the ADR-0031 rule-3 id-format completion applied in the SAME
|
|
5
|
+
-- migration since the table is new, no retrofit needed), then the listing
|
|
6
|
+
-- BACKFILL.
|
|
7
|
+
--
|
|
8
|
+
-- The table is APPEND-ONLY (D9): no updated_at, and deliberately NO unique
|
|
9
|
+
-- constraint on (subject, purpose) — current state is the newest row per
|
|
10
|
+
-- pair, so a later withdrawal never erases the earlier grant. Declines are
|
|
11
|
+
-- stored as granted = false (D8). subject_type/subject_id are plain scalars
|
|
12
|
+
-- with no cross-namespace FK (D10, ADR 0009). statement is denormalized (D7).
|
|
13
|
+
--
|
|
14
|
+
-- BACKFILL: one row per pre-existing project_listings row (D11, spec §5.3
|
|
15
|
+
-- second boundary). Those founders ticked a consent box, but the system never
|
|
16
|
+
-- RENDERED any statement to them — the control was a select carrying one fake
|
|
17
|
+
-- option, and the client sent a hardcoded `consent: true` — so there is no
|
|
18
|
+
-- per-listing wording to recover. Each backfilled row is therefore marked
|
|
19
|
+
-- WORDING-UNRECOVERABLE rather than stamped with today's default statement:
|
|
20
|
+
-- stamping current wording onto a grant nobody read would fabricate evidence.
|
|
21
|
+
-- The sentinel is the exact `UNRECOVERABLE_CONSENT_STATEMENT` literal from
|
|
22
|
+
-- apps/api/src/services/consent.ts, its hash is COMPUTED here rather than
|
|
23
|
+
-- hand-typed, locale is BCP-47 `und` (undetermined), and created_at is the
|
|
24
|
+
-- listing's own consent_at — the one fact about the act that IS recoverable.
|
|
25
|
+
-- This migration never UPDATEs or ALTERs project_listings; consent_at is left
|
|
26
|
+
-- exactly as it was (spec §2.3).
|
|
27
|
+
|
|
28
|
+
-- CreateTable
|
|
29
|
+
CREATE TABLE "consent_grants" (
|
|
30
|
+
"id" TEXT NOT NULL DEFAULT ('cnsnt_'::text || (uuidv7())::text),
|
|
31
|
+
"site_id" TEXT NOT NULL,
|
|
32
|
+
"subject_type" TEXT NOT NULL,
|
|
33
|
+
"subject_id" TEXT NOT NULL,
|
|
34
|
+
"purpose_id" TEXT NOT NULL,
|
|
35
|
+
"granted" BOOLEAN NOT NULL,
|
|
36
|
+
"locale" TEXT NOT NULL,
|
|
37
|
+
"statement" TEXT NOT NULL,
|
|
38
|
+
"statement_hash" TEXT NOT NULL,
|
|
39
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
40
|
+
|
|
41
|
+
CONSTRAINT "consent_grants_pkey" PRIMARY KEY ("id")
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- CreateIndex
|
|
45
|
+
CREATE INDEX "consent_grants_site_id_subject_type_subject_id_idx" ON "consent_grants"("site_id", "subject_type", "subject_id");
|
|
46
|
+
|
|
47
|
+
-- CreateIndex
|
|
48
|
+
CREATE INDEX "consent_grants_site_id_purpose_id_idx" ON "consent_grants"("site_id", "purpose_id");
|
|
49
|
+
|
|
50
|
+
-- ADR-0031 rule 3 — consent_grants: cnsnt_ (registered in
|
|
51
|
+
-- packages/ids/src/registry.ts ENTITY_PREFIX.consentGrant; this migration's
|
|
52
|
+
-- path is added to migration-sync.test.ts's PK_CHECK_MIGRATIONS).
|
|
53
|
+
ALTER TABLE "consent_grants" ALTER COLUMN "id" TYPE text COLLATE "C";
|
|
54
|
+
ALTER TABLE "consent_grants" ALTER COLUMN "id" SET DEFAULT ('cnsnt_'::text || (uuidv7())::text);
|
|
55
|
+
ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_id_format" CHECK ("id" ~ '^cnsnt_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
|
|
56
|
+
|
|
57
|
+
-- Backfill (D11): one wording-unrecoverable `service_terms` grant per
|
|
58
|
+
-- pre-existing listing, stamped at the listing's own consent_at. Read-only
|
|
59
|
+
-- against project_listings.
|
|
60
|
+
INSERT INTO "consent_grants" ("site_id", "subject_type", "subject_id", "purpose_id", "granted", "locale", "statement", "statement_hash", "created_at")
|
|
61
|
+
SELECT
|
|
62
|
+
"site_id",
|
|
63
|
+
'project_listing',
|
|
64
|
+
"id",
|
|
65
|
+
'service_terms',
|
|
66
|
+
TRUE,
|
|
67
|
+
'und',
|
|
68
|
+
'[unrecoverable: no statement was rendered by this system for this grant]',
|
|
69
|
+
encode(sha256(convert_to('[unrecoverable: no statement was rendered by this system for this grant]', 'UTF8')), 'hex'),
|
|
70
|
+
"consent_at"
|
|
71
|
+
FROM "project_listings";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- The site's own answers store on `project_listings` (#5285).
|
|
2
|
+
--
|
|
3
|
+
-- The founder application used to couple the platform to a QUESTION: a step
|
|
4
|
+
-- kind rendered one control and wrote one column, so a site could not ask
|
|
5
|
+
-- anything of its own without an upstream release. The field contract names
|
|
6
|
+
-- the fields the platform actually READS — the ones that derive a slug,
|
|
7
|
+
-- render a card, key the concurrency limit, build the operator's issue — and
|
|
8
|
+
-- those keep their columns. Everything else a site wants to ask is editorial,
|
|
9
|
+
-- and lands here instead: one JSON object of `{ questionId: answer }`.
|
|
10
|
+
--
|
|
11
|
+
-- BOUNDED, not free-form. The write path is ANONYMOUS — a resume token and
|
|
12
|
+
-- nothing else — and an unbounded JSON bag on an anonymous surface is a place
|
|
13
|
+
-- for a stranger to park arbitrary text in the table. So the server accepts
|
|
14
|
+
-- only ids the site declared in its own `SiteListingConfig.data.siteAnswers`,
|
|
15
|
+
-- at that site's own count and length caps, and refuses the WHOLE request
|
|
16
|
+
-- otherwise: an answer for an id the site does not ask is a 400 and nothing
|
|
17
|
+
-- is written. What can land in this column is therefore never larger than the
|
|
18
|
+
-- questions an operator chose to ask.
|
|
19
|
+
--
|
|
20
|
+
-- Nullable with no default and no backfill: null means "this site asks
|
|
21
|
+
-- nothing of its own", which is every existing row and every site that has
|
|
22
|
+
-- declared no questions. An empty object would claim the founder was asked
|
|
23
|
+
-- and answered nothing, which is a different fact.
|
|
24
|
+
--
|
|
25
|
+
-- No index, and no GIN index in particular. Nothing queries INTO this column:
|
|
26
|
+
-- every read of it is already keyed by the listing's primary key or by an
|
|
27
|
+
-- existing (site_id, …) index, and an answer the platform searched on would
|
|
28
|
+
-- by definition be an answer the platform reads — which is what a contract
|
|
29
|
+
-- field and a column of its own are for.
|
|
30
|
+
ALTER TABLE "project_listings" ADD COLUMN "site_answers" JSONB;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- The verified owner link on `project_listings` (#5295).
|
|
2
|
+
--
|
|
3
|
+
-- A listing carried `founder_email` and `lead_id` and no user link, so there
|
|
4
|
+
-- was no query for "the listings belonging to this signed-in person". The only
|
|
5
|
+
-- join available was a session's email against `founder_email` — and
|
|
6
|
+
-- `founder_email` is an UNVERIFIED string typed into an anonymous form, so that
|
|
7
|
+
-- join hands a stranger someone else's submission the first time two people
|
|
8
|
+
-- type the same address. These two columns are the ownership claim instead.
|
|
9
|
+
--
|
|
10
|
+
-- Plain scalars, no foreign key into `users`: ADR 0009 invariant #1 forbids a
|
|
11
|
+
-- relation across the namespace boundary, and `leads.promoted_user_id` /
|
|
12
|
+
-- `promoted_at` (20260714000000_add_lead_promotion_fields) are the same shape
|
|
13
|
+
-- for the same reason. The join is stable-id matching in the service layer.
|
|
14
|
+
--
|
|
15
|
+
-- `apps/api/src/services/listing-promotion.ts` is the sole writer, and it sets
|
|
16
|
+
-- the pair only when all three of these hold:
|
|
17
|
+
-- 1. the reconciling user's OWN email is verified (`users.email_verified`) —
|
|
18
|
+
-- never a claimed or session-asserted address;
|
|
19
|
+
-- 2. that verified address matches the row's `founder_email`;
|
|
20
|
+
-- 3. the row's `site_id` is one of that user's own tenant workspaces.
|
|
21
|
+
-- Any one failing writes nothing.
|
|
22
|
+
--
|
|
23
|
+
-- NO BACKFILL, deliberately and permanently. Every existing row predates
|
|
24
|
+
-- verification, so deriving `owner_user_id` from `founder_email` here would
|
|
25
|
+
-- assert exactly the link nobody proved — the bug the column exists to remove.
|
|
26
|
+
-- Existing rows stay null, and a listing submitted by someone who never signs
|
|
27
|
+
-- in stays null for the life of the row: that is a legitimate resting state,
|
|
28
|
+
-- not a backlog to clean up.
|
|
29
|
+
--
|
|
30
|
+
-- The index backs the self-scoped read's whole WHERE clause
|
|
31
|
+
-- (`GET /v1/project-listings/me` — `owner_user_id = $1`, and nothing else: the
|
|
32
|
+
-- owner link IS the scope, so no site term joins it). Single-column by design —
|
|
33
|
+
-- the promotion match (`founder_email` + `site_id` + a null owner) is already
|
|
34
|
+
-- served by the existing (site_id, founder_email) index.
|
|
35
|
+
--
|
|
36
|
+
-- Forward-additive only: both columns are nullable with no default, so every
|
|
37
|
+
-- existing read and write is unaffected.
|
|
38
|
+
--
|
|
39
|
+
-- Rollback:
|
|
40
|
+
-- DROP INDEX "project_listings_owner_user_id_idx";
|
|
41
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "owner_linked_at";
|
|
42
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "owner_user_id";
|
|
43
|
+
|
|
44
|
+
-- AlterTable
|
|
45
|
+
ALTER TABLE "project_listings" ADD COLUMN "owner_user_id" TEXT,
|
|
46
|
+
ADD COLUMN "owner_linked_at" TIMESTAMP(3);
|
|
47
|
+
|
|
48
|
+
-- CreateIndex
|
|
49
|
+
CREATE INDEX "project_listings_owner_user_id_idx" ON "project_listings"("owner_user_id");
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
-- The founder's structured identity and PROVEN email address on
|
|
2
|
+
-- `project_listings` (#5407).
|
|
3
|
+
--
|
|
4
|
+
-- Step one of the application collected ONE `founder_name` line and an email.
|
|
5
|
+
-- samhenry spec 316 D1 asks for first name, last name, email and phone, with
|
|
6
|
+
-- the two name parts stored separately, and 316 D6b asks that an application
|
|
7
|
+
-- whose emailed resume link was opened be marked verified so the operator can
|
|
8
|
+
-- filter on it at review.
|
|
9
|
+
--
|
|
10
|
+
-- FIVE NULLABLE COLUMNS, NO BACKFILL, NO CHANGE TO ANY EXISTING ONE.
|
|
11
|
+
--
|
|
12
|
+
-- `founder_first_name` / `founder_last_name` sit BESIDE `founder_name`, not
|
|
13
|
+
-- instead of it — the #5279 precedent that put six address parts beside the
|
|
14
|
+
-- single-line `address`. `founder_name` stays NOT NULL and stays the DISPLAY
|
|
15
|
+
-- string every existing reader already reads (the submit-time slug fallback,
|
|
16
|
+
-- the approve/reject email's greeting, the Slack review card's Founder row,
|
|
17
|
+
-- the build issue, the `routeLead` identity); where the parts are written the
|
|
18
|
+
-- server COMPOSES it from them, and where they are not it is the line the
|
|
19
|
+
-- founder typed, verbatim.
|
|
20
|
+
--
|
|
21
|
+
-- Composition runs ONE WAY ONLY, and that is why nothing here backfills.
|
|
22
|
+
-- "First" + "Last" composes into a display line deterministically; a display
|
|
23
|
+
-- line does not decompose into parts. Splitting on the last space mangles
|
|
24
|
+
-- "van der Berg", "Maria del Carmen" and every mononym, and a wrong split is
|
|
25
|
+
-- silent — it would put an invented surname on the row the approve email
|
|
26
|
+
-- greets and the operator reviews, with nothing to mark it as invented. A
|
|
27
|
+
-- null part is honest; a guessed one is not. Every existing row therefore
|
|
28
|
+
-- keeps `founder_name` byte-for-byte and keeps both parts NULL for the life
|
|
29
|
+
-- of the row.
|
|
30
|
+
--
|
|
31
|
+
-- `founder_phone` is the contact detail spec 316 D1 adds. Nullable at rest
|
|
32
|
+
-- for the #5036 D1 reason — every field the submit payload requires must be
|
|
33
|
+
-- storable half-filled — and nullable permanently besides: a row written
|
|
34
|
+
-- before #5407 has none, and a site may declare the question optional through
|
|
35
|
+
-- its own listing config. It NEVER rides the public read; the public row
|
|
36
|
+
-- shape omits it beside `founder_email`.
|
|
37
|
+
--
|
|
38
|
+
-- `email_verify_nonce_hash` and `email_verified_at` are the verification pair,
|
|
39
|
+
-- and the first is what makes the second mean what it says.
|
|
40
|
+
--
|
|
41
|
+
-- The obvious design is to stamp verification when the emailed resume link is
|
|
42
|
+
-- opened, keyed on the resume token that link carries. That design does not
|
|
43
|
+
-- work, and the reason is worth stating here because the column name does not
|
|
44
|
+
-- carry it: `start` returns the resume token to WHOEVER POSTED THE FORM, in
|
|
45
|
+
-- its own 201 body. The emailed link is a second copy of a credential the
|
|
46
|
+
-- applicant already holds — so two HTTP calls (open a draft under a
|
|
47
|
+
-- stranger's address, take the token out of the response, present it back)
|
|
48
|
+
-- would forge the stamp with no mailbox involved. Caught by this PR's
|
|
49
|
+
-- security review before it shipped.
|
|
50
|
+
--
|
|
51
|
+
-- So verification requires a SECOND credential the applicant never receives
|
|
52
|
+
-- any other way. `email_verify_nonce_hash` holds the sha-256 digest of a
|
|
53
|
+
-- 256-bit nonce minted at `start`, handed to nothing except the resume-email
|
|
54
|
+
-- builder (apps/api/src/services/project-listing-resume-email.ts), and
|
|
55
|
+
-- present in no response body and no log line. It reaches exactly one place:
|
|
56
|
+
-- the `?verify=` parameter of the link inside the email. Holding it means
|
|
57
|
+
-- having read mail sent to `founder_email`, which is what the stamp claims.
|
|
58
|
+
--
|
|
59
|
+
-- `email_verified_at` is therefore genuine evidence of address control, and
|
|
60
|
+
-- is still WRITE-ONCE: the verify call sets it only where it is still NULL,
|
|
61
|
+
-- by compare-and-swap, so a founder who opens the link five times is stamped
|
|
62
|
+
-- with the first opening and two concurrent openings produce one stamp
|
|
63
|
+
-- between them.
|
|
64
|
+
--
|
|
65
|
+
-- WHAT IT STILL DOES NOT SAY is who the founder is. `owner_user_id`
|
|
66
|
+
-- (20260912120000_listing_owner_link) is the identity claim and needs a
|
|
67
|
+
-- signed-in user whose own address the auth system verified. A row can carry
|
|
68
|
+
-- either, both, or neither.
|
|
69
|
+
--
|
|
70
|
+
-- Neither is a backfill target. Stamping an existing row would assert exactly
|
|
71
|
+
-- the event nobody recorded. NULL means "not verified", never "verification
|
|
72
|
+
-- failed" — and it is also what a site that has not switched the resume email
|
|
73
|
+
-- on will read forever, because the nonce is minted there and simply never
|
|
74
|
+
-- delivered. That is the correct outcome, not a gap.
|
|
75
|
+
--
|
|
76
|
+
-- `email_verify_nonce_hash` is UNIQUE, mirroring `resume_token_hash`: two
|
|
77
|
+
-- rows can never share a nonce, and the digest lookup is indexed. It is
|
|
78
|
+
-- cleared at `submit` beside the resume-token digest, so a credential that
|
|
79
|
+
-- leaks after the draft stops being a draft is inert twice over. It has NO
|
|
80
|
+
-- EXPIRY otherwise, by design and for the reason the service records: the
|
|
81
|
+
-- same message carries the resume token, so a TTL on the nonce alone would
|
|
82
|
+
-- bound nothing.
|
|
83
|
+
--
|
|
84
|
+
-- OPERATIONAL NOTE on the index. `CREATE UNIQUE INDEX` below is not
|
|
85
|
+
-- `CONCURRENTLY`, so it takes a SHARE lock and blocks writes to
|
|
86
|
+
-- `project_listings` while it builds. Every existing row is NULL there and
|
|
87
|
+
-- Postgres treats NULLs as distinct in a unique index (no NULLS NOT
|
|
88
|
+
-- DISTINCT), so it cannot fail on existing data and builds fast — and the
|
|
89
|
+
-- same property is why `submit` setting many rows back to NULL never
|
|
90
|
+
-- collides. Stated so whoever runs this against a large table knows it is
|
|
91
|
+
-- write-blocking DDL; it matches the `resume_token_hash` precedent.
|
|
92
|
+
--
|
|
93
|
+
-- Distinct from `owner_user_id` (20260912120000_listing_owner_link), which
|
|
94
|
+
-- needs a SIGNED-IN user whose own address the auth system verified. This
|
|
95
|
+
-- column needs no account at all: it records that a capability sent to
|
|
96
|
+
-- `founder_email` came back. A row can carry either, both, or neither.
|
|
97
|
+
--
|
|
98
|
+
-- NO INDEX on the other four. The verify write is keyed by the row's primary
|
|
99
|
+
-- key plus two digests, both already UNIQUE, and the operator filters
|
|
100
|
+
-- verified applications inside a site's already-indexed page of listings
|
|
101
|
+
-- (`project_listings_site_id_status_idx`) rather than by scanning on
|
|
102
|
+
-- `email_verified_at`.
|
|
103
|
+
--
|
|
104
|
+
-- Forward-additive only: all five columns are nullable with no default, so
|
|
105
|
+
-- every existing read and write is unaffected.
|
|
106
|
+
--
|
|
107
|
+
-- Rollback:
|
|
108
|
+
-- DROP INDEX "project_listings_email_verify_nonce_hash_key";
|
|
109
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "email_verified_at";
|
|
110
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "email_verify_nonce_hash";
|
|
111
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "founder_phone";
|
|
112
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "founder_last_name";
|
|
113
|
+
-- ALTER TABLE "project_listings" DROP COLUMN "founder_first_name";
|
|
114
|
+
|
|
115
|
+
-- AlterTable
|
|
116
|
+
ALTER TABLE "project_listings" ADD COLUMN "founder_first_name" TEXT,
|
|
117
|
+
ADD COLUMN "founder_last_name" TEXT,
|
|
118
|
+
ADD COLUMN "founder_phone" TEXT,
|
|
119
|
+
ADD COLUMN "email_verify_nonce_hash" TEXT,
|
|
120
|
+
ADD COLUMN "email_verified_at" TIMESTAMP(3);
|
|
121
|
+
|
|
122
|
+
-- CreateIndex
|
|
123
|
+
CREATE UNIQUE INDEX "project_listings_email_verify_nonce_hash_key" ON "project_listings"("email_verify_nonce_hash");
|
|
File without changes
|