create-ailk 0.1.0 → 0.2.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 (620) hide show
  1. package/README.md +124 -3
  2. package/component-catalog.md +456 -0
  3. package/dist/cli.js +92 -16
  4. package/dist/component-catalog.d.ts +11 -0
  5. package/dist/component-catalog.js +39 -0
  6. package/dist/copy.d.ts +7 -0
  7. package/dist/copy.js +24 -0
  8. package/dist/derive-architecture.d.ts +13 -0
  9. package/dist/derive-architecture.js +238 -0
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.js +19 -2
  12. package/dist/lib/extract-module-bundle.d.ts +16 -0
  13. package/dist/lib/extract-module-bundle.js +184 -0
  14. package/dist/lib/fetch-module.d.ts +55 -0
  15. package/dist/lib/fetch-module.js +125 -0
  16. package/dist/lib/module-license-gate.d.ts +48 -0
  17. package/dist/lib/module-license-gate.js +67 -0
  18. package/dist/lib/module-tier.d.ts +35 -0
  19. package/dist/lib/module-tier.js +37 -0
  20. package/dist/module-architecture.d.ts +175 -0
  21. package/dist/module-architecture.js +663 -0
  22. package/dist/parse-args.d.ts +47 -1
  23. package/dist/parse-args.js +143 -2
  24. package/dist/programmatic.d.ts +160 -3
  25. package/dist/programmatic.js +196 -7
  26. package/dist/surfaces.d.ts +211 -0
  27. package/dist/surfaces.js +532 -0
  28. package/dist/sync-routes.d.ts +92 -0
  29. package/dist/sync-routes.js +350 -0
  30. package/package.json +12 -5
  31. package/templates/.claude/rules/architecture.md +6 -2
  32. package/templates/.claude/skills/README.md +9 -9
  33. package/templates/.claude/skills/add-schema/SKILL.md +14 -7
  34. package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
  35. package/templates/.claude/skills/provision-config/SKILL.md +1 -1
  36. package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
  37. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
  38. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
  39. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
  40. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
  41. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
  42. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
  43. package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
  44. package/templates/.env.example +126 -2
  45. package/templates/CLAUDE.md +11 -5
  46. package/templates/README.md +1 -1
  47. package/templates/apps/api/.env.example +38 -0
  48. package/templates/apps/api/CLAUDE.md +78 -1
  49. package/templates/apps/api/LICENSE +201 -0
  50. package/templates/apps/api/api/index.ts +6 -141
  51. package/templates/apps/api/jest.config.cjs +16 -0
  52. package/templates/apps/api/package.json +22 -6
  53. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
  54. package/templates/apps/api/src/__tests__/server.test.ts +46 -0
  55. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
  56. package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
  57. package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
  58. package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
  59. package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
  60. package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
  61. package/templates/apps/api/src/bin/seed-presets.ts +58 -0
  62. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
  63. package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
  64. package/templates/apps/api/src/cli.ts +5 -11
  65. package/templates/apps/api/src/config/index.ts +35 -0
  66. package/templates/apps/api/src/config/modules.ts +43 -0
  67. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
  68. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
  69. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
  70. package/templates/apps/api/src/lib/site.ts +16 -0
  71. package/templates/apps/api/src/lib/stripe.ts +22 -0
  72. package/templates/apps/api/src/lib/tenant-db.ts +225 -0
  73. package/templates/apps/api/src/lib/ws-token.ts +91 -0
  74. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
  75. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
  77. package/templates/apps/api/src/middleware/auth.ts +43 -8
  78. package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
  79. package/templates/apps/api/src/middleware/tenant.ts +99 -0
  80. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
  81. package/templates/apps/api/src/openapi/spec.ts +312 -3
  82. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
  83. package/templates/apps/api/src/routes/aeo/index.ts +107 -0
  84. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
  85. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
  86. package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
  87. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
  88. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
  89. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
  90. package/templates/apps/api/src/routes/billing/index.ts +36 -0
  91. package/templates/apps/api/src/routes/billing/portal.ts +182 -0
  92. package/templates/apps/api/src/routes/billing/read.ts +75 -0
  93. package/templates/apps/api/src/routes/billing/usage.ts +153 -0
  94. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
  95. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
  96. package/templates/apps/api/src/routes/content/create.ts +1 -0
  97. package/templates/apps/api/src/routes/content/delete.ts +1 -0
  98. package/templates/apps/api/src/routes/content/index.ts +42 -6
  99. package/templates/apps/api/src/routes/content/update.ts +1 -0
  100. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
  101. package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
  102. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
  103. package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
  104. package/templates/apps/api/src/routes/flows/README.md +147 -0
  105. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
  106. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
  107. package/templates/apps/api/src/routes/flows/index.ts +202 -0
  108. package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
  109. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
  110. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
  111. package/templates/apps/api/src/routes/leads/index.ts +43 -9
  112. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
  113. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
  114. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
  115. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
  116. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
  120. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
  121. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
  122. package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
  123. package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
  124. package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
  125. package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
  126. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
  127. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
  128. package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
  129. package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
  130. package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
  131. package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
  132. package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
  133. package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
  134. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
  135. package/templates/apps/api/src/routes/schedule/index.ts +249 -0
  136. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
  137. package/templates/apps/api/src/routes/slack/actions.ts +177 -0
  138. package/templates/apps/api/src/routes/slack/index.ts +38 -0
  139. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
  140. package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
  141. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
  142. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
  143. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
  144. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
  145. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
  146. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
  147. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
  148. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
  149. package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
  150. package/templates/apps/api/src/routes/webhooks/README.md +35 -12
  151. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
  152. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
  153. package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
  154. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
  155. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
  156. package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
  157. package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
  158. package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
  159. package/templates/apps/api/src/server.ts +307 -7
  160. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
  161. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
  162. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
  163. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  164. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  165. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
  166. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
  167. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
  168. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
  169. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
  170. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
  171. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
  172. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
  173. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
  174. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
  175. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
  176. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
  177. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
  178. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
  179. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
  180. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
  181. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
  182. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
  183. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
  184. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
  185. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
  186. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
  187. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
  188. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
  189. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
  190. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
  191. package/templates/apps/api/src/services/abandon-email.ts +307 -0
  192. package/templates/apps/api/src/services/aeo-score.ts +288 -0
  193. package/templates/apps/api/src/services/answer-distributions.ts +138 -0
  194. package/templates/apps/api/src/services/consent.ts +236 -0
  195. package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
  196. package/templates/apps/api/src/services/entitlements.ts +102 -0
  197. package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
  198. package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
  199. package/templates/apps/api/src/services/flow-engine.ts +1296 -0
  200. package/templates/apps/api/src/services/lead-promotion.ts +176 -0
  201. package/templates/apps/api/src/services/lead-routing.ts +3 -3
  202. package/templates/apps/api/src/services/listing-config.ts +102 -0
  203. package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
  204. package/templates/apps/api/src/services/listing-promotion.ts +342 -0
  205. package/templates/apps/api/src/services/playbook-compile.ts +398 -0
  206. package/templates/apps/api/src/services/playbook-render.ts +263 -0
  207. package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
  208. package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
  209. package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
  210. package/templates/apps/api/src/services/project-listings.ts +1472 -0
  211. package/templates/apps/api/src/services/recommender-capture.ts +835 -0
  212. package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
  213. package/templates/apps/api/src/services/scheduling/index.ts +63 -0
  214. package/templates/apps/api/src/services/scheduling/types.ts +88 -0
  215. package/templates/apps/api/src/services/slack-notify.ts +240 -0
  216. package/templates/apps/api/src/services/tenant-context.ts +451 -0
  217. package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
  218. package/templates/apps/api/src/services/usage-credits.ts +340 -0
  219. package/templates/apps/api/src/services/usage-metering.ts +699 -0
  220. package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
  221. package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
  222. package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
  223. package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
  224. package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
  225. package/templates/apps/api/src/vercel-handler.ts +165 -0
  226. package/templates/apps/api/tsconfig.json +0 -12
  227. package/templates/apps/mcp/CLAUDE.md +19 -9
  228. package/templates/apps/mcp/LICENSE +201 -0
  229. package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
  230. package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
  231. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
  232. package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
  233. package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
  234. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
  235. package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
  236. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
  237. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
  238. package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
  239. package/templates/apps/mcp/package.json +6 -4
  240. package/templates/apps/mcp/src/config/modules.ts +37 -0
  241. package/templates/apps/mcp/src/server.ts +109 -18
  242. package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
  243. package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
  244. package/templates/apps/mcp/src/tools/create_page.ts +1 -0
  245. package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
  246. package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
  247. package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
  248. package/templates/apps/mcp/src/tools/index.ts +25 -0
  249. package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
  250. package/templates/apps/mcp/src/tools/purchase.ts +3 -0
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
  252. package/templates/apps/mcp/src/tools/update_page.ts +1 -0
  253. package/templates/apps/mcp/tsconfig.json +1 -9
  254. package/templates/apps/web/.env.example +44 -0
  255. package/templates/apps/web/CLAUDE.md +28 -0
  256. package/templates/apps/web/LICENSE +201 -0
  257. package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
  258. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
  259. package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
  260. package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
  261. package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
  262. package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
  263. package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
  264. package/templates/apps/web/__tests__/setup.ts +22 -32
  265. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
  266. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
  267. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
  268. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
  269. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
  270. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
  271. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
  272. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
  273. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
  274. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
  275. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
  276. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
  277. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
  278. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
  279. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
  280. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
  281. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
  282. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
  283. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
  284. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
  285. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
  286. package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
  287. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
  288. package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
  289. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
  290. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
  291. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  292. package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
  293. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
  294. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  295. package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
  296. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
  297. package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
  298. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
  299. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
  300. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
  301. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
  302. package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
  303. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
  304. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
  305. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
  306. package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
  307. package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
  308. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
  309. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  310. package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
  311. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
  312. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
  313. package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
  314. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
  315. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
  316. package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
  317. package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
  318. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
  319. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
  320. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
  321. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
  322. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
  323. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
  324. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
  325. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
  326. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
  327. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
  328. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
  329. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
  330. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
  331. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
  332. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
  333. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
  334. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  335. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
  336. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  337. package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
  338. package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
  339. package/templates/apps/web/app/[locale]/layout.tsx +127 -16
  340. package/templates/apps/web/app/[locale]/page.tsx +10 -1
  341. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
  342. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  343. package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
  344. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
  345. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  346. package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
  347. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
  348. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
  349. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
  350. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
  351. package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
  352. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
  353. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
  354. package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
  355. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
  356. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
  357. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
  358. package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
  359. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
  360. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  361. package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
  362. package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
  363. package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
  364. package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
  365. package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
  366. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
  367. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
  368. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  369. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
  370. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  371. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
  372. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  373. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
  374. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
  375. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
  376. package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
  377. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
  378. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  379. package/templates/apps/web/app/globals.css +35 -0
  380. package/templates/apps/web/app/llms.txt/route.ts +2 -1
  381. package/templates/apps/web/app/og/route.tsx +10 -2
  382. package/templates/apps/web/auth.ts +8 -0
  383. package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
  384. package/templates/apps/web/content/en/blog/README.txt +25 -0
  385. package/templates/apps/web/content/en/docs/index.mdx +28 -0
  386. package/templates/apps/web/jest.config.cjs +90 -27
  387. package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
  388. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  389. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
  390. package/templates/apps/web/lib/account-config.ts +13 -0
  391. package/templates/apps/web/lib/server-api.ts +85 -0
  392. package/templates/apps/web/lib/site-brand.tsx +121 -0
  393. package/templates/apps/web/lib/site-theme.ts +74 -0
  394. package/templates/apps/web/messages/en.json +275 -7
  395. package/templates/apps/web/middleware.ts +11 -3
  396. package/templates/apps/web/next.config.mjs +8 -2
  397. package/templates/apps/web/package.json +17 -7
  398. package/templates/apps/web/postcss.config.mjs +23 -0
  399. package/templates/apps/web/public/.well-known/security.txt +3 -3
  400. package/templates/apps/web/src/lib/routes.ts +15 -2
  401. package/templates/apps/web/tsconfig.json +1 -16
  402. package/templates/content/_site.mdx +36 -25
  403. package/templates/content/en/AboutPage/about.mdx +24 -14
  404. package/templates/content/en/ContactPage/contact.mdx +23 -14
  405. package/templates/content/en/FAQPage/faq.mdx +10 -392
  406. package/templates/content/en/HomePage/home.mdx +14 -285
  407. package/templates/database/CHANGELOG.md +443 -0
  408. package/templates/database/LICENSE +201 -0
  409. package/templates/database/README.md +3 -1
  410. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
  411. package/templates/database/__tests__/flows-presets.test.ts +173 -0
  412. package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
  413. package/templates/database/__tests__/seed.test.ts +88 -0
  414. package/templates/database/__tests__/user-cascade.test.ts +19 -4
  415. package/templates/database/auth/schema.prisma +521 -0
  416. package/templates/database/config/schema.prisma +234 -0
  417. package/templates/database/content/schema.prisma +263 -0
  418. package/templates/database/flows/listing-diagnostic.ts +182 -0
  419. package/templates/database/flows/presets.ts +315 -0
  420. package/templates/database/inbox/__tests__/schema.test.ts +4 -0
  421. package/templates/database/inbox/schema.prisma +574 -3
  422. package/templates/database/jest.config.cjs +8 -0
  423. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
  424. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
  425. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
  426. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
  427. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
  428. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
  429. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
  430. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
  431. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
  432. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
  433. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
  434. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
  435. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
  436. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
  437. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
  438. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
  439. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
  440. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
  441. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
  442. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
  443. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
  444. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
  445. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
  446. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
  447. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
  448. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
  449. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
  450. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
  451. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
  452. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
  453. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
  454. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
  455. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  456. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  457. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  458. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  459. package/templates/database/ops/schema.prisma +27 -0
  460. package/templates/database/package.json +42 -4
  461. package/templates/database/prisma.config.ts +1 -1
  462. package/templates/database/schema.prisma +19 -435
  463. package/templates/database/seed-test.ts +14 -4
  464. package/templates/database/seed.ts +148 -0
  465. package/templates/database/tenancy.ts +45 -0
  466. package/templates/database/tsconfig.flows.json +15 -0
  467. package/templates/database/tsconfig.json +7 -1
  468. package/templates/database/tsconfig.tenancy.json +14 -0
  469. package/templates/package.json +30 -55
  470. package/templates/pnpm-workspace.yaml +0 -3
  471. package/templates/project.yaml +1 -1
  472. package/templates/tsconfig.json +1 -37
  473. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
  474. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
  475. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
  476. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
  477. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
  478. package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
  479. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
  480. package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
  481. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
  482. package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
  483. package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
  484. package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
  485. package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
  486. package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
  487. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
  488. package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
  489. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
  490. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
  491. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
  492. package/templates/apps/web/__tests__/page.test.tsx +0 -230
  493. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
  494. package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
  495. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
  496. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
  497. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
  498. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
  499. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
  500. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
  501. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
  502. package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
  503. package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
  504. package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
  505. package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
  506. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
  507. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
  508. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
  509. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
  510. package/templates/apps/web/content/en/blog/index.mdx +0 -15
  511. package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
  512. package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
  513. package/templates/content/.gitkeep +0 -0
  514. package/templates/content/CLAUDE.md +0 -85
  515. package/templates/content/ar/HomePage/home.mdx +0 -123
  516. package/templates/content/ar/_site.mdx +0 -35
  517. package/templates/content/de/_site.mdx +0 -29
  518. package/templates/content/en/.gitkeep +0 -0
  519. package/templates/content/en/AboutPage/about.schema.json +0 -82
  520. package/templates/content/en/BookCall/book-call.mdx +0 -19
  521. package/templates/content/en/BookCall/book-call.schema.json +0 -121
  522. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
  523. package/templates/content/en/CareersList/careers.mdx +0 -24
  524. package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
  525. package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
  526. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
  527. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
  528. package/templates/content/en/ClientsList/clients.mdx +0 -38
  529. package/templates/content/en/ContactPage/contact.schema.json +0 -100
  530. package/templates/content/en/ContactPoint/primary.mdx +0 -14
  531. package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
  532. package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
  533. package/templates/content/en/CoursesList/courses.mdx +0 -22
  534. package/templates/content/en/DownloadsList/downloads.mdx +0 -22
  535. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
  536. package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
  537. package/templates/content/en/EventsList/events.mdx +0 -26
  538. package/templates/content/en/FAQPage/faq.schema.json +0 -231
  539. package/templates/content/en/GlossaryList/glossary.mdx +0 -19
  540. package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
  541. package/templates/content/en/HomePage/home.schema.json +0 -88
  542. package/templates/content/en/HomePage/launch-service.mdx +0 -475
  543. package/templates/content/en/HomePage/launch-service.schema.json +0 -138
  544. package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
  545. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
  546. package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
  547. package/templates/content/en/Integration/github-actions.mdx +0 -31
  548. package/templates/content/en/Integration/openai-api.mdx +0 -26
  549. package/templates/content/en/Integration/vercel.mdx +0 -32
  550. package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
  551. package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
  552. package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
  553. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
  554. package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
  555. package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
  556. package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
  557. package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
  558. package/templates/content/en/LandingPage/developers.mdx +0 -19
  559. package/templates/content/en/LandingPage/developers.schema.json +0 -109
  560. package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
  561. package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
  562. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
  563. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
  564. package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
  565. package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
  566. package/templates/content/en/LandingPage/platform.mdx +0 -369
  567. package/templates/content/en/LandingPage/platform.schema.json +0 -166
  568. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
  569. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
  570. package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
  571. package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
  572. package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
  573. package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
  574. package/templates/content/en/Organization/site-owner.mdx +0 -16
  575. package/templates/content/en/Partner/prisma.mdx +0 -31
  576. package/templates/content/en/Partner/supabase.mdx +0 -30
  577. package/templates/content/en/Partner/vercel.mdx +0 -28
  578. package/templates/content/en/PartnersList/partners.mdx +0 -22
  579. package/templates/content/en/Person/founder.mdx +0 -18
  580. package/templates/content/en/Pricing/pricing.mdx +0 -538
  581. package/templates/content/en/Pricing/pricing.schema.json +0 -209
  582. package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
  583. package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
  584. package/templates/content/en/Product/ailk.mdx +0 -19
  585. package/templates/content/en/Product/ailk.schema.json +0 -94
  586. package/templates/content/en/Product/api-launch-kit.mdx +0 -26
  587. package/templates/content/en/Product/launch-kit.mdx +0 -28
  588. package/templates/content/en/ProductsList/products.mdx +0 -26
  589. package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
  590. package/templates/content/en/ProjectsList/projects.mdx +0 -25
  591. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
  592. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
  593. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
  594. package/templates/content/en/ResourcesHub/resources.mdx +0 -22
  595. package/templates/content/en/Service/consulting.mdx +0 -22
  596. package/templates/content/en/Service/implementation.mdx +0 -30
  597. package/templates/content/en/Service/launch-service.mdx +0 -232
  598. package/templates/content/en/Service/launch.mdx +0 -228
  599. package/templates/content/en/ServicesList/services.mdx +0 -84
  600. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
  601. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
  602. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
  603. package/templates/content/en/TeamList/team.mdx +0 -63
  604. package/templates/content/en/TeamMember/founder.mdx +0 -72
  605. package/templates/content/en/TermsPage/terms.mdx +0 -19
  606. package/templates/content/en/TermsPage/terms.schema.json +0 -99
  607. package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
  608. package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
  609. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
  610. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
  611. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
  612. package/templates/content/en-XA/AboutPage/about.mdx +0 -8
  613. package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
  614. package/templates/content/en-XA/HomePage/home.mdx +0 -8
  615. package/templates/content/es/_site.mdx +0 -30
  616. package/templates/content/fr/_site.mdx +0 -30
  617. package/templates/content/ja/_site.mdx +0 -30
  618. package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
  619. package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
  620. package/templates/content/pt-BR/HomePage/home.mdx +0 -23
@@ -0,0 +1,815 @@
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
+ });