create-ailk 0.2.0 → 0.3.0

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