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,947 +0,0 @@
|
|
|
1
|
-
// oss: false
|
|
2
|
-
/**
|
|
3
|
-
* @file waitlist-dashboard.ts
|
|
4
|
-
* @description The operator-dashboard READ service (#4665 D2/D3/D4/D5, B0).
|
|
5
|
-
*
|
|
6
|
-
* Read-only (`findMany` / `findFirst` / `findUnique` only — no write verb is
|
|
7
|
-
* reachable from this module, asserted by
|
|
8
|
-
* `__tests__/waitlist-dashboard.test.ts` and spec §5.2 AC4's
|
|
9
|
-
* `grep -cE "prisma\.[a-zA-Z]+\.(create|update|upsert|delete|deleteMany|updateMany|createMany)"`
|
|
10
|
-
* probe). Every query is scoped: `{ siteId }` for `kind: "site"`, nothing
|
|
11
|
-
* extra for `kind: "all"` (D3) — `scopeWhere` is the single place that
|
|
12
|
-
* predicate is expressed, so a query missing it is a code-review-visible
|
|
13
|
-
* omission rather than a silent gap.
|
|
14
|
-
*
|
|
15
|
-
* Experiments are addressed by `id` (`wlexp_…`) — never `key`, which is
|
|
16
|
-
* unique only per `(siteId, key)` and collides under the `all` scope (D3). A
|
|
17
|
-
* lead is loaded THROUGH its experiment — `findFirst({ id: leadId, siteId:
|
|
18
|
-
* experiment.siteId, payload: { path: ["experimentKey"], equals:
|
|
19
|
-
* experiment.key } })` — so a foreign or unrelated lead is `not_found`,
|
|
20
|
-
* indistinguishable from a missing one (the `tenant.ts` no-enumeration
|
|
21
|
-
* posture).
|
|
22
|
-
*
|
|
23
|
-
* Aggregation runs over ONE scoped `Lead` read (`select: { id, siteId,
|
|
24
|
-
* email, source, payload, createdAt }`) and ONE scoped `WaitlistScore` read
|
|
25
|
-
* per experiment (D4) — never a query per question or per source; `list`
|
|
26
|
-
* runs the same pair ONCE across every returned experiment, grouped in
|
|
27
|
-
* memory by `${siteId}:${experimentKey}`. For the recommending experiments
|
|
28
|
-
* in the list it adds ONE scoped `FlowSession` read (completed sessions on
|
|
29
|
-
* their flow slugs — `recommended` is recomputed from each session's state
|
|
30
|
-
* with the pure `recommend()`, #4839 D1) and ONE scoped `FlowCheckout` read
|
|
31
|
-
* (rows at `status: "completed"`, #4839 D3); `saved` comes from the Lead
|
|
32
|
-
* read already in hand (#4839 D2). Neither read runs when no recommending
|
|
33
|
-
* experiment is in scope.
|
|
34
|
-
*
|
|
35
|
-
* Lives in `apps/api/src/services/` (ADR 0009 D3) — it assembles across the
|
|
36
|
-
* `auth` `TenantContext` (`tenant-context.ts`) and the `inbox` rows
|
|
37
|
-
* (`WaitlistExperiment` / `WaitlistScore` / `Lead`).
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import type { Prisma } from "@working-theory/database";
|
|
41
|
-
import { isSentinel } from "@working-theory/ids";
|
|
42
|
-
import { logger } from "@working-theory/observability";
|
|
43
|
-
import {
|
|
44
|
-
recommend,
|
|
45
|
-
waitlistExperimentConfigSchema,
|
|
46
|
-
type WaitlistAnswers,
|
|
47
|
-
type WaitlistExperimentConfig,
|
|
48
|
-
type WaitlistExperimentOfferCounts,
|
|
49
|
-
type WaitlistDashboardList,
|
|
50
|
-
type WaitlistExperimentComparison,
|
|
51
|
-
type WaitlistExperimentSummary,
|
|
52
|
-
type WaitlistMetricRow,
|
|
53
|
-
type WaitlistSignupDetail,
|
|
54
|
-
type WaitlistSignupList,
|
|
55
|
-
} from "@working-theory/validation";
|
|
56
|
-
|
|
57
|
-
import { prisma } from "../lib/prisma.js";
|
|
58
|
-
|
|
59
|
-
import {
|
|
60
|
-
computeAnswerDistributions,
|
|
61
|
-
logAggregationCapHit,
|
|
62
|
-
MAX_AGGREGATION_ROWS,
|
|
63
|
-
readPayloadAnswer,
|
|
64
|
-
} from "./answer-distributions.js";
|
|
65
|
-
import type { MembershipRole, TenantContext, TenantResult } from "./tenant-context.js";
|
|
66
|
-
|
|
67
|
-
// ─── Scope (D2) ──────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
export type WaitlistReadScope =
|
|
70
|
-
| { kind: "site"; siteId: string }
|
|
71
|
-
| { kind: "all" };
|
|
72
|
-
|
|
73
|
-
/** Reads require `owner` or `admin` in the active organization (D2) — the dashboard exposes signup PII. */
|
|
74
|
-
export const WAITLIST_DASHBOARD_ROLES: ReadonlySet<MembershipRole> = new Set<
|
|
75
|
-
MembershipRole
|
|
76
|
-
>(["owner", "admin"]);
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The platform operator is an `owner`-role member of the sentinel default
|
|
80
|
-
* Organization (D2) — exactly the identity `bindOperatorMembership` mints
|
|
81
|
-
* for a single-tenant deployment, and, on a hosted multi-tenant deployment,
|
|
82
|
-
* the platform's own org owners. Keyed on `isSentinel`, never a literal
|
|
83
|
-
* (ADR-0031 rule 7).
|
|
84
|
-
*
|
|
85
|
-
* Gated by an explicit deployment pin (Sam-disposed 2026-09-02, PR #4687
|
|
86
|
-
* security review — fail closed): `AILK_OPERATOR_EMAIL` must be SET before
|
|
87
|
-
* this predicate can ever be true. `bindOperatorMembership`
|
|
88
|
-
* (`tenant-context.ts`) TOFU-binds the sentinel-owner Membership to whichever
|
|
89
|
-
* caller wins a first-login race when the pin is unset; before this read
|
|
90
|
-
* surface existed, winning that race granted scope over only the winner's
|
|
91
|
-
* own workspace. This surface turns it into cross-tenant signup-PII read
|
|
92
|
-
* access, so a deployment must opt in explicitly rather than inherit an
|
|
93
|
-
* unpinned race's outcome. With the pin unset, even a sentinel-org owner
|
|
94
|
-
* reads at `kind:"site"` scope like any other tenant.
|
|
95
|
-
*/
|
|
96
|
-
export function isPlatformOperator(tenant: TenantContext): boolean {
|
|
97
|
-
const pinned = process.env.AILK_OPERATOR_EMAIL?.trim();
|
|
98
|
-
if (!pinned) return false;
|
|
99
|
-
return isSentinel(tenant.organizationId) && tenant.role === "owner";
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Owner scope = `siteId == tenant.workspaceId`; platform operator = every site. */
|
|
103
|
-
export function resolveWaitlistReadScope(tenant: TenantContext): WaitlistReadScope {
|
|
104
|
-
return isPlatformOperator(tenant)
|
|
105
|
-
? { kind: "all" }
|
|
106
|
-
: { kind: "site", siteId: tenant.workspaceId };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function scopeWhere(scope: WaitlistReadScope): { siteId?: string } {
|
|
110
|
-
return scope.kind === "site" ? { siteId: scope.siteId } : {};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ─── Row shapes ──────────────────────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
const EXPERIMENT_SELECT = {
|
|
116
|
-
id: true,
|
|
117
|
-
siteId: true,
|
|
118
|
-
key: true,
|
|
119
|
-
mode: true,
|
|
120
|
-
status: true,
|
|
121
|
-
config: true,
|
|
122
|
-
createdAt: true,
|
|
123
|
-
updatedAt: true,
|
|
124
|
-
} as const satisfies Prisma.WaitlistExperimentSelect;
|
|
125
|
-
type ExperimentRow = Prisma.WaitlistExperimentGetPayload<{
|
|
126
|
-
select: typeof EXPERIMENT_SELECT;
|
|
127
|
-
}>;
|
|
128
|
-
|
|
129
|
-
const LEAD_SELECT = {
|
|
130
|
-
id: true,
|
|
131
|
-
siteId: true,
|
|
132
|
-
email: true,
|
|
133
|
-
source: true,
|
|
134
|
-
payload: true,
|
|
135
|
-
createdAt: true,
|
|
136
|
-
} as const satisfies Prisma.LeadSelect;
|
|
137
|
-
type LeadRow = Prisma.LeadGetPayload<{ select: typeof LEAD_SELECT }>;
|
|
138
|
-
|
|
139
|
-
const SCORE_SELECT = {
|
|
140
|
-
leadId: true,
|
|
141
|
-
siteId: true,
|
|
142
|
-
experimentKey: true,
|
|
143
|
-
tier: true,
|
|
144
|
-
score: true,
|
|
145
|
-
categoryScores: true,
|
|
146
|
-
ruleTrace: true,
|
|
147
|
-
updatedAt: true,
|
|
148
|
-
} as const satisfies Prisma.WaitlistScoreSelect;
|
|
149
|
-
type ScoreRow = Prisma.WaitlistScoreGetPayload<{ select: typeof SCORE_SELECT }>;
|
|
150
|
-
|
|
151
|
-
const FLOW_SESSION_SELECT = {
|
|
152
|
-
siteId: true,
|
|
153
|
-
flowSlug: true,
|
|
154
|
-
state: true,
|
|
155
|
-
} as const satisfies Prisma.FlowSessionSelect;
|
|
156
|
-
type FlowSessionRow = Prisma.FlowSessionGetPayload<{ select: typeof FLOW_SESSION_SELECT }>;
|
|
157
|
-
|
|
158
|
-
const FLOW_CHECKOUT_SELECT = {
|
|
159
|
-
siteId: true,
|
|
160
|
-
experimentKey: true,
|
|
161
|
-
offerKey: true,
|
|
162
|
-
} as const satisfies Prisma.FlowCheckoutSelect;
|
|
163
|
-
type FlowCheckoutRow = Prisma.FlowCheckoutGetPayload<{ select: typeof FLOW_CHECKOUT_SELECT }>;
|
|
164
|
-
|
|
165
|
-
// `MAX_AGGREGATION_ROWS` and `logAggregationCapHit` — the defensive upper
|
|
166
|
-
// bound on an aggregation read (Medium finding, PR #4687 security review) —
|
|
167
|
-
// moved to `./answer-distributions.js` (#4771 D4), which both this module
|
|
168
|
-
// and `flow-aggregate.ts` now import.
|
|
169
|
-
|
|
170
|
-
// ─── payload / config helpers ────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Parse a `WaitlistExperiment.config` JSON column back into its validated
|
|
174
|
-
* shape, or `undefined` on a parse failure. `config` is written ONLY
|
|
175
|
-
* through `upsertWaitlistExperiment` (`waitlist-experiments.ts`), which
|
|
176
|
-
* validates against this same schema before writing — so a parse failure
|
|
177
|
-
* here signals corrupted row data, not a legitimate read-time possibility.
|
|
178
|
-
* Mirrors `flow-engine.ts`'s `flowConfigSchema.safeParse` read-back
|
|
179
|
-
* discipline.
|
|
180
|
-
*/
|
|
181
|
-
function tryParseConfig(
|
|
182
|
-
raw: unknown,
|
|
183
|
-
): { config: WaitlistExperimentConfig; message?: undefined } | { config?: undefined; message: string } {
|
|
184
|
-
const parsed = waitlistExperimentConfigSchema.safeParse(raw);
|
|
185
|
-
if (!parsed.success) {
|
|
186
|
-
return { message: parsed.error.message };
|
|
187
|
-
}
|
|
188
|
-
return { config: parsed.data };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Throwing form of `tryParseConfig`, for the single-experiment paths
|
|
193
|
-
* (`getExperimentComparison` / `getSignup`) where a corrupt row genuinely is
|
|
194
|
-
* an internal error for that one request — there is no cross-tenant
|
|
195
|
-
* availability coupling to protect against (the request already named this
|
|
196
|
-
* one experiment by id). `listExperiments`'s `kind:"all"` loop uses
|
|
197
|
-
* `tryParseConfig` directly instead, so one tenant's corrupt row can never
|
|
198
|
-
* abort the platform operator's whole list (Medium finding, PR #4687
|
|
199
|
-
* security review).
|
|
200
|
-
*/
|
|
201
|
-
function parseConfig(raw: unknown): WaitlistExperimentConfig {
|
|
202
|
-
const result = tryParseConfig(raw);
|
|
203
|
-
if (result.config === undefined) {
|
|
204
|
-
throw new Error(`waitlist-dashboard: experiment config failed to parse: ${result.message}`);
|
|
205
|
-
}
|
|
206
|
-
return result.config;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Flatten a completed session's step-keyed `state` into the answer map
|
|
211
|
-
* `recommend()` takes, keeping only `string | string[]` values. Mirrors
|
|
212
|
-
* `flow-aggregate.ts`'s module-local `flattenSessionState` (itself a mirror
|
|
213
|
-
* of `flow-engine.ts`'s) — the "mirrors, not imported" convention those two
|
|
214
|
-
* already record (#4839 D1).
|
|
215
|
-
*/
|
|
216
|
-
function flattenSessionAnswers(state: unknown): WaitlistAnswers {
|
|
217
|
-
const answers: WaitlistAnswers = {};
|
|
218
|
-
if (!state || typeof state !== "object") return answers;
|
|
219
|
-
for (const stepData of Object.values(state as Record<string, unknown>)) {
|
|
220
|
-
if (!stepData || typeof stepData !== "object") continue;
|
|
221
|
-
for (const [name, value] of Object.entries(stepData as Record<string, unknown>)) {
|
|
222
|
-
if (typeof value === "string") answers[name] = value;
|
|
223
|
-
else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
224
|
-
answers[name] = value as string[];
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return answers;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* The memo key for one completed session's answers under `config` (#4861 D1).
|
|
233
|
-
*
|
|
234
|
-
* Built from the flattened answers PROJECTED onto the register's declared
|
|
235
|
-
* question ids, in declared order — never the raw flattened map, which also
|
|
236
|
-
* carries a walk's field-step values (a visitor's email) and would make almost
|
|
237
|
-
* every real session's key unique.
|
|
238
|
-
*
|
|
239
|
-
* The projection is sound because `recommend()` reads answers only through the
|
|
240
|
-
* predicate contexts it builds per offer rule and pairing, and through
|
|
241
|
-
* `evaluateWaitlistScoring` — both address answers by question id, and
|
|
242
|
-
* `waitlistExperimentConfigSchema` rejects a config whose predicate leaf
|
|
243
|
-
* (`waitlist.ts:449-452`) or scoring point (`:759-765`) names an undeclared
|
|
244
|
-
* question. `computeOfferCounts` only ever runs on a config that passed that
|
|
245
|
-
* parse, so an answer key outside the projection is unreadable by
|
|
246
|
-
* `recommend()`, and two sessions sharing a signature share a recommendation.
|
|
247
|
-
*
|
|
248
|
-
* The key is the JSON encoding of the projected VALUES in declared-id order —
|
|
249
|
-
* not a delimiter-joined string. Answer values for a `text` question are raw
|
|
250
|
-
* visitor input under no charset constraint, so any separator character a
|
|
251
|
-
* joined key used would be forgeable: one visitor typing `q_team=agency` into
|
|
252
|
-
* a free-text box could otherwise collide with another session that really
|
|
253
|
-
* answered it. JSON quoting also keeps `"a\u001fb"` distinct from
|
|
254
|
-
* `["a", "b"]`, which `includesAny` evaluates differently, and an unanswered
|
|
255
|
-
* question (`null`) distinct from an empty one.
|
|
256
|
-
*/
|
|
257
|
-
function answerSignature(questionIds: readonly string[], answers: WaitlistAnswers): string {
|
|
258
|
-
return JSON.stringify(questionIds.map((id) => answers[id] ?? null));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** `payload.recommender` as #4837 D2 writes it — `{ choice, fits }` — or undefined. */
|
|
262
|
-
function readPayloadRecommender(
|
|
263
|
-
payload: unknown,
|
|
264
|
-
): { choice: string; fits: string[] } | undefined {
|
|
265
|
-
if (typeof payload !== "object" || payload === null) return undefined;
|
|
266
|
-
const value = (payload as Record<string, unknown>)["recommender"];
|
|
267
|
-
if (typeof value !== "object" || value === null) return undefined;
|
|
268
|
-
const { choice, fits } = value as Record<string, unknown>;
|
|
269
|
-
if (typeof choice !== "string" || !Array.isArray(fits)) return undefined;
|
|
270
|
-
return { choice, fits: fits.filter((f): f is string => typeof f === "string") };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* The per-offer funnel for one recommending experiment (#4839 FR2–FR4,
|
|
275
|
-
* FR6): rows in register `offers[]` order, an all-zero offer still present.
|
|
276
|
-
*/
|
|
277
|
-
function computeOfferCounts(
|
|
278
|
-
config: WaitlistExperimentConfig,
|
|
279
|
-
sessions: FlowSessionRow[],
|
|
280
|
-
leads: LeadRow[],
|
|
281
|
-
checkouts: FlowCheckoutRow[],
|
|
282
|
-
): WaitlistExperimentOfferCounts[] | undefined {
|
|
283
|
-
if (config.mode !== "recommending" || !config.offers) return undefined;
|
|
284
|
-
const counts = new Map<string, WaitlistExperimentOfferCounts>(
|
|
285
|
-
config.offers.map((o) => [o.key, { offerKey: o.key, recommended: 0, saved: 0, checkedOut: 0 }]),
|
|
286
|
-
);
|
|
287
|
-
const slugs = new Set(config.waitlists.map((w) => w.flowSlug));
|
|
288
|
-
// #4861 D1 — `recommend()` is pure in `(register, answers)` and this loop
|
|
289
|
-
// always calls it with `flags` defaulted, so the walk costs the DISTINCT
|
|
290
|
-
// answer sets, not the session rows: at the 20k cap a real register collects
|
|
291
|
-
// a handful of them. The memo lives for this one call, so an operator's
|
|
292
|
-
// register edit shows up on the next read with nothing to invalidate (D6).
|
|
293
|
-
const questionIds = config.questionSet.questions.map((q) => q.id);
|
|
294
|
-
const fitsBySignature = new Map<string, { fits: string[] } | { errName: string }>();
|
|
295
|
-
// D5 — one warn per experiment per list call, never one per row: a register
|
|
296
|
-
// rename that breaks a whole flow used to emit up to MAX_AGGREGATION_ROWS
|
|
297
|
-
// warn lines from ONE request (PR #4856 round-2 acceptance review).
|
|
298
|
-
let skipped = 0;
|
|
299
|
-
let skippedErrName = "UnknownError";
|
|
300
|
-
let skippedSiteId = "";
|
|
301
|
-
for (const session of sessions) {
|
|
302
|
-
if (!slugs.has(session.flowSlug)) continue;
|
|
303
|
-
// Both helpers are total over a session row (they walk plain JSON and
|
|
304
|
-
// return), so only `recommend()` needs the try below — the same arm HEAD
|
|
305
|
-
// guarded.
|
|
306
|
-
const answers = flattenSessionAnswers(session.state);
|
|
307
|
-
const signature = answerSignature(questionIds, answers);
|
|
308
|
-
let entry = fitsBySignature.get(signature);
|
|
309
|
-
if (entry === undefined) {
|
|
310
|
-
try {
|
|
311
|
-
entry = { fits: recommend(config, answers).fits.map((f) => f.offerKey) };
|
|
312
|
-
} catch (err) {
|
|
313
|
-
// A session whose state cannot be recommended against the CURRENT
|
|
314
|
-
// register (a question renamed since it completed) counts nowhere —
|
|
315
|
-
// memoized like a fit so the throw costs the distinct answer sets too.
|
|
316
|
-
entry = { errName: err instanceof Error ? err.name : "UnknownError" };
|
|
317
|
-
}
|
|
318
|
-
fitsBySignature.set(signature, entry);
|
|
319
|
-
}
|
|
320
|
-
if ("errName" in entry) {
|
|
321
|
-
skipped += 1;
|
|
322
|
-
skippedErrName = entry.errName;
|
|
323
|
-
skippedSiteId = session.siteId;
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
for (const key of entry.fits) {
|
|
327
|
-
const row = counts.get(key);
|
|
328
|
-
if (row) row.recommended += 1;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (skipped > 0) {
|
|
332
|
-
// Counts and an error NAME only — never the answers (the logging
|
|
333
|
-
// discipline the corrupt-config skip already follows).
|
|
334
|
-
logger.warn(
|
|
335
|
-
{ siteId: skippedSiteId, experimentKey: config.key, skipped, errName: skippedErrName },
|
|
336
|
-
"[waitlist-dashboard] skipped completed sessions recommend() could not score",
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
for (const lead of leads) {
|
|
340
|
-
const rec = readPayloadRecommender(lead.payload);
|
|
341
|
-
if (!rec || rec.choice !== "save") continue;
|
|
342
|
-
for (const key of rec.fits) {
|
|
343
|
-
const row = counts.get(key);
|
|
344
|
-
if (row) row.saved += 1;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
for (const checkout of checkouts) {
|
|
348
|
-
const row = counts.get(checkout.offerKey);
|
|
349
|
-
if (row) row.checkedOut += 1;
|
|
350
|
-
}
|
|
351
|
-
return [...counts.values()];
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function readPayloadString(payload: unknown, key: string): string | undefined {
|
|
355
|
-
if (typeof payload !== "object" || payload === null) return undefined;
|
|
356
|
-
const value = (payload as Record<string, unknown>)[key];
|
|
357
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// `readPayloadAnswer` moved to `./answer-distributions.js` (#4771 D4),
|
|
361
|
-
// imported above — every call site below is unchanged.
|
|
362
|
-
|
|
363
|
-
function toSummaryWaitlists(
|
|
364
|
-
config: WaitlistExperimentConfig,
|
|
365
|
-
): WaitlistExperimentSummary["waitlists"] {
|
|
366
|
-
return config.waitlists.map((w) => ({
|
|
367
|
-
key: w.key,
|
|
368
|
-
flowSlug: w.flowSlug,
|
|
369
|
-
...(w.entryPageKey ? { entryPageKey: w.entryPageKey } : {}),
|
|
370
|
-
}));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ─── D4 — the metric row (one waitlist, or the total) ───────────────────────
|
|
374
|
-
|
|
375
|
-
function computeMetricRow(
|
|
376
|
-
key: WaitlistMetricRow["key"],
|
|
377
|
-
meta: { campaign?: string | undefined; channels?: string[] | undefined },
|
|
378
|
-
leads: LeadRow[],
|
|
379
|
-
scoresByLeadId: Map<string, ScoreRow>,
|
|
380
|
-
config: WaitlistExperimentConfig,
|
|
381
|
-
): WaitlistMetricRow {
|
|
382
|
-
const scoring = config.scoring;
|
|
383
|
-
const serviceableExcludes = new Set(scoring?.serviceableExcludes ?? []);
|
|
384
|
-
const raw = leads.length;
|
|
385
|
-
|
|
386
|
-
// byTier — grouped over the scoped WaitlistScore read (D4).
|
|
387
|
-
const tierCounts = new Map<string, number>();
|
|
388
|
-
for (const lead of leads) {
|
|
389
|
-
const score = scoresByLeadId.get(lead.id);
|
|
390
|
-
if (!score) continue;
|
|
391
|
-
tierCounts.set(score.tier, (tierCounts.get(score.tier) ?? 0) + 1);
|
|
392
|
-
}
|
|
393
|
-
const byTier = [...tierCounts.entries()].map(([tier, count]) => ({ tier, count }));
|
|
394
|
-
|
|
395
|
-
// qualified — null when the experiment carries no scoring register (D4).
|
|
396
|
-
let qualified: number | null = null;
|
|
397
|
-
if (scoring) {
|
|
398
|
-
qualified = 0;
|
|
399
|
-
for (const [tier, count] of tierCounts) {
|
|
400
|
-
if (!serviceableExcludes.has(tier)) qualified += count;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
const qualificationRate = qualified === null || raw === 0 ? null : qualified / raw;
|
|
404
|
-
|
|
405
|
-
// bySource — grouped by the tracked UTM param NAME (config.tracking.utm.source);
|
|
406
|
-
// an absent/empty value groups under "(direct)" (D4, Clarifications).
|
|
407
|
-
const utmParam = config.tracking.utm.source;
|
|
408
|
-
const bySourceMap = new Map<string, { raw: number; qualified: number }>();
|
|
409
|
-
for (const lead of leads) {
|
|
410
|
-
const source = readPayloadString(lead.payload, utmParam) ?? "(direct)";
|
|
411
|
-
const bucket = bySourceMap.get(source) ?? { raw: 0, qualified: 0 };
|
|
412
|
-
bucket.raw += 1;
|
|
413
|
-
const score = scoresByLeadId.get(lead.id);
|
|
414
|
-
if (scoring && score && !serviceableExcludes.has(score.tier)) {
|
|
415
|
-
bucket.qualified += 1;
|
|
416
|
-
}
|
|
417
|
-
bySourceMap.set(source, bucket);
|
|
418
|
-
}
|
|
419
|
-
const bySource = [...bySourceMap.entries()].map(([source, bucket]) => ({
|
|
420
|
-
source,
|
|
421
|
-
raw: bucket.raw,
|
|
422
|
-
qualified: scoring ? bucket.qualified : null,
|
|
423
|
-
qualificationRate: scoring && bucket.raw > 0 ? bucket.qualified / bucket.raw : null,
|
|
424
|
-
}));
|
|
425
|
-
|
|
426
|
-
// distributions — one per declared question (D4); a value outside the
|
|
427
|
-
// question's declared options is counted under "(other)" (Clarifications).
|
|
428
|
-
// Bucketing itself is the shared `computeAnswerDistributions` (#4771 D4) —
|
|
429
|
-
// this call site only supplies the row → flattened-payload mapping.
|
|
430
|
-
const distributions = computeAnswerDistributions(
|
|
431
|
-
config.questionSet.questions,
|
|
432
|
-
leads.map((lead) => lead.payload),
|
|
433
|
-
);
|
|
434
|
-
|
|
435
|
-
return {
|
|
436
|
-
key,
|
|
437
|
-
...(meta.campaign ? { campaign: meta.campaign } : {}),
|
|
438
|
-
...(meta.channels ? { channels: meta.channels } : {}),
|
|
439
|
-
raw,
|
|
440
|
-
qualified,
|
|
441
|
-
qualificationRate,
|
|
442
|
-
byTier,
|
|
443
|
-
bySource,
|
|
444
|
-
distributions,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function summaryFromRow(
|
|
449
|
-
row: ExperimentRow,
|
|
450
|
-
config: WaitlistExperimentConfig,
|
|
451
|
-
counts: WaitlistExperimentSummary["counts"],
|
|
452
|
-
): WaitlistExperimentSummary {
|
|
453
|
-
return {
|
|
454
|
-
id: row.id,
|
|
455
|
-
siteId: row.siteId,
|
|
456
|
-
key: row.key,
|
|
457
|
-
mode: row.mode as WaitlistExperimentSummary["mode"],
|
|
458
|
-
status: row.status as WaitlistExperimentSummary["status"],
|
|
459
|
-
createdAt: row.createdAt.toISOString(),
|
|
460
|
-
updatedAt: row.updatedAt.toISOString(),
|
|
461
|
-
waitlists: toSummaryWaitlists(config),
|
|
462
|
-
counts,
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// ─── List + rollup (D4, AC1) ─────────────────────────────────────────────────
|
|
467
|
-
|
|
468
|
-
export async function listExperiments(
|
|
469
|
-
scope: WaitlistReadScope,
|
|
470
|
-
): Promise<WaitlistDashboardList> {
|
|
471
|
-
const experiments = await prisma.waitlistExperiment.findMany({
|
|
472
|
-
where: scopeWhere(scope),
|
|
473
|
-
orderBy: { createdAt: "desc" },
|
|
474
|
-
select: EXPERIMENT_SELECT,
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
const emptyRollup: WaitlistDashboardList["rollup"] = {
|
|
478
|
-
experiments: { total: 0, live: 0, closed: 0, draft: 0 },
|
|
479
|
-
raw: 0,
|
|
480
|
-
qualified: null,
|
|
481
|
-
qualificationRate: null,
|
|
482
|
-
sites: 0,
|
|
483
|
-
};
|
|
484
|
-
if (experiments.length === 0) {
|
|
485
|
-
return { scope: scope.kind, experiments: [], rollup: emptyRollup };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const keys = [...new Set(experiments.map((e) => e.key))];
|
|
489
|
-
// ONE scoped Lead read + ONE scoped WaitlistScore read across every
|
|
490
|
-
// returned experiment (D4) — an OR over experimentKey equality is one
|
|
491
|
-
// query, not one per experiment.
|
|
492
|
-
const [leads, scores] = await Promise.all([
|
|
493
|
-
prisma.lead.findMany({
|
|
494
|
-
where: {
|
|
495
|
-
...scopeWhere(scope),
|
|
496
|
-
OR: keys.map((key) => ({
|
|
497
|
-
payload: { path: ["experimentKey"], equals: key },
|
|
498
|
-
})),
|
|
499
|
-
},
|
|
500
|
-
// Deterministic read order (#5107) — without an explicit orderBy,
|
|
501
|
-
// Postgres does not guarantee row order, which rows survive
|
|
502
|
-
// MAX_AGGREGATION_ROWS is then arbitrary. Mirrors the flowSession/
|
|
503
|
-
// flowCheckout reads below.
|
|
504
|
-
orderBy: { createdAt: "asc" },
|
|
505
|
-
take: MAX_AGGREGATION_ROWS,
|
|
506
|
-
select: LEAD_SELECT,
|
|
507
|
-
}),
|
|
508
|
-
prisma.waitlistScore.findMany({
|
|
509
|
-
where: { ...scopeWhere(scope), experimentKey: { in: keys } },
|
|
510
|
-
orderBy: { createdAt: "asc" },
|
|
511
|
-
take: MAX_AGGREGATION_ROWS,
|
|
512
|
-
select: SCORE_SELECT,
|
|
513
|
-
}),
|
|
514
|
-
]);
|
|
515
|
-
logAggregationCapHit("listExperiments.lead", leads.length);
|
|
516
|
-
logAggregationCapHit("listExperiments.waitlistScore", scores.length);
|
|
517
|
-
|
|
518
|
-
// #4839 D1/D3 — ONE FlowSession read + ONE FlowCheckout read across every
|
|
519
|
-
// recommending experiment in the list; none when there is none. The
|
|
520
|
-
// register is parsed once more here only to learn the slug set — the
|
|
521
|
-
// per-row parse below is the authoritative one and skips a corrupt row.
|
|
522
|
-
const recommending = experiments
|
|
523
|
-
.map((row) => ({ row, config: tryParseConfig(row.config).config }))
|
|
524
|
-
.filter((e): e is { row: ExperimentRow; config: WaitlistExperimentConfig } =>
|
|
525
|
-
e.config?.mode === "recommending" && e.config.offers !== undefined,
|
|
526
|
-
);
|
|
527
|
-
let sessions: FlowSessionRow[] = [];
|
|
528
|
-
let checkouts: FlowCheckoutRow[] = [];
|
|
529
|
-
if (recommending.length > 0) {
|
|
530
|
-
const slugs = [...new Set(recommending.flatMap((e) => e.config.waitlists.map((w) => w.flowSlug)))];
|
|
531
|
-
const recommendingKeys = [...new Set(recommending.map((e) => e.row.key))];
|
|
532
|
-
[sessions, checkouts] = await Promise.all([
|
|
533
|
-
prisma.flowSession.findMany({
|
|
534
|
-
where: { ...scopeWhere(scope), flowSlug: { in: slugs }, completedAt: { not: null } },
|
|
535
|
-
orderBy: { completedAt: "asc" },
|
|
536
|
-
take: MAX_AGGREGATION_ROWS,
|
|
537
|
-
select: FLOW_SESSION_SELECT,
|
|
538
|
-
}),
|
|
539
|
-
prisma.flowCheckout.findMany({
|
|
540
|
-
where: { ...scopeWhere(scope), experimentKey: { in: recommendingKeys }, status: "completed" },
|
|
541
|
-
orderBy: { createdAt: "asc" },
|
|
542
|
-
take: MAX_AGGREGATION_ROWS,
|
|
543
|
-
select: FLOW_CHECKOUT_SELECT,
|
|
544
|
-
}),
|
|
545
|
-
]);
|
|
546
|
-
logAggregationCapHit("listExperiments.flowSession", sessions.length);
|
|
547
|
-
logAggregationCapHit("listExperiments.flowCheckout", checkouts.length);
|
|
548
|
-
}
|
|
549
|
-
const sessionsBySite = new Map<string, FlowSessionRow[]>();
|
|
550
|
-
for (const session of sessions) {
|
|
551
|
-
const bucket = sessionsBySite.get(session.siteId) ?? [];
|
|
552
|
-
bucket.push(session);
|
|
553
|
-
sessionsBySite.set(session.siteId, bucket);
|
|
554
|
-
}
|
|
555
|
-
const checkoutsByExp = new Map<string, FlowCheckoutRow[]>();
|
|
556
|
-
for (const checkout of checkouts) {
|
|
557
|
-
const mapKey = `${checkout.siteId}:${checkout.experimentKey}`;
|
|
558
|
-
const bucket = checkoutsByExp.get(mapKey) ?? [];
|
|
559
|
-
bucket.push(checkout);
|
|
560
|
-
checkoutsByExp.set(mapKey, bucket);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const leadsByExp = new Map<string, LeadRow[]>();
|
|
564
|
-
for (const lead of leads) {
|
|
565
|
-
const experimentKey = readPayloadString(lead.payload, "experimentKey");
|
|
566
|
-
if (!experimentKey) continue;
|
|
567
|
-
const mapKey = `${lead.siteId}:${experimentKey}`;
|
|
568
|
-
const bucket = leadsByExp.get(mapKey) ?? [];
|
|
569
|
-
bucket.push(lead);
|
|
570
|
-
leadsByExp.set(mapKey, bucket);
|
|
571
|
-
}
|
|
572
|
-
const scoresByExp = new Map<string, ScoreRow[]>();
|
|
573
|
-
for (const score of scores) {
|
|
574
|
-
const mapKey = `${score.siteId}:${score.experimentKey}`;
|
|
575
|
-
const bucket = scoresByExp.get(mapKey) ?? [];
|
|
576
|
-
bucket.push(score);
|
|
577
|
-
scoresByExp.set(mapKey, bucket);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const summaries: WaitlistExperimentSummary[] = [];
|
|
581
|
-
const statusCounts = { live: 0, closed: 0, draft: 0 };
|
|
582
|
-
const sitesSeen = new Set<string>();
|
|
583
|
-
let validCount = 0;
|
|
584
|
-
let rollupRaw = 0;
|
|
585
|
-
let rollupQualified = 0;
|
|
586
|
-
let anyQualifiedKnown = false;
|
|
587
|
-
|
|
588
|
-
for (const row of experiments) {
|
|
589
|
-
// A corrupt config on ONE experiment must never abort the platform
|
|
590
|
-
// operator's WHOLE list under `kind:"all"` (Medium finding, PR #4687
|
|
591
|
-
// security review) — skip and log rather than throw, BEFORE this row
|
|
592
|
-
// contributes to any rollup count. Only `{id, siteId, key}` are logged,
|
|
593
|
-
// never `config` (PII/register boundary, mirrors `waitlist-experiments.ts`'s
|
|
594
|
-
// own logging discipline).
|
|
595
|
-
const parsedConfig = tryParseConfig(row.config);
|
|
596
|
-
if (parsedConfig.config === undefined) {
|
|
597
|
-
logger.warn(
|
|
598
|
-
{ experimentId: row.id, siteId: row.siteId, key: row.key, reason: parsedConfig.message },
|
|
599
|
-
"[waitlist-dashboard] skipping experiment — config failed to parse",
|
|
600
|
-
);
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
const config = parsedConfig.config;
|
|
604
|
-
|
|
605
|
-
validCount += 1;
|
|
606
|
-
sitesSeen.add(row.siteId);
|
|
607
|
-
if (row.status === "live") statusCounts.live += 1;
|
|
608
|
-
else if (row.status === "closed") statusCounts.closed += 1;
|
|
609
|
-
else if (row.status === "draft") statusCounts.draft += 1;
|
|
610
|
-
|
|
611
|
-
const mapKey = `${row.siteId}:${row.key}`;
|
|
612
|
-
const expLeads = leadsByExp.get(mapKey) ?? [];
|
|
613
|
-
const expScores = scoresByExp.get(mapKey) ?? [];
|
|
614
|
-
const scoresByLeadId = new Map(expScores.map((s) => [s.leadId, s]));
|
|
615
|
-
const metricRow = computeMetricRow("total", {}, expLeads, scoresByLeadId, config);
|
|
616
|
-
|
|
617
|
-
rollupRaw += metricRow.raw;
|
|
618
|
-
if (metricRow.qualified !== null) {
|
|
619
|
-
rollupQualified += metricRow.qualified;
|
|
620
|
-
anyQualifiedKnown = true;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const offers = computeOfferCounts(
|
|
624
|
-
config,
|
|
625
|
-
sessionsBySite.get(row.siteId) ?? [],
|
|
626
|
-
expLeads,
|
|
627
|
-
checkoutsByExp.get(mapKey) ?? [],
|
|
628
|
-
);
|
|
629
|
-
summaries.push(
|
|
630
|
-
summaryFromRow(row, config, {
|
|
631
|
-
raw: metricRow.raw,
|
|
632
|
-
qualified: metricRow.qualified,
|
|
633
|
-
qualificationRate: metricRow.qualificationRate,
|
|
634
|
-
...(offers !== undefined && { offers }),
|
|
635
|
-
}),
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
return {
|
|
640
|
-
scope: scope.kind,
|
|
641
|
-
experiments: summaries,
|
|
642
|
-
rollup: {
|
|
643
|
-
experiments: { total: validCount, ...statusCounts },
|
|
644
|
-
raw: rollupRaw,
|
|
645
|
-
qualified: anyQualifiedKnown ? rollupQualified : null,
|
|
646
|
-
qualificationRate:
|
|
647
|
-
anyQualifiedKnown && rollupRaw > 0 ? rollupQualified / rollupRaw : null,
|
|
648
|
-
sites: sitesSeen.size,
|
|
649
|
-
},
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// ─── Comparison (D4, AC2) ────────────────────────────────────────────────────
|
|
654
|
-
|
|
655
|
-
export async function getExperimentComparison(
|
|
656
|
-
scope: WaitlistReadScope,
|
|
657
|
-
experimentId: string,
|
|
658
|
-
): Promise<TenantResult<WaitlistExperimentComparison>> {
|
|
659
|
-
const experiment = await prisma.waitlistExperiment.findFirst({
|
|
660
|
-
where: { id: experimentId, ...scopeWhere(scope) },
|
|
661
|
-
select: EXPERIMENT_SELECT,
|
|
662
|
-
});
|
|
663
|
-
if (!experiment) {
|
|
664
|
-
return { ok: false, error: { kind: "not_found", message: "Experiment not found" } };
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const config = parseConfig(experiment.config);
|
|
668
|
-
|
|
669
|
-
const [leads, scores] = await Promise.all([
|
|
670
|
-
prisma.lead.findMany({
|
|
671
|
-
where: {
|
|
672
|
-
siteId: experiment.siteId,
|
|
673
|
-
payload: { path: ["experimentKey"], equals: experiment.key },
|
|
674
|
-
},
|
|
675
|
-
// Deterministic read order (#5107) — see the matching comment on
|
|
676
|
-
// listExperiments's identical read above.
|
|
677
|
-
orderBy: { createdAt: "asc" },
|
|
678
|
-
take: MAX_AGGREGATION_ROWS,
|
|
679
|
-
select: LEAD_SELECT,
|
|
680
|
-
}),
|
|
681
|
-
prisma.waitlistScore.findMany({
|
|
682
|
-
where: { siteId: experiment.siteId, experimentKey: experiment.key },
|
|
683
|
-
orderBy: { createdAt: "asc" },
|
|
684
|
-
take: MAX_AGGREGATION_ROWS,
|
|
685
|
-
select: SCORE_SELECT,
|
|
686
|
-
}),
|
|
687
|
-
]);
|
|
688
|
-
logAggregationCapHit("getExperimentComparison.lead", leads.length);
|
|
689
|
-
logAggregationCapHit("getExperimentComparison.waitlistScore", scores.length);
|
|
690
|
-
const scoresByLeadId = new Map(scores.map((s) => [s.leadId, s]));
|
|
691
|
-
|
|
692
|
-
const waitlistRows = config.waitlists.map((w) => {
|
|
693
|
-
const waitlistLeads = leads.filter(
|
|
694
|
-
(lead) => readPayloadString(lead.payload, "waitlistKey") === w.key,
|
|
695
|
-
);
|
|
696
|
-
const trackingMeta = config.tracking.waitlists[w.key];
|
|
697
|
-
return computeMetricRow(
|
|
698
|
-
w.key,
|
|
699
|
-
{ campaign: trackingMeta?.campaign, channels: trackingMeta?.channels },
|
|
700
|
-
waitlistLeads,
|
|
701
|
-
scoresByLeadId,
|
|
702
|
-
config,
|
|
703
|
-
);
|
|
704
|
-
});
|
|
705
|
-
const totalRow = computeMetricRow("total", {}, leads, scoresByLeadId, config);
|
|
706
|
-
|
|
707
|
-
const questions = config.questionSet.questions.map((q) => ({
|
|
708
|
-
id: q.id,
|
|
709
|
-
prompt: q.prompt,
|
|
710
|
-
answerType: q.answerType,
|
|
711
|
-
...(q.options
|
|
712
|
-
? { options: q.options.map((o) => ({ value: o.value, label: o.label })) }
|
|
713
|
-
: {}),
|
|
714
|
-
}));
|
|
715
|
-
|
|
716
|
-
const summary = summaryFromRow(experiment, config, {
|
|
717
|
-
raw: totalRow.raw,
|
|
718
|
-
qualified: totalRow.qualified,
|
|
719
|
-
qualificationRate: totalRow.qualificationRate,
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
return {
|
|
723
|
-
ok: true,
|
|
724
|
-
value: {
|
|
725
|
-
experiment: summary,
|
|
726
|
-
questions,
|
|
727
|
-
serviceableExcludes: config.scoring?.serviceableExcludes ?? [],
|
|
728
|
-
waitlists: waitlistRows,
|
|
729
|
-
total: totalRow,
|
|
730
|
-
},
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ─── Signups — paginated list (D7, AC1) ─────────────────────────────────────
|
|
735
|
-
|
|
736
|
-
/** `listSignups`'s own error kind — `TenantErrorKind` (`tenant-context.ts`)
|
|
737
|
-
* has no 400-mapped member, and this file does not modify that shared type. */
|
|
738
|
-
export type ListSignupsErrorKind = "not_found" | "invalid_cursor";
|
|
739
|
-
export type ListSignupsResult =
|
|
740
|
-
| { ok: true; value: WaitlistSignupList }
|
|
741
|
-
| { ok: false; error: { kind: ListSignupsErrorKind; message: string } };
|
|
742
|
-
|
|
743
|
-
export async function listSignups(
|
|
744
|
-
scope: WaitlistReadScope,
|
|
745
|
-
experimentId: string,
|
|
746
|
-
params: { waitlist?: string | undefined; cursor?: string | undefined; limit: number },
|
|
747
|
-
): Promise<ListSignupsResult> {
|
|
748
|
-
const experiment = await prisma.waitlistExperiment.findFirst({
|
|
749
|
-
where: { id: experimentId, ...scopeWhere(scope) },
|
|
750
|
-
select: EXPERIMENT_SELECT,
|
|
751
|
-
});
|
|
752
|
-
if (!experiment) {
|
|
753
|
-
return { ok: false, error: { kind: "not_found", message: "Experiment not found" } };
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
let cursorLead: { createdAt: Date; id: string } | undefined;
|
|
757
|
-
if (params.cursor) {
|
|
758
|
-
// Scoped to THIS experiment (siteId + experimentKey), not just siteId —
|
|
759
|
-
// a cursor belonging to a different experiment on the same site is
|
|
760
|
-
// exactly as invalid as one that does not exist (Low finding, PR #4687
|
|
761
|
-
// security review: a supplied-but-unresolvable cursor must 400, not
|
|
762
|
-
// silently degrade to page 1).
|
|
763
|
-
cursorLead =
|
|
764
|
-
(await prisma.lead.findFirst({
|
|
765
|
-
where: {
|
|
766
|
-
id: params.cursor,
|
|
767
|
-
siteId: experiment.siteId,
|
|
768
|
-
payload: { path: ["experimentKey"], equals: experiment.key },
|
|
769
|
-
},
|
|
770
|
-
select: { id: true, createdAt: true },
|
|
771
|
-
})) ?? undefined;
|
|
772
|
-
if (!cursorLead) {
|
|
773
|
-
return {
|
|
774
|
-
ok: false,
|
|
775
|
-
error: { kind: "invalid_cursor", message: "cursor does not resolve within this experiment" },
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
const pathFilters: Prisma.LeadWhereInput[] = [
|
|
781
|
-
{ payload: { path: ["experimentKey"], equals: experiment.key } },
|
|
782
|
-
];
|
|
783
|
-
if (params.waitlist) {
|
|
784
|
-
pathFilters.push({ payload: { path: ["waitlistKey"], equals: params.waitlist } });
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const rows = await prisma.lead.findMany({
|
|
788
|
-
where: {
|
|
789
|
-
siteId: experiment.siteId,
|
|
790
|
-
AND: [
|
|
791
|
-
...pathFilters,
|
|
792
|
-
...(cursorLead
|
|
793
|
-
? [
|
|
794
|
-
{
|
|
795
|
-
OR: [
|
|
796
|
-
{ createdAt: { lt: cursorLead.createdAt } },
|
|
797
|
-
{
|
|
798
|
-
createdAt: cursorLead.createdAt,
|
|
799
|
-
id: { lt: cursorLead.id },
|
|
800
|
-
},
|
|
801
|
-
],
|
|
802
|
-
},
|
|
803
|
-
]
|
|
804
|
-
: []),
|
|
805
|
-
],
|
|
806
|
-
},
|
|
807
|
-
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
808
|
-
take: params.limit + 1,
|
|
809
|
-
select: LEAD_SELECT,
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
const hasMore = rows.length > params.limit;
|
|
813
|
-
const page = hasMore ? rows.slice(0, params.limit) : rows;
|
|
814
|
-
const leadIds = page.map((r) => r.id);
|
|
815
|
-
const scores =
|
|
816
|
-
leadIds.length > 0
|
|
817
|
-
? await prisma.waitlistScore.findMany({
|
|
818
|
-
// `siteId` is included even though `leadIds` are already scope-derived
|
|
819
|
-
// (D3: scope is a parameter of EVERY query — the invariant holds by
|
|
820
|
-
// construction here too, matching `getSignup`'s defensive re-check).
|
|
821
|
-
where: { leadId: { in: leadIds }, siteId: experiment.siteId },
|
|
822
|
-
select: SCORE_SELECT,
|
|
823
|
-
})
|
|
824
|
-
: [];
|
|
825
|
-
const scoreByLeadId = new Map(scores.map((s) => [s.leadId, s]));
|
|
826
|
-
|
|
827
|
-
// A lead missing `waitlistKey` is corrupt/anomalous data (same posture as
|
|
828
|
-
// the corrupt-config skip above) — skipped rather than emitted with an
|
|
829
|
-
// out-of-contract empty string (Low finding, PR #4687 security review).
|
|
830
|
-
// `nextCursor`/`hasMore` are computed from `page` BEFORE this filter (the
|
|
831
|
-
// true pagination boundary), so a skip here never desyncs pagination.
|
|
832
|
-
const items: WaitlistSignupList["items"] = [];
|
|
833
|
-
for (const lead of page) {
|
|
834
|
-
const waitlistKey = readPayloadString(lead.payload, "waitlistKey");
|
|
835
|
-
if (!waitlistKey) {
|
|
836
|
-
logger.warn(
|
|
837
|
-
{ experimentId: experiment.id, siteId: experiment.siteId, leadId: lead.id },
|
|
838
|
-
"[waitlist-dashboard] skipping signup — missing waitlistKey",
|
|
839
|
-
);
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
const score = scoreByLeadId.get(lead.id);
|
|
843
|
-
items.push({
|
|
844
|
-
leadId: lead.id,
|
|
845
|
-
waitlistKey,
|
|
846
|
-
email: lead.email,
|
|
847
|
-
createdAt: lead.createdAt.toISOString(),
|
|
848
|
-
source: lead.source,
|
|
849
|
-
tier: score?.tier ?? null,
|
|
850
|
-
score: score?.score ?? null,
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
return {
|
|
855
|
-
ok: true,
|
|
856
|
-
value: {
|
|
857
|
-
experimentId: experiment.id,
|
|
858
|
-
items,
|
|
859
|
-
nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null,
|
|
860
|
-
},
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// ─── Signup drill-down (D7, AC3) ─────────────────────────────────────────────
|
|
865
|
-
|
|
866
|
-
export async function getSignup(
|
|
867
|
-
scope: WaitlistReadScope,
|
|
868
|
-
experimentId: string,
|
|
869
|
-
leadId: string,
|
|
870
|
-
): Promise<TenantResult<WaitlistSignupDetail>> {
|
|
871
|
-
const experiment = await prisma.waitlistExperiment.findFirst({
|
|
872
|
-
where: { id: experimentId, ...scopeWhere(scope) },
|
|
873
|
-
select: EXPERIMENT_SELECT,
|
|
874
|
-
});
|
|
875
|
-
if (!experiment) {
|
|
876
|
-
return { ok: false, error: { kind: "not_found", message: "Experiment not found" } };
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Loaded THROUGH its experiment (D3) — a foreign or unrelated lead is
|
|
880
|
-
// not_found, indistinguishable from a missing one.
|
|
881
|
-
const lead = await prisma.lead.findFirst({
|
|
882
|
-
where: {
|
|
883
|
-
id: leadId,
|
|
884
|
-
siteId: experiment.siteId,
|
|
885
|
-
payload: { path: ["experimentKey"], equals: experiment.key },
|
|
886
|
-
},
|
|
887
|
-
select: LEAD_SELECT,
|
|
888
|
-
});
|
|
889
|
-
if (!lead) {
|
|
890
|
-
return { ok: false, error: { kind: "not_found", message: "Signup not found" } };
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
const config = parseConfig(experiment.config);
|
|
894
|
-
// `waitlistKey` is server-written LAST alongside `experimentKey`
|
|
895
|
-
// (flow-engine.ts D4) — a Lead whose payload carries `experimentKey` but
|
|
896
|
-
// no `waitlistKey` is corrupt/anomalous data, not a legitimate state.
|
|
897
|
-
// Treated as not_found rather than emitting an out-of-contract empty
|
|
898
|
-
// string (`waitlistKey` is a non-empty `flowIdentifierSchema` on the wire,
|
|
899
|
-
// Low finding, PR #4687 security review) — the same posture the corrupt
|
|
900
|
-
// WaitlistExperiment.config skip already applies one level up.
|
|
901
|
-
const waitlistKey = readPayloadString(lead.payload, "waitlistKey");
|
|
902
|
-
if (!waitlistKey) {
|
|
903
|
-
logger.warn(
|
|
904
|
-
{ experimentId: experiment.id, siteId: experiment.siteId, leadId: lead.id },
|
|
905
|
-
"[waitlist-dashboard] signup missing waitlistKey — treating as not_found",
|
|
906
|
-
);
|
|
907
|
-
return { ok: false, error: { kind: "not_found", message: "Signup not found" } };
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const answers = config.questionSet.questions.map((q) => ({
|
|
911
|
-
questionId: q.id,
|
|
912
|
-
prompt: q.prompt,
|
|
913
|
-
value: readPayloadAnswer(lead.payload, q.id) ?? null,
|
|
914
|
-
}));
|
|
915
|
-
|
|
916
|
-
// `leadId` is `@@unique`, so `findFirst` with the scope predicates ANDed in
|
|
917
|
-
// still resolves at most one row — the scope is now a parameter of this
|
|
918
|
-
// query (D3), not a post-filter (Low finding, PR #4687 security review).
|
|
919
|
-
const scoreRow = await prisma.waitlistScore.findFirst({
|
|
920
|
-
where: { leadId: lead.id, siteId: experiment.siteId, experimentKey: experiment.key },
|
|
921
|
-
select: SCORE_SELECT,
|
|
922
|
-
});
|
|
923
|
-
const score = scoreRow
|
|
924
|
-
? {
|
|
925
|
-
tier: scoreRow.tier,
|
|
926
|
-
score: scoreRow.score,
|
|
927
|
-
categoryScores: scoreRow.categoryScores as Record<string, number>,
|
|
928
|
-
ruleTrace: scoreRow.ruleTrace,
|
|
929
|
-
updatedAt: scoreRow.updatedAt.toISOString(),
|
|
930
|
-
}
|
|
931
|
-
: null;
|
|
932
|
-
|
|
933
|
-
return {
|
|
934
|
-
ok: true,
|
|
935
|
-
value: {
|
|
936
|
-
leadId: lead.id,
|
|
937
|
-
experimentId: experiment.id,
|
|
938
|
-
experimentKey: experiment.key,
|
|
939
|
-
waitlistKey,
|
|
940
|
-
email: lead.email,
|
|
941
|
-
createdAt: lead.createdAt.toISOString(),
|
|
942
|
-
source: lead.source,
|
|
943
|
-
answers,
|
|
944
|
-
score,
|
|
945
|
-
},
|
|
946
|
-
};
|
|
947
|
-
}
|