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,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
- });