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,490 +0,0 @@
1
- /**
2
- * @file project-listing-decision.ts
3
- * @description The one Approve/Reject transition for a `ProjectListing`
4
- * (#4999) — the SAME function `PATCH /v1/project-listings/:id` (tenant-scoped,
5
- * via `routes/project-listings/patch.ts`) and `POST /v1/slack/actions` (the
6
- * Slack button click, no tenant context) both call, so the stamp + backlink
7
- * logic exists in exactly one place.
8
- *
9
- * `siteId` is optional: the tenant-scoped PATCH route passes it (so a foreign
10
- * listing id 404s, matching every other tenant-scoped read/write in this
11
- * service group); the Slack handler omits it (the button's `value` carries
12
- * only the listing id — no tenant context to check).
13
- *
14
- * Approve stamps `windowEndsAt = decidedAt + the row's windowDays`, the
15
- * applicant's requested window already clamped at create against the site's
16
- * `SiteListingConfig` (#5033 D4). The ninety days this used to hardcode were
17
- * one site's commercial terms; there is no duration constant here now. A row
18
- * already decided (`status` already `active` or `rejected`) is idempotent —
19
- * `{ ok: true, changed: false }`, the row untouched, no second stamp — the
20
- * "second click" AC (#4999): both the PATCH route (an operator re-submitting
21
- * the same decision) and the Slack route (a stale button click after the
22
- * message has already updated) see the same 200/no-op shape. Only a listing
23
- * still `requested` can transition; anything else (an already-decided row
24
- * asking for a DIFFERENT decision, or a `draft` asking to be decided) is
25
- * `invalid_transition`.
26
- *
27
- * `value` is a strict `ProjectListing` (safe to pass straight through
28
- * `routes/project-listings/respond.ts#sendValidated`, which re-parses
29
- * against `projectListingSchema.strict()`); `slackChannel`/`slackTs` live
30
- * OUTSIDE it, in the sibling `slack` field, so an unexpected key never
31
- * reaches that contract check. The row shape/select and the `waitlistCount`
32
- * join still mirror `services/project-listings.ts` rather than importing it,
33
- * but the one piece that MUST NOT drift — the computed `closed` state — is
34
- * now imported from there (`computeListingStatus`), so both modules resolve a
35
- * row's lifecycle status through the same function.
36
- *
37
- * Also exports `sendListingDecisionEmail` — the founder email dispatch
38
- * `routes/slack/actions.ts` (on a changed Slack decision) and
39
- * `routes/project-listings/patch.ts` (on a changed PATCH decision) both
40
- * call, so the founder is emailed exactly once regardless of which channel
41
- * decided. See that function's own doc comment for the env-var reuse this
42
- * issue makes (no new email-specific env var beyond the three Slack ones).
43
- * On an APPROVE, it also fires `fileListingIssue` (#5000,
44
- * `project-listing-issue.ts`) fire-and-forget after the email — the one
45
- * work-order GitHub issue for the row, on the site's own repo. Both
46
- * callers reach it through this one function, so the filing (like the
47
- * email) fires exactly once per changed decision, never per caller.
48
- *
49
- * Also exports `onListingDecided` — the approve-path side-effect seam (#5001):
50
- * a `requested` → `active` transition projects the row into its live
51
- * diagnostic (`generateListingFlow`). `decideListing` calls it after a CHANGED
52
- * approve, and since #5033 collapsed the lifecycle that is the ONLY caller —
53
- * `requested → active | rejected` is the only transition there is, and both
54
- * the PATCH route and the Slack click reach it through `decideListing`.
55
- * Fail-soft by construction — a projection failure is logged
56
- * and swallowed; the decision has already committed. #5000 (the build issue)
57
- * joins the same seam.
58
- *
59
- * PII boundary: `decideListing` never logs (the DB mutation only).
60
- * `sendListingDecisionEmail` logs `listingId`/`decision`/an error KIND only —
61
- * never founderEmail/founderName/questions (mirrors
62
- * `services/project-listings.ts` and `services/lead-routing.ts`'s
63
- * discipline).
64
- */
65
-
66
- import type { Prisma } from "@working-theory/database";
67
- import { sendListingApprovedEmail, sendListingRejectedEmail } from "@working-theory/email";
68
- import { logger } from "@working-theory/observability";
69
- import {
70
- clampWindowDays,
71
- projectListingQuestionsSchema,
72
- type ProjectListing,
73
- type ProjectListingFaqs,
74
- type ProjectListingQuestions,
75
- } from "@working-theory/validation";
76
-
77
- import { config } from "../config/index.js";
78
- import { prisma } from "../lib/prisma.js";
79
-
80
- import { listingConfigFor } from "./listing-config.js";
81
- import { generateListingFlow } from "./project-listing-flow.js";
82
- import { fileListingIssue } from "./project-listing-issue.js";
83
- import { computeListingStatus } from "./project-listings.js";
84
-
85
- export type ListingDecision = "active" | "rejected";
86
-
87
- /** Days to milliseconds. A unit conversion, not a policy duration. */
88
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
89
-
90
- const SELECT = {
91
- id: true,
92
- siteId: true,
93
- leadId: true,
94
- slug: true,
95
- name: true,
96
- oneLiner: true,
97
- problem: true,
98
- url: true,
99
- product: true,
100
- commitment: true,
101
- teamComposition: true,
102
- faqs: true,
103
- anythingElse: true,
104
- address: true,
105
- questions: true,
106
- founderName: true,
107
- founderEmail: true,
108
- founderCompany: true,
109
- headline: true,
110
- logoMarkUrl: true,
111
- logoWordmarkUrl: true,
112
- featured: true,
113
- status: true,
114
- consentAt: true,
115
- decidedAt: true,
116
- decidedBy: true,
117
- issueUrl: true,
118
- experimentKey: true,
119
- windowDays: true,
120
- windowEndsAt: true,
121
- slackChannel: true,
122
- slackTs: true,
123
- createdAt: true,
124
- updatedAt: true,
125
- } as const satisfies Prisma.ProjectListingSelect;
126
-
127
- type Row = Prisma.ProjectListingGetPayload<{ select: typeof SELECT }>;
128
-
129
- /**
130
- * Mirrors `project-listings.ts#attachWaitlistCounts`'s join shape for the
131
- * single row this module ever handles.
132
- *
133
- * Since #5036 D6d the listing stores the experiment's KEY, so this is one
134
- * count against `WaitlistScore.experimentKey` — the `WaitlistExperiment` read
135
- * that existed only to translate an id into that key is gone from both
136
- * modules.
137
- */
138
- async function waitlistCountFor(row: Row): Promise<number> {
139
- if (!row.experimentKey) return 0;
140
- return prisma.waitlistScore.count({
141
- where: { siteId: row.siteId, experimentKey: row.experimentKey },
142
- });
143
- }
144
-
145
- function toListing(row: Row, waitlistCount: number): ProjectListing {
146
- return {
147
- id: row.id,
148
- siteId: row.siteId,
149
- leadId: row.leadId,
150
- slug: row.slug,
151
- name: row.name,
152
- headline: row.headline,
153
- oneLiner: row.oneLiner,
154
- problem: row.problem,
155
- url: row.url,
156
- product: row.product as ProjectListing["product"],
157
- commitment: row.commitment as ProjectListing["commitment"],
158
- teamComposition: row.teamComposition as ProjectListing["teamComposition"],
159
- faqs: row.faqs as unknown as ProjectListingFaqs | null,
160
- anythingElse: row.anythingElse,
161
- address: row.address,
162
- founderName: row.founderName,
163
- founderEmail: row.founderEmail,
164
- founderCompany: row.founderCompany,
165
- questions: row.questions as unknown as ProjectListingQuestions | null,
166
- logoMarkUrl: row.logoMarkUrl,
167
- logoWordmarkUrl: row.logoWordmarkUrl,
168
- featured: row.featured,
169
- status: computeListingStatus(row.status, row.windowEndsAt, new Date(), row.featured),
170
- consentAt: row.consentAt.toISOString(),
171
- decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
172
- decidedBy: row.decidedBy,
173
- issueUrl: row.issueUrl,
174
- experimentKey: row.experimentKey,
175
- windowEndsAt: row.windowEndsAt ? row.windowEndsAt.toISOString() : null,
176
- waitlistCount,
177
- createdAt: row.createdAt.toISOString(),
178
- updatedAt: row.updatedAt.toISOString(),
179
- };
180
- }
181
-
182
- export type DecideListingInput = {
183
- id: string;
184
- decision: ListingDecision;
185
- decidedBy: string;
186
- /** Tenant scope for the PATCH route; omitted by the Slack handler (no tenant context on a button click). */
187
- siteId?: string;
188
- };
189
-
190
- export type DecideListingResult =
191
- | {
192
- ok: true;
193
- changed: boolean;
194
- value: ProjectListing;
195
- /** The row's posted-message coordinates, for the Slack route's `chat.update` — never part of `value`. */
196
- slack: { channel: string | null; ts: string | null };
197
- }
198
- | {
199
- ok: false;
200
- error: { kind: "not_found" | "invalid_transition"; message: string };
201
- };
202
-
203
- const DECIDED_STATUSES: ReadonlySet<string> = new Set(["active", "rejected"]);
204
-
205
- /**
206
- * The window, in whole days, to stamp on an approve. The row's own
207
- * `windowDays` (the applicant's request, clamped at create against the site's
208
- * terms) when it has one; otherwise the site's configured default clamped the
209
- * same way, for a row that was already pending when the column shipped.
210
- * `null` when the row does not exist — the caller's fresh read reports that.
211
- */
212
- async function windowDaysFor(id: string, siteId: string | undefined): Promise<number | null> {
213
- const row = await prisma.projectListing.findFirst({
214
- where: siteId !== undefined ? { id, siteId } : { id },
215
- select: { siteId: true, windowDays: true },
216
- });
217
- if (!row) return null;
218
- if (row.windowDays !== null) return row.windowDays;
219
- return clampWindowDays(undefined, await listingConfigFor(row.siteId));
220
- }
221
-
222
- /**
223
- * The transition write is a GUARDED `updateMany` — `where` includes
224
- * `status: "applied"`, so the write only lands if the row is STILL `applied`
225
- * at the moment Postgres evaluates it (row-level locking makes this atomic;
226
- * mirrors the compare-and-swap shape `routes/webhooks/stripe.ts#stampFlowCheckout`
227
- * uses on `FlowCheckout.status`). `count === 1` means THIS call made the
228
- * transition; `count === 0` means it didn't — either the row was never
229
- * `applied` to begin with, or a CONCURRENT call already decided it between
230
- * this call's own read and write (security review, PR #4999: a plain
231
- * read-then-write here would let two racing calls — e.g. Slack's own retry,
232
- * or a Slack click racing an operator's PATCH — both observe `applied` and
233
- * both "succeed", double-sending the founder email and possibly leaving the
234
- * row in whichever decision wrote last). Either way, a fresh read after the
235
- * guarded write tells the caller which: already decided (idempotent
236
- * `changed: false`) or a genuinely invalid transition (`live`/`closed`).
237
- */
238
- export async function decideListing({
239
- id,
240
- decision,
241
- decidedBy,
242
- siteId,
243
- }: DecideListingInput): Promise<DecideListingResult> {
244
- const decidedAt = new Date();
245
-
246
- // #5033 — the window this listing was granted, resolved BEFORE the guarded
247
- // write because a `updateMany` cannot read a column it is also setting.
248
- // `windowDays` is immutable once stamped at create, so reading it outside
249
- // the guard is not part of the compare-and-swap. A row that predates the
250
- // column (an application still pending when this shipped) has none, and
251
- // takes the site's configured default instead.
252
- const windowDays = await windowDaysFor(id, siteId);
253
-
254
- const updateResult = await prisma.projectListing.updateMany({
255
- where: siteId !== undefined ? { id, siteId, status: "requested" } : { id, status: "requested" },
256
- data: {
257
- status: decision,
258
- decidedAt,
259
- decidedBy,
260
- ...(decision === "active" &&
261
- windowDays !== null && {
262
- windowEndsAt: new Date(decidedAt.getTime() + windowDays * MS_PER_DAY),
263
- }),
264
- },
265
- });
266
-
267
- const current = await prisma.projectListing.findFirst({
268
- where: siteId !== undefined ? { id, siteId } : { id },
269
- select: SELECT,
270
- });
271
- if (!current) {
272
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
273
- }
274
-
275
- if (updateResult.count === 1) {
276
- // #5001 — the approve's downstream effects (the diagnostic projection).
277
- // Never throws; a failure is logged and the decision stands. Re-read
278
- // afterwards so `value.experimentKey` reflects the projection.
279
- await onListingDecided(current.siteId, current.id, decision);
280
- const projected =
281
- (await prisma.projectListing.findFirst({ where: { id: current.id }, select: SELECT })) ?? current;
282
- const waitlistCount = await waitlistCountFor(projected);
283
- return {
284
- ok: true,
285
- changed: true,
286
- value: toListing(projected, waitlistCount),
287
- slack: { channel: projected.slackChannel, ts: projected.slackTs },
288
- };
289
- }
290
-
291
- // The guarded write matched nothing — the row wasn't `applied` when
292
- // Postgres evaluated the guard. Read its CURRENT status to tell an
293
- // already-decided idempotent no-op from a genuinely invalid transition.
294
- if (DECIDED_STATUSES.has(current.status)) {
295
- const waitlistCount = await waitlistCountFor(current);
296
- return {
297
- ok: true,
298
- changed: false,
299
- value: toListing(current, waitlistCount),
300
- slack: { channel: current.slackChannel, ts: current.slackTs },
301
- };
302
- }
303
-
304
- return {
305
- ok: false,
306
- error: {
307
- kind: "invalid_transition",
308
- message: `cannot transition from '${current.status}' to '${decision}'`,
309
- },
310
- };
311
- }
312
-
313
- /**
314
- * Sends the founder's approve/reject email for a just-decided listing
315
- * (#4999 step 5).
316
- *
317
- * Reasonable-call env-var reuse (this issue's footprint is the three Slack
318
- * vars only — no new email-specific var): `fromAddress` reads the existing
319
- * `AILK_LEAD_NOTIFICATION_FROM` (already the site's configured "From:" for
320
- * lead-facing system email, per `services/lead-routing.ts`); the Working
321
- * Session and diagnostic links are derived from the existing
322
- * `config.FRONTEND_URL` + fixed paths. The diagnostic page itself is #5001's
323
- * build — this link is a forward reference to a route that doesn't exist
324
- * yet. `brand` is left unset (the template's clean, unbranded default) since
325
- * no per-site branding source is wired into `apps/api` today.
326
- *
327
- * Never throws; a missing `AILK_LEAD_NOTIFICATION_FROM` no-ops (logs
328
- * `listing-email-skipped`, id + decision only) rather than failing the
329
- * decision that has already landed.
330
- *
331
- * On an APPROVE, also fires `fileListingIssue` (#5000) after the email —
332
- * fire-and-forget, `.catch`-guarded on top of that function's own internal
333
- * try/catch, so neither a slow GitHub call nor a filing failure can delay or
334
- * reach the approve path that has already committed. Runs regardless of the
335
- * email's own outcome (a Resend failure and a GitHub failure are independent
336
- * best-effort side effects of the same committed decision).
337
- */
338
- export async function sendListingDecisionEmail(
339
- listing: ProjectListing,
340
- decision: ListingDecision,
341
- ): Promise<void> {
342
- const fromAddress = process.env["AILK_LEAD_NOTIFICATION_FROM"];
343
- if (!fromAddress) {
344
- logger.info({ listingId: listing.id, decision }, "listing-email-skipped");
345
- fileIssueOnApprove(listing, decision);
346
- return;
347
- }
348
-
349
- const workingSession = `${config.FRONTEND_URL}/working-session`;
350
- // #5036 D1 — `name` and `slug` are nullable at rest so a half-filled draft
351
- // is storable, but a decision only ever lands on a SUBMITTED row, which
352
- // proved both. The fallbacks discharge the type, they do not paper over a
353
- // reachable state.
354
- const projectName = listing.name ?? listing.founderName;
355
- const diagnosticSlug = listing.slug ?? listing.id;
356
-
357
- const result =
358
- decision === "active"
359
- ? await sendListingApprovedEmail({
360
- to: listing.founderEmail,
361
- fromAddress,
362
- founderName: listing.founderName,
363
- projectName,
364
- links: { workingSession },
365
- })
366
- : await sendListingRejectedEmail({
367
- to: listing.founderEmail,
368
- fromAddress,
369
- founderName: listing.founderName,
370
- projectName,
371
- links: { workingSession, diagnostic: `${config.FRONTEND_URL}/diagnostic/${diagnosticSlug}` },
372
- });
373
-
374
- if (!result.ok) {
375
- logger.warn(
376
- { listingId: listing.id, decision, errorKind: result.error.kind },
377
- "listing email send failed",
378
- );
379
- }
380
-
381
- fileIssueOnApprove(listing, decision);
382
- }
383
-
384
- /**
385
- * Fire-and-forget entry to `fileListingIssue` (#5000) — a no-op on
386
- * `rejected`. `fileListingIssue` already never throws (its own try/catch
387
- * around the fetch + DB write); this `.catch` is a second boundary for the
388
- * one thing outside that try — `renderListingIssue` running before it — so
389
- * NOTHING from this call can ever propagate into `sendListingDecisionEmail`'s
390
- * caller.
391
- */
392
- function fileIssueOnApprove(listing: ProjectListing, decision: ListingDecision): void {
393
- if (decision !== "active") return;
394
- fileListingIssue(listing).catch((err: unknown) => {
395
- logger.warn(
396
- { listingId: listing.id, errName: err instanceof Error ? err.name : "UnknownError" },
397
- "listing issue filing rejected unexpectedly",
398
- );
399
- });
400
- }
401
-
402
- // ─── #5001 — the approve-path side-effect seam ─────────────────────────────
403
-
404
- /** The status whose arrival triggers the projection — the approved state. */
405
- const APPROVED = "active";
406
-
407
- /**
408
- * Run the decision's downstream effects for one listing. Called by
409
- * `patchProjectListing` immediately after a successful transition write;
410
- * never throws.
411
- */
412
- export async function onListingDecided(
413
- siteId: string,
414
- listingId: string,
415
- toStatus: string,
416
- ): Promise<void> {
417
- if (toStatus !== APPROVED) return;
418
-
419
- try {
420
- const row = await prisma.projectListing.findFirst({
421
- where: { id: listingId, siteId },
422
- select: {
423
- id: true,
424
- siteId: true,
425
- slug: true,
426
- name: true,
427
- oneLiner: true,
428
- questions: true,
429
- },
430
- });
431
- if (!row) return;
432
-
433
- // The stored `questions` blob is validated on write (#4998's POST route)
434
- // — re-parsing here is defense in depth, and the one place a row that
435
- // predates or drifts from the template is skipped rather than projected
436
- // into a malformed flow.
437
- // #5036 D1 — `slug`/`name`/`oneLiner` are nullable at rest so a half-filled
438
- // draft is storable. A row can only reach `active` through `submit`, which
439
- // asserts all three, so this discharges a type-level possibility rather
440
- // than a reachable state — and skips rather than throwing, matching the
441
- // fail-soft contract this whole seam carries.
442
- if (row.slug === null || row.name === null || row.oneLiner === null) {
443
- logger.warn(
444
- { siteId, listingId, kind: "incomplete_listing" },
445
- "[project-listing-decision] skipping flow generation — an approved listing is missing submit-time fields",
446
- );
447
- return;
448
- }
449
-
450
- const questions = projectListingQuestionsSchema.safeParse(row.questions);
451
- if (!questions.success) {
452
- logger.warn(
453
- { siteId, listingId, slug: row.slug, kind: "invalid_questions" },
454
- "[project-listing-decision] skipping flow generation — stored questions failed schema validation",
455
- );
456
- return;
457
- }
458
-
459
- const result = await generateListingFlow({
460
- id: row.id,
461
- siteId: row.siteId,
462
- slug: row.slug,
463
- name: row.name,
464
- oneLiner: row.oneLiner,
465
- questions: questions.data,
466
- });
467
-
468
- if (!result.ok) {
469
- logger.error(
470
- { siteId, listingId, slug: row.slug, kind: result.error.kind },
471
- "[project-listing-decision] flow generation failed — the listing is approved but has no diagnostic",
472
- );
473
- return;
474
- }
475
-
476
- logger.info(
477
- { siteId, listingId, slug: row.slug, kind: "generated" },
478
- "[project-listing-decision] diagnostic flow + experiment generated",
479
- );
480
- } catch (err) {
481
- logger.error(
482
- {
483
- siteId,
484
- listingId,
485
- errKind: err instanceof Error ? err.constructor.name : typeof err,
486
- },
487
- "[project-listing-decision] approve hook failed — the transition still succeeded",
488
- );
489
- }
490
- }