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,354 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file waitlist-signups.test.ts
|
|
3
|
-
*
|
|
4
|
-
* The respondent read (#4731 D2/D3/D4/D5). Mocked Prisma with a
|
|
5
|
-
* where-clause-honoring in-memory Lead table (the `waitlist-dashboard.test.ts`
|
|
6
|
-
* discipline) so the case-insensitive email match, the site scope, the
|
|
7
|
-
* experimentKey payload filter, and newest-wins ordering are exercised by
|
|
8
|
-
* what the service actually passes to the DB — not by a static return.
|
|
9
|
-
* Every fence is asserted: exact seven keys; unverified → NO Lead read;
|
|
10
|
-
* no lead → signup_not_found; foreign site → signup_not_found; every read
|
|
11
|
-
* scoped; no other payload key leaves the row.
|
|
12
|
-
*/
|
|
13
|
-
import { readMySignup } from "../waitlist-signups";
|
|
14
|
-
|
|
15
|
-
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
16
|
-
jest.mock("../../lib/prisma.js", () => ({
|
|
17
|
-
prisma: {
|
|
18
|
-
user: { findUnique: jest.fn() },
|
|
19
|
-
lead: { findFirst: jest.fn() },
|
|
20
|
-
waitlistScore: { findFirst: jest.fn() },
|
|
21
|
-
flowSession: { findFirst: jest.fn() },
|
|
22
|
-
},
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
|
-
import { prisma } from "../../lib/prisma.js";
|
|
26
|
-
|
|
27
|
-
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
const SITE = "wksp_00000000-0000-7000-8000-000000000001";
|
|
30
|
-
const OTHER_SITE = "wksp_00000000-0000-7000-8000-0000000000bb";
|
|
31
|
-
const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
|
|
32
|
-
const EXPERIMENT = "ailk-founder-diagnostic";
|
|
33
|
-
|
|
34
|
-
const LEAD_OLD = "lead_00000000-0000-7000-8000-0000000000a1";
|
|
35
|
-
const LEAD_NEW = "lead_00000000-0000-7000-8000-0000000000a2";
|
|
36
|
-
const LEAD_FOREIGN = "lead_00000000-0000-7000-8000-0000000000b1";
|
|
37
|
-
|
|
38
|
-
type LeadRow = {
|
|
39
|
-
id: string;
|
|
40
|
-
siteId: string;
|
|
41
|
-
email: string;
|
|
42
|
-
payload: Record<string, unknown>;
|
|
43
|
-
createdAt: Date;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const LEADS: LeadRow[] = [
|
|
47
|
-
{
|
|
48
|
-
id: LEAD_OLD,
|
|
49
|
-
siteId: SITE,
|
|
50
|
-
// Stored AS TYPED (mixed case) — the D3 normalization case.
|
|
51
|
-
email: "Alice@Example.com",
|
|
52
|
-
payload: {
|
|
53
|
-
experimentKey: EXPERIMENT,
|
|
54
|
-
waitlistKey: "early_bird",
|
|
55
|
-
q3: "old answer",
|
|
56
|
-
name: "Alice",
|
|
57
|
-
url: "https://alice.example",
|
|
58
|
-
email: "Alice@Example.com",
|
|
59
|
-
},
|
|
60
|
-
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
id: LEAD_NEW,
|
|
64
|
-
siteId: SITE,
|
|
65
|
-
email: "alice@example.com",
|
|
66
|
-
payload: {
|
|
67
|
-
experimentKey: EXPERIMENT,
|
|
68
|
-
waitlistKey: "general",
|
|
69
|
-
q1: "solo_builder",
|
|
70
|
-
q3: "Our site never ships.",
|
|
71
|
-
name: "Alice",
|
|
72
|
-
url: "https://alice.example",
|
|
73
|
-
email: "alice@example.com",
|
|
74
|
-
aeoScore: { status: "unavailable", reason: "crawl_failed", message: "x" },
|
|
75
|
-
},
|
|
76
|
-
createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
id: LEAD_FOREIGN,
|
|
80
|
-
siteId: OTHER_SITE,
|
|
81
|
-
email: "alice@example.com",
|
|
82
|
-
payload: { experimentKey: EXPERIMENT, waitlistKey: "foreign", q3: "foreign" },
|
|
83
|
-
createdAt: new Date("2026-09-04T00:00:00.000Z"),
|
|
84
|
-
},
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
const SCORES = [
|
|
88
|
-
{ leadId: LEAD_NEW, siteId: SITE, experimentKey: EXPERIMENT, tier: "hot", score: 12 },
|
|
89
|
-
{ leadId: LEAD_OLD, siteId: SITE, experimentKey: EXPERIMENT, tier: "warm", score: 7 },
|
|
90
|
-
{ leadId: LEAD_FOREIGN, siteId: OTHER_SITE, experimentKey: EXPERIMENT, tier: "hot", score: 99 },
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
const AEO_UNAVAILABLE = { status: "unavailable", reason: "crawl_failed", message: "This URL cannot be scored." };
|
|
94
|
-
|
|
95
|
-
const SESSIONS = [
|
|
96
|
-
{
|
|
97
|
-
leadId: LEAD_NEW,
|
|
98
|
-
siteId: SITE,
|
|
99
|
-
aeoScore: AEO_UNAVAILABLE,
|
|
100
|
-
completedAt: new Date("2026-09-03T00:00:05.000Z"),
|
|
101
|
-
},
|
|
102
|
-
{ leadId: LEAD_OLD, siteId: SITE, aeoScore: null, completedAt: new Date("2026-09-01T00:00:05.000Z") },
|
|
103
|
-
];
|
|
104
|
-
|
|
105
|
-
// ── A where-clause-honoring in-memory matcher ────────────────────────────────
|
|
106
|
-
|
|
107
|
-
type Where = Record<string, unknown>;
|
|
108
|
-
|
|
109
|
-
function matchesLead(row: LeadRow, where: Where): boolean {
|
|
110
|
-
for (const [key, cond] of Object.entries(where)) {
|
|
111
|
-
if (key === "email") {
|
|
112
|
-
const c = cond as { equals: string; mode?: string };
|
|
113
|
-
const stored = c.mode === "insensitive" ? row.email.toLowerCase() : row.email;
|
|
114
|
-
const wanted = c.mode === "insensitive" ? c.equals.toLowerCase() : c.equals;
|
|
115
|
-
if (stored !== wanted) return false;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (key === "payload") {
|
|
119
|
-
const { path, equals } = cond as { path: string[]; equals: unknown };
|
|
120
|
-
if (row.payload[path[0] as string] !== equals) return false;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if ((row as Record<string, unknown>)[key] !== cond) return false;
|
|
124
|
-
}
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function matchesScalar(row: Record<string, unknown>, where: Where): boolean {
|
|
129
|
-
for (const [key, cond] of Object.entries(where)) {
|
|
130
|
-
if (cond !== null && typeof cond === "object" && "not" in (cond as object)) {
|
|
131
|
-
if (row[key] === (cond as { not: unknown }).not) return false;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
if (row[key] !== cond) return false;
|
|
135
|
-
}
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function installFixtureMocks(): void {
|
|
140
|
-
(prisma.lead.findFirst as jest.Mock).mockImplementation(
|
|
141
|
-
({ where, orderBy }: { where: Where; orderBy?: { createdAt: "asc" | "desc" } }) => {
|
|
142
|
-
const rows = LEADS.filter((r) => matchesLead(r, where));
|
|
143
|
-
if (orderBy?.createdAt === "desc") rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
144
|
-
else rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
145
|
-
const hit = rows[0];
|
|
146
|
-
return Promise.resolve(hit ? { id: hit.id, payload: hit.payload, createdAt: hit.createdAt } : null);
|
|
147
|
-
},
|
|
148
|
-
);
|
|
149
|
-
(prisma.waitlistScore.findFirst as jest.Mock).mockImplementation(({ where }: { where: Where }) => {
|
|
150
|
-
const hit = SCORES.find((r) => matchesScalar(r, where));
|
|
151
|
-
return Promise.resolve(hit ? { tier: hit.tier, score: hit.score } : null);
|
|
152
|
-
});
|
|
153
|
-
(prisma.flowSession.findFirst as jest.Mock).mockImplementation(({ where }: { where: Where }) => {
|
|
154
|
-
const hit = SESSIONS.find((r) => matchesScalar(r, where));
|
|
155
|
-
return Promise.resolve(hit ? { aeoScore: hit.aeoScore, completedAt: hit.completedAt } : null);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function user(overrides?: Partial<{ email: string; emailVerified: boolean }>) {
|
|
160
|
-
return {
|
|
161
|
-
id: USER_ID,
|
|
162
|
-
email: overrides?.email ?? "alice@example.com",
|
|
163
|
-
emailVerified: overrides?.emailVerified ?? true,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
beforeEach(() => {
|
|
168
|
-
jest.clearAllMocks();
|
|
169
|
-
process.env.AILK_SITE_ID = SITE;
|
|
170
|
-
installFixtureMocks();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
afterAll(() => {
|
|
174
|
-
delete process.env.AILK_SITE_ID;
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
178
|
-
|
|
179
|
-
describe("readMySignup — the verified, found path (D5)", () => {
|
|
180
|
-
it("returns EXACTLY the seven contract keys and only q3 under answers", async () => {
|
|
181
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
182
|
-
|
|
183
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
184
|
-
|
|
185
|
-
expect(result.ok).toBe(true);
|
|
186
|
-
if (!result.ok) throw new Error("unreachable");
|
|
187
|
-
expect(Object.keys(result.value).sort()).toEqual(
|
|
188
|
-
["answers", "aeoScore", "completedAt", "experimentKey", "score", "tier", "waitlistKey"].sort(),
|
|
189
|
-
);
|
|
190
|
-
expect(Object.keys(result.value.answers)).toEqual(["q3"]);
|
|
191
|
-
expect(result.value).toEqual({
|
|
192
|
-
experimentKey: EXPERIMENT,
|
|
193
|
-
waitlistKey: "general",
|
|
194
|
-
completedAt: "2026-09-03T00:00:05.000Z",
|
|
195
|
-
tier: "hot",
|
|
196
|
-
score: 12,
|
|
197
|
-
aeoScore: AEO_UNAVAILABLE,
|
|
198
|
-
answers: { q3: "Our site never ships." },
|
|
199
|
-
});
|
|
200
|
-
// Nothing else from the payload leaves the row.
|
|
201
|
-
const serialized = JSON.stringify(result.value);
|
|
202
|
-
for (const leaked of ["name", "url", "q1", "solo_builder", "alice.example", "@example.com"]) {
|
|
203
|
-
expect(serialized).not.toContain(leaked);
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("reads the User exactly as lead-promotion.ts does (id, email, emailVerified by id)", async () => {
|
|
208
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
209
|
-
|
|
210
|
-
await readMySignup(USER_ID, EXPERIMENT);
|
|
211
|
-
|
|
212
|
-
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
|
213
|
-
where: { id: USER_ID },
|
|
214
|
-
select: { id: true, email: true, emailVerified: true },
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("newest createdAt wins when two leads match (AC-5)", async () => {
|
|
219
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
220
|
-
|
|
221
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
222
|
-
|
|
223
|
-
expect(result.ok && result.value.waitlistKey).toBe("general"); // LEAD_NEW, not LEAD_OLD
|
|
224
|
-
expect(prisma.lead.findFirst).toHaveBeenCalledWith(
|
|
225
|
-
expect.objectContaining({ orderBy: { createdAt: "desc" } }),
|
|
226
|
-
);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("matches a mixed-case stored Lead.email case-insensitively on the trimmed user email (D3)", async () => {
|
|
230
|
-
// Only the OLD lead carries the mixed-case stored email; pick it by
|
|
231
|
-
// restricting the fixture to that row.
|
|
232
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user({ email: " ALICE@example.COM " }));
|
|
233
|
-
(prisma.lead.findFirst as jest.Mock).mockImplementation(({ where }: { where: Where }) => {
|
|
234
|
-
const hit = LEADS.filter((r) => r.id === LEAD_OLD).find((r) => matchesLead(r, where));
|
|
235
|
-
return Promise.resolve(hit ? { id: hit.id, payload: hit.payload, createdAt: hit.createdAt } : null);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
239
|
-
|
|
240
|
-
expect(result.ok && result.value.waitlistKey).toBe("early_bird");
|
|
241
|
-
const call = (prisma.lead.findFirst as jest.Mock).mock.calls[0][0] as { where: Where };
|
|
242
|
-
expect(call.where.email).toEqual({ equals: "alice@example.com", mode: "insensitive" });
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("scopes EVERY read to currentSiteId() and filters the Lead by payload.experimentKey (AC-7)", async () => {
|
|
246
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
247
|
-
|
|
248
|
-
await readMySignup(USER_ID, EXPERIMENT);
|
|
249
|
-
|
|
250
|
-
const leadWhere = (prisma.lead.findFirst as jest.Mock).mock.calls[0][0].where as Where;
|
|
251
|
-
expect(leadWhere.siteId).toBe(SITE);
|
|
252
|
-
expect(leadWhere.payload).toEqual({ path: ["experimentKey"], equals: EXPERIMENT });
|
|
253
|
-
const scoreWhere = (prisma.waitlistScore.findFirst as jest.Mock).mock.calls[0][0].where as Where;
|
|
254
|
-
expect(scoreWhere).toEqual({ leadId: LEAD_NEW, siteId: SITE, experimentKey: EXPERIMENT });
|
|
255
|
-
const sessionWhere = (prisma.flowSession.findFirst as jest.Mock).mock.calls[0][0].where as Where;
|
|
256
|
-
expect(sessionWhere).toEqual({ leadId: LEAD_NEW, siteId: SITE, completedAt: { not: null } });
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("a lead that exists only at another site is signup_not_found (never cross-site)", async () => {
|
|
260
|
-
process.env.AILK_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000cc";
|
|
261
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
262
|
-
|
|
263
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
264
|
-
|
|
265
|
-
expect(result).toEqual({
|
|
266
|
-
ok: false,
|
|
267
|
-
error: { kind: "signup_not_found", message: "Signup not found" },
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("returns null tier/score when the fail-soft score row is absent, and null aeoScore when unparsable", async () => {
|
|
272
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
273
|
-
(prisma.waitlistScore.findFirst as jest.Mock).mockResolvedValue(null);
|
|
274
|
-
(prisma.flowSession.findFirst as jest.Mock).mockResolvedValue({
|
|
275
|
-
aeoScore: { status: "bogus" },
|
|
276
|
-
completedAt: new Date("2026-09-03T00:00:05.000Z"),
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
280
|
-
|
|
281
|
-
expect(result.ok && result.value.tier).toBeNull();
|
|
282
|
-
expect(result.ok && result.value.score).toBeNull();
|
|
283
|
-
expect(result.ok && result.value.aeoScore).toBeNull();
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("falls back to Lead.createdAt for completedAt when no completing session exists, and q3 null when absent", async () => {
|
|
287
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
288
|
-
(prisma.flowSession.findFirst as jest.Mock).mockResolvedValue(null);
|
|
289
|
-
(prisma.lead.findFirst as jest.Mock).mockResolvedValue({
|
|
290
|
-
id: LEAD_NEW,
|
|
291
|
-
payload: { experimentKey: EXPERIMENT, waitlistKey: "general", q3: 42 },
|
|
292
|
-
createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
296
|
-
|
|
297
|
-
expect(result.ok && result.value.completedAt).toBe("2026-09-03T00:00:00.000Z");
|
|
298
|
-
expect(result.ok && result.value.answers.q3).toBeNull();
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
describe("readMySignup — the refusals (D2/D4)", () => {
|
|
303
|
-
it("email_unverified: refuses BEFORE any Lead read (AC-3)", async () => {
|
|
304
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user({ emailVerified: false }));
|
|
305
|
-
|
|
306
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
307
|
-
|
|
308
|
-
expect(result.ok).toBe(false);
|
|
309
|
-
expect(!result.ok && result.error.kind).toBe("email_unverified");
|
|
310
|
-
expect(prisma.lead.findFirst).not.toHaveBeenCalled();
|
|
311
|
-
expect(prisma.waitlistScore.findFirst).not.toHaveBeenCalled();
|
|
312
|
-
expect(prisma.flowSession.findFirst).not.toHaveBeenCalled();
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it("not_found_user: no User row → no Lead read", async () => {
|
|
316
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
317
|
-
|
|
318
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
319
|
-
|
|
320
|
-
expect(!result.ok && result.error.kind).toBe("not_found_user");
|
|
321
|
-
expect(prisma.lead.findFirst).not.toHaveBeenCalled();
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("signup_not_found: a different email owns the leads (AC-4)", async () => {
|
|
325
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user({ email: "bob@example.com" }));
|
|
326
|
-
|
|
327
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
328
|
-
|
|
329
|
-
expect(!result.ok && result.error.kind).toBe("signup_not_found");
|
|
330
|
-
expect(prisma.waitlistScore.findFirst).not.toHaveBeenCalled();
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("signup_not_found: no lead for this experimentKey (AC-4)", async () => {
|
|
334
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
335
|
-
|
|
336
|
-
const result = await readMySignup(USER_ID, "some-other-experiment");
|
|
337
|
-
|
|
338
|
-
expect(!result.ok && result.error.kind).toBe("signup_not_found");
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it("signup_not_found: a corrupt lead missing waitlistKey is not-found, never an out-of-contract value", async () => {
|
|
342
|
-
(prisma.user.findUnique as jest.Mock).mockResolvedValue(user());
|
|
343
|
-
(prisma.lead.findFirst as jest.Mock).mockResolvedValue({
|
|
344
|
-
id: LEAD_NEW,
|
|
345
|
-
payload: { experimentKey: EXPERIMENT, q3: "x" },
|
|
346
|
-
createdAt: new Date("2026-09-03T00:00:00.000Z"),
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
const result = await readMySignup(USER_ID, EXPERIMENT);
|
|
350
|
-
|
|
351
|
-
expect(!result.ok && result.error.kind).toBe("signup_not_found");
|
|
352
|
-
expect(prisma.waitlistScore.findFirst).not.toHaveBeenCalled();
|
|
353
|
-
});
|
|
354
|
-
});
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file flow-aggregate.ts
|
|
3
|
-
* @description The anonymous aggregate-response service (#4771 D1/D2/D3).
|
|
4
|
-
*
|
|
5
|
-
* Read-only (no write verb is reachable from this module — the same
|
|
6
|
-
* `prisma\.[a-zA-Z]+\.(create|update|upsert|delete|deleteMany|updateMany|createMany)`
|
|
7
|
-
* probe `waitlist-dashboard.ts` asserts of itself returns 0 here too).
|
|
8
|
-
*
|
|
9
|
-
* Three scoped reads, each carrying `siteId`:
|
|
10
|
-
* 1. `flowDefinition.findUnique` — existence only, mirroring `serveFlow`'s
|
|
11
|
-
* own lookup (`flow-engine.ts`). A slug with no `FlowDefinition` is
|
|
12
|
-
* `flow_not_found` (FR6).
|
|
13
|
-
* 2. `waitlistExperiment.findMany` — resolves the slug to its backing
|
|
14
|
-
* register (D2), for question metadata + the `resultsReveal` flag. A
|
|
15
|
-
* flow with no backing register is legal (D2) — the response then
|
|
16
|
-
* carries an empty `questions[]`.
|
|
17
|
-
* 3. `flowSession.findMany` — the respondent set: **completed** sessions
|
|
18
|
-
* for `(siteId, flowSlug)`, `completedAt` non-null, regardless of
|
|
19
|
-
* whether a `Lead` was ever created (D1 — a recommending-mode
|
|
20
|
-
* respondent has no Lead until the email moment, which the reveal is
|
|
21
|
-
* shown before).
|
|
22
|
-
*
|
|
23
|
-
* Per-question bucketing is the shared `computeAnswerDistributions`
|
|
24
|
-
* (`./answer-distributions.js`, D4) — the same module `waitlist-dashboard.ts`
|
|
25
|
-
* imports, fed `sessions.map((s) => flattenSessionState(s.state))` rather
|
|
26
|
-
* than `leads.map((l) => l.payload)`.
|
|
27
|
-
*/
|
|
28
|
-
import type { Prisma } from "@working-theory/database";
|
|
29
|
-
import { logger } from "@working-theory/observability";
|
|
30
|
-
import {
|
|
31
|
-
waitlistExperimentConfigSchema,
|
|
32
|
-
type FlowAggregate,
|
|
33
|
-
type FlowAggregateAnswerType,
|
|
34
|
-
type WaitlistExperimentConfig,
|
|
35
|
-
} from "@working-theory/validation";
|
|
36
|
-
|
|
37
|
-
import { prisma } from "../lib/prisma.js";
|
|
38
|
-
|
|
39
|
-
import {
|
|
40
|
-
computeAnswerDistributions,
|
|
41
|
-
logAggregationCapHit,
|
|
42
|
-
MAX_AGGREGATION_ROWS,
|
|
43
|
-
} from "./answer-distributions.js";
|
|
44
|
-
import type { FlowEngineError } from "./flow-engine.js";
|
|
45
|
-
|
|
46
|
-
export type GetFlowAggregateResult =
|
|
47
|
-
{ ok: true; data: FlowAggregate } | { ok: false; error: FlowEngineError };
|
|
48
|
-
|
|
49
|
-
// ─── DB-required guard ──────────────────────────────────────────────────────
|
|
50
|
-
// Mirrors flow-engine.ts's own private `requireDb` (#3956 D5) — not exported
|
|
51
|
-
// there, so re-declared here rather than widening that module's surface for
|
|
52
|
-
// one boolean check (the same engine-local-helper convention `flow-engine.ts`
|
|
53
|
-
// already documents for `flattenFlowState`, below).
|
|
54
|
-
|
|
55
|
-
const FLOWS_NOT_CONFIGURED_ERROR: FlowEngineError = {
|
|
56
|
-
kind: "flows_not_configured",
|
|
57
|
-
message: "Flows are not configured.",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function requireDb(): FlowEngineError | null {
|
|
61
|
-
if (!process.env.DATABASE_URL) return FLOWS_NOT_CONFIGURED_ERROR;
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ─── flattenSessionState ────────────────────────────────────────────────────
|
|
66
|
-
// Mirrors flow-engine.ts's private `flattenFlowState` (:682-689) — collapses
|
|
67
|
-
// FlowSession.state's step-keyed two-level shape (`{ [stepKey]: { [field]:
|
|
68
|
-
// value } }`) into the flat `{ [field]: value }` map `readPayloadAnswer`
|
|
69
|
-
// expects. Not imported: `flattenFlowState` is engine-local and unexported,
|
|
70
|
-
// the exact "mirrors ... engine-local, since that one isn't exported"
|
|
71
|
-
// convention flow-engine.ts's own doc comment already states for its
|
|
72
|
-
// sibling helper. Behaviour is byte-identical to the mirrored function.
|
|
73
|
-
|
|
74
|
-
function flattenSessionState(state: unknown): Record<string, unknown> {
|
|
75
|
-
const flat: Record<string, unknown> = {};
|
|
76
|
-
if (!state || typeof state !== "object") return flat;
|
|
77
|
-
for (const stepKey of Object.keys(state as Record<string, unknown>)) {
|
|
78
|
-
const stepData = (state as Record<string, unknown>)[stepKey];
|
|
79
|
-
if (stepData && typeof stepData === "object") Object.assign(flat, stepData);
|
|
80
|
-
}
|
|
81
|
-
return flat;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ─── Row shapes ──────────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
const FLOW_DEFINITION_EXISTS_SELECT = {
|
|
87
|
-
id: true,
|
|
88
|
-
} as const satisfies Prisma.FlowDefinitionSelect;
|
|
89
|
-
|
|
90
|
-
const EXPERIMENT_CONFIG_SELECT = {
|
|
91
|
-
id: true,
|
|
92
|
-
config: true,
|
|
93
|
-
} as const satisfies Prisma.WaitlistExperimentSelect;
|
|
94
|
-
type ExperimentConfigRow = Omit<
|
|
95
|
-
Prisma.WaitlistExperimentGetPayload<{
|
|
96
|
-
select: typeof EXPERIMENT_CONFIG_SELECT;
|
|
97
|
-
}>,
|
|
98
|
-
"config"
|
|
99
|
-
> & { config: unknown };
|
|
100
|
-
|
|
101
|
-
const FLOW_SESSION_STATE_SELECT = {
|
|
102
|
-
state: true,
|
|
103
|
-
} as const satisfies Prisma.FlowSessionSelect;
|
|
104
|
-
type FlowSessionStateRow = Omit<
|
|
105
|
-
Prisma.FlowSessionGetPayload<{ select: typeof FLOW_SESSION_STATE_SELECT }>,
|
|
106
|
-
"state"
|
|
107
|
-
> & { state: unknown };
|
|
108
|
-
|
|
109
|
-
// ─── getFlowAggregate ────────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
export async function getFlowAggregate(
|
|
112
|
-
siteId: string,
|
|
113
|
-
slug: string,
|
|
114
|
-
): Promise<GetFlowAggregateResult> {
|
|
115
|
-
const dbError = requireDb();
|
|
116
|
-
if (dbError) return { ok: false, error: dbError };
|
|
117
|
-
|
|
118
|
-
// Existence check only — mirrors serveFlow's own FlowDefinition lookup
|
|
119
|
-
// (flow-engine.ts), so "unknown slug" resolves identically for both
|
|
120
|
-
// routes (FR6).
|
|
121
|
-
const definition = await prisma.flowDefinition.findUnique({
|
|
122
|
-
where: { siteId_slug: { siteId, slug } },
|
|
123
|
-
select: FLOW_DEFINITION_EXISTS_SELECT,
|
|
124
|
-
});
|
|
125
|
-
if (!definition) {
|
|
126
|
-
return {
|
|
127
|
-
ok: false,
|
|
128
|
-
error: { kind: "flow_not_found", message: "Flow not found." },
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// D2 — question metadata + the resultsReveal flag come from the register,
|
|
133
|
-
// not the compiled flow. A flow with no backing register is legal (D2) —
|
|
134
|
-
// `matches` is then empty and `flaggedQuestions` stays [].
|
|
135
|
-
const experiments: ExperimentConfigRow[] =
|
|
136
|
-
await prisma.waitlistExperiment.findMany({
|
|
137
|
-
where: { siteId },
|
|
138
|
-
select: EXPERIMENT_CONFIG_SELECT,
|
|
139
|
-
// Deterministic read order (code-review finding, #4771) — without an
|
|
140
|
-
// explicit orderBy, Postgres does not guarantee row order, which
|
|
141
|
-
// would make the matches.length > 1 fallback below pick a genuinely
|
|
142
|
-
// arbitrary row rather than a defined "oldest wins" policy.
|
|
143
|
-
orderBy: { createdAt: "asc" },
|
|
144
|
-
// Bounds this anonymous read the same way the sibling
|
|
145
|
-
// flowSession.findMany read below is bounded (#5109).
|
|
146
|
-
take: MAX_AGGREGATION_ROWS,
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
const matches: WaitlistExperimentConfig[] = [];
|
|
150
|
-
for (const row of experiments) {
|
|
151
|
-
const parsed = waitlistExperimentConfigSchema.safeParse(row.config);
|
|
152
|
-
if (!parsed.success) {
|
|
153
|
-
// A corrupted register row must not abort an anonymous read — the
|
|
154
|
-
// same "skip, don't throw" posture waitlist-dashboard.ts's
|
|
155
|
-
// `listExperiments` applies to a corrupt config under kind:"all".
|
|
156
|
-
logger.warn(
|
|
157
|
-
{ siteId, experimentId: row.id },
|
|
158
|
-
"[flow-aggregate] experiment config failed to parse — skipped",
|
|
159
|
-
);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (parsed.data.waitlists.some((w) => w.flowSlug === slug)) {
|
|
163
|
-
matches.push(parsed.data);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (matches.length > 1) {
|
|
167
|
-
// Spec §5.3 escalation boundary — a duplicate flowSlug across
|
|
168
|
-
// experiments for one site is a register-integrity question this
|
|
169
|
-
// service does not resolve arbitrarily. Logged rather than thrown so
|
|
170
|
-
// the anonymous read still degrades (first match, by the oldest
|
|
171
|
-
// `createdAt` per the orderBy above) instead of 500ing a public
|
|
172
|
-
// endpoint.
|
|
173
|
-
logger.warn(
|
|
174
|
-
{ siteId, slug, matchCount: matches.length },
|
|
175
|
-
"[flow-aggregate] slug resolves to more than one WaitlistExperiment — using the first",
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
const config = matches[0];
|
|
179
|
-
const flaggedQuestions = (config?.questionSet.questions ?? []).filter(
|
|
180
|
-
(q) => q.resultsReveal === true,
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
// D1 — respondents are completed FlowSession rows, not Leads.
|
|
184
|
-
const sessions: FlowSessionStateRow[] = await prisma.flowSession.findMany({
|
|
185
|
-
where: { siteId, flowSlug: slug, completedAt: { not: null } },
|
|
186
|
-
select: FLOW_SESSION_STATE_SELECT,
|
|
187
|
-
// Deterministic read order (code-review finding, #4771) — earliest
|
|
188
|
-
// completions first, so a flow that exceeds MAX_AGGREGATION_ROWS drops
|
|
189
|
-
// its most recent respondents consistently rather than an arbitrary set.
|
|
190
|
-
orderBy: { completedAt: "asc" },
|
|
191
|
-
take: MAX_AGGREGATION_ROWS,
|
|
192
|
-
});
|
|
193
|
-
logAggregationCapHit(`flow-aggregate.${slug}`, sessions.length);
|
|
194
|
-
|
|
195
|
-
const answerSets = sessions.map((session) =>
|
|
196
|
-
flattenSessionState(session.state),
|
|
197
|
-
);
|
|
198
|
-
const distributions = computeAnswerDistributions(
|
|
199
|
-
flaggedQuestions,
|
|
200
|
-
answerSets,
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
const questions: FlowAggregate["questions"] = distributions.map((d, i) => {
|
|
204
|
-
const question = flaggedQuestions[i]!;
|
|
205
|
-
const labelsByValue = new Map(
|
|
206
|
-
(question.options ?? []).map((o) => [o.value, o.label]),
|
|
207
|
-
);
|
|
208
|
-
return {
|
|
209
|
-
id: d.questionId,
|
|
210
|
-
// Safe: flaggedQuestions is filtered to resultsReveal === true, and
|
|
211
|
-
// waitlist.ts's own superRefine only permits that flag on single/multi
|
|
212
|
-
// questions (D3) — no other answerType is reachable here.
|
|
213
|
-
answerType: d.answerType as FlowAggregateAnswerType,
|
|
214
|
-
answered: d.answered,
|
|
215
|
-
// Declared options only — an off-register "(other)" bucket has no
|
|
216
|
-
// register label to ship, and this schema's `label` is required
|
|
217
|
-
// (never fabricated). Still counted in `answered` above.
|
|
218
|
-
options: d.options
|
|
219
|
-
.filter((o) => labelsByValue.has(o.value))
|
|
220
|
-
.map((o) => ({
|
|
221
|
-
value: o.value,
|
|
222
|
-
label: labelsByValue.get(o.value)!,
|
|
223
|
-
count: o.count,
|
|
224
|
-
})),
|
|
225
|
-
};
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
return {
|
|
229
|
-
ok: true,
|
|
230
|
-
data: { slug, respondents: sessions.length, questions },
|
|
231
|
-
};
|
|
232
|
-
}
|