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,136 +0,0 @@
1
- // oss: false
2
- /**
3
- * PATCH /v1/project-listings/:id
4
- *
5
- * Tenant-scoped listing mutation (#4998 AC3, reshaped by #5033). The body is
6
- * `.strict()` and carries at most two optional fields, at least one:
7
- *
8
- * - `status` — `active` | `rejected`, the only transition there is. The
9
- * lifecycle collapsed to five states with `closed` COMPUTED (#5033 D3),
10
- * so `approved -> live` and `live -> closed` no longer exist and `closed`
11
- * is not a value this route (or any route) can write. Routed through
12
- * `decideListing` — the SAME function `POST /v1/slack/actions` calls — so
13
- * the stamp, the window end and the "already decided" idempotency live in
14
- * one place. On a changed decision the founder is emailed once via
15
- * `sendListingDecisionEmail`.
16
- * - `featured` — the operator's shelf flag (#5033 D6), through
17
- * `setListingFeatured`. Whether it reaches the public read at all is the
18
- * site's `featuredEnabled` config, read server-side; this route sets the
19
- * row's own flag and never the config.
20
- *
21
- * Sent together, the featured write lands first and the decision's response
22
- * is what the caller gets back, so one call cannot return a row that
23
- * contradicts either write.
24
- *
25
- * `:id` is validated at the boundary the same way `get.ts` does; a foreign or
26
- * missing id is `404`. An operator re-PATCHing an already-decided listing
27
- * gets `200` with the row unchanged (`decideListing`'s idempotent "second
28
- * click" branch — #4999 AC).
29
- */
30
- import { zProjectListingId } from "@working-theory/ids";
31
- import { projectListingPatchSchema, projectListingSchema } from "@working-theory/validation";
32
- import type { FastifyReply, FastifyRequest } from "fastify";
33
- import { z } from "zod";
34
-
35
- import {
36
- decideListing,
37
- sendListingDecisionEmail,
38
- } from "../../services/project-listing-decision.js";
39
- import { setListingFeatured, PROJECT_LISTING_ROLES } from "../../services/project-listings.js";
40
-
41
- import { sendValidated } from "./respond.js";
42
-
43
- const paramsSchema = z.object({ id: zProjectListingId });
44
-
45
- function unauthorized(reply: FastifyReply) {
46
- return reply.status(401).send({
47
- ok: false,
48
- error: { code: "unauthorized", message: "Authentication required" },
49
- });
50
- }
51
-
52
- function forbidden(reply: FastifyReply) {
53
- return reply.status(403).send({
54
- ok: false,
55
- error: { code: "forbidden", message: "Forbidden" },
56
- });
57
- }
58
-
59
- function invalidParams(reply: FastifyReply) {
60
- return reply.status(400).send({
61
- ok: false,
62
- error: { code: "validation_failed", message: "Invalid project listing id" },
63
- });
64
- }
65
-
66
- function invalidBody(reply: FastifyReply, message: string) {
67
- return reply.status(400).send({
68
- ok: false,
69
- error: { code: "invalid_input", message },
70
- });
71
- }
72
-
73
- function notFound(reply: FastifyReply) {
74
- return reply.status(404).send({
75
- ok: false,
76
- error: { code: "not_found", message: "Project listing not found" },
77
- });
78
- }
79
-
80
- export async function patchProjectListingHandler(
81
- request: FastifyRequest,
82
- reply: FastifyReply,
83
- ): Promise<void> {
84
- const tenant = request.tenant;
85
- if (!tenant) return unauthorized(reply);
86
- if (!PROJECT_LISTING_ROLES.has(tenant.role)) {
87
- request.log.warn(
88
- { method: request.method, path: request.url, status: 403 },
89
- "[project-listings] patch denied; insufficient role",
90
- );
91
- return forbidden(reply);
92
- }
93
-
94
- const parsedParams = paramsSchema.safeParse(request.params);
95
- if (!parsedParams.success) return invalidParams(reply);
96
-
97
- const parsedBody = projectListingPatchSchema.safeParse(request.body);
98
- if (!parsedBody.success) {
99
- return invalidBody(reply, parsedBody.error.issues[0]?.message ?? "invalid input");
100
- }
101
-
102
- const { status, featured } = parsedBody.data;
103
-
104
- if (featured !== undefined) {
105
- const featuredResult = await setListingFeatured(
106
- tenant.workspaceId,
107
- parsedParams.data.id,
108
- featured,
109
- );
110
- if (!featuredResult.ok) return notFound(reply);
111
- if (status === undefined) {
112
- return sendValidated(reply, projectListingSchema, featuredResult.value, "patch");
113
- }
114
- }
115
-
116
- if (status === undefined) {
117
- // Unreachable — the body schema refuses an empty object — but the
118
- // narrowing has to be discharged before `decideListing`.
119
- return invalidBody(reply, "patch body must carry at least one of 'status' or 'featured'");
120
- }
121
-
122
- const decisionResult = await decideListing({
123
- id: parsedParams.data.id,
124
- decision: status,
125
- decidedBy: tenant.userId,
126
- siteId: tenant.workspaceId,
127
- });
128
- if (!decisionResult.ok) {
129
- if (decisionResult.error.kind === "not_found") return notFound(reply);
130
- return invalidBody(reply, decisionResult.error.message);
131
- }
132
- if (decisionResult.changed) {
133
- await sendListingDecisionEmail(decisionResult.value, status);
134
- }
135
- return sendValidated(reply, projectListingSchema, decisionResult.value, "patch");
136
- }
@@ -1,490 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file index.test.ts
4
- * Route-level inject tests for GET /v1/schedule/availability,
5
- * GET /v1/schedule/event-meta (#4633), and POST /v1/schedule/book (#4416).
6
- *
7
- * Mirrors apps/api/src/routes/flows/__tests__/index.test.ts's shape: a bare
8
- * Fastify instance with only the routes under test registered. The provider
9
- * itself is stubbed at the `fetch` boundary rather than mocked out, so these
10
- * tests cover the real Cal provider's parsing alongside the route's envelope
11
- * and status mapping.
12
- */
13
-
14
- import type { FastifyInstance } from "fastify";
15
-
16
- import {
17
- createBookingRoute,
18
- getEventMetaRoute,
19
- listAvailabilityRoute,
20
- } from "../index";
21
-
22
- const CAL_LINK = "sam/fit-check";
23
- const FROM = "2026-08-26T00:00:00.000Z";
24
- const TO = "2026-09-02T00:00:00.000Z";
25
-
26
- /**
27
- * Cal's observed event-type shape (GET /v2/event-types, 2024-06-14): `price`
28
- * is minor units (0 on an account with no payment app), `currency` sits
29
- * beside it, `description` may be empty. Overridable per test.
30
- */
31
- function eventTypeResponse(overrides: Record<string, unknown> = {}) {
32
- return {
33
- data: {
34
- id: 4242,
35
- slug: "fit-check",
36
- title: "15-Minute Call with Sam",
37
- description: "A quick fit check before we book real time.",
38
- hidden: false,
39
- lengthInMinutes: 15,
40
- price: 0,
41
- currency: "usd",
42
- scheduleTimeZone: "America/Denver",
43
- locations: [{ type: "integrations_google_meet" }],
44
- ...overrides,
45
- },
46
- };
47
- }
48
-
49
- function json(body: unknown, status = 200): Response {
50
- return new Response(JSON.stringify(body), {
51
- status,
52
- headers: { "Content-Type": "application/json" },
53
- });
54
- }
55
-
56
- /** Route calls by URL fragment so each test declares only what it changes. */
57
- type FetchStub = jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
58
-
59
- /**
60
- * Install a stub at the `fetch` boundary and return it. Typed as the platform
61
- * signature so it assigns to `globalThis.fetch` directly — no assertion, and
62
- * the provider under test runs for real against it.
63
- */
64
- function stubFetch(
65
- handlers: Partial<{
66
- eventTypes: (url: string) => Response;
67
- slots: () => Response;
68
- bookings: () => Response;
69
- }>,
70
- ): FetchStub {
71
- const mock: FetchStub = jest.fn((input: RequestInfo | URL) => {
72
- const url = String(input);
73
- if (url.includes("/v2/event-types")) {
74
- return Promise.resolve(
75
- (handlers.eventTypes ?? (() => json(eventTypeResponse())))(url),
76
- );
77
- }
78
- if (url.includes("/v2/slots")) {
79
- return Promise.resolve(
80
- (handlers.slots ?? (() => json({ data: {} })))(),
81
- );
82
- }
83
- if (url.includes("/v2/bookings")) {
84
- return Promise.resolve(
85
- (handlers.bookings ?? (() => json({ data: {} }, 500)))(),
86
- );
87
- }
88
- return Promise.resolve(json({}, 404));
89
- });
90
- globalThis.fetch = mock;
91
- return mock;
92
- }
93
-
94
- async function buildApp(): Promise<FastifyInstance> {
95
- const fastify = require("fastify")();
96
- await fastify.register(listAvailabilityRoute);
97
- await fastify.register(getEventMetaRoute);
98
- await fastify.register(createBookingRoute);
99
- return fastify as FastifyInstance;
100
- }
101
-
102
- describe("the /v1/schedule/* routes", () => {
103
- let app: FastifyInstance;
104
- const realFetch = globalThis.fetch;
105
-
106
- beforeEach(async () => {
107
- jest.clearAllMocks();
108
- process.env.CAL_API_KEY = "cal_test_key";
109
- app = await buildApp();
110
- });
111
-
112
- afterEach(async () => {
113
- await app.close();
114
- globalThis.fetch = realFetch;
115
- delete process.env.CAL_API_KEY;
116
- delete process.env.CAL_API_BASE_URL;
117
- delete process.env.SCHEDULING_PUBLIC_BASE_URL;
118
- });
119
-
120
- // ── availability ─────────────────────────────────────────────────────────
121
-
122
- it("returns derived event meta alongside the flattened slots", async () => {
123
- stubFetch({
124
- slots: () =>
125
- json({
126
- data: {
127
- "2026-08-27": [
128
- { start: "2026-08-27T16:00:00.000Z" },
129
- { start: "2026-08-27T16:30:00.000Z" },
130
- ],
131
- },
132
- }),
133
- });
134
-
135
- const res = await app.inject({
136
- method: "GET",
137
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}`,
138
- });
139
-
140
- expect(res.statusCode).toBe(200);
141
- const body = res.json() as {
142
- ok: boolean;
143
- data: {
144
- event: { durationMinutes: number; meetingType?: string; timeZone: string };
145
- slots: { start: string; end: string }[];
146
- };
147
- };
148
- expect(body.ok).toBe(true);
149
- // Duration, meeting type, and timezone are DERIVED — nothing in the
150
- // request supplied them.
151
- expect(body.data.event.durationMinutes).toBe(15);
152
- expect(body.data.event.meetingType).toBe("Google Meet");
153
- expect(body.data.event.timeZone).toBe("America/Denver");
154
- // Description and price (#4633) ride the same derived meta.
155
- expect(
156
- (body.data.event as { description?: string }).description,
157
- ).toBe("A quick fit check before we book real time.");
158
- expect(
159
- (body.data.event as { price?: object }).price,
160
- ).toEqual({ amountMinor: 0, currency: "usd" });
161
- expect(body.data.slots).toEqual([
162
- { start: "2026-08-27T16:00:00.000Z", end: "2026-08-27T16:15:00.000Z" },
163
- { start: "2026-08-27T16:30:00.000Z", end: "2026-08-27T16:45:00.000Z" },
164
- ]);
165
- });
166
-
167
- it("honours timeZone and never sends the api key to the client", async () => {
168
- const fetchMock = stubFetch({ slots: () => json({ data: {} }) });
169
-
170
- const res = await app.inject({
171
- method: "GET",
172
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}&timeZone=Europe/Lisbon`,
173
- });
174
-
175
- expect(res.statusCode).toBe(200);
176
- const slotsCall = fetchMock.mock.calls.find((c) =>
177
- String(c[0]).includes("/v2/slots"),
178
- );
179
- expect(String(slotsCall?.[0])).toContain("timeZone=Europe%2FLisbon");
180
- expect(res.body).not.toContain("cal_test_key");
181
- });
182
-
183
- it("rejects an unknown query parameter with 400 invalid_input", async () => {
184
- stubFetch({});
185
-
186
- const res = await app.inject({
187
- method: "GET",
188
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}&siteId=evil`,
189
- });
190
-
191
- expect(res.statusCode).toBe(400);
192
- expect((res.json() as { error: { code: string } }).error.code).toBe(
193
- "invalid_input",
194
- );
195
- });
196
-
197
- it("rejects a range longer than the 60-day cap", async () => {
198
- stubFetch({});
199
-
200
- const res = await app.inject({
201
- method: "GET",
202
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=2027-08-26T00:00:00.000Z`,
203
- });
204
-
205
- expect(res.statusCode).toBe(400);
206
- });
207
-
208
- it("maps an unknown event to 404 event_not_found", async () => {
209
- stubFetch({
210
- eventTypes: () => json({}, 404),
211
- });
212
-
213
- const res = await app.inject({
214
- method: "GET",
215
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}`,
216
- });
217
-
218
- expect(res.statusCode).toBe(404);
219
- expect((res.json() as { error: { code: string } }).error.code).toBe(
220
- "event_not_found",
221
- );
222
- });
223
-
224
- it("maps a provider failure to 502 without leaking the upstream detail", async () => {
225
- stubFetch({
226
- slots: () => json({ message: "internal cal trace: user 91" }, 500),
227
- });
228
-
229
- const res = await app.inject({
230
- method: "GET",
231
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}`,
232
- });
233
-
234
- expect(res.statusCode).toBe(502);
235
- expect(res.body).not.toContain("user 91");
236
- });
237
-
238
- // ── event meta (#4633) ───────────────────────────────────────────────────
239
-
240
- it("reads meta for several slugs without ever fetching availability", async () => {
241
- const fetchMock = stubFetch({
242
- eventTypes: (url) =>
243
- url.includes("eventTypeSlug=power-hour")
244
- ? json(
245
- eventTypeResponse({
246
- id: 4243,
247
- slug: "power-hour",
248
- title: "Power Hour",
249
- description: "Sixty focused minutes on your positioning.",
250
- lengthInMinutes: 60,
251
- price: 25000,
252
- }),
253
- )
254
- : json(eventTypeResponse()),
255
- });
256
-
257
- const res = await app.inject({
258
- method: "GET",
259
- url: "/v1/schedule/event-meta?calLinks=sam/fit-check,sam/power-hour",
260
- });
261
-
262
- expect(res.statusCode).toBe(200);
263
- const body = res.json() as {
264
- ok: boolean;
265
- data: {
266
- events: {
267
- calLink: string;
268
- event: {
269
- title: string;
270
- durationMinutes: number;
271
- description?: string;
272
- price?: { amountMinor: number; currency: string };
273
- };
274
- }[];
275
- };
276
- };
277
- expect(body.ok).toBe(true);
278
- expect(body.data.events.map((e) => e.calLink)).toEqual([
279
- "sam/fit-check",
280
- "sam/power-hour",
281
- ]);
282
- // A priced event and a free one, both straight from the provider: minor
283
- // units + currency, never a hand-copied badge string.
284
- expect(body.data.events[0]?.event.price).toEqual({
285
- amountMinor: 0,
286
- currency: "usd",
287
- });
288
- expect(body.data.events[1]?.event).toMatchObject({
289
- title: "Power Hour",
290
- durationMinutes: 60,
291
- description: "Sixty focused minutes on your positioning.",
292
- price: { amountMinor: 25000, currency: "usd" },
293
- });
294
- // The whole point of the route: rendering tiles costs event-type reads
295
- // only — no availability window is fetched for any of them.
296
- const urls = fetchMock.mock.calls.map((c) => String(c[0]));
297
- expect(urls.filter((u) => u.includes("/v2/event-types"))).toHaveLength(2);
298
- expect(urls.some((u) => u.includes("/v2/slots"))).toBe(false);
299
- // Same invariant the availability route pins: the credential stays
300
- // server-side on this route too.
301
- expect(res.body).not.toContain("cal_test_key");
302
- });
303
-
304
- it("omits the description when the event has none", async () => {
305
- stubFetch({
306
- eventTypes: () => json(eventTypeResponse({ description: "" })),
307
- });
308
-
309
- const res = await app.inject({
310
- method: "GET",
311
- url: "/v1/schedule/event-meta?calLinks=sam/fit-check",
312
- });
313
-
314
- expect(res.statusCode).toBe(200);
315
- const body = res.json() as {
316
- data: { events: { event: { description?: string } }[] };
317
- };
318
- expect(body.data.events[0]?.event.description).toBeUndefined();
319
- expect(res.body).not.toContain("description");
320
- });
321
-
322
- it("rejects a malformed link in the list with 400", async () => {
323
- stubFetch({});
324
-
325
- const res = await app.inject({
326
- method: "GET",
327
- url: "/v1/schedule/event-meta?calLinks=sam/fit-check,not-a-link",
328
- });
329
-
330
- expect(res.statusCode).toBe(400);
331
- expect((res.json() as { error: { code: string } }).error.code).toBe(
332
- "invalid_input",
333
- );
334
- });
335
-
336
- it("answers all-or-nothing: one unknown slug fails the read", async () => {
337
- stubFetch({
338
- eventTypes: (url) =>
339
- url.includes("eventTypeSlug=ghost")
340
- ? json({}, 404)
341
- : json(eventTypeResponse()),
342
- });
343
-
344
- const res = await app.inject({
345
- method: "GET",
346
- url: "/v1/schedule/event-meta?calLinks=sam/fit-check,sam/ghost",
347
- });
348
-
349
- expect(res.statusCode).toBe(404);
350
- expect((res.json() as { error: { code: string } }).error.code).toBe(
351
- "event_not_found",
352
- );
353
- });
354
-
355
- it("returns 503 not_configured without a fallback URL", async () => {
356
- delete process.env.CAL_API_KEY;
357
-
358
- const res = await app.inject({
359
- method: "GET",
360
- url: "/v1/schedule/event-meta?calLinks=sam/fit-check",
361
- });
362
-
363
- expect(res.statusCode).toBe(503);
364
- const body = res.json() as {
365
- error: { code: string; fallbackUrl?: string };
366
- };
367
- expect(body.error.code).toBe("not_configured");
368
- // fallbackUrl names ONE event's public page; this read is 1..n events.
369
- expect(body.error.fallbackUrl).toBeUndefined();
370
- });
371
-
372
- // ── degraded state ───────────────────────────────────────────────────────
373
-
374
- it("returns 503 not_configured plus the provider's own booking page", async () => {
375
- delete process.env.CAL_API_KEY;
376
-
377
- const res = await app.inject({
378
- method: "GET",
379
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}`,
380
- });
381
-
382
- expect(res.statusCode).toBe(503);
383
- const body = res.json() as {
384
- error: { code: string; fallbackUrl?: string };
385
- };
386
- expect(body.error.code).toBe("not_configured");
387
- expect(body.error.fallbackUrl).toBe("https://cal.com/sam/fit-check");
388
- });
389
-
390
- it("builds the fallback URL from SCHEDULING_PUBLIC_BASE_URL when set", async () => {
391
- delete process.env.CAL_API_KEY;
392
- process.env.SCHEDULING_PUBLIC_BASE_URL = "https://book.example.com/";
393
-
394
- const res = await app.inject({
395
- method: "GET",
396
- url: `/v1/schedule/availability?calLink=${CAL_LINK}&from=${FROM}&to=${TO}`,
397
- });
398
-
399
- expect(
400
- (res.json() as { error: { fallbackUrl?: string } }).error.fallbackUrl,
401
- ).toBe("https://book.example.com/sam/fit-check");
402
- });
403
-
404
- it("omits the fallback URL when the calLink is unusable", async () => {
405
- delete process.env.CAL_API_KEY;
406
-
407
- const res = await app.inject({
408
- method: "GET",
409
- url: `/v1/schedule/availability?calLink=not-a-link&from=${FROM}&to=${TO}`,
410
- });
411
-
412
- expect(res.statusCode).toBe(503);
413
- expect(
414
- (res.json() as { error: { fallbackUrl?: string } }).error.fallbackUrl,
415
- ).toBeUndefined();
416
- });
417
-
418
- // ── booking ──────────────────────────────────────────────────────────────
419
-
420
- it("creates a booking and returns 201 with the provider's identifier", async () => {
421
- stubFetch({
422
- bookings: () =>
423
- json(
424
- {
425
- data: {
426
- uid: "bkg_9",
427
- start: "2026-08-27T16:00:00.000Z",
428
- end: "2026-08-27T16:15:00.000Z",
429
- meetingUrl: "https://meet.google.com/abc-defg-hij",
430
- },
431
- },
432
- 201,
433
- ),
434
- });
435
-
436
- const res = await app.inject({
437
- method: "POST",
438
- url: "/v1/schedule/book",
439
- payload: {
440
- calLink: CAL_LINK,
441
- start: "2026-08-27T16:00:00.000Z",
442
- attendee: { name: "Sam Rivera", email: "sam@example.com" },
443
- },
444
- });
445
-
446
- expect(res.statusCode).toBe(201);
447
- const body = res.json() as {
448
- data: { bookingId: string; meetingUrl?: string };
449
- };
450
- expect(body.data.bookingId).toBe("bkg_9");
451
- expect(body.data.meetingUrl).toBe("https://meet.google.com/abc-defg-hij");
452
- });
453
-
454
- it("maps a lost race to 409 slot_unavailable", async () => {
455
- stubFetch({
456
- bookings: () => json({ error: "no_available_users_found" }, 409),
457
- });
458
-
459
- const res = await app.inject({
460
- method: "POST",
461
- url: "/v1/schedule/book",
462
- payload: {
463
- calLink: CAL_LINK,
464
- start: "2026-08-27T16:00:00.000Z",
465
- attendee: { name: "Sam Rivera", email: "sam@example.com" },
466
- },
467
- });
468
-
469
- expect(res.statusCode).toBe(409);
470
- expect((res.json() as { error: { code: string } }).error.code).toBe(
471
- "slot_unavailable",
472
- );
473
- });
474
-
475
- it("rejects a malformed attendee email with 400", async () => {
476
- stubFetch({});
477
-
478
- const res = await app.inject({
479
- method: "POST",
480
- url: "/v1/schedule/book",
481
- payload: {
482
- calLink: CAL_LINK,
483
- start: "2026-08-27T16:00:00.000Z",
484
- attendee: { name: "Sam Rivera", email: "not-an-email" },
485
- },
486
- });
487
-
488
- expect(res.statusCode).toBe(400);
489
- });
490
- });