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