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.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. 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
- }