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,426 +0,0 @@
1
- /**
2
- * @file project-listing-flow.ts
3
- * @description The approve-time projection (#5001): a `ProjectListing` row's
4
- * `questions` block becomes a live diagnostic — one `FlowDefinition` the
5
- * visitor walks at `/v1/flows/{slug}` and one `WaitlistExperiment` keyed
6
- * `{slug}` that scores the walk through the shared scoring rule
7
- * (`@working-theory/database/flows/listing-diagnostic`).
8
- *
9
- * `generateListingFlow` writes through the two existing validated paths —
10
- * `upsertFlowDefinition` (`flow-engine.ts`) and `upsertWaitlistExperiment`
11
- * (`waitlist-experiments.ts`) — never a second ad-hoc Prisma write, and
12
- * never `seedWaitlistExperiments`/`materializeWaitlistFlows`. Both underlying
13
- * writes upsert on `[siteId, slug]` / `[siteId, key]`, and the `experimentKey`
14
- * write-back is the same value every time, so re-approving a listing is a
15
- * no-op rather than a duplicate.
16
- *
17
- * ONE vocabulary for the scored questions, since #5036 D4: the flow renders
18
- * the founder's own option LABELS under the founder's own option VALUES, the
19
- * register keys its `optionWeights` on those same values
20
- * (`buildListingDiagnosticScoring`), and the experiment's `questionSet`
21
- * declares that same per-listing option list — which is what satisfies
22
- * `waitlistExperimentConfigSchema`'s "every weights key must be a declared
23
- * option of the question it names" cross-validation. The old
24
- * `q{n}:{i}of{k}` positional token, and the finite value universe it forced
25
- * the question set to declare, are both gone. Only the EXTRAS keep a
26
- * positional encoding (`x1:0of3`); they are unscored, so nothing resolves
27
- * them by value.
28
- *
29
- * The tier NEVER reaches the visitor: the flow's terminal `message` step
30
- * carries the confirmation line and the founder's own one-liner, and the
31
- * config declares no `thankYou` / `deliverables` block, so nothing on the
32
- * visitor's path can render a scoring outcome.
33
- *
34
- * PII boundary: logs and returned errors carry only `{ siteId, listingId,
35
- * slug }` — never `founderEmail`, `founderName`, or `questions` (the same
36
- * discipline `project-listings.ts` and `lead-routing.ts` document).
37
- */
38
-
39
- import { buildListingDiagnosticScoring } from "@working-theory/database/flows/listing-diagnostic";
40
- import type {
41
- FlowConfig,
42
- FlowStep,
43
- ProjectListingQuestions,
44
- WaitlistQuestion,
45
- } from "@working-theory/validation";
46
-
47
- import { prisma } from "../lib/prisma.js";
48
-
49
- import { upsertFlowDefinition } from "./flow-engine.js";
50
- import { upsertWaitlistExperiment } from "./waitlist-experiments.js";
51
-
52
- // ─── Types ────────────────────────────────────────────────────────────────────
53
-
54
- /** The subset of the row the projection reads. */
55
- export type ListingFlowSource = {
56
- id: string;
57
- siteId: string;
58
- slug: string;
59
- name: string;
60
- oneLiner: string;
61
- questions: ProjectListingQuestions;
62
- };
63
-
64
- export type GenerateListingFlowError = {
65
- kind: "invalid_input" | "flows_not_configured";
66
- message: string;
67
- };
68
-
69
- export type GenerateListingFlowResult =
70
- | { ok: true; flowSlug: string; experimentKey: string }
71
- | { ok: false; error: GenerateListingFlowError };
72
-
73
- const DEFAULT_LOCALE = "en";
74
- const en = (s: string) => ({ [DEFAULT_LOCALE]: s });
75
-
76
- /** At most three extras (`projectListingQuestionsSchema`). */
77
- type ExtraIndex = 1 | 2 | 3;
78
-
79
- // ─── Identifier derivation ──────────────────────────────────────────────────
80
-
81
- /**
82
- * The register-internal waitlist key for a listing slug. A listing slug is
83
- * kebab AND may start with a digit (`^[a-z0-9-]{3,48}$`, #4998), while
84
- * `flowIdentifierSchema` demands `^[a-z][a-z0-9_]*$` — so the `listing_`
85
- * prefix is load-bearing, not decoration. 8 + 48 = 56 ≤ the 64-char cap.
86
- */
87
- export function listingWaitlistKey(slug: string): string {
88
- return `listing_${slug.replace(/-/g, "_")}`;
89
- }
90
-
91
- /** `extra_1` … `extra_3` — the flow field name and question id for one extra. */
92
- function extraFieldName(index: ExtraIndex): string {
93
- return `extra_${index}`;
94
- }
95
-
96
- /**
97
- * An extra's option value — `x1:0of3`. Extras carry no points and no
98
- * register resolves them, so the positional encoding survives #5036 D4's move
99
- * to value-keyed scoring: nothing depends on an extra's value MEANING
100
- * anything, only on it being stable and listing-independent for the CSV and
101
- * any later reader.
102
- */
103
- function extraOptionValue(index: ExtraIndex, optionIndex: number, arity: number): string {
104
- return `x${index}:${optionIndex}of${arity}`;
105
- }
106
-
107
- // ─── The flow (what the visitor walks) ──────────────────────────────────────
108
-
109
- /**
110
- * One option as every optioned question declares it — the founder's own
111
- * `{ value, label }` pair. Since #5036 D4 the scored Q3–Q6 carry exactly the
112
- * same shape as the unscored Q2: no positional rewrite in between.
113
- */
114
- type OptionPair = { value: string; label: string };
115
-
116
- /** One single-select `field` step — the shape every diagnostic question takes. */
117
- function selectStep(
118
- key: string,
119
- fieldName: string,
120
- prompt: string,
121
- options: readonly OptionPair[],
122
- ): FlowStep {
123
- return {
124
- key,
125
- type: "field",
126
- copy: { title: en(prompt) },
127
- fields: [
128
- {
129
- name: fieldName,
130
- inputType: "select",
131
- required: true,
132
- options: options.map((o) => ({ value: o.value, label: en(o.label) })),
133
- copy: { label: en(prompt) },
134
- },
135
- ],
136
- };
137
- }
138
-
139
- /**
140
- * The six-category step order (#5001): contact → segment → problem → fit →
141
- * urgency → price → open → extras, then the confirmation.
142
- */
143
- function buildFlowSteps(listing: ListingFlowSource): FlowStep[] {
144
- const { questions } = listing;
145
-
146
- const steps: FlowStep[] = [
147
- {
148
- key: "contact",
149
- type: "field",
150
- copy: { title: en(`Join the ${listing.name} waitlist`) },
151
- fields: [
152
- {
153
- name: "name",
154
- inputType: "text",
155
- mapsTo: "name",
156
- copy: { label: en("Name") },
157
- },
158
- {
159
- name: "email",
160
- inputType: "email",
161
- required: true,
162
- mapsTo: "email",
163
- copy: { label: en("Email") },
164
- },
165
- ],
166
- },
167
- // Every optioned question — the unscored Q2 (including the fixed
168
- // `just-curious` bail-out its schema pins last) and the scored Q3–Q6
169
- // alike — renders under the founder's OWN option values, so the raw value
170
- // in the session state the weekly CSV reads is the same value the
171
- // register scores.
172
- selectStep("segment", "q2", questions.q2.prompt, questions.q2.options),
173
- selectStep("problem", "q3", questions.q3.prompt, questions.q3.options),
174
- selectStep("fit", "q4", questions.q4.prompt, questions.q4.options),
175
- selectStep("urgency", "q5", questions.q5.prompt, questions.q5.options),
176
- selectStep("price", "q6", questions.q6.prompt, questions.q6.options),
177
- {
178
- key: "open",
179
- type: "field",
180
- copy: { title: en(questions.q7.prompt) },
181
- fields: [
182
- {
183
- name: "q7",
184
- inputType: "textarea",
185
- copy: { label: en(questions.q7.prompt) },
186
- },
187
- ],
188
- },
189
- ];
190
-
191
- if (questions.extras.length > 0) {
192
- steps.push({
193
- key: "extras",
194
- type: "field",
195
- copy: { title: en("A few more questions") },
196
- fields: questions.extras.map((extra, i) => {
197
- const index = (i + 1) as ExtraIndex;
198
- return {
199
- name: extraFieldName(index),
200
- inputType: "select" as const,
201
- required: true,
202
- options: extra.options.map((option, optionIndex) => ({
203
- value: extraOptionValue(index, optionIndex, extra.options.length),
204
- label: en(option.label),
205
- })),
206
- copy: { label: en(extra.prompt) },
207
- };
208
- }),
209
- });
210
- }
211
-
212
- // Confirmation only — the founder's own line, never the tier.
213
- steps.push({
214
- key: "done",
215
- type: "message",
216
- copy: {
217
- title: en(`You're on the ${listing.name} waitlist`),
218
- ...(listing.oneLiner.trim().length > 0 && { body: en(listing.oneLiner) }),
219
- },
220
- });
221
-
222
- return steps;
223
- }
224
-
225
- function buildFlowConfig(listing: ListingFlowSource): FlowConfig {
226
- return {
227
- slug: listing.slug,
228
- defaultLocale: DEFAULT_LOCALE,
229
- steps: buildFlowSteps(listing),
230
- experiment: {
231
- key: listing.slug,
232
- waitlist: listingWaitlistKey(listing.slug),
233
- },
234
- };
235
- }
236
-
237
- // ─── The experiment (what scores the walk) ──────────────────────────────────
238
-
239
- /**
240
- * The register's question set — every question mirrors the flow exactly,
241
- * Q3–Q6 included: since #5036 D4 the register scores this listing's OWN
242
- * option values, so the question set declares this listing's own options and
243
- * the config-level cross-validation passes without a value universe.
244
- */
245
- function buildQuestionSet(listing: ListingFlowSource): WaitlistQuestion[] {
246
- const { questions } = listing;
247
-
248
- const scored = (["q3", "q4", "q5", "q6"] as const).map((id) => ({
249
- id,
250
- prompt: en(questions[id].prompt),
251
- answerType: "single" as const,
252
- options: questions[id].options.map((o) => ({ value: o.value, label: en(o.label) })),
253
- }));
254
-
255
- return [
256
- {
257
- id: "name",
258
- prompt: en("Name"),
259
- answerType: "text" as const,
260
- required: false,
261
- mapsTo: "name" as const,
262
- },
263
- {
264
- id: "email",
265
- prompt: en("Email"),
266
- answerType: "email" as const,
267
- mapsTo: "email" as const,
268
- },
269
- {
270
- id: "q2",
271
- prompt: en(questions.q2.prompt),
272
- answerType: "single" as const,
273
- options: questions.q2.options.map((o) => ({ value: o.value, label: en(o.label) })),
274
- },
275
- ...scored,
276
- {
277
- id: "q7",
278
- prompt: en(questions.q7.prompt),
279
- answerType: "text" as const,
280
- required: false,
281
- },
282
- ...questions.extras.map((extra, i) => {
283
- const index = (i + 1) as ExtraIndex;
284
- return {
285
- id: extraFieldName(index),
286
- prompt: en(extra.prompt),
287
- answerType: "single" as const,
288
- options: extra.options.map((option, optionIndex) => ({
289
- value: extraOptionValue(index, optionIndex, extra.options.length),
290
- label: en(option.label),
291
- })),
292
- };
293
- }),
294
- ];
295
- }
296
-
297
- function buildExperimentConfig(listing: ListingFlowSource): unknown {
298
- const waitlistKey = listingWaitlistKey(listing.slug);
299
- return {
300
- key: listing.slug,
301
- mode: "qualifying",
302
- status: "live",
303
- waitlists: [{ key: waitlistKey, flowSlug: listing.slug }],
304
- questionSet: { version: 1, questions: buildQuestionSet(listing) },
305
- // The shared scoring RULE, applied to this listing's own option values
306
- // — same point names, same total, same tier floors for every listing;
307
- // only the Q3/Q4 weight keys differ, because those keys are the founder's
308
- // own values (#5036 D4). Embedded verbatim:
309
- // `waitlistExperimentConfigSchema` carries scoring by value and declares
310
- // no by-reference slot.
311
- scoring: buildListingDiagnosticScoring(listing.questions),
312
- tracking: {
313
- version: 1,
314
- utm: { source: "utm_source", medium: "utm_medium", campaign: "utm_campaign" },
315
- waitlists: { [waitlistKey]: { campaign: listing.slug, channels: ["direct"] } },
316
- },
317
- stripeConfigRef: null,
318
- };
319
- }
320
-
321
- // ─── The inverse (what the weekly CSV reads) ────────────────────────────────
322
-
323
- /**
324
- * `field name -> option value -> the founder's own label`, for every optioned
325
- * question the projection emits. The weekly CSV (`listing-csv-sweep.ts`)
326
- * renders a signup's stored answer through this so the founder reads their own
327
- * wording rather than a bare value.
328
- *
329
- * Q2–Q6 collapse to a plain value→label map since #5036 D4: the flow renders
330
- * the founder's own values, so the inverse is the founder's own option list
331
- * read the other way round. Only the extras still need the positional
332
- * `extraOptionValue` — derived from the SAME call the projection emits, so
333
- * the two can never disagree about what a stored value means.
334
- */
335
- export function listingAnswerLabels(
336
- questions: ProjectListingQuestions,
337
- ): Record<string, Record<string, string>> {
338
- const labels: Record<string, Record<string, string>> = {};
339
-
340
- for (const question of ["q2", "q3", "q4", "q5", "q6"] as const) {
341
- labels[question] = Object.fromEntries(
342
- questions[question].options.map((option) => [option.value, option.label]),
343
- );
344
- }
345
-
346
- questions.extras.forEach((extra, i) => {
347
- const index = (i + 1) as ExtraIndex;
348
- labels[extraFieldName(index)] = Object.fromEntries(
349
- extra.options.map((option, optionIndex) => [
350
- extraOptionValue(index, optionIndex, extra.options.length),
351
- option.label,
352
- ]),
353
- );
354
- });
355
-
356
- return labels;
357
- }
358
-
359
- // ─── generateListingFlow ────────────────────────────────────────────────────
360
-
361
- /**
362
- * Project an approved listing into its live diagnostic. Idempotent: both
363
- * writes upsert on their natural key and the `experimentKey` write-back is
364
- * stable, so a second approve changes nothing.
365
- *
366
- * Both joins are stated rather than inferred (#5036 D6d). The definition
367
- * carries the listing's id, so a session started on this flow names the
368
- * listing directly instead of relying on the flow's slug matching it; and the
369
- * listing stores the experiment's KEY, which is the string `WaitlistScore`
370
- * itself holds, so reading a listing's submissions is one hop rather than an
371
- * id-to-key lookup. ADR 0009 forbids a relation across the namespace
372
- * boundary, so both are plain strings — which is exactly why each has to be
373
- * the string the other side actually stores.
374
- *
375
- * The FLOW is written first: an experiment row whose `waitlists[].flowSlug`
376
- * resolves to nothing would leave `GET /v1/flows/{slug}` answering
377
- * `flow_not_found` while the dashboard already showed a live experiment. The
378
- * reverse ordering (a servable flow whose experiment has not landed yet)
379
- * degrades to "collected, not yet scored" — recoverable on the next approve.
380
- */
381
- export async function generateListingFlow(
382
- listing: ListingFlowSource,
383
- ): Promise<GenerateListingFlowResult> {
384
- const flow = await upsertFlowDefinition(
385
- listing.siteId,
386
- buildFlowConfig(listing),
387
- listing.id,
388
- );
389
- if (!flow.ok) {
390
- return {
391
- ok: false,
392
- error: {
393
- kind: flow.error.kind === "flows_not_configured" ? "flows_not_configured" : "invalid_input",
394
- message: flow.error.message,
395
- },
396
- };
397
- }
398
-
399
- const experiment = await upsertWaitlistExperiment(
400
- listing.siteId,
401
- buildExperimentConfig(listing),
402
- );
403
- if (!experiment.ok) {
404
- return {
405
- ok: false,
406
- error: {
407
- kind:
408
- experiment.error.kind === "flows_not_configured"
409
- ? "flows_not_configured"
410
- : "invalid_input",
411
- message: experiment.error.message,
412
- },
413
- };
414
- }
415
-
416
- // The experiment's key IS the listing's slug (`buildExperimentConfig`), and
417
- // it is what `WaitlistScore.experimentKey` stores.
418
- const experimentKey = listing.slug;
419
-
420
- await prisma.projectListing.update({
421
- where: { id: listing.id },
422
- data: { experimentKey },
423
- });
424
-
425
- return { ok: true, flowSlug: listing.slug, experimentKey };
426
- }
@@ -1,251 +0,0 @@
1
- /**
2
- * @file project-listing-issue.ts
3
- * @description The approve-time GitHub issue filing (#5000): an approved
4
- * `ProjectListing` becomes a work order filed on the SITE's own repo
5
- * (`AILK_GITHUB_REPO`), in the shape that repo's issue-claiming pipeline
6
- * reads (`.claude/skills/file-issue/references/issue.md`'s canonical Issue
7
- * body — `## What` / `## Why` / `## Acceptance Criteria` / `## Scope` /
8
- * `## Grounding` / the `<!-- dep-graph: -->` block).
9
- *
10
- * `renderListingIssue` is pure — no fetch, no Prisma, no config read — so
11
- * the shape is unit-testable against a fixture row with no network double.
12
- * `fileListingIssue` is the effectful half: skip (env absent, or `issueUrl`
13
- * already set) -> POST -> write `issueUrl` back on 201 -> log + return on
14
- * ANY failure. It never throws; `project-listing-decision.ts`'s
15
- * `sendListingDecisionEmail` calls it fire-and-forget after the founder
16
- * email, with its own `.catch` boundary on top of the try/catch below, so a
17
- * filing failure can never reach the approve path that has already
18
- * committed.
19
- *
20
- * Injection hardening, mirroring `slack-notify.ts`'s `escapeMrkdwn` for the
21
- * same class of anonymous-input risk (PR #4999 security review): every
22
- * founder-authored scalar field (name/oneLiner/audience/problem/founderName/
23
- * founderCompany/url) is (1) collapsed to a single line — an embedded
24
- * newline could otherwise open a line with `## ` and forge a section a
25
- * naive line-based consumer parser reads as real (e.g. a fake `## Scope`
26
- * re-pointing what a worker builds), and (2) rendered as inline code, which
27
- * GitHub never parses for an `@mention` or `#issue` auto-link. The
28
- * `questions` block is embedded as fenced JSON instead — `JSON.stringify`
29
- * already escapes any embedded newline to the two-character `\n` sequence,
30
- * so no extra sanitization is needed there, and it stays byte-for-byte the
31
- * stored value (#5000 AC2 — "the questions block verbatim").
32
- *
33
- * PII boundary: `founderEmail` lives on `ProjectListing` but is never
34
- * referenced here — the row has it; this file never renders or logs it.
35
- */
36
- import { logger } from "@working-theory/observability";
37
- import type { ProjectListing } from "@working-theory/validation";
38
-
39
- import { config } from "../config/index.js";
40
- import { prisma } from "../lib/prisma.js";
41
-
42
- const GITHUB_API_BASE = "https://api.github.com";
43
- const ISSUE_LABELS = ["status/ready"] as const;
44
-
45
- const SCOPE_PATHS = [
46
- "apps/web/app/[locale]/projects/[slug]/**",
47
- "apps/web/src/content/projects/**",
48
- "apps/web/src/content/diagnostic/**",
49
- ] as const;
50
-
51
- export type RenderListingIssueOptions = {
52
- /** `owner/name` of the repo the issue is filed ON — the site's own repo, never this one. */
53
- targetRepo: string;
54
- };
55
-
56
- export type RenderedListingIssue = {
57
- title: string;
58
- body: string;
59
- labels: string[];
60
- };
61
-
62
- /**
63
- * Collapses an embedded line ending (CR and/or LF) to a space — see the
64
- * module doc's injection note — and strips backticks. Without stripping
65
- * backticks, a founder-authored value containing one (e.g. `` Bob` @someone ``)
66
- * closes `code()`'s single-backtick span early, leaving everything after it
67
- * as live, unfenced markdown GitHub WILL autolink into a real `@mention`
68
- * (PR #5011 security review, Finding 1) — the same failure mode `escapeMrkdwn`
69
- * exists to prevent for Slack, just triggered by a different delimiter.
70
- * A backtick is not meaningful content in any of these fields (a name, a
71
- * one-liner, a URL, …), so substituting a straight quote is a lossless-enough
72
- * trade for closing the injection outright, rather than computing a
73
- * CommonMark-safe variable-length fence.
74
- */
75
- function singleLine(value: string): string {
76
- return value.replace(/[\r\n]+/g, " ").replace(/`/g, "'").trim();
77
- }
78
-
79
- /** What an absent field renders as. */
80
- const ABSENT = "—";
81
-
82
- /**
83
- * Inline code: verbatim AND immune to GitHub's `@mention`/`#issue` markdown
84
- * parsing.
85
- *
86
- * `null` is accepted because every application field became nullable at rest
87
- * in #5036 D1 — a half-filled draft is a legitimate row. An issue is only ever
88
- * filed from an APPROVED listing, which `submit` proved complete, so the null
89
- * branch discharges a type-level possibility rather than a reachable state.
90
- */
91
- function code(value: string | null): string {
92
- return value === null ? ABSENT : `\`${singleLine(value)}\``;
93
- }
94
-
95
- /**
96
- * Renders the build-issue title/body/labels from an approved listing row.
97
- * Pure — every value comes from `listing` and `targetRepo`, nothing is read
98
- * from config or the network.
99
- */
100
- export function renderListingIssue(
101
- listing: ProjectListing,
102
- { targetRepo }: RenderListingIssueOptions,
103
- ): RenderedListingIssue {
104
- // `slug` is null only on a draft, and a draft never reaches this renderer.
105
- const slug = listing.slug ?? listing.id;
106
- const title = `feat(web): /projects/${slug} listing + diagnostic (listing ${listing.id})`;
107
-
108
- const rowLines = [
109
- `- id: ${code(listing.id)}`,
110
- `- slug: ${code(listing.slug)}`,
111
- `- name: ${code(listing.name)}`,
112
- `- oneLiner: ${code(listing.oneLiner)}`,
113
- `- problem: ${code(listing.problem)}`,
114
- `- url: ${listing.url ? code(listing.url) : ABSENT}`,
115
- `- product: ${code(listing.product)}`,
116
- `- commitment: ${code(listing.commitment)}`,
117
- `- teamComposition: ${code(listing.teamComposition)}`,
118
- `- address: ${code(listing.address)}`,
119
- `- anythingElse: ${listing.anythingElse ? code(listing.anythingElse) : ABSENT}`,
120
- `- founderName: ${code(listing.founderName)}`,
121
- `- founderCompany: ${listing.founderCompany ? code(listing.founderCompany) : ABSENT}`,
122
- ].join("\n");
123
-
124
- const questionsJson = JSON.stringify(listing.questions, null, 2);
125
- // #5036 — the three FAQ entries the detail page renders. Emitted as FENCED
126
- // JSON rather than as `- ` bullets, the same treatment `questions` already
127
- // gets: `answer` is 1000 characters of anonymous free text with no
128
- // control-character rule, and a bullet list is exactly the shape
129
- // docs/lessons/project-listings/07 describes — anonymous text landing in a
130
- // document a downstream tool parses by heading. Inside a fence it forges
131
- // nothing.
132
- const faqsJson = JSON.stringify(listing.faqs ?? [], null, 2);
133
- const scopeLines = SCOPE_PATHS.map((p) => `- ${p}`).join("\n");
134
-
135
- const body = `<!-- ailk-listing-issue: repo=${targetRepo} listing=${listing.id} -->
136
-
137
- ## What
138
-
139
- Build the public listing page and its diagnostic flow for ${code(listing.name)}:
140
-
141
- - The page at \`/projects/${slug}\`, rendered from this row via the public read (\`GET /v1/project-listings/public\`).
142
- - The diagnostic at \`/flows/${slug}\`, registered from the flow generated on approve.
143
-
144
- **Row**
145
-
146
- ${rowLines}
147
-
148
- **FAQs**
149
-
150
- \`\`\`\`json
151
- ${faqsJson}
152
- \`\`\`\`
153
-
154
- **Questions**
155
-
156
- \`\`\`\`json
157
- ${questionsJson}
158
- \`\`\`\`
159
-
160
- ## Why
161
-
162
- An approved application must be a complete work order a worker can build from without the operator touching it again.
163
-
164
- ## Acceptance Criteria
165
-
166
- - [ ] page renders from the row
167
- - [ ] diagnostic flow live at /flows/${slug}
168
- - [ ] waitlist joins land under the listing's experiment
169
- - [ ] /projects tile appears in the D6 order
170
-
171
- ## Scope
172
-
173
- ${scopeLines}
174
-
175
- ## Grounding
176
-
177
- - **Governance:** \`internal\`
178
- - **Nature:** \`feature\`
179
- - **Resourced:** \`n/a\`
180
-
181
- <!-- dep-graph:
182
- blocked-by:
183
- blocks:
184
- priority:
185
- scope-paths: ${SCOPE_PATHS.join(", ")}
186
- -->
187
- `;
188
-
189
- return { title, body, labels: [...ISSUE_LABELS] };
190
- }
191
-
192
- /**
193
- * Files the build issue for an approved listing, writing `issueUrl` back on
194
- * success. No-op (`github-skipped`) when `AILK_GITHUB_REPO`/`AILK_GITHUB_TOKEN`
195
- * are absent, or when the row already carries `issueUrl` (idempotent — a
196
- * second approve, or a stray re-call, never double-files). Never throws: any
197
- * failure (network, non-201, a malformed response) is logged — id + status
198
- * only, never the token, never a response body that could carry one — and
199
- * the row is left with `issueUrl: null` for a later retry via the PATCH
200
- * route.
201
- */
202
- export async function fileListingIssue(listing: ProjectListing): Promise<void> {
203
- const targetRepo = config.AILK_GITHUB_REPO;
204
- const token = config.AILK_GITHUB_TOKEN;
205
- if (!targetRepo || !token) {
206
- logger.info({ listingId: listing.id }, "github-skipped");
207
- return;
208
- }
209
- if (listing.issueUrl) {
210
- return;
211
- }
212
-
213
- const { title, body, labels } = renderListingIssue(listing, { targetRepo });
214
-
215
- try {
216
- const res = await fetch(`${GITHUB_API_BASE}/repos/${targetRepo}/issues`, {
217
- method: "POST",
218
- headers: {
219
- authorization: `Bearer ${token}`,
220
- accept: "application/vnd.github+json",
221
- "content-type": "application/json",
222
- "X-GitHub-Api-Version": "2022-11-28",
223
- },
224
- body: JSON.stringify({ title, body, labels }),
225
- });
226
-
227
- if (res.status !== 201) {
228
- logger.warn({ listingId: listing.id, status: res.status }, "listing issue filing failed");
229
- return;
230
- }
231
-
232
- const json = (await res.json()) as { html_url?: unknown };
233
- if (typeof json.html_url !== "string" || json.html_url.length === 0) {
234
- logger.warn(
235
- { listingId: listing.id, status: res.status },
236
- "listing issue filing returned no html_url",
237
- );
238
- return;
239
- }
240
-
241
- await prisma.projectListing.update({
242
- where: { id: listing.id },
243
- data: { issueUrl: json.html_url },
244
- });
245
- } catch (err) {
246
- logger.warn(
247
- { listingId: listing.id, errName: err instanceof Error ? err.name : "UnknownError" },
248
- "listing issue filing threw",
249
- );
250
- }
251
- }