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