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,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file lead-promotion.ts
|
|
3
|
-
* @description Lead → user promotion across the inbox/auth namespace boundary
|
|
4
|
-
* (ADR 0009 D3 service-layer pattern; spec
|
|
5
|
-
* docs/specs/3586-lead-user-promotion-spec.md).
|
|
6
|
-
*
|
|
7
|
-
* Reconciles an anonymous inbox.Lead with an auth.User by stable email, once
|
|
8
|
-
* the reconciling user's own email is verified (D2 — the consent basis for
|
|
9
|
-
* linking anonymous-lead PII to a user) and only within that user's own
|
|
10
|
-
* tenant workspaces (D3 — the tenant-safety invariant). Workspace resolution
|
|
11
|
-
* is delegated to tenant-context.ts's `listTenancy` — the sole cross-namespace
|
|
12
|
-
* tenancy assembler — so this file never issues a raw membership or workspace
|
|
13
|
-
* model query of its own (architecture.yaml's tenant-scope forbidden pattern
|
|
14
|
-
* reserves those to tenant-context.ts / tenant-db.ts). That delegation is
|
|
15
|
-
* module-conditional as of #5182; see the note above TENANT_CONTEXT_SPECIFIER.
|
|
16
|
-
*
|
|
17
|
-
* Mirrors lead-routing.ts's shape: discriminated `{ ok } | { ok: false, error }`
|
|
18
|
-
* result, no throw on the expected (no-op) paths, PII-safe (never logs email /
|
|
19
|
-
* payload / name / phone).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type { Id } from "@working-theory/ids";
|
|
23
|
-
|
|
24
|
-
import { isModuleEnabled } from "../config/modules.js";
|
|
25
|
-
import { prisma } from "../lib/prisma.js";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* `tenant-context.ts` is a multi-tenant SLICE file the OSS strip removes
|
|
29
|
-
* (#5182), and `listTenancy` is a VALUE — a runtime call, not an erasable type
|
|
30
|
-
* — so neither a static import nor a literal `await import("./tenant-context.js")`
|
|
31
|
-
* survives the strip: `tsc` resolves a string-literal specifier whether or not
|
|
32
|
-
* the branch containing it can ever run, and either form is TS2307 on the
|
|
33
|
-
* public tree (spec D1).
|
|
34
|
-
*
|
|
35
|
-
* The remedy is the one `server.ts` and `routes/content/index.ts` already use:
|
|
36
|
-
* gate the call on `isModuleEnabled('multi-tenant')` and read the specifier
|
|
37
|
-
* from this constant so it is not a literal written at the call site. What
|
|
38
|
-
* defeats resolution is the INDIRECTION, not the type of the value.
|
|
39
|
-
*
|
|
40
|
-
* `apps/api` is a `tsc`-only surface with no bundler, which is the precondition
|
|
41
|
-
* that makes this mechanism sound here — it is NOT sound in `packages/ui`,
|
|
42
|
-
* where webpack compiles a non-analysable `import()` to an empty context.
|
|
43
|
-
*
|
|
44
|
-
* Semantics on a build without the module: there is no workspace concept at
|
|
45
|
-
* all, so a user has no workspaces, so no lead can match `siteId ∈ workspaceIds`
|
|
46
|
-
* — which is exactly the zero-workspace early return below. The no-op is
|
|
47
|
-
* correct rather than degraded.
|
|
48
|
-
*/
|
|
49
|
-
const TENANT_CONTEXT_SPECIFIER = "./tenant-context.js";
|
|
50
|
-
|
|
51
|
-
/** The one export this file needs from the multi-tenant slice. */
|
|
52
|
-
interface TenantContextSlice {
|
|
53
|
-
listTenancy: (params: {
|
|
54
|
-
userId: Id<"user">;
|
|
55
|
-
sessionTenant: undefined;
|
|
56
|
-
}) => Promise<
|
|
57
|
-
| {
|
|
58
|
-
ok: true;
|
|
59
|
-
value: { organizations: Array<{ workspaces: Array<{ id: string }> }> };
|
|
60
|
-
}
|
|
61
|
-
| { ok: false; error: { message: string } }
|
|
62
|
-
>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
export type PromotedLead = {
|
|
68
|
-
leadId: string;
|
|
69
|
-
siteId: string;
|
|
70
|
-
source: string;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export type PromoteLeadsResult =
|
|
74
|
-
| { ok: true; count: number; promoted: PromotedLead[] }
|
|
75
|
-
| {
|
|
76
|
-
ok: false;
|
|
77
|
-
error: { kind: "user_not_found" | "internal_error"; message: string };
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Promote every un-promoted `inbox.Lead` matching the verifying user's own
|
|
84
|
-
* email, within that user's own tenant workspaces (D1/D3). No-ops
|
|
85
|
-
* (`count: 0`) — never throws — when: the user's email is unverified (D2),
|
|
86
|
-
* the user belongs to no workspace, or no matching un-promoted lead exists.
|
|
87
|
-
* Idempotent: a lead already carrying `promotedUserId` is excluded from the
|
|
88
|
-
* match set, so re-invocation promotes nothing (AC-6).
|
|
89
|
-
*/
|
|
90
|
-
export async function promoteLeadsForVerifiedUser(params: {
|
|
91
|
-
userId: string;
|
|
92
|
-
}): Promise<PromoteLeadsResult> {
|
|
93
|
-
const { userId } = params;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const user = await prisma.user.findUnique({
|
|
97
|
-
where: { id: userId },
|
|
98
|
-
select: { id: true, email: true, emailVerified: true },
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
if (!user) {
|
|
102
|
-
return {
|
|
103
|
-
ok: false,
|
|
104
|
-
error: { kind: "user_not_found", message: "User not found" },
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// D2 — the verified-email gate is a service-level invariant, not a
|
|
109
|
-
// caller obligation: it cannot be bypassed by a wrong call-site.
|
|
110
|
-
if (!user.emailVerified) {
|
|
111
|
-
return { ok: true, count: 0, promoted: [] };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// D3/D4 — resolve the verifying user's OWN workspaces via the sole
|
|
115
|
-
// tenant-context assembler (never a raw membership or workspace model
|
|
116
|
-
// query in this file). Module-conditional per the note on
|
|
117
|
-
// TENANT_CONTEXT_SPECIFIER above: no multi-tenant module, no workspaces.
|
|
118
|
-
let workspaceIds: string[] = [];
|
|
119
|
-
if (isModuleEnabled("multi-tenant")) {
|
|
120
|
-
const specifier: string = TENANT_CONTEXT_SPECIFIER;
|
|
121
|
-
const { listTenancy } = (await import(specifier)) as TenantContextSlice;
|
|
122
|
-
const tenancy = await listTenancy({
|
|
123
|
-
userId: user.id as Id<"user">,
|
|
124
|
-
sessionTenant: undefined,
|
|
125
|
-
});
|
|
126
|
-
if (!tenancy.ok) {
|
|
127
|
-
return {
|
|
128
|
-
ok: false,
|
|
129
|
-
error: { kind: "internal_error", message: tenancy.error.message },
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
workspaceIds = tenancy.value.organizations.flatMap((org) =>
|
|
134
|
-
org.workspaces.map((workspace) => workspace.id as string),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (workspaceIds.length === 0) {
|
|
139
|
-
return { ok: true, count: 0, promoted: [] };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// D3/AC-4 (tenant-safety — load-bearing): the match set is siteId ∈ the
|
|
143
|
-
// user's OWN workspaceIds, so a lead captured under a different
|
|
144
|
-
// organization's workspace is never matched, by construction.
|
|
145
|
-
const candidates = await prisma.lead.findMany({
|
|
146
|
-
where: {
|
|
147
|
-
email: user.email,
|
|
148
|
-
siteId: { in: workspaceIds },
|
|
149
|
-
promotedUserId: null,
|
|
150
|
-
},
|
|
151
|
-
select: { id: true, siteId: true, source: true },
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
if (candidates.length === 0) {
|
|
155
|
-
return { ok: true, count: 0, promoted: [] };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
await prisma.lead.updateMany({
|
|
159
|
-
where: { id: { in: candidates.map((lead) => lead.id) } },
|
|
160
|
-
data: { promotedUserId: user.id, promotedAt: new Date() },
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
ok: true,
|
|
165
|
-
count: candidates.length,
|
|
166
|
-
promoted: candidates.map((lead) => ({
|
|
167
|
-
leadId: lead.id,
|
|
168
|
-
siteId: lead.siteId,
|
|
169
|
-
source: lead.source,
|
|
170
|
-
})),
|
|
171
|
-
};
|
|
172
|
-
} catch (err) {
|
|
173
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
174
|
-
return { ok: false, error: { kind: "internal_error", message } };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
@@ -1,455 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file listing-csv-sweep.ts
|
|
3
|
-
* @description The weekly founder-CSV sweep (#5001) — the founder's list
|
|
4
|
-
* arrives without a login.
|
|
5
|
-
*
|
|
6
|
-
* `runListingCsvSweep` is the one entry point: for each `live`
|
|
7
|
-
* `ProjectListing`, select the `WaitlistScore` rows for its experiment that
|
|
8
|
-
* landed AFTER the last recorded send, join each to its completed
|
|
9
|
-
* `FlowSession`'s answers, render one CSV, store it as a `DeliverableAsset`,
|
|
10
|
-
* grant it to the founder's own `Lead`, and email the capability-token
|
|
11
|
-
* download link with `sendDeliverableEmail` — the same store → grant → link
|
|
12
|
-
* path `deliverable-fulfillment.ts` (#3961 D5) uses for a flow's promised
|
|
13
|
-
* asset, so the founder's list is served by `GET /v1/deliverables/:grantId`
|
|
14
|
-
* with no account, no session, and no second download surface.
|
|
15
|
-
*
|
|
16
|
-
* Invoked by the CLI entry (`src/bin/listing-csv-sweep.ts`) on an
|
|
17
|
-
* operator-supplied cadence — this service is stateless and has no timer of
|
|
18
|
-
* its own (the `abandon-email.ts` #3962 D1 / `waitlist-followup.ts` #4732 D1
|
|
19
|
-
* posture, unchanged).
|
|
20
|
-
*
|
|
21
|
-
* Two properties the data carries, not just the control flow:
|
|
22
|
-
* - NO NEW ROWS ⇒ NO SEND. A listing whose experiment produced no score
|
|
23
|
-
* since its last `ListingCsvSend` writes nothing and sends nothing, so a
|
|
24
|
-
* second run in the same week is a no-op rather than a duplicate email.
|
|
25
|
-
* - The stamp is written only AFTER a successful send. A send failure
|
|
26
|
-
* leaves the watermark where it was, so next week's CSV still carries
|
|
27
|
-
* those rows rather than losing them to a Resend hiccup.
|
|
28
|
-
*
|
|
29
|
-
* Per-listing failures are caught, logged, and skipped — one listing's bad
|
|
30
|
-
* data or failed send never stops the sweep for the listings after it.
|
|
31
|
-
*
|
|
32
|
-
* PII boundary: logs carry only `{ siteId, listingId, slug, outcome }` —
|
|
33
|
-
* never `founderEmail`, a signup's email/name, or any answer value (the same
|
|
34
|
-
* discipline `waitlist-followup.ts` and `lead-routing.ts` document). The CSV
|
|
35
|
-
* itself carries signup PII by design; it reaches the founder through the
|
|
36
|
-
* opaque grant id alone, never through a log line or a link parameter.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import { sendDeliverableEmail } from "@working-theory/email";
|
|
40
|
-
import { logger } from "@working-theory/observability";
|
|
41
|
-
import { projectListingQuestionsSchema } from "@working-theory/validation";
|
|
42
|
-
|
|
43
|
-
import { prisma } from "../lib/prisma.js";
|
|
44
|
-
|
|
45
|
-
import { listingAnswerLabels } from "./project-listing-flow.js";
|
|
46
|
-
|
|
47
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
export type ListingCsvSweepSummary = {
|
|
50
|
-
scanned: number;
|
|
51
|
-
sent: number;
|
|
52
|
-
skipped: number;
|
|
53
|
-
failed: number;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export type ListingCsvSweepError = {
|
|
57
|
-
kind: "flows_not_configured" | "unexpected_error";
|
|
58
|
-
message: string;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export type ListingCsvSweepResult =
|
|
62
|
-
| { ok: true; summary: ListingCsvSweepSummary }
|
|
63
|
-
| { ok: false; error: ListingCsvSweepError };
|
|
64
|
-
|
|
65
|
-
export type RunListingCsvSweepOptions = {
|
|
66
|
-
siteId?: string;
|
|
67
|
-
now?: Date;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
type ListingRow = {
|
|
71
|
-
id: string;
|
|
72
|
-
siteId: string;
|
|
73
|
-
// #5036 — nullable at rest: the Lead is written at submit, and every column
|
|
74
|
-
// an application fills in is nullable so a half-filled draft is storable.
|
|
75
|
-
// This sweep only ever selects `active` rows, which reached that state
|
|
76
|
-
// through `submit` and therefore carry all four — the guards below discharge
|
|
77
|
-
// the types rather than describing a reachable state.
|
|
78
|
-
leadId: string | null;
|
|
79
|
-
slug: string | null;
|
|
80
|
-
name: string | null;
|
|
81
|
-
founderEmail: string;
|
|
82
|
-
experimentKey: string | null;
|
|
83
|
-
questions: unknown;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
type ScoreRow = { leadId: string; tier: string; createdAt: Date };
|
|
87
|
-
type SessionRow = { leadId: string | null; email: string | null; state: unknown };
|
|
88
|
-
|
|
89
|
-
// ─── DB-required guard (mirrors waitlist-followup.ts's requireDb) ─────────────
|
|
90
|
-
|
|
91
|
-
function requireDb(): ListingCsvSweepError | null {
|
|
92
|
-
if (!process.env.DATABASE_URL) {
|
|
93
|
-
return { kind: "flows_not_configured", message: "Flows are not configured." };
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ─── PII-safe outcome logging ────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
type ListingOutcome =
|
|
101
|
-
| "sent"
|
|
102
|
-
| "failed"
|
|
103
|
-
| "no_experiment"
|
|
104
|
-
| "no_new_signups"
|
|
105
|
-
| "invalid_questions"
|
|
106
|
-
| "incomplete_listing"
|
|
107
|
-
| "sender_unconfigured";
|
|
108
|
-
|
|
109
|
-
const OUTCOME_MESSAGE: Record<ListingOutcome, string> = {
|
|
110
|
-
sent: "[listing-csv-sweep] listing selected — CSV sent",
|
|
111
|
-
failed: "[listing-csv-sweep] listing selected — send failed (unstamped, retried next run)",
|
|
112
|
-
no_experiment: "[listing-csv-sweep] listing skipped — no experiment generated yet",
|
|
113
|
-
no_new_signups: "[listing-csv-sweep] listing skipped — no signups since the last send",
|
|
114
|
-
invalid_questions:
|
|
115
|
-
"[listing-csv-sweep] listing skipped — stored questions failed schema validation",
|
|
116
|
-
incomplete_listing:
|
|
117
|
-
"[listing-csv-sweep] listing skipped — an active listing is missing submit-time fields",
|
|
118
|
-
sender_unconfigured:
|
|
119
|
-
"[listing-csv-sweep] listing skipped (unstamped, retried next run) — no from-address configured",
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
function logOutcome(listing: ListingRow, outcome: ListingOutcome): void {
|
|
123
|
-
logger.info(
|
|
124
|
-
{ siteId: listing.siteId, listingId: listing.id, slug: listing.slug, outcome },
|
|
125
|
-
OUTCOME_MESSAGE[outcome],
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ─── CSV rendering ───────────────────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
/** The column order the founder's weekly CSV always carries (#5001). */
|
|
132
|
-
export const LISTING_CSV_COLUMNS = [
|
|
133
|
-
"joined_at",
|
|
134
|
-
"email",
|
|
135
|
-
"name",
|
|
136
|
-
"tier",
|
|
137
|
-
"segment",
|
|
138
|
-
"q3",
|
|
139
|
-
"q4",
|
|
140
|
-
"q5",
|
|
141
|
-
"q6",
|
|
142
|
-
"q7",
|
|
143
|
-
"extra_1",
|
|
144
|
-
"extra_2",
|
|
145
|
-
"extra_3",
|
|
146
|
-
] as const;
|
|
147
|
-
|
|
148
|
-
/** Leading characters a spreadsheet reads as the start of a formula. */
|
|
149
|
-
const FORMULA_LEAD = /^[=+\-@\t\r]/;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* One RFC-4180 field. A value a spreadsheet would evaluate as a formula is
|
|
153
|
-
* prefixed with an apostrophe first — this CSV is emailed to a founder and
|
|
154
|
-
* opened in Excel/Sheets, and the free-text Q7 column is visitor-supplied.
|
|
155
|
-
*/
|
|
156
|
-
function csvField(value: string): string {
|
|
157
|
-
const guarded = FORMULA_LEAD.test(value) ? `'${value}` : value;
|
|
158
|
-
return /[",\r\n]/.test(guarded) ? `"${guarded.replace(/"/g, '""')}"` : guarded;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function csvLine(cells: readonly string[]): string {
|
|
162
|
-
return cells.map(csvField).join(",");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** A session's persisted answer for one field, as a plain string. */
|
|
166
|
-
function answerOf(state: Record<string, unknown>, field: string): string {
|
|
167
|
-
const raw = state[field];
|
|
168
|
-
return typeof raw === "string" ? raw : "";
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ─── The sweep ───────────────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Build the CSV body for one listing's new signups. Scored/segment answers
|
|
175
|
-
* are rendered as the founder's OWN option wording (the label) rather than
|
|
176
|
-
* the stored option value — since #5036 D4 that value IS the founder's own
|
|
177
|
-
* (`some-slug`), but a slug is still not what a founder reading a
|
|
178
|
-
* spreadsheet wants to see. An unmapped value falls through verbatim rather
|
|
179
|
-
* than becoming blank.
|
|
180
|
-
*/
|
|
181
|
-
function renderCsv(
|
|
182
|
-
scores: readonly ScoreRow[],
|
|
183
|
-
sessionsByLead: Map<string, SessionRow>,
|
|
184
|
-
labels: Record<string, Record<string, string>>,
|
|
185
|
-
): string {
|
|
186
|
-
const lines = [csvLine(LISTING_CSV_COLUMNS)];
|
|
187
|
-
|
|
188
|
-
for (const score of scores) {
|
|
189
|
-
const session = sessionsByLead.get(score.leadId);
|
|
190
|
-
const state =
|
|
191
|
-
session && typeof session.state === "object" && session.state !== null
|
|
192
|
-
? (session.state as Record<string, unknown>)
|
|
193
|
-
: {};
|
|
194
|
-
|
|
195
|
-
const labelled = (field: string): string => {
|
|
196
|
-
const value = answerOf(state, field);
|
|
197
|
-
if (value === "") return "";
|
|
198
|
-
return labels[field]?.[value] ?? value;
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
lines.push(
|
|
202
|
-
csvLine([
|
|
203
|
-
score.createdAt.toISOString(),
|
|
204
|
-
session?.email ?? answerOf(state, "email"),
|
|
205
|
-
answerOf(state, "name"),
|
|
206
|
-
score.tier,
|
|
207
|
-
labelled("q2"),
|
|
208
|
-
labelled("q3"),
|
|
209
|
-
labelled("q4"),
|
|
210
|
-
labelled("q5"),
|
|
211
|
-
labelled("q6"),
|
|
212
|
-
answerOf(state, "q7"),
|
|
213
|
-
labelled("extra_1"),
|
|
214
|
-
labelled("extra_2"),
|
|
215
|
-
labelled("extra_3"),
|
|
216
|
-
]),
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return `${lines.join("\n")}\n`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* The unguarded sweep body — mutates `summary` in place as it goes, so a
|
|
225
|
-
* mid-loop throw still leaves the caller's catch able to log how far it got
|
|
226
|
-
* (mirrors `waitlist-followup.ts`'s own posture).
|
|
227
|
-
*/
|
|
228
|
-
async function sweepAllListings(
|
|
229
|
-
opts: RunListingCsvSweepOptions,
|
|
230
|
-
now: Date,
|
|
231
|
-
fromAddress: string | undefined,
|
|
232
|
-
summary: ListingCsvSweepSummary,
|
|
233
|
-
): Promise<void> {
|
|
234
|
-
const listings = (await prisma.projectListing.findMany({
|
|
235
|
-
// #5033 — `live` and `approved` collapsed into the single stored `active`
|
|
236
|
-
// state, and `closed` became COMPUTED rather than stored. The stored
|
|
237
|
-
// status alone is no longer the whole filter: a listing whose window has
|
|
238
|
-
// elapsed reads `closed` everywhere else, and this sweep mails the
|
|
239
|
-
// founder a CSV of their signups' answers, so it must stop at the same
|
|
240
|
-
// moment the public read does. Without the `windowEndsAt` predicate an
|
|
241
|
-
// expired listing would keep exporting signup data every run, forever —
|
|
242
|
-
// and the migration rewrites every historically `closed` row to `active`
|
|
243
|
-
// with a past window, so the first run after deploy would re-enrol all of
|
|
244
|
-
// them.
|
|
245
|
-
//
|
|
246
|
-
// #5036 D6b narrowed the null-window branch: a FEATURED row has no
|
|
247
|
-
// meaningful window and never closes, so it stays in scope; a NON-featured
|
|
248
|
-
// active row with no window is a data error `computeListingStatus` reads
|
|
249
|
-
// as `closed`, and this filter must agree with it rather than keep mailing
|
|
250
|
-
// a founder's signup data out of a row nothing bounds.
|
|
251
|
-
where: {
|
|
252
|
-
status: "active",
|
|
253
|
-
OR: [{ featured: true }, { windowEndsAt: { gt: now } }],
|
|
254
|
-
...(opts.siteId !== undefined && { siteId: opts.siteId }),
|
|
255
|
-
},
|
|
256
|
-
select: {
|
|
257
|
-
id: true,
|
|
258
|
-
siteId: true,
|
|
259
|
-
leadId: true,
|
|
260
|
-
slug: true,
|
|
261
|
-
name: true,
|
|
262
|
-
founderEmail: true,
|
|
263
|
-
experimentKey: true,
|
|
264
|
-
questions: true,
|
|
265
|
-
},
|
|
266
|
-
})) as ListingRow[];
|
|
267
|
-
|
|
268
|
-
const day = now.toISOString().slice(0, 10);
|
|
269
|
-
|
|
270
|
-
for (const listing of listings) {
|
|
271
|
-
summary.scanned += 1;
|
|
272
|
-
try {
|
|
273
|
-
const outcome = await sweepOneListing(listing, now, day, fromAddress);
|
|
274
|
-
if (outcome === "sent") summary.sent += 1;
|
|
275
|
-
else if (outcome === "failed") summary.failed += 1;
|
|
276
|
-
else summary.skipped += 1;
|
|
277
|
-
} catch (err) {
|
|
278
|
-
// One listing's bad row, malformed session state, or DB hiccup must not
|
|
279
|
-
// stop the listings after it — the whole point of a sweep over N
|
|
280
|
-
// independent founders.
|
|
281
|
-
summary.failed += 1;
|
|
282
|
-
logger.error(
|
|
283
|
-
{
|
|
284
|
-
siteId: listing.siteId,
|
|
285
|
-
listingId: listing.id,
|
|
286
|
-
slug: listing.slug,
|
|
287
|
-
errKind: err instanceof Error ? err.constructor.name : typeof err,
|
|
288
|
-
},
|
|
289
|
-
"[listing-csv-sweep] listing failed — continuing with the next listing",
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function sweepOneListing(
|
|
296
|
-
listing: ListingRow,
|
|
297
|
-
now: Date,
|
|
298
|
-
day: string,
|
|
299
|
-
fromAddress: string | undefined,
|
|
300
|
-
): Promise<ListingOutcome> {
|
|
301
|
-
// #5036 D6d — the listing stores the experiment's KEY, which is the same
|
|
302
|
-
// string `WaitlistScore.experimentKey` holds. The `WaitlistExperiment` read
|
|
303
|
-
// this used to make purely to translate an id into that key is gone.
|
|
304
|
-
const experimentKey = listing.experimentKey;
|
|
305
|
-
if (!experimentKey) {
|
|
306
|
-
logOutcome(listing, "no_experiment");
|
|
307
|
-
return "no_experiment";
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (listing.leadId === null || listing.slug === null || listing.name === null) {
|
|
311
|
-
logOutcome(listing, "incomplete_listing");
|
|
312
|
-
return "incomplete_listing";
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const questions = projectListingQuestionsSchema.safeParse(listing.questions);
|
|
316
|
-
if (!questions.success) {
|
|
317
|
-
logOutcome(listing, "invalid_questions");
|
|
318
|
-
return "invalid_questions";
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const lastSend = (await prisma.listingCsvSend.findFirst({
|
|
322
|
-
where: { listingId: listing.id },
|
|
323
|
-
orderBy: { sentAt: "desc" },
|
|
324
|
-
select: { sentAt: true },
|
|
325
|
-
})) as { sentAt: Date } | null;
|
|
326
|
-
|
|
327
|
-
const scores = (await prisma.waitlistScore.findMany({
|
|
328
|
-
where: {
|
|
329
|
-
siteId: listing.siteId,
|
|
330
|
-
experimentKey,
|
|
331
|
-
...(lastSend && { createdAt: { gt: lastSend.sentAt } }),
|
|
332
|
-
},
|
|
333
|
-
select: { leadId: true, tier: true, createdAt: true },
|
|
334
|
-
orderBy: { createdAt: "asc" },
|
|
335
|
-
})) as ScoreRow[];
|
|
336
|
-
|
|
337
|
-
if (scores.length === 0) {
|
|
338
|
-
logOutcome(listing, "no_new_signups");
|
|
339
|
-
return "no_new_signups";
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (!fromAddress) {
|
|
343
|
-
// An env gap must not consume the week's send: nothing is stamped, so a
|
|
344
|
-
// later configured run still delivers these rows (the `waitlist-followup.ts`
|
|
345
|
-
// D4 posture).
|
|
346
|
-
logOutcome(listing, "sender_unconfigured");
|
|
347
|
-
return "sender_unconfigured";
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const sessions = (await prisma.flowSession.findMany({
|
|
351
|
-
where: {
|
|
352
|
-
siteId: listing.siteId,
|
|
353
|
-
flowSlug: listing.slug,
|
|
354
|
-
completedAt: { not: null },
|
|
355
|
-
leadId: { in: scores.map((s) => s.leadId) },
|
|
356
|
-
},
|
|
357
|
-
select: { leadId: true, email: true, state: true },
|
|
358
|
-
})) as SessionRow[];
|
|
359
|
-
const sessionsByLead = new Map<string, SessionRow>();
|
|
360
|
-
for (const session of sessions) {
|
|
361
|
-
if (session.leadId) sessionsByLead.set(session.leadId, session);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const csv = renderCsv(scores, sessionsByLead, listingAnswerLabels(questions.data));
|
|
365
|
-
const bytes = Buffer.from(csv, "utf8");
|
|
366
|
-
const key = `listing-${listing.slug}-${day}`;
|
|
367
|
-
const filename = `${listing.slug}-waitlist-${day}.csv`;
|
|
368
|
-
|
|
369
|
-
const asset = (await prisma.deliverableAsset.upsert({
|
|
370
|
-
where: { siteId_key: { siteId: listing.siteId, key } },
|
|
371
|
-
create: {
|
|
372
|
-
siteId: listing.siteId,
|
|
373
|
-
key,
|
|
374
|
-
filename,
|
|
375
|
-
contentType: "text/csv",
|
|
376
|
-
bytes,
|
|
377
|
-
size: bytes.byteLength,
|
|
378
|
-
},
|
|
379
|
-
update: { filename, contentType: "text/csv", bytes, size: bytes.byteLength },
|
|
380
|
-
select: { id: true },
|
|
381
|
-
})) as { id: string };
|
|
382
|
-
|
|
383
|
-
// The grant IS the token (#3961 D5). `sessionId` records the listing this
|
|
384
|
-
// CSV was cut for — a plain-scalar provenance ref (ADR 0009 invariant 1),
|
|
385
|
-
// the same posture `assetId`/`leadId` beside it already take; this grant is
|
|
386
|
-
// not born of a flow walk, so there is no FlowSession to name.
|
|
387
|
-
const grant = (await prisma.deliverableGrant.create({
|
|
388
|
-
data: {
|
|
389
|
-
siteId: listing.siteId,
|
|
390
|
-
assetId: asset.id,
|
|
391
|
-
leadId: listing.leadId,
|
|
392
|
-
sessionId: listing.id,
|
|
393
|
-
},
|
|
394
|
-
select: { id: true },
|
|
395
|
-
})) as { id: string };
|
|
396
|
-
|
|
397
|
-
// The same link expression `deliverable-fulfillment.ts` builds — the opaque
|
|
398
|
-
// grant id and nothing else. No PII in the URL.
|
|
399
|
-
const base = process.env.AILK_PUBLIC_API_URL ?? "";
|
|
400
|
-
const downloadLink = `${base}/v1/deliverables/${grant.id}`;
|
|
401
|
-
|
|
402
|
-
const sendResult = await sendDeliverableEmail({
|
|
403
|
-
to: listing.founderEmail,
|
|
404
|
-
fromAddress,
|
|
405
|
-
subject: `Your ${listing.name} waitlist, week of ${day}`,
|
|
406
|
-
body: `${scores.length} new ${scores.length === 1 ? "signup" : "signups"} joined the ${listing.name} waitlist this week. Your CSV is ready to download.`,
|
|
407
|
-
cta: "Download this week's CSV",
|
|
408
|
-
downloadLink,
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
if (!sendResult.ok) {
|
|
412
|
-
// UNSTAMPED on purpose — the watermark stays put so these rows are in
|
|
413
|
-
// next week's CSV rather than lost.
|
|
414
|
-
logOutcome(listing, "failed");
|
|
415
|
-
return "failed";
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
await prisma.listingCsvSend.create({
|
|
419
|
-
data: {
|
|
420
|
-
listingId: listing.id,
|
|
421
|
-
sentAt: now,
|
|
422
|
-
rowCount: scores.length,
|
|
423
|
-
grantId: grant.id,
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
logOutcome(listing, "sent");
|
|
428
|
-
return "sent";
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export async function runListingCsvSweep(
|
|
432
|
-
opts: RunListingCsvSweepOptions = {},
|
|
433
|
-
): Promise<ListingCsvSweepResult> {
|
|
434
|
-
const dbError = requireDb();
|
|
435
|
-
if (dbError) return { ok: false, error: dbError };
|
|
436
|
-
|
|
437
|
-
const now = opts.now ?? new Date();
|
|
438
|
-
const fromAddress =
|
|
439
|
-
process.env.AILK_DELIVERABLE_EMAIL_FROM ?? process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
440
|
-
|
|
441
|
-
const summary: ListingCsvSweepSummary = { scanned: 0, sent: 0, skipped: 0, failed: 0 };
|
|
442
|
-
|
|
443
|
-
try {
|
|
444
|
-
await sweepAllListings(opts, now, fromAddress, summary);
|
|
445
|
-
} catch (err) {
|
|
446
|
-
const message = err instanceof Error ? err.message : "Unknown error";
|
|
447
|
-
logger.error(
|
|
448
|
-
{ partialSummary: summary, err: message },
|
|
449
|
-
"[listing-csv-sweep] sweep failed mid-run",
|
|
450
|
-
);
|
|
451
|
-
return { ok: false, error: { kind: "unexpected_error", message } };
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
return { ok: true, summary };
|
|
455
|
-
}
|