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,815 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file copy-edit.test.ts
4
- *
5
- * THE ACCEPTANCE GATE for `PATCH /v1/project-listings/:id/copy` — the
6
- * operator's copy edit, the third review outcome beside approve and reject.
7
- *
8
- * Registration mirrors `tenant-isolation.test.ts` and `server.ts`: REAL
9
- * `authPreHandlerPlugin -> tenantPreHandlerPlugin -> projectListingTenantRoutes`,
10
- * mocked `lib/prisma.js`. Going through the real `projectListingTenantRoutes`
11
- * plugin is deliberate and load-bearing: this route is reached only via the
12
- * dynamic `TENANT_HANDLERS` table (#5182), never a static import, so a row
13
- * missing from that table fails every case here rather than passing on an
14
- * import the production server does not make.
15
- *
16
- * The `derived body` block is the load-bearing group. Its cases assert bounds
17
- * NOBODY declared in this repo's route layer — the word caps, the
18
- * exactly-three-FAQ rule, the fixed Q5 urgency values, the single-line
19
- * headline rule — which hold only because the body is
20
- * `projectListingApplicationFieldsSchema.pick(…).partial().strict()`. Replace
21
- * that derivation with a hand-written `z.object({ headline: z.string(), … })`
22
- * and every REJECTION case in that block fails, which is what makes them tests
23
- * of the derivation rather than of the route.
24
- *
25
- * The two ACCEPTANCE cases in that block (a ten-word headline, a 600-character
26
- * problem) are the lower halves of boundary probes and discriminate nothing on
27
- * their own — any schema that accepts anything passes them. They are paired
28
- * with the rejection case one unit above the bound, and are only meaningful as
29
- * that pair.
30
- */
31
- import Fastify from "fastify";
32
- import type { FastifyInstance } from "fastify";
33
-
34
- // `services/project-listings.ts` imports `routeLead` from `lead-routing.js` at
35
- // module scope, which transitively imports `@working-theory/email` and
36
- // `@working-theory/license` — mocked purely so that import chain resolves,
37
- // mirroring `tenant-isolation.test.ts`.
38
- jest.mock("@working-theory/email", () => ({
39
- sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
40
- }));
41
- jest.mock("@working-theory/license", () => ({
42
- checkLicense: jest.fn().mockResolvedValue({
43
- tier: "free",
44
- plan: null,
45
- features: [],
46
- expiresAt: null,
47
- revoked: false,
48
- }),
49
- }));
50
-
51
- // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
52
- jest.mock("../../../lib/prisma.js", () => ({
53
- prisma: {
54
- session: { findUnique: jest.fn() },
55
- apiToken: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) },
56
- workspace: { findUnique: jest.fn(), count: jest.fn() },
57
- membership: { findUnique: jest.fn() },
58
- projectListing: { findFirst: jest.fn(), findMany: jest.fn(), updateMany: jest.fn() },
59
- siteListingConfig: { findUnique: jest.fn() },
60
- waitlistScore: { count: jest.fn() },
61
- },
62
- }));
63
-
64
- import { prisma } from "../../../lib/prisma.js";
65
- import { authPreHandlerPlugin } from "../../../middleware/auth.js";
66
- import { tenantPreHandlerPlugin } from "../../../middleware/tenant.js";
67
- import { projectListingTenantRoutes } from "../index.js";
68
-
69
- const FRONTEND = "https://app.example.com";
70
- const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
71
- const WINDOW_OPEN = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
72
- const WINDOW_ELAPSED = new Date(Date.now() - 24 * 60 * 60 * 1000);
73
-
74
- const ORG_A = "org_00000000-0000-7000-8000-000000000001";
75
- const WS_A = "wksp_00000000-0000-7000-8000-000000000001";
76
- const ORG_B = "org_01991234-5678-7000-8000-0000000000bb";
77
- const WS_B = "wksp_00000000-0000-7000-8000-0000000000bb";
78
-
79
- const WORKSPACES = [
80
- { id: WS_A, organizationId: ORG_A },
81
- { id: WS_B, organizationId: ORG_B },
82
- ];
83
-
84
- const MEMBERSHIPS = [
85
- { organizationId: ORG_A, userId: "alice", role: "owner" },
86
- { organizationId: ORG_B, userId: "erin", role: "admin" },
87
- { organizationId: ORG_B, userId: "carol", role: "member" },
88
- ];
89
-
90
- const SESSION_ROWS: Record<string, Record<string, unknown>> = {
91
- cookie_alice: {
92
- id: "sess_alice",
93
- userId: "alice",
94
- expiresAt: FUTURE,
95
- activeOrganizationId: ORG_A,
96
- activeWorkspaceId: WS_A,
97
- },
98
- cookie_erin: {
99
- id: "sess_erin",
100
- userId: "erin",
101
- expiresAt: FUTURE,
102
- activeOrganizationId: ORG_B,
103
- activeWorkspaceId: WS_B,
104
- },
105
- cookie_carol: {
106
- id: "sess_carol",
107
- userId: "carol",
108
- expiresAt: FUTURE,
109
- activeOrganizationId: ORG_B,
110
- activeWorkspaceId: WS_B,
111
- },
112
- };
113
-
114
- const opt = (value: string, label: string) => ({ value, label });
115
-
116
- /** A valid `questions` instance — every refinement the package declares satisfied. */
117
- const QUESTIONS = {
118
- q2: { prompt: "Why now?", options: [opt("scale", "Scale"), opt("just-curious", "Just curious")] },
119
- q3: { prompt: "What stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
120
- q4: { prompt: "What team?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
121
- q5: {
122
- prompt: "How urgent?",
123
- options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
124
- },
125
- q6: {
126
- prompt: "How painful?",
127
- options: [opt("low", "Low"), opt("medium", "Medium"), opt("high", "High"), opt("critical", "Critical")],
128
- },
129
- q7: { prompt: "Anything else?" },
130
- extras: [],
131
- };
132
-
133
- /** Exactly three — the detail page's fixed FAQ slot. */
134
- const FAQS = [
135
- { question: "What is it?", answer: "A thing." },
136
- { question: "Who is it for?", answer: "Founders." },
137
- { question: "What does it cost?", answer: "Nothing." },
138
- ];
139
-
140
- const LISTING_A = "prjl_00000000-0000-7000-8000-0000000000a1";
141
- const LISTING_B = "prjl_00000000-0000-7000-8000-0000000000b1";
142
-
143
- const ORIGINAL_HEADLINE = "The headline the applicant submitted";
144
- const ORIGINAL_ONE_LINER = "The one-liner the applicant submitted.";
145
- const ORIGINAL_PROBLEM = "The problem the applicant described.";
146
-
147
- function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
148
- return {
149
- id: LISTING_A,
150
- siteId: WS_A,
151
- leadId: "lead_00000000-0000-7000-8000-000000000001",
152
- slug: "my-project",
153
- name: "My Project",
154
- headline: ORIGINAL_HEADLINE,
155
- oneLiner: ORIGINAL_ONE_LINER,
156
- problem: ORIGINAL_PROBLEM,
157
- url: null,
158
- product: "working-prototype",
159
- commitment: "part-time",
160
- teamComposition: "just-me",
161
- faqs: FAQS,
162
- anythingElse: "A private note to the reviewer.",
163
- address: "1 Example Street, Exampleton, EX1 2AM",
164
- questions: QUESTIONS,
165
- founderName: "Ada Founder",
166
- founderEmail: "ada@example.com",
167
- founderCompany: null,
168
- logoMarkUrl: null,
169
- logoWordmarkUrl: null,
170
- featured: false,
171
- status: "requested",
172
- consentAt: new Date("2026-09-01T00:00:00.000Z"),
173
- decidedAt: null,
174
- decidedBy: null,
175
- submittedCopy: null,
176
- copyEditedAt: null,
177
- copyEditedBy: null,
178
- issueUrl: null,
179
- experimentKey: null,
180
- windowDays: 21,
181
- windowEndsAt: null,
182
- slackChannel: null,
183
- slackTs: null,
184
- createdAt: new Date("2026-09-01T00:00:00.000Z"),
185
- updatedAt: new Date("2026-09-01T00:00:00.000Z"),
186
- ...overrides,
187
- };
188
- }
189
-
190
- /**
191
- * Evaluate one `where` term against a row, covering only the operators this
192
- * route's own WHERE actually uses: scalar equality and `windowEndsAt: { gt }`.
193
- * `OR` is evaluated as a disjunction over these same terms, which is what
194
- * makes the computed-`closed` cases below a real test of the route's guard
195
- * rather than of the mock.
196
- */
197
- function matchesTerm(row: Record<string, unknown>, term: Record<string, unknown>): boolean {
198
- return Object.entries(term).every(([key, expected]) => {
199
- if (key === "OR") {
200
- return (expected as Record<string, unknown>[]).some((t) => matchesTerm(row, t));
201
- }
202
- if (expected !== null && typeof expected === "object" && "gt" in expected) {
203
- const actual = row[key];
204
- const bound = (expected as { gt: Date }).gt;
205
- return actual instanceof Date && actual.getTime() > bound.getTime();
206
- }
207
- return row[key] === expected;
208
- });
209
- }
210
-
211
- function installFixtureMocks(rows = [makeRow()]): void {
212
- (prisma.session.findUnique as jest.Mock).mockImplementation(
213
- ({ where }: { where: { token?: string } }) =>
214
- Promise.resolve(where.token ? (SESSION_ROWS[where.token] ?? null) : null),
215
- );
216
- (prisma.workspace.findUnique as jest.Mock).mockImplementation(
217
- ({ where }: { where: { id?: string } }) =>
218
- Promise.resolve(WORKSPACES.find((w) => w.id === where.id) ?? null),
219
- );
220
- (prisma.workspace.count as jest.Mock).mockResolvedValue(WORKSPACES.length);
221
- (prisma.membership.findUnique as jest.Mock).mockImplementation(
222
- ({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
223
- const key = where.organizationId_userId;
224
- if (!key) return Promise.resolve(null);
225
- return Promise.resolve(
226
- MEMBERSHIPS.find((m) => m.organizationId === key.organizationId && m.userId === key.userId) ??
227
- null,
228
- );
229
- },
230
- );
231
-
232
- (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
233
- ({ where }: { where: Record<string, unknown> }) =>
234
- Promise.resolve(rows.find((r) => matchesTerm(r, where)) ?? null),
235
- );
236
- // The guarded compare-and-swap: a row is mutated only when EVERY `where`
237
- // term matches, so a status the route refuses returns `{ count: 0 }` with the
238
- // row untouched — the property every refusal case below asserts.
239
- (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
240
- ({ where, data }: { where: Record<string, unknown>; data: Record<string, unknown> }) => {
241
- const row = rows.find((r) => matchesTerm(r, where));
242
- if (!row) return Promise.resolve({ count: 0 });
243
- Object.assign(row, data);
244
- return Promise.resolve({ count: 1 });
245
- },
246
- );
247
- // No `SiteListingConfig` row — `listingConfigFor` falls back to
248
- // `SITE_LISTING_CONFIG_DEFAULTS`, whose word caps are the platform's ten and
249
- // twenty-five and whose `collectedFields` names every application field. That
250
- // is the shape most sites run in, and it is what makes the derived-body
251
- // assertions below read against the package's own defaults. The configured
252
- // cases install a row of their own.
253
- (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
254
- (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(0);
255
- }
256
-
257
- /** Install a site config row, for the cases that exercise configured bounds. */
258
- function installSiteConfig(data: Record<string, unknown>): void {
259
- (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({ data });
260
- }
261
-
262
- async function buildApp(): Promise<FastifyInstance> {
263
- const app = Fastify();
264
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
265
- app.register(async (scope) => {
266
- await scope.register(tenantPreHandlerPlugin);
267
- await scope.register(projectListingTenantRoutes);
268
- });
269
- await app.ready();
270
- return app;
271
- }
272
-
273
- function cookieHeaders(token: string): Record<string, string> {
274
- return { cookie: `better-auth.session_token=${token}`, origin: FRONTEND };
275
- }
276
-
277
- function copyPatch(
278
- app: FastifyInstance,
279
- payload: unknown,
280
- { cookie = "cookie_alice", id = LISTING_A }: { cookie?: string; id?: string } = {},
281
- ) {
282
- return app.inject({
283
- method: "PATCH",
284
- url: `/v1/project-listings/${id}/copy`,
285
- headers: cookieHeaders(cookie),
286
- payload: payload as Record<string, unknown>,
287
- });
288
- }
289
-
290
- describe("PATCH /v1/project-listings/:id/copy", () => {
291
- let app: FastifyInstance;
292
-
293
- beforeEach(async () => {
294
- jest.clearAllMocks();
295
- app = await buildApp();
296
- });
297
-
298
- afterEach(async () => {
299
- await app.close();
300
- });
301
-
302
- // ── Registration ────────────────────────────────────────────────────────
303
-
304
- it("is reachable through the dynamic TENANT_HANDLERS table, not a static import", async () => {
305
- installFixtureMocks();
306
- const res = await copyPatch(app, { headline: "A trimmed headline" });
307
- // A 404 from Fastify's own router (route not registered) carries no `ok`
308
- // envelope; anything with one means the handler ran.
309
- expect(res.statusCode).not.toBe(404);
310
- expect(res.json()).toHaveProperty("ok");
311
- expect(app.hasRoute({ method: "PATCH", url: "/v1/project-listings/:id/copy" })).toBe(true);
312
- });
313
-
314
- // ── The happy paths ─────────────────────────────────────────────────────
315
-
316
- it("edits a `requested` listing's headline and returns the changed row", async () => {
317
- const rows = [makeRow({ status: "requested" })];
318
- installFixtureMocks(rows);
319
- const res = await copyPatch(app, { headline: "A trimmed headline" });
320
- expect(res.statusCode).toBe(200);
321
- expect(res.json().data.headline).toBe("A trimmed headline");
322
- expect(rows[0]!.headline).toBe("A trimmed headline");
323
- });
324
-
325
- it("edits an `active` listing whose window is still in force", async () => {
326
- const rows = [makeRow({ status: "active", windowEndsAt: WINDOW_OPEN })];
327
- installFixtureMocks(rows);
328
- const res = await copyPatch(app, { oneLiner: "A trimmed one-liner." });
329
- expect(res.statusCode).toBe(200);
330
- expect(res.json().data.oneLiner).toBe("A trimmed one-liner.");
331
- expect(rows[0]!.oneLiner).toBe("A trimmed one-liner.");
332
- });
333
-
334
- it("edits a featured `active` listing, which is window-exempt", async () => {
335
- const rows = [makeRow({ status: "active", featured: true, windowEndsAt: null })];
336
- installFixtureMocks(rows);
337
- const res = await copyPatch(app, { problem: "A trimmed problem." });
338
- expect(res.statusCode).toBe(200);
339
- expect(rows[0]!.problem).toBe("A trimmed problem.");
340
- });
341
-
342
- it("writes only the keys the operator sent, leaving the rest of the copy alone", async () => {
343
- const rows = [makeRow()];
344
- installFixtureMocks(rows);
345
- const res = await copyPatch(app, { headline: "Only this one" });
346
- expect(res.statusCode).toBe(200);
347
- expect(rows[0]!.oneLiner).toBe(ORIGINAL_ONE_LINER);
348
- expect(rows[0]!.problem).toBe(ORIGINAL_PROBLEM);
349
- expect(rows[0]!.faqs).toEqual(FAQS);
350
- expect(rows[0]!.questions).toEqual(QUESTIONS);
351
- });
352
-
353
- it("edits `questions`, whose prompts and option labels render publicly", async () => {
354
- const rows = [makeRow()];
355
- installFixtureMocks(rows);
356
- const edited = { ...QUESTIONS, q7: { prompt: "A civil closing question?" } };
357
- const res = await copyPatch(app, { questions: edited });
358
- expect(res.statusCode).toBe(200);
359
- expect((rows[0]!.questions as typeof QUESTIONS).q7.prompt).toBe("A civil closing question?");
360
- });
361
-
362
- // ── The status floor ────────────────────────────────────────────────────
363
-
364
- it.each([
365
- ["draft", { status: "draft" }],
366
- ["rejected", { status: "rejected" }],
367
- ["closed (an `active` row whose window has elapsed)", {
368
- status: "active",
369
- windowEndsAt: WINDOW_ELAPSED,
370
- }],
371
- ["closed (a non-featured `active` row carrying no window at all)", {
372
- status: "active",
373
- windowEndsAt: null,
374
- }],
375
- ])("refuses a %s listing and leaves every copy field untouched", async (_name, overrides) => {
376
- const rows = [makeRow(overrides)];
377
- installFixtureMocks(rows);
378
- const res = await copyPatch(app, { headline: "Should never land" });
379
- expect(res.statusCode).toBe(404);
380
- expect(rows[0]!.headline).toBe(ORIGINAL_HEADLINE);
381
- expect(rows[0]!.submittedCopy).toBeNull();
382
- expect(rows[0]!.copyEditedAt).toBeNull();
383
- expect(rows[0]!.copyEditedBy).toBeNull();
384
- });
385
-
386
- // ── Derived body — the load-bearing block ───────────────────────────────
387
- //
388
- // Every REJECTION case here asserts a bound this route never declares. They
389
- // pass only because the body is derived from
390
- // `projectListingApplicationFieldsSchema.pick(…)`; against a hand-written
391
- // `z.object({ headline: z.string(), oneLiner: z.string(), problem:
392
- // z.string(), faqs: z.array(...), questions: z.record(...) })` every one of
393
- // them fails — verified by actually making that substitution and watching
394
- // them go red. The two acceptance cases are boundary lower-halves and do not
395
- // discriminate on their own; see the file docstring.
396
-
397
- describe("derived body", () => {
398
- it("rejects an eleven-word headline, on the package's ten-word cap", async () => {
399
- const rows = [makeRow()];
400
- installFixtureMocks(rows);
401
- const res = await copyPatch(app, {
402
- headline: "one two three four five six seven eight nine ten eleven",
403
- });
404
- expect(res.statusCode).toBe(400);
405
- expect(res.json().error.code).toBe("invalid_input");
406
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
407
- expect(rows[0]!.headline).toBe(ORIGINAL_HEADLINE);
408
- });
409
-
410
- it("accepts a ten-word headline, so the cap is the package's and not an off-by-one", async () => {
411
- installFixtureMocks();
412
- const res = await copyPatch(app, {
413
- headline: "one two three four five six seven eight nine ten",
414
- });
415
- expect(res.statusCode).toBe(200);
416
- });
417
-
418
- it("rejects a twenty-six-word one-liner, on the package's twenty-five-word cap", async () => {
419
- installFixtureMocks();
420
- const words = Array.from({ length: 26 }, (_v, i) => `w${i}`).join(" ");
421
- const res = await copyPatch(app, { oneLiner: words });
422
- expect(res.statusCode).toBe(400);
423
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
424
- });
425
-
426
- it("rejects two FAQs, on the package's exactly-three rule", async () => {
427
- const rows = [makeRow()];
428
- installFixtureMocks(rows);
429
- const res = await copyPatch(app, { faqs: FAQS.slice(0, 2) });
430
- expect(res.statusCode).toBe(400);
431
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
432
- expect(rows[0]!.faqs).toEqual(FAQS);
433
- });
434
-
435
- it("rejects four FAQs on the same rule", async () => {
436
- installFixtureMocks();
437
- const res = await copyPatch(app, {
438
- faqs: [...FAQS, { question: "One more?", answer: "No." }],
439
- });
440
- expect(res.statusCode).toBe(400);
441
- });
442
-
443
- it("rejects a q5 option value outside its fixed urgency set", async () => {
444
- const rows = [makeRow()];
445
- installFixtureMocks(rows);
446
- const res = await copyPatch(app, {
447
- questions: {
448
- ...QUESTIONS,
449
- q5: {
450
- prompt: "How urgent?",
451
- options: [opt("whenever", "Whenever"), opt("planning", "Soon"), opt("exploring", "Someday")],
452
- },
453
- },
454
- });
455
- expect(res.statusCode).toBe(400);
456
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
457
- expect(rows[0]!.questions).toEqual(QUESTIONS);
458
- });
459
-
460
- it("rejects a headline carrying a newline, on the package's single-line rule", async () => {
461
- const rows = [makeRow()];
462
- installFixtureMocks(rows);
463
- const res = await copyPatch(app, { headline: "Two\nlines" });
464
- expect(res.statusCode).toBe(400);
465
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
466
- expect(rows[0]!.headline).toBe(ORIGINAL_HEADLINE);
467
- });
468
-
469
- it.each([
470
- ["headline", 121],
471
- ["oneLiner", 401],
472
- ["problem", 601],
473
- ])("rejects a `%s` one character over the package's character cap", async (field, len) => {
474
- installFixtureMocks();
475
- // Single characters separated by spaces stay well inside every WORD cap,
476
- // so only the character bound can be what rejects this.
477
- const value = "a".repeat(len);
478
- const res = await copyPatch(app, { [field]: value });
479
- expect(res.statusCode).toBe(400);
480
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
481
- });
482
-
483
- it("accepts a `problem` exactly at the 600-character cap", async () => {
484
- installFixtureMocks();
485
- const res = await copyPatch(app, { problem: "a".repeat(600) });
486
- expect(res.statusCode).toBe(200);
487
- });
488
-
489
- it.each(["headline", "oneLiner", "problem"])(
490
- "rejects an empty `%s`, on the package's min-length rule",
491
- async (field) => {
492
- installFixtureMocks();
493
- const res = await copyPatch(app, { [field]: "" });
494
- expect(res.statusCode).toBe(400);
495
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
496
- },
497
- );
498
-
499
- it("rejects an empty FAQ answer, on the package's inner bounds", async () => {
500
- installFixtureMocks();
501
- const res = await copyPatch(app, {
502
- faqs: [{ question: "What is it?", answer: "" }, FAQS[1]!, FAQS[2]!],
503
- });
504
- expect(res.statusCode).toBe(400);
505
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
506
- });
507
-
508
- it("rejects an over-long FAQ answer, on the package's 1000-character inner cap", async () => {
509
- installFixtureMocks();
510
- const res = await copyPatch(app, {
511
- faqs: [{ question: "What is it?", answer: "a".repeat(1001) }, FAQS[1]!, FAQS[2]!],
512
- });
513
- expect(res.statusCode).toBe(400);
514
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
515
- });
516
-
517
- it("rejects an extra FAQ key, because the FAQ entry schema is strict too", async () => {
518
- installFixtureMocks();
519
- const res = await copyPatch(app, {
520
- faqs: [{ question: "What is it?", answer: "A thing.", note: "x" }, FAQS[1]!, FAQS[2]!],
521
- });
522
- expect(res.statusCode).toBe(400);
523
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
524
- });
525
-
526
- it("rejects a q2 whose final option is not the fixed `just-curious` bail-out", async () => {
527
- installFixtureMocks();
528
- const res = await copyPatch(app, {
529
- questions: {
530
- ...QUESTIONS,
531
- q2: { prompt: "Why now?", options: [opt("scale", "Scale"), opt("growth", "Growth")] },
532
- },
533
- });
534
- expect(res.statusCode).toBe(400);
535
- });
536
- });
537
-
538
- // ── The founder's extra questions ───────────────────────────────────────
539
- //
540
- // `questions` is replaced wholesale, and `extras` carries a `.default([])` in
541
- // the package schema — so a payload omitting the key parses to `extras: []`,
542
- // which is indistinguishable from a deliberate clear. Accepting it would
543
- // silently delete up to three founder-authored questions. The route refuses
544
- // the ambiguous body instead.
545
-
546
- describe("questions.extras", () => {
547
- const EXTRAS = [
548
- { prompt: "An extra question?", options: [opt("a", "A"), opt("b", "B")] },
549
- ];
550
-
551
- it("refuses a `questions` payload that omits `extras`, rather than clearing them", async () => {
552
- const rows = [makeRow({ questions: { ...QUESTIONS, extras: EXTRAS } })];
553
- installFixtureMocks(rows);
554
-
555
- const { extras: _dropped, ...withoutExtras } = QUESTIONS;
556
- const res = await copyPatch(app, { questions: withoutExtras });
557
-
558
- expect(res.statusCode).toBe(400);
559
- expect(res.json().error.message).toContain("extras");
560
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
561
- expect((rows[0]!.questions as { extras: unknown }).extras).toEqual(EXTRAS);
562
- });
563
-
564
- it("accepts an explicit empty `extras`, which is how the operator clears them", async () => {
565
- const rows = [makeRow({ questions: { ...QUESTIONS, extras: EXTRAS } })];
566
- installFixtureMocks(rows);
567
- const res = await copyPatch(app, { questions: { ...QUESTIONS, extras: [] } });
568
- expect(res.statusCode).toBe(200);
569
- expect((rows[0]!.questions as { extras: unknown }).extras).toEqual([]);
570
- });
571
-
572
- it("preserves the founder's extras when the operator sends them back unchanged", async () => {
573
- const rows = [makeRow({ questions: { ...QUESTIONS, extras: EXTRAS } })];
574
- installFixtureMocks(rows);
575
- const res = await copyPatch(app, {
576
- questions: { ...QUESTIONS, extras: EXTRAS, q7: { prompt: "A trimmed closing question?" } },
577
- });
578
- expect(res.statusCode).toBe(200);
579
- const written = rows[0]!.questions as { extras: unknown; q7: { prompt: string } };
580
- expect(written.extras).toEqual(EXTRAS);
581
- expect(written.q7.prompt).toBe("A trimmed closing question?");
582
- });
583
-
584
- it("does not require `extras` when the body carries no `questions` at all", async () => {
585
- installFixtureMocks();
586
- const res = await copyPatch(app, { headline: "A trimmed headline" });
587
- expect(res.statusCode).toBe(200);
588
- });
589
- });
590
-
591
- // ── The SITE's configured bounds, not the platform's ────────────────────
592
- //
593
- // Since #5048 D5/D6 the word caps and the collected field set are the site's
594
- // own configuration. The applicant's write path compiles its schema from
595
- // them; so must the operator's, or the two disagree about the same listing.
596
-
597
- describe("site-configured bounds", () => {
598
- it("accepts a fourteen-word headline on a site configured to fifteen", async () => {
599
- installFixtureMocks();
600
- installSiteConfig({ wordCaps: { headline: 15, oneLiner: 25 } });
601
- const res = await copyPatch(app, {
602
- headline: "one two three four five six seven eight nine ten eleven twelve thirteen fourteen",
603
- });
604
- // Against the platform default of ten this is a 400. The operator must be
605
- // able to re-save copy the applicant was legitimately allowed to submit.
606
- expect(res.statusCode).toBe(200);
607
- });
608
-
609
- it("rejects a seven-word headline on a site configured to six", async () => {
610
- const rows = [makeRow()];
611
- installFixtureMocks(rows);
612
- installSiteConfig({ wordCaps: { headline: 6, oneLiner: 25 } });
613
- const res = await copyPatch(app, { headline: "one two three four five six seven" });
614
- expect(res.statusCode).toBe(400);
615
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
616
- expect(rows[0]!.headline).toBe(ORIGINAL_HEADLINE);
617
- });
618
-
619
- it("refuses a field the site does not collect, so the operator cannot author one", async () => {
620
- const rows = [makeRow()];
621
- installFixtureMocks(rows);
622
- installSiteConfig({ collectedFields: ["name", "headline", "oneLiner", "problem"] });
623
- const res = await copyPatch(app, { faqs: FAQS });
624
- expect(res.statusCode).toBe(400);
625
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
626
- expect(rows[0]!.faqs).toEqual(FAQS);
627
- });
628
-
629
- it("still accepts a field the site does collect, on that same narrowed config", async () => {
630
- installFixtureMocks();
631
- installSiteConfig({ collectedFields: ["name", "headline", "oneLiner", "problem"] });
632
- const res = await copyPatch(app, { headline: "A trimmed headline" });
633
- expect(res.statusCode).toBe(200);
634
- });
635
-
636
- it("400s rather than 500s on a site collecting none of the five copy fields", async () => {
637
- installFixtureMocks();
638
- // `pickCollected` THROWS on an empty mask — it guards the applicant's
639
- // surface, where a schema requiring nothing is never legitimate. Reaching
640
- // it here would be a 500, so the route refuses first.
641
- installSiteConfig({ collectedFields: ["name", "url"] });
642
- const res = await copyPatch(app, { headline: "A trimmed headline" });
643
- expect(res.statusCode).toBe(400);
644
- expect(res.json().error.code).toBe("invalid_input");
645
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
646
- });
647
- });
648
-
649
- // ── Strictness and the at-least-one-key refine ──────────────────────────
650
-
651
- it("rejects an unknown key, because the derived body is strict", async () => {
652
- const rows = [makeRow()];
653
- installFixtureMocks(rows);
654
- const res = await copyPatch(app, { headline: "Fine", somethingElse: "nope" });
655
- expect(res.statusCode).toBe(400);
656
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
657
- expect(rows[0]!.headline).toBe(ORIGINAL_HEADLINE);
658
- });
659
-
660
- it.each(["name", "url", "product", "commitment", "teamComposition", "address", "founderCompany", "anythingElse"])(
661
- "rejects `%s`, which is outside the editable set, and writes nothing",
662
- async (field) => {
663
- const rows = [makeRow()];
664
- installFixtureMocks(rows);
665
- const res = await copyPatch(app, { headline: "Fine", [field]: "whatever" });
666
- expect(res.statusCode).toBe(400);
667
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
668
- expect(rows[0]!.name).toBe("My Project");
669
- expect(rows[0]!.anythingElse).toBe("A private note to the reviewer.");
670
- },
671
- );
672
-
673
- it.each(["status", "featured", "decidedBy", "siteId", "submittedCopy", "copyEditedBy"])(
674
- "rejects `%s`, so the copy route cannot reach the decision or audit surface",
675
- async (field) => {
676
- const rows = [makeRow()];
677
- installFixtureMocks(rows);
678
- const res = await copyPatch(app, { [field]: "active" });
679
- expect(res.statusCode).toBe(400);
680
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
681
- expect(rows[0]!.status).toBe("requested");
682
- },
683
- );
684
-
685
- it("rejects an empty body, on the at-least-one-key refine `.partial()` would otherwise allow", async () => {
686
- const rows = [makeRow()];
687
- installFixtureMocks(rows);
688
- const res = await copyPatch(app, {});
689
- expect(res.statusCode).toBe(400);
690
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
691
- expect(rows[0]!.copyEditedAt).toBeNull();
692
- });
693
-
694
- // ── The write-once snapshot ─────────────────────────────────────────────
695
-
696
- it("captures the applicant's submitted copy on the FIRST edit", async () => {
697
- const rows = [makeRow()];
698
- installFixtureMocks(rows);
699
- const res = await copyPatch(app, { headline: "A trimmed headline" });
700
- expect(res.statusCode).toBe(200);
701
- expect(rows[0]!.submittedCopy).toEqual({
702
- headline: ORIGINAL_HEADLINE,
703
- oneLiner: ORIGINAL_ONE_LINER,
704
- problem: ORIGINAL_PROBLEM,
705
- faqs: FAQS,
706
- questions: QUESTIONS,
707
- });
708
- });
709
-
710
- it("leaves the snapshot at its first-edit value on a SECOND edit", async () => {
711
- const rows = [makeRow()];
712
- installFixtureMocks(rows);
713
-
714
- const first = await copyPatch(app, { headline: "First trim" });
715
- expect(first.statusCode).toBe(200);
716
- const afterFirst = structuredClone(rows[0]!.submittedCopy);
717
-
718
- const second = await copyPatch(app, { headline: "Second trim", problem: "Second problem." });
719
- expect(second.statusCode).toBe(200);
720
-
721
- // The row moved; the snapshot did not.
722
- expect(rows[0]!.headline).toBe("Second trim");
723
- expect(rows[0]!.problem).toBe("Second problem.");
724
- expect(rows[0]!.submittedCopy).toEqual(afterFirst);
725
- expect((rows[0]!.submittedCopy as { headline: string }).headline).toBe(ORIGINAL_HEADLINE);
726
- expect((rows[0]!.submittedCopy as { problem: string }).problem).toBe(ORIGINAL_PROBLEM);
727
- });
728
-
729
- it("does not re-send the snapshot at all on a second edit", async () => {
730
- installFixtureMocks();
731
- await copyPatch(app, { headline: "First trim" });
732
- await copyPatch(app, { headline: "Second trim" });
733
-
734
- const writes = (prisma.projectListing.updateMany as jest.Mock).mock.calls.map(
735
- ([arg]) => (arg as { data: Record<string, unknown> }).data,
736
- );
737
- expect(writes).toHaveLength(2);
738
- expect(writes[0]).toHaveProperty("submittedCopy");
739
- expect(writes[1]).not.toHaveProperty("submittedCopy");
740
- });
741
-
742
- it("stamps copyEditedAt and copyEditedBy on EVERY successful edit, from the session's user", async () => {
743
- const rows = [makeRow()];
744
- installFixtureMocks(rows);
745
-
746
- await copyPatch(app, { headline: "First trim" });
747
- expect(rows[0]!.copyEditedBy).toBe("alice");
748
- const firstStamp = rows[0]!.copyEditedAt as Date;
749
- expect(firstStamp).toBeInstanceOf(Date);
750
-
751
- await copyPatch(app, { headline: "Second trim" });
752
- expect(rows[0]!.copyEditedBy).toBe("alice");
753
- expect((rows[0]!.copyEditedAt as Date).getTime()).toBeGreaterThanOrEqual(firstStamp.getTime());
754
- });
755
-
756
- it("never returns the audit columns on the wire — the response is the unchanged row contract", async () => {
757
- installFixtureMocks();
758
- const res = await copyPatch(app, { headline: "A trimmed headline" });
759
- expect(res.statusCode).toBe(200);
760
- const body = res.json().data;
761
- expect(body).not.toHaveProperty("submittedCopy");
762
- expect(body).not.toHaveProperty("copyEditedAt");
763
- expect(body).not.toHaveProperty("copyEditedBy");
764
- });
765
-
766
- // ── Tenant isolation, matching tenant-isolation.test.ts's posture ────────
767
-
768
- it("404s a copy edit against another site's listing id, writing nothing", async () => {
769
- const rows = [makeRow(), makeRow({ id: LISTING_B, siteId: WS_B, headline: "Site B's headline" })];
770
- installFixtureMocks(rows);
771
- const res = await copyPatch(app, { headline: "Cross-tenant" }, { id: LISTING_B });
772
- expect(res.statusCode).toBe(404);
773
- expect(rows[1]!.headline).toBe("Site B's headline");
774
- expect(rows[1]!.submittedCopy).toBeNull();
775
- });
776
-
777
- it("404s an unknown id indistinguishably from a foreign one", async () => {
778
- const rows = [makeRow(), makeRow({ id: LISTING_B, siteId: WS_B })];
779
- installFixtureMocks(rows);
780
- const foreign = await copyPatch(app, { headline: "Cross-tenant" }, { id: LISTING_B });
781
- const missing = await copyPatch(
782
- app,
783
- { headline: "Cross-tenant" },
784
- { id: "prjl_00000000-0000-7000-8000-00000000dead" },
785
- );
786
- expect(missing.statusCode).toBe(404);
787
- expect(missing.body).toBe(foreign.body);
788
- });
789
-
790
- it("403s a member, before any listing read", async () => {
791
- installFixtureMocks([makeRow({ id: LISTING_B, siteId: WS_B })]);
792
- const res = await copyPatch(app, { headline: "Nope" }, { cookie: "cookie_carol", id: LISTING_B });
793
- expect(res.statusCode).toBe(403);
794
- expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
795
- expect(prisma.projectListing.updateMany).not.toHaveBeenCalled();
796
- });
797
-
798
- it("401s with no cookie, before any listing read", async () => {
799
- installFixtureMocks();
800
- const res = await app.inject({
801
- method: "PATCH",
802
- url: `/v1/project-listings/${LISTING_A}/copy`,
803
- payload: { headline: "Nope" },
804
- });
805
- expect(res.statusCode).toBe(401);
806
- expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
807
- });
808
-
809
- it("400s a malformed :id", async () => {
810
- installFixtureMocks();
811
- const res = await copyPatch(app, { headline: "Nope" }, { id: "not-a-valid-id" });
812
- expect(res.statusCode).toBe(400);
813
- expect(prisma.projectListing.findFirst).not.toHaveBeenCalled();
814
- });
815
- });