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,1472 @@
1
+ /**
2
+ * @file project-listings.ts
3
+ * @description The founder's-application-row service (#4998, reshaped by
4
+ * #5036) — writes, reads, and transitions `ProjectListing` (ADR 0009 D3
5
+ * service-layer convention, mirroring `lead-routing.ts` and
6
+ * `waitlist-dashboard.ts`).
7
+ *
8
+ * The single anonymous create became THREE operations when the application
9
+ * became a ten-step form that saves as it goes (#5036 D1):
10
+ *
11
+ * start name + email + consent -> a `draft` row and a resume token
12
+ * patch merges a partial payload; asserts no completeness
13
+ * submit validates the WHOLE application, `draft` -> `requested`
14
+ *
15
+ * Validation therefore moved from write-time to submit-time, and that is why
16
+ * every application field is nullable on the row: a half-filled draft is a
17
+ * legitimate row, and only `submit` asserts it is finished.
18
+ *
19
+ * `submitProjectListingDraft` calls `routeLead` (kind `submit`, source
20
+ * `projects-apply`) for the Lead write — never a duplicate Lead-shaped insert.
21
+ * The Lead is written at SUBMIT rather than at start: the founder's contact
22
+ * details are already captured on the listing row itself at step one, so a
23
+ * Lead at step one adds no capture, and what it DOES add is an operator
24
+ * notification email per abandoned first step on an anonymous route (PR #5042
25
+ * security review, finding 4). Every tenant read/write
26
+ * is scoped by `siteId` (`request.tenant.workspaceId`) — there is no
27
+ * client-supplied site id on any tenant route. `waitlistCount` joins THROUGH
28
+ * `experimentId` (`WaitlistExperiment.id` -> `.key` -> `WaitlistScore.experimentKey`,
29
+ * scoped by `siteId`), 0 when a listing carries no `experimentKey`.
30
+ *
31
+ * PII boundary: this module never logs `founderEmail`, `founderName`, or
32
+ * `questions` — the same discipline `lead-routing.ts` documents. The resume
33
+ * token is never logged either, at any level: it is the ONLY credential on
34
+ * the anonymous draft surface.
35
+ */
36
+
37
+ import { createHash, randomBytes } from "node:crypto";
38
+
39
+ import type { Prisma, Role } from "@working-theory/database";
40
+ import { logger } from "@working-theory/observability";
41
+ import {
42
+ clampWindowDays,
43
+ buildProjectListingDraftPatchSchema,
44
+ buildProjectListingSubmitSchema,
45
+ composeAddressLine,
46
+ flowAddressValueSchema,
47
+ publicSiteAnswers,
48
+ PROJECT_LISTING_CONCURRENT_LIMIT,
49
+ PROJECT_LISTING_NON_TERMINAL_STATUSES,
50
+ } from "@working-theory/validation";
51
+ import type {
52
+ FlowAddressValue,
53
+ ProjectListing,
54
+ ProjectListingDraftPatchInput,
55
+ ProjectListingFaqs,
56
+ ProjectListingPublic,
57
+ ProjectListingQuestions,
58
+ ProjectListingStartInput,
59
+ } from "@working-theory/validation";
60
+
61
+ import { prisma } from "../lib/prisma.js";
62
+
63
+ import {
64
+ consentConfigForSite,
65
+ MAX_CONSENT_KEYS,
66
+ recordConsent,
67
+ recordLegacyConsent,
68
+ requiredConsentUnmet,
69
+ } from "./consent.js";
70
+ import { routeLead, type SourceMeta } from "./lead-routing.js";
71
+ import { listingConfigFor } from "./listing-config.js";
72
+
73
+ // ─── Role floor ─────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * The membership role this floor is expressed in (#5182).
77
+ *
78
+ * Previously `import type { MembershipRole } from "./tenant-context.js"` — a
79
+ * multi-tenant SLICE file the OSS strip removes, which makes a type-only import
80
+ * of it a TS2307 on the public tree exactly as a value import would be (spec
81
+ * D1). `Role` is the Prisma enum `Membership.role` is actually declared as
82
+ * (`database/auth/schema.prisma:25-33`), so it is the same three values from
83
+ * the non-slice home they originate in rather than a restatement — and this
84
+ * file already imports from `@working-theory/database` two lines up.
85
+ */
86
+
87
+ /** Reads/writes require `owner` or `admin` — the same floor as the waitlist dashboard. */
88
+ export const PROJECT_LISTING_ROLES: ReadonlySet<Role> = new Set<Role>([
89
+ "owner",
90
+ "admin",
91
+ ]);
92
+
93
+ // ─── The computed lifecycle state ───────────────────────────────────────────
94
+
95
+ type ProjectListingStatus = ProjectListing["status"];
96
+
97
+ /**
98
+ * Resolve a row's LIFECYCLE status from its STORED status (#5033 D3).
99
+ *
100
+ * `closed` is the one state nothing writes. An `active` listing whose window
101
+ * end has passed reads `closed`, here, on every read, with no job having run.
102
+ * Storing it instead would need a scheduler whose failure leaves expired
103
+ * listings publicly open — a silent, wrong-way failure. This makes that
104
+ * unrepresentable.
105
+ *
106
+ * A FEATURED row is exempt (#5036 D6b). The operator's own products become
107
+ * listing rows carrying `featured: true`, and such a row is not a time-boxed
108
+ * validation experiment: it has no meaningful window and must not vanish from
109
+ * the shelf because a date passed. Its `windowEndsAt` may be null, and it
110
+ * reads `active` either way.
111
+ *
112
+ * A null window on a NON-featured `active` row is an ERROR, not a licence to
113
+ * stay open forever: every real listing is time-boxed by design, and the whole
114
+ * point of computing `closed` rather than storing it is that an expired
115
+ * listing cannot linger. It is logged and read `closed` — failing toward the
116
+ * closed shelf rather than toward a publicly-open listing nothing bounds.
117
+ * `draft`, `requested` and `rejected` rows legitimately carry no window and
118
+ * are not touched by any of this.
119
+ */
120
+ export function computeListingStatus(
121
+ storedStatus: string,
122
+ windowEndsAt: Date | null,
123
+ now: Date,
124
+ featured: boolean,
125
+ ): ProjectListingStatus {
126
+ if (storedStatus !== "active") return storedStatus as ProjectListingStatus;
127
+ if (featured) return "active";
128
+ if (windowEndsAt === null) {
129
+ logger.error(
130
+ { kind: "active_listing_without_window" },
131
+ "[project-listings] a non-featured active listing carries no window end; reading it closed",
132
+ );
133
+ return "closed";
134
+ }
135
+ return windowEndsAt.getTime() <= now.getTime() ? "closed" : "active";
136
+ }
137
+
138
+ // ─── Slug derivation (#5033 D2) ─────────────────────────────────────────────
139
+
140
+ /** The `name`-derived slug's length cap. */
141
+ const SLUG_MAX_LENGTH = 48;
142
+
143
+ /** The slug a name with no alphanumeric content falls back to before suffixing. */
144
+ const SLUG_FALLBACK = "listing";
145
+
146
+ /**
147
+ * Derive a slug from a listing `name`: lowercase, every non-alphanumeric run
148
+ * to a single hyphen, trimmed of leading/trailing hyphens, truncated to 48.
149
+ * A name carrying no alphanumerics at all (the payload allows it — `name` is
150
+ * only bounded by length) falls back to `listing`, which the site-scoped
151
+ * suffixing below then disambiguates like any other collision.
152
+ */
153
+ export function deriveListingSlug(name: string): string {
154
+ const base = name
155
+ .toLowerCase()
156
+ .replace(/[^a-z0-9]+/g, "-")
157
+ .replace(/^-+|-+$/g, "")
158
+ .slice(0, SLUG_MAX_LENGTH)
159
+ .replace(/-+$/, "");
160
+ return base.length > 0 ? base : SLUG_FALLBACK;
161
+ }
162
+
163
+ /**
164
+ * The most same-prefix slugs one create will read before giving up.
165
+ *
166
+ * The collision read is a prefix scan, and `base` derives from a fully
167
+ * caller-controlled `name` on an ANONYMOUS route — a one-character name makes
168
+ * the prefix match most of the site's slugs, and every accepted application
169
+ * adds another row to that family. Unbounded, the cost of a create would grow
170
+ * with the number of listings an attacker had already filed. Bounded, a
171
+ * pathological family degrades to the same `slug_taken` 409 a genuine race
172
+ * already produces, rather than to an ever-slower table scan.
173
+ */
174
+ const SLUG_COLLISION_SCAN_LIMIT = 200;
175
+
176
+ /**
177
+ * The first free slug for `base` on this site. Uniqueness is already scoped
178
+ * `@@unique([siteId, slug])`, so the suffix is too: a second "Atlas" on this
179
+ * site becomes `atlas-2` even if `atlas` is taken on another.
180
+ *
181
+ * Two concurrent creates of the same name can still both resolve the same
182
+ * free slug and race to the insert; the loser hits the unique constraint and
183
+ * is reported as `slug_taken`, exactly as it was when the client supplied the
184
+ * slug itself.
185
+ */
186
+ async function firstFreeSlug(siteId: string, base: string): Promise<string> {
187
+ const rows = await prisma.projectListing.findMany({
188
+ where: { siteId, slug: { startsWith: base } },
189
+ select: { slug: true },
190
+ take: SLUG_COLLISION_SCAN_LIMIT,
191
+ });
192
+ const taken = new Set(rows.map((r) => r.slug));
193
+ if (!taken.has(base)) return base;
194
+
195
+ // Bounded by `taken.size + 1` iterations — one of the first `n` candidates
196
+ // is necessarily free.
197
+ let suffix = 2;
198
+ while (taken.has(`${base}-${suffix}`)) suffix += 1;
199
+ return `${base}-${suffix}`;
200
+ }
201
+
202
+ // ─── Row shape ──────────────────────────────────────────────────────────────
203
+
204
+ const PROJECT_LISTING_SELECT = {
205
+ id: true,
206
+ siteId: true,
207
+ leadId: true,
208
+ slug: true,
209
+ name: true,
210
+ oneLiner: true,
211
+ problem: true,
212
+ url: true,
213
+ product: true,
214
+ commitment: true,
215
+ teamComposition: true,
216
+ faqs: true,
217
+ anythingElse: true,
218
+ address: true,
219
+ // #5279 — the structured parts beside the display string.
220
+ addressLine1: true,
221
+ addressLine2: true,
222
+ addressCity: true,
223
+ addressRegion: true,
224
+ addressPostalCode: true,
225
+ addressCountry: true,
226
+ questions: true,
227
+ // #5285 — the site's own answers, and the window the applicant asked for.
228
+ // `windowDays` rides the select because `submit` re-validates the assembled
229
+ // application, and `requestedWindowDays` is one of its fields now.
230
+ siteAnswers: true,
231
+ windowDays: true,
232
+ founderName: true,
233
+ founderEmail: true,
234
+ founderCompany: true,
235
+ headline: true,
236
+ logoMarkUrl: true,
237
+ logoWordmarkUrl: true,
238
+ featured: true,
239
+ status: true,
240
+ consentAt: true,
241
+ decidedAt: true,
242
+ decidedBy: true,
243
+ issueUrl: true,
244
+ experimentKey: true,
245
+ windowEndsAt: true,
246
+ createdAt: true,
247
+ updatedAt: true,
248
+ } as const satisfies Prisma.ProjectListingSelect;
249
+
250
+ type ProjectListingRow = Prisma.ProjectListingGetPayload<{
251
+ select: typeof PROJECT_LISTING_SELECT;
252
+ }>;
253
+
254
+ /**
255
+ * The structured address parts of a row, or null (#5279).
256
+ *
257
+ * Exported because `project-listing-decision.ts` renders the same row shape
258
+ * and must answer the same way — two readers guessing separately at what
259
+ * "has parts" means is exactly how one of them starts emitting a half-filled
260
+ * object.
261
+ *
262
+ * The rule is the composite's REQUIRED set: the five non-optional parts are
263
+ * all present, or there are no parts. `addressLine2` is optional in the
264
+ * fieldset, so it rides along when present and is simply absent when not. A
265
+ * row written before #5279 has all six columns null and answers null here —
266
+ * never a partial object reconstructed from `address`, which nothing in this
267
+ * repo parses.
268
+ *
269
+ * `.safeParse` rather than a cast: these are six nullable TEXT columns, and
270
+ * the emitted object must satisfy `flowAddressValueSchema` or the row fails
271
+ * its own response schema downstream. A shape that somehow does not (a
272
+ * `country` that is not an alpha-2 code, say) answers null rather than
273
+ * putting an invalid address on the PUBLIC read.
274
+ */
275
+ export function addressPartsFromRow(row: {
276
+ addressLine1: string | null;
277
+ addressLine2: string | null;
278
+ addressCity: string | null;
279
+ addressRegion: string | null;
280
+ addressPostalCode: string | null;
281
+ addressCountry: string | null;
282
+ }): FlowAddressValue | null {
283
+ if (
284
+ row.addressLine1 === null ||
285
+ row.addressCity === null ||
286
+ row.addressRegion === null ||
287
+ row.addressPostalCode === null ||
288
+ row.addressCountry === null
289
+ ) {
290
+ return null;
291
+ }
292
+ const parsed = flowAddressValueSchema.safeParse({
293
+ addressLine1: row.addressLine1,
294
+ ...(row.addressLine2 !== null && { addressLine2: row.addressLine2 }),
295
+ city: row.addressCity,
296
+ region: row.addressRegion,
297
+ postalCode: row.addressPostalCode,
298
+ country: row.addressCountry,
299
+ });
300
+ return parsed.success ? parsed.data : null;
301
+ }
302
+
303
+ /**
304
+ * The site-answers object of a row, or null (#5285).
305
+ *
306
+ * Exported for the same reason `addressPartsFromRow` is: the operator's
307
+ * decision payload renders the same row shape and must answer the same way.
308
+ *
309
+ * The column is `Json?`, so Prisma types it as `JsonValue` — an array, a
310
+ * number and a string all satisfy it. Only ever written by
311
+ * `draftUpdateData` from a body the site's own declarations validated, so a
312
+ * row holding anything else is a row somebody wrote around this service.
313
+ * A non-object answers null rather than being cast: a value of the wrong
314
+ * SHAPE reaching the response schema would fail the whole read, and one
315
+ * malformed row must not take a listing page down.
316
+ *
317
+ * Non-string members are dropped for the same reason — the store is
318
+ * `Record<string, string>`, and what is handed back has to actually be one.
319
+ */
320
+ export function siteAnswersFromRow(value: Prisma.JsonValue | null): Record<string, string> | null {
321
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
322
+ return Object.fromEntries(
323
+ Object.entries(value).filter(
324
+ (entry): entry is [string, string] => typeof entry[1] === "string",
325
+ ),
326
+ );
327
+ }
328
+
329
+ /**
330
+ * `now` is passed rather than read inside so one read renders every row of a
331
+ * list against a single instant. `featuredEnabled` is the site's config
332
+ * switch (#5033 D6): where a site has not enabled a featured shelf, the flag
333
+ * never surfaces. The tenant dashboard passes `true` — an operator manages
334
+ * the flag and must see what they set.
335
+ */
336
+ function toResponseRow(
337
+ row: ProjectListingRow,
338
+ waitlistCount: number,
339
+ now: Date,
340
+ featuredEnabled: boolean,
341
+ ): ProjectListing {
342
+ return {
343
+ id: row.id,
344
+ siteId: row.siteId,
345
+ leadId: row.leadId,
346
+ slug: row.slug,
347
+ name: row.name,
348
+ headline: row.headline,
349
+ oneLiner: row.oneLiner,
350
+ problem: row.problem,
351
+ url: row.url,
352
+ product: row.product as ProjectListing["product"],
353
+ commitment: row.commitment as ProjectListing["commitment"],
354
+ teamComposition: row.teamComposition as ProjectListing["teamComposition"],
355
+ faqs: row.faqs as unknown as ProjectListingFaqs | null,
356
+ anythingElse: row.anythingElse,
357
+ address: row.address,
358
+ addressParts: addressPartsFromRow(row),
359
+ founderName: row.founderName,
360
+ founderEmail: row.founderEmail,
361
+ founderCompany: row.founderCompany,
362
+ questions: row.questions as unknown as ProjectListingQuestions | null,
363
+ logoMarkUrl: row.logoMarkUrl,
364
+ logoWordmarkUrl: row.logoWordmarkUrl,
365
+ // #5285 — UNFILTERED here, deliberately. This row shape serves the tenant
366
+ // dashboard and the founder's own draft read, both of which are entitled
367
+ // to every answer. The PUBLIC read is the one that must not carry an
368
+ // `operator-only` answer, and it narrows this through `publicSiteAnswers`
369
+ // in `publicProjectListings` — one filter, on the one read that publishes.
370
+ siteAnswers: siteAnswersFromRow(row.siteAnswers),
371
+ featured: featuredEnabled && row.featured,
372
+ // The row's OWN flag decides the closed exemption (#5036 D6b), not the
373
+ // site's `featuredEnabled` switch: that switch governs whether the flag
374
+ // SURFACES on a read, not whether the listing is time-boxed.
375
+ status: computeListingStatus(row.status, row.windowEndsAt, now, row.featured),
376
+ consentAt: row.consentAt.toISOString(),
377
+ decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
378
+ decidedBy: row.decidedBy,
379
+ issueUrl: row.issueUrl,
380
+ experimentKey: row.experimentKey,
381
+ windowEndsAt: row.windowEndsAt ? row.windowEndsAt.toISOString() : null,
382
+ waitlistCount,
383
+ createdAt: row.createdAt.toISOString(),
384
+ updatedAt: row.updatedAt.toISOString(),
385
+ };
386
+ }
387
+
388
+ /**
389
+ * `waitlistCount` per row, keyed by `experimentKey`.
390
+ *
391
+ * Since #5036 D6d the listing stores the experiment's KEY rather than its id,
392
+ * and that key is the same string `WaitlistScore.experimentKey` holds — so the
393
+ * `WaitlistExperiment` read that existed purely to translate one into the
394
+ * other is gone. One `WaitlistScore.count` per DISTINCT key remains, bounded
395
+ * by the small number of distinct experiments a dashboard page carries rather
396
+ * than by row count (`waitlist-dashboard.ts`'s "one read across every returned
397
+ * row" discipline).
398
+ */
399
+ async function attachWaitlistCounts(
400
+ siteId: string,
401
+ rows: readonly ProjectListingRow[],
402
+ ): Promise<Map<string, number>> {
403
+ const experimentKeys = [
404
+ ...new Set(rows.map((r) => r.experimentKey).filter((v): v is string => v != null)),
405
+ ];
406
+ if (experimentKeys.length === 0) return new Map();
407
+
408
+ const counts = new Map<string, number>();
409
+ await Promise.all(
410
+ experimentKeys.map(async (experimentKey) => {
411
+ const count = await prisma.waitlistScore.count({
412
+ where: { siteId, experimentKey },
413
+ });
414
+ counts.set(experimentKey, count);
415
+ }),
416
+ );
417
+ return counts;
418
+ }
419
+
420
+ function waitlistCountFor(row: ProjectListingRow, counts: Map<string, number>): number {
421
+ return row.experimentKey ? (counts.get(row.experimentKey) ?? 0) : 0;
422
+ }
423
+
424
+ // ─── The founder's key (#5036 D7) ──────────────────────────────────────────
425
+
426
+ /**
427
+ * Normalize the email a founder is counted under.
428
+ *
429
+ * Trim and lowercase, so `Ada@Example.com ` and `ada@example.com` are one
430
+ * founder rather than two. Postgres text equality is case-sensitive, so
431
+ * without this the concurrent-listing limit is evaded by holding down shift.
432
+ *
433
+ * Deliberately NOT stripping plus-tags: `ada+two@example.com` still counts as
434
+ * a different founder. Plus-addressing is a legitimate, widely-used feature
435
+ * and treating it as evasion would refuse real applicants; closing that gap
436
+ * properly needs a verified email, which this anonymous surface does not have.
437
+ */
438
+ function normalizeFounderEmail(email: string): string {
439
+ return email.trim().toLowerCase();
440
+ }
441
+
442
+ /**
443
+ * How long an unsubmitted draft occupies one of a founder's two slots
444
+ * (#5036 D7).
445
+ *
446
+ * Drafts MUST count, or the limit is evaded by never submitting. But
447
+ * `founderEmail` is unverified on an anonymous surface, so anyone can open a
448
+ * draft in a stranger's name — and a draft that counted forever would let two
449
+ * anonymous requests lock a named founder out of ever applying, with no
450
+ * operator remedy (there is no route that deletes a listing; PR #5042 security
451
+ * review, finding 1).
452
+ *
453
+ * Bounding the window keeps D7's intent — an application in progress holds a
454
+ * slot, so not submitting evades nothing — while making that lockout expire on
455
+ * its own. Fourteen days is far longer than a ten-step form takes and far
456
+ * shorter than "forever".
457
+ *
458
+ * This bounds the QUOTA impact, not retention: an abandoned draft still holds
459
+ * the founder's name, email and address in the table. A retention sweep is a
460
+ * separate unit and is not in this change.
461
+ */
462
+ const DRAFT_HOLDS_A_SLOT_DAYS = 14;
463
+
464
+ // ─── The resume token (#5036 D1) ───────────────────────────────────────────
465
+
466
+ /**
467
+ * The token's entropy, in bytes. 256 bits — the same floor the repo's other
468
+ * capability tokens carry, and far beyond guessing at any request rate the
469
+ * `leadsScope` rate limiter permits.
470
+ */
471
+ const RESUME_TOKEN_BYTES = 32;
472
+
473
+ /**
474
+ * Mint an opaque resume token. Returned to the caller EXACTLY once, at
475
+ * `start`: only its digest is stored, so the server cannot hand it back a
476
+ * second time and an attacker who reads the table still cannot present one.
477
+ */
478
+ function mintResumeToken(): string {
479
+ return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
480
+ }
481
+
482
+ /**
483
+ * What the column holds. A plain sha-256 with no salt and no stretching is
484
+ * correct here and a KDF would not be: the input is 256 bits of CSPRNG output,
485
+ * not a human-chosen secret, so there is no dictionary to precompute and
486
+ * nothing for a work factor to slow down.
487
+ */
488
+ function hashResumeToken(token: string): string {
489
+ return createHash("sha256").update(token).digest("hex");
490
+ }
491
+
492
+ /**
493
+ * Resolve the ONE draft a token grants access to (#5036 D1).
494
+ *
495
+ * Both the id and the token's digest are in the `where`, so a valid token
496
+ * presented against a different listing's id resolves to nothing: a token is
497
+ * bound to one listing and grants access to no other. `status: "draft"` is in
498
+ * there too — `submit` clears the digest, so a token that leaks after
499
+ * submission is inert twice over.
500
+ *
501
+ * The comparison is an equality test on a 256-bit digest inside Postgres.
502
+ * There is no application-level secret compare to make constant-time, and no
503
+ * practical timing signal in matching digests of that width.
504
+ */
505
+ async function findDraftByToken(
506
+ id: string,
507
+ resumeToken: string,
508
+ ): Promise<ProjectListingRow | null> {
509
+ return prisma.projectListing.findFirst({
510
+ where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
511
+ select: PROJECT_LISTING_SELECT,
512
+ });
513
+ }
514
+
515
+ // ─── start (POST, anonymous) ────────────────────────────────────────────────
516
+
517
+ /**
518
+ * `start` has one failure mode of its own to report (#5275): a `consents`
519
+ * map that does not GRANT every purpose the site's registry marks required —
520
+ * declined, or simply absent. The legacy literal says the founder consented;
521
+ * a `false` (or nothing) beside it for the required purpose says they did
522
+ * not — the body contradicts itself, and the row must not be created on it
523
+ * (it would go on to publish the founder's address against a recorded
524
+ * decline, or with no evidence row at all). An oversized map is the same
525
+ * 400. Everything else is as before: the site's config falls back to the
526
+ * platform defaults rather than erroring, and a Prisma failure is a 500
527
+ * through the framework. The Lead write — the step that used to give this
528
+ * operation a `persist_failed`/`config_missing` surface — moved to `submit`.
529
+ */
530
+ export type StartProjectListingDraftResult =
531
+ | {
532
+ ok: true;
533
+ id: string;
534
+ resumeToken: string;
535
+ }
536
+ | { ok: false; error: { kind: "invalid_input"; message: string } };
537
+
538
+ /**
539
+ * Create a `draft` from step one — the founder's name, email and consent.
540
+ *
541
+ * `siteId` is a PARAMETER, not a field of `input` (#5048 D2). The payload
542
+ * names the site by its public key and the route resolves it through
543
+ * `siteIdForPublicKey` before calling here, so no request body carries a
544
+ * tenant identifier and nothing a caller sends can choose which site an
545
+ * application is filed against.
546
+ *
547
+ * The site's own terms are read SERVER-SIDE and stamped now, so a listing is
548
+ * granted the terms that were in force when it applied; nothing in `input`
549
+ * can reach them. The applicant's *requested* window is clamped here, not
550
+ * rejected, because a founder who could post their own bound could post an
551
+ * unbounded one.
552
+ *
553
+ * No slug yet: it is derived from `name` at SUBMIT (#5033 D2), which is the
554
+ * first moment a name exists.
555
+ */
556
+ export async function startProjectListingDraft(
557
+ siteId: string,
558
+ input: ProjectListingStartInput,
559
+ ): Promise<StartProjectListingDraftResult> {
560
+ const { founderName, requestedWindowDays } = input;
561
+ const founderEmail = normalizeFounderEmail(input.founderEmail);
562
+
563
+ const listingConfig = await listingConfigFor(siteId);
564
+ const windowDays = clampWindowDays(requestedWindowDays, listingConfig);
565
+
566
+ // #5275 — refuse BEFORE the row exists: a required purpose not granted
567
+ // (declined or absent) is a body that contradicts its own `consent: true`,
568
+ // and an oversized map is not a form's answer.
569
+ const consentConfig = await consentConfigForSite(siteId);
570
+ if (input.consents) {
571
+ if (Object.keys(input.consents).length > MAX_CONSENT_KEYS) {
572
+ return {
573
+ ok: false,
574
+ error: {
575
+ kind: "invalid_input",
576
+ message: `consents carries more than ${MAX_CONSENT_KEYS} purposes`,
577
+ },
578
+ };
579
+ }
580
+ const unmet = requiredConsentUnmet(consentConfig, input.consents);
581
+ if (unmet !== undefined) {
582
+ return {
583
+ ok: false,
584
+ error: {
585
+ kind: "invalid_input",
586
+ message: `required consent purpose "${unmet}" was not granted`,
587
+ },
588
+ };
589
+ }
590
+ }
591
+
592
+ const resumeToken = mintResumeToken();
593
+ // #5275 — the row and its grant record land in ONE transaction: from here
594
+ // on `consentAt` and the ConsentGrant row are stamped by the SAME
595
+ // affirmative act, and a grant write that fails takes the row with it
596
+ // rather than stranding a "consented" listing with no evidence (security
597
+ // review, Medium 2). A body carrying `consents` records the wording the
598
+ // founder read (resolved server-side from the same registry the renderer
599
+ // used, D7); a body carrying only the legacy `consent: true` literal
600
+ // records one grant marked unrecoverable (D11) rather than today's
601
+ // wording, which nobody was shown.
602
+ const consents = input.consents;
603
+ const locale = input.locale ?? "en";
604
+ const row = await prisma.$transaction(async (tx) => {
605
+ const created = await tx.projectListing.create({
606
+ data: {
607
+ siteId,
608
+ founderName,
609
+ founderEmail,
610
+ status: "draft",
611
+ consentAt: new Date(),
612
+ resumeTokenHash: hashResumeToken(resumeToken),
613
+ windowDays,
614
+ },
615
+ select: { id: true },
616
+ });
617
+ const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
618
+ if (consents) {
619
+ await recordConsent({ ...subject, locale, config: consentConfig, consents }, tx);
620
+ } else {
621
+ await recordLegacyConsent({ ...subject, config: consentConfig }, tx);
622
+ }
623
+ return created;
624
+ });
625
+
626
+ return { ok: true, id: row.id, resumeToken };
627
+ }
628
+
629
+ // ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
630
+
631
+ export type PatchProjectListingDraftResult =
632
+ | { ok: true; value: ProjectListing }
633
+ | {
634
+ ok: false;
635
+ error:
636
+ | { kind: "not_found"; message: string }
637
+ | { kind: "invalid_input"; message: string };
638
+ };
639
+
640
+ /**
641
+ * What `draftUpdateData` cannot read off the patch body (#5285).
642
+ *
643
+ * Both members are the SERVER's, and that is why they are a second argument
644
+ * rather than two more payload fields: `windowDays` is the applicant's
645
+ * request already clamped against the site's own maximum, and
646
+ * `storedSiteAnswers` is what is on the row, which the merge needs and a
647
+ * request body must never be able to supply.
648
+ */
649
+ interface DraftUpdateContext {
650
+ /** The clamped window, when the patch asked for one. */
651
+ windowDays?: number;
652
+ /** The answers already stored, for the merge. */
653
+ storedSiteAnswers?: Record<string, string>;
654
+ }
655
+
656
+ /**
657
+ * Translate a validated partial application into a Prisma update.
658
+ *
659
+ * Only the keys the caller actually sent are written: an absent key is
660
+ * `undefined`, which Prisma omits from the UPDATE, so patching one field
661
+ * cannot blank the nine the founder filled in on earlier steps.
662
+ *
663
+ * #5279 — THIS IS WHERE THE DISPLAY ADDRESS IS COMPOSED, and the only place.
664
+ * `address` is a union of the legacy display STRING and the structured PARTS
665
+ * (`flowAddressValueSchema`), and the two branches write different columns:
666
+ *
667
+ * string → `address` verbatim, no part column touched. A founder who typed
668
+ * a line gets that line stored exactly as typed, and a draft
669
+ * written before #5279 keeps patching as it always did.
670
+ * parts → the six part columns, PLUS `address` composed from them by
671
+ * `composeAddressLine`.
672
+ *
673
+ * The derivation runs ONE WAY ONLY. Nothing anywhere derives parts from a
674
+ * line: address parsing guesses, and a wrong guess silently corrupts the
675
+ * party identification a listing's privacy notice points at (#5036 D6), with
676
+ * nothing on the row to mark it as invented.
677
+ */
678
+ function draftUpdateData(
679
+ patch: ProjectListingDraftPatchInput,
680
+ context: DraftUpdateContext,
681
+ ): Prisma.ProjectListingUpdateInput {
682
+ return {
683
+ ...(patch.name !== undefined && { name: patch.name }),
684
+ ...(patch.headline !== undefined && { headline: patch.headline }),
685
+ ...(patch.oneLiner !== undefined && { oneLiner: patch.oneLiner }),
686
+ ...(patch.problem !== undefined && { problem: patch.problem }),
687
+ ...(patch.url !== undefined && { url: patch.url }),
688
+ ...(patch.product !== undefined && { product: patch.product }),
689
+ ...(patch.commitment !== undefined && { commitment: patch.commitment }),
690
+ ...(patch.teamComposition !== undefined && { teamComposition: patch.teamComposition }),
691
+ ...(patch.faqs !== undefined && { faqs: patch.faqs as unknown as Prisma.InputJsonValue }),
692
+ ...(patch.anythingElse !== undefined && { anythingElse: patch.anythingElse }),
693
+ ...(patch.address !== undefined &&
694
+ (typeof patch.address === "string"
695
+ ? { address: patch.address }
696
+ : {
697
+ address: composeAddressLine(patch.address),
698
+ addressLine1: patch.address.addressLine1,
699
+ addressLine2: patch.address.addressLine2 ?? null,
700
+ addressCity: patch.address.city,
701
+ addressRegion: patch.address.region,
702
+ addressPostalCode: patch.address.postalCode,
703
+ addressCountry: patch.address.country,
704
+ })),
705
+ ...(patch.founderCompany !== undefined && { founderCompany: patch.founderCompany }),
706
+ ...(patch.questions !== undefined && {
707
+ questions: patch.questions as unknown as Prisma.InputJsonValue,
708
+ }),
709
+ ...(patch.logoMarkUrl !== undefined && { logoMarkUrl: patch.logoMarkUrl }),
710
+ ...(patch.logoWordmarkUrl !== undefined && {
711
+ logoWordmarkUrl: patch.logoWordmarkUrl,
712
+ }),
713
+ // #5285 — the applicant's REQUEST, already clamped by the caller against
714
+ // the site's own `maxWindowDays`. It is written to `windowDays`, the same
715
+ // column `start` writes, because there is one window per listing and the
716
+ // screen that asks for it is the site's choice of screen, not a second
717
+ // field. Clamped and not refused, for the reason `startProjectListingDraft`
718
+ // gives: a founder who could post their own bound could post an unbounded
719
+ // one.
720
+ ...(context.windowDays !== undefined && { windowDays: context.windowDays }),
721
+ // #5285 — MERGED, never replaced. A founder patches one screen at a time,
722
+ // so a body carrying screen four's two answers must not blank screen
723
+ // two's. The incoming keys win; everything already stored is carried.
724
+ // Every incoming id was checked against the site's declarations by the
725
+ // compiled patch schema, so nothing undeclared can enter here.
726
+ ...(patch.siteAnswers !== undefined && {
727
+ siteAnswers: {
728
+ ...context.storedSiteAnswers,
729
+ ...patch.siteAnswers,
730
+ } as Prisma.InputJsonValue,
731
+ }),
732
+ };
733
+ }
734
+
735
+ /**
736
+ * Merge a partial application into a draft and hand back the merged row.
737
+ *
738
+ * Enforces NO completeness — that is `submit`'s job alone, and the split is
739
+ * the whole point of the lifecycle. Returning the merged row is what makes a
740
+ * step's save verifiable by the client that wrote it.
741
+ */
742
+ export async function patchProjectListingDraft(
743
+ id: string,
744
+ resumeToken: string,
745
+ body: unknown,
746
+ ): Promise<PatchProjectListingDraftResult> {
747
+ // The word caps are the SITE's (#5048 D6), so the body cannot be validated
748
+ // until the draft's site is known. This read resolves that and nothing
749
+ // else — it is not the write's guard, which stays the compare-and-swap
750
+ // below. A caller holding no valid token never gets past it, so a bad token
751
+ // still answers `not_found` before any body is inspected.
752
+ const owner = await prisma.projectListing.findFirst({
753
+ where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
754
+ // #5285 — `siteAnswers` rides this read because the write MERGES into it
755
+ // (a founder patches one screen at a time). It is still not the write's
756
+ // guard: the compare-and-swap below is, and a concurrent patch of a
757
+ // DIFFERENT screen can still lose its answers to this one, exactly as it
758
+ // can already lose a column. The store is one row's worth of one
759
+ // founder's own form, not a shared document.
760
+ select: { siteId: true, siteAnswers: true },
761
+ });
762
+ if (!owner) {
763
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
764
+ }
765
+
766
+ const listingConfig = await listingConfigFor(owner.siteId);
767
+ const parsed = buildProjectListingDraftPatchSchema(
768
+ listingConfig.wordCaps,
769
+ listingConfig.collectedFields,
770
+ listingConfig.siteAnswers,
771
+ ).safeParse(body);
772
+ if (!parsed.success) {
773
+ return {
774
+ ok: false,
775
+ error: {
776
+ kind: "invalid_input",
777
+ message: parsed.error.issues[0]?.message ?? "invalid input",
778
+ },
779
+ };
780
+ }
781
+ const patch: ProjectListingDraftPatchInput = parsed.data;
782
+
783
+ const stored = siteAnswersFromRow(owner.siteAnswers);
784
+ const context: DraftUpdateContext = {
785
+ // THE CLAMP, and the reason `requestedWindowDays` is safe to accept from
786
+ // an anonymous body at all (#5285). The site's `maxWindowDays` is read
787
+ // server-side and caps everything; the payload carries a REQUEST. A
788
+ // founder who could post their own bound could post an unbounded one, and
789
+ // this is the only place a patched window reaches a column.
790
+ ...(patch.requestedWindowDays !== undefined && {
791
+ windowDays: clampWindowDays(patch.requestedWindowDays, listingConfig),
792
+ }),
793
+ // What is on the row already, so the merge below adds to it rather than
794
+ // replacing it. It comes from the ROW and never from the body.
795
+ ...(stored !== null && { storedSiteAnswers: stored }),
796
+ };
797
+
798
+ // A GUARDED `updateMany`, not a read-then-write: the token check and the
799
+ // write are one statement, so a concurrent `submit` cannot flip the row to
800
+ // `requested` in between and let this patch land on an application the
801
+ // operator is already reviewing (PR #5042 security review, finding 3). The
802
+ // same compare-and-swap shape `submit` and `decideListing` already use.
803
+ const updated = await prisma.projectListing.updateMany({
804
+ where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
805
+ data: draftUpdateData(patch, context),
806
+ });
807
+ if (updated.count === 0) {
808
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
809
+ }
810
+
811
+ const row = await prisma.projectListing.findFirst({
812
+ where: { id },
813
+ select: PROJECT_LISTING_SELECT,
814
+ });
815
+ if (!row) {
816
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
817
+ }
818
+
819
+ return { ok: true, value: toResponseRow(row, 0, new Date(), true) };
820
+ }
821
+
822
+ // ─── submit (POST, anonymous, token-gated) ──────────────────────────────────
823
+
824
+ export type SubmitProjectListingDraftResult =
825
+ | { ok: true; id: string; slug: string; listing: ProjectListing }
826
+ | {
827
+ ok: false;
828
+ error:
829
+ | { kind: "not_found"; message: string }
830
+ | { kind: "incomplete"; message: string; missingFields: string[] }
831
+ | { kind: "listing_limit_reached"; message: string }
832
+ | { kind: "slug_taken"; message: string };
833
+ };
834
+
835
+ /**
836
+ * The application, assembled from the stored row for `submit` to validate.
837
+ *
838
+ * Narrowed to the site's COLLECTED fields (#5048 D5). The built submit schema
839
+ * is `.strict()` over exactly that set, so a stored value for a field the site
840
+ * does not collect would otherwise fail as an unrecognised key — which is the
841
+ * right shape for a REQUEST body and the wrong one for a row that may carry a
842
+ * value written before the site narrowed its step set.
843
+ */
844
+ function applicationFromRow(
845
+ row: ProjectListingRow,
846
+ collectedFields: readonly string[],
847
+ ): Record<string, unknown> {
848
+ const collected = new Set(collectedFields);
849
+ const all: Record<string, unknown> = {
850
+ name: row.name,
851
+ headline: row.headline,
852
+ oneLiner: row.oneLiner,
853
+ problem: row.problem,
854
+ ...(row.url !== null && { url: row.url }),
855
+ product: row.product,
856
+ commitment: row.commitment,
857
+ teamComposition: row.teamComposition,
858
+ faqs: row.faqs,
859
+ ...(row.anythingElse !== null && { anythingElse: row.anythingElse }),
860
+ address: row.address,
861
+ ...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
862
+ questions: row.questions,
863
+ // #5285 — the three fields that became writable. Each is optional in the
864
+ // application schema, so a row that never carried one simply omits it and
865
+ // `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
866
+ // `windowDays` the row actually holds, not what was asked for: the
867
+ // request is not stored anywhere, and re-asserting it would be asserting
868
+ // a number nothing ever agreed to.
869
+ ...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
870
+ ...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
871
+ ...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
872
+ };
873
+ return Object.fromEntries(
874
+ Object.entries(all).filter(([key]) => collected.has(key)),
875
+ );
876
+ }
877
+
878
+ /**
879
+ * Count the founder's OTHER non-terminal listings on this site (#5036 D7).
880
+ *
881
+ * Drafts count. A limit drafts escaped would be evaded by never submitting,
882
+ * which is exactly the state a ten-step form leaves a founder in by default.
883
+ *
884
+ * The stored-status filter is a first pass, not the answer: `closed` is
885
+ * computed, so an `active` row whose window has elapsed is terminal and must
886
+ * not count. `computeListingStatus` is the one place that knows this, and
887
+ * consulting it here is the same discipline the CSV sweep learned in #5033.
888
+ */
889
+ async function countOtherNonTerminalListings(
890
+ siteId: string,
891
+ founderEmail: string,
892
+ exceptId: string,
893
+ ): Promise<number> {
894
+ const now = new Date();
895
+ const draftCutoff = new Date(now.getTime() - DRAFT_HOLDS_A_SLOT_DAYS * 24 * 60 * 60 * 1000);
896
+
897
+ const rows = await prisma.projectListing.findMany({
898
+ where: {
899
+ siteId,
900
+ founderEmail,
901
+ id: { not: exceptId },
902
+ OR: [
903
+ // Every non-terminal state except `draft`, which the clause below
904
+ // handles on its own terms. Derived from the shared constant rather
905
+ // than typed out, so a fifth non-terminal state is counted the day it
906
+ // is added.
907
+ {
908
+ status: {
909
+ in: PROJECT_LISTING_NON_TERMINAL_STATUSES.filter((s) => s !== "draft"),
910
+ },
911
+ },
912
+ // A draft holds a slot only while it is live — see
913
+ // `DRAFT_HOLDS_A_SLOT_DAYS`. It still counts, so not submitting evades
914
+ // nothing; it just stops counting once the application is plainly
915
+ // abandoned, which is what keeps an unverified email from locking a
916
+ // named founder out permanently.
917
+ { status: "draft", createdAt: { gt: draftCutoff } },
918
+ ],
919
+ },
920
+ select: { status: true, windowEndsAt: true, featured: true },
921
+ });
922
+ return rows.filter(
923
+ (r) => computeListingStatus(r.status, r.windowEndsAt, now, r.featured) !== "closed",
924
+ ).length;
925
+ }
926
+
927
+ /**
928
+ * Validate the whole application and move the row `draft` -> `requested`.
929
+ *
930
+ * Three things happen here and nowhere else: completeness is asserted (an
931
+ * incomplete draft comes back with the offending fields NAMED, so the form can
932
+ * send the founder to the step that owns each one); the concurrent-listing
933
+ * limit binds; and the slug is derived, which is the first moment a `name`
934
+ * exists to derive it from.
935
+ *
936
+ * The transition write is a GUARDED `updateMany` on `status: "draft"`, so two
937
+ * concurrent submits of the same draft cannot both succeed — the loser reads
938
+ * as `not_found`, the same answer a spent token gets. The token's digest is
939
+ * cleared in that same write: a token that leaks after submission grants
940
+ * nothing.
941
+ *
942
+ * The Lead write and the Slack post are both side effects of that committed
943
+ * transition and both fail soft, in that order — never before it, or a submit
944
+ * that lost the race would still have put an application in the operator's
945
+ * pipeline with no listing behind it.
946
+ */
947
+ export async function submitProjectListingDraft(
948
+ id: string,
949
+ resumeToken: string,
950
+ sourceMeta: SourceMeta,
951
+ ): Promise<SubmitProjectListingDraftResult> {
952
+ const draft = await findDraftByToken(id, resumeToken);
953
+ if (!draft) {
954
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
955
+ }
956
+
957
+ // #5048 D5 — completeness derives from the site's CONFIGURED collected-field
958
+ // set, never from a fixed schema. Steps are individually optional, so a
959
+ // field whose step the site never rendered was never asked for; requiring it
960
+ // would refuse a valid application naming a question the founder never saw.
961
+ //
962
+ // The set comes from the site's own server-side `SiteListingConfig`. It is
963
+ // never read from the request: a caller that could post its own required-
964
+ // field set could post an empty one and submit a blank listing.
965
+ const listingConfig = await listingConfigFor(draft.siteId);
966
+ const submitSchema = buildProjectListingSubmitSchema(
967
+ listingConfig.wordCaps,
968
+ listingConfig.collectedFields,
969
+ );
970
+ const parsed = submitSchema.safeParse(
971
+ applicationFromRow(draft, listingConfig.collectedFields),
972
+ );
973
+ if (!parsed.success) {
974
+ const missingFields = [
975
+ ...new Set(parsed.error.issues.map((issue) => String(issue.path[0]))),
976
+ ].sort();
977
+ return {
978
+ ok: false,
979
+ error: {
980
+ kind: "incomplete",
981
+ message: `application is incomplete: ${missingFields.join(", ")}`,
982
+ missingFields,
983
+ },
984
+ };
985
+ }
986
+
987
+ const concurrent = await countOtherNonTerminalListings(
988
+ draft.siteId,
989
+ draft.founderEmail,
990
+ draft.id,
991
+ );
992
+ if (concurrent >= PROJECT_LISTING_CONCURRENT_LIMIT) {
993
+ return {
994
+ ok: false,
995
+ error: {
996
+ kind: "listing_limit_reached",
997
+ message: `a founder may hold at most ${PROJECT_LISTING_CONCURRENT_LIMIT} listings at once`,
998
+ },
999
+ };
1000
+ }
1001
+
1002
+ // `name` may be absent from the picked schema if a site does not collect it
1003
+ // (#5048 D5), so the slug derives from the ROW rather than from the parsed
1004
+ // payload. A site that collects no name has none to derive from, and the
1005
+ // founder's own name is the only other stable string on the row.
1006
+ const base = deriveListingSlug(draft.name ?? draft.founderName);
1007
+ const slug = await firstFreeSlug(draft.siteId, base);
1008
+
1009
+ const transition = async (candidate: string): Promise<number | "taken"> => {
1010
+ try {
1011
+ const updated = await prisma.projectListing.updateMany({
1012
+ where: { id, status: "draft" },
1013
+ data: { status: "requested", slug: candidate, resumeTokenHash: null },
1014
+ });
1015
+ return updated.count;
1016
+ } catch (err) {
1017
+ if (isUniqueViolation(err)) return "taken";
1018
+ throw err;
1019
+ }
1020
+ };
1021
+
1022
+ let count = await transition(slug);
1023
+ if (count === "taken") {
1024
+ // `firstFreeSlug` samples at most 200 same-prefix rows, so a family larger
1025
+ // than that can hand back a slug that is in fact taken — and a plain retry
1026
+ // resamples and fails the same way, which would let a squatted prefix wall
1027
+ // off every later applicant whose name derives to it (PR #5042 security
1028
+ // review, finding 2). One random suffix ends that: an ugly slug beats an
1029
+ // application that cannot be submitted at all.
1030
+ count = await transition(`${base}-${randomBytes(3).toString("hex")}`);
1031
+ }
1032
+ if (count === "taken") {
1033
+ return {
1034
+ ok: false,
1035
+ error: { kind: "slug_taken", message: "slug already in use for this site" },
1036
+ };
1037
+ }
1038
+ if (count === 0) {
1039
+ // A concurrent submit won the race and already spent this draft.
1040
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1041
+ }
1042
+
1043
+ // The Lead write reuses routeLead — never a duplicate persistence path — and
1044
+ // happens at SUBMIT rather than at start, so an anonymous stranger cannot
1045
+ // cause an operator notification by opening a draft they never finish.
1046
+ //
1047
+ // It runs AFTER the transition, and fails soft. Before the transition it
1048
+ // would write a Lead (and mail the operator) for every submit that then lost
1049
+ // the race or hit a taken slug — an application in the operator's pipeline
1050
+ // with no listing behind it, and two Leads for the one submit that did land.
1051
+ // After it, the worst case is a `requested` listing whose `leadId` is null,
1052
+ // which the model allows and the weekly CSV sweep already skips explicitly.
1053
+ // The submit itself has landed either way, and that is what the founder
1054
+ // asked for; reporting it as a failure would be the wrong answer.
1055
+ const leadResult = await routeLead(
1056
+ {
1057
+ kind: "submit",
1058
+ siteId: draft.siteId,
1059
+ source: "projects-apply",
1060
+ email: draft.founderEmail,
1061
+ payload: {
1062
+ name: parsed.data.name,
1063
+ oneLiner: parsed.data.oneLiner,
1064
+ problem: parsed.data.problem,
1065
+ product: parsed.data.product,
1066
+ founderName: draft.founderName,
1067
+ questions: parsed.data.questions,
1068
+ },
1069
+ },
1070
+ sourceMeta,
1071
+ );
1072
+ if (leadResult.ok) {
1073
+ await prisma.projectListing.update({
1074
+ where: { id },
1075
+ data: { leadId: leadResult.id },
1076
+ });
1077
+ } else {
1078
+ logger.error(
1079
+ { listingId: id, kind: leadResult.error.kind },
1080
+ "[project-listings] the Lead write failed after a submitted listing already landed",
1081
+ );
1082
+ }
1083
+
1084
+ const row = await prisma.projectListing.findFirst({
1085
+ where: { id },
1086
+ select: PROJECT_LISTING_SELECT,
1087
+ });
1088
+ if (!row) {
1089
+ return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1090
+ }
1091
+
1092
+ return {
1093
+ ok: true,
1094
+ id,
1095
+ slug: row.slug!,
1096
+ listing: toResponseRow(row, 0, new Date(), true),
1097
+ };
1098
+ }
1099
+
1100
+ function isUniqueViolation(err: unknown): boolean {
1101
+ return (
1102
+ err != null &&
1103
+ typeof err === "object" &&
1104
+ "code" in err &&
1105
+ (err as { code: string }).code === "P2002"
1106
+ );
1107
+ }
1108
+
1109
+ // ─── List + get (tenant scope) ──────────────────────────────────────────────
1110
+
1111
+ export async function listProjectListings(siteId: string): Promise<ProjectListing[]> {
1112
+ const rows = await prisma.projectListing.findMany({
1113
+ where: { siteId },
1114
+ orderBy: { createdAt: "desc" },
1115
+ select: PROJECT_LISTING_SELECT,
1116
+ });
1117
+ const counts = await attachWaitlistCounts(siteId, rows);
1118
+ const now = new Date();
1119
+ return rows.map((row) => toResponseRow(row, waitlistCountFor(row, counts), now, true));
1120
+ }
1121
+
1122
+ export type GetProjectListingResult =
1123
+ | { ok: true; value: ProjectListing }
1124
+ | { ok: false; error: { kind: "not_found"; message: string } };
1125
+
1126
+ export async function getProjectListing(siteId: string, id: string): Promise<GetProjectListingResult> {
1127
+ const row = await prisma.projectListing.findFirst({
1128
+ where: { id, siteId },
1129
+ select: PROJECT_LISTING_SELECT,
1130
+ });
1131
+ if (!row) {
1132
+ return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1133
+ }
1134
+ const counts = await attachWaitlistCounts(siteId, [row]);
1135
+ return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
1136
+ }
1137
+
1138
+ // ─── The founder's own listings (auth scope; #5295) ─────────────────────────
1139
+
1140
+ /**
1141
+ * The listings the CALLING USER owns, newest first.
1142
+ *
1143
+ * The join is the owner link and ONLY the owner link:
1144
+ * `ownerUserId === userId`, the scalar `listing-promotion.ts` stamps once that
1145
+ * user's own email is verified, matches the row's `founderEmail`, and the row's
1146
+ * site resolves to one of the user's own workspaces. `founderEmail` is NEVER
1147
+ * compared here, at any stage — an un-linked listing that happens to carry the
1148
+ * caller's address is not theirs until a promotion said so, and a read-time
1149
+ * email match would reintroduce exactly the cross-founder capture the link
1150
+ * exists to remove. (`flow-checkouts.ts` draws the same line for `Lead`.)
1151
+ *
1152
+ * No `siteId` parameter and no `currentSiteId()` scope, deliberately — and the
1153
+ * reason is NOT that the two can never coincide. `siteIdForPublicKey`
1154
+ * (`listing-config.ts`) does fall back to `currentSiteId()`, so a listing's
1155
+ * `siteId` CAN be `AILK_SITE_ID` or the literal `"default"` on a single-tenant
1156
+ * deployment. The reason is that the owner link is already the narrower scope:
1157
+ * it is only ever stamped for a site inside the user's own tenancy
1158
+ * (`listing-promotion.ts`, condition 3), so adding a site term could only
1159
+ * remove rows the caller owns — and on the multi-tenant shape, where a
1160
+ * listing's `siteId` is a WORKSPACE id rather than `AILK_SITE_ID`, it would
1161
+ * remove all of them. The response omits `siteId` regardless, so no workspace
1162
+ * id leaves the read.
1163
+ *
1164
+ * The one thing this gives up against the sibling `/me` routes: on a database
1165
+ * shared by more than one site, a caller's rows from every such site come back
1166
+ * together. They are all the caller's own rows, which is why that is acceptable
1167
+ * here and not a disclosure.
1168
+ *
1169
+ * No verified-email re-check either. `flow-checkouts.ts` performs one as
1170
+ * defence in depth beside its email-matching sibling
1171
+ * (`waitlist-signups.ts`, where the gate is load-bearing because the match IS
1172
+ * an email). Here the link is the proof, and it was only issuable under
1173
+ * verification — so a re-check adds no safety, while failing a caller whose
1174
+ * address later changed to an unverified one would wrongly hide the rows they
1175
+ * already proved they own.
1176
+ *
1177
+ * `waitlistCount` is resolved PER SITE: the count is keyed by
1178
+ * `(siteId, experimentKey)`, and a founder may own listings in more than one of
1179
+ * their own workspaces, so counting them all under one site id would report
1180
+ * another site's number.
1181
+ *
1182
+ * An empty array for a caller who owns nothing is the normal answer, not an
1183
+ * error.
1184
+ */
1185
+ export async function listMyProjectListings(userId: string): Promise<ProjectListing[]> {
1186
+ const rows = await prisma.projectListing.findMany({
1187
+ where: { ownerUserId: userId },
1188
+ orderBy: { createdAt: "desc" },
1189
+ select: PROJECT_LISTING_SELECT,
1190
+ });
1191
+ if (rows.length === 0) return [];
1192
+
1193
+ const rowsBySite = new Map<string, ProjectListingRow[]>();
1194
+ for (const row of rows) {
1195
+ const forSite = rowsBySite.get(row.siteId);
1196
+ if (forSite) forSite.push(row);
1197
+ else rowsBySite.set(row.siteId, [row]);
1198
+ }
1199
+ const countsBySite = new Map<string, Map<string, number>>();
1200
+ await Promise.all(
1201
+ [...rowsBySite].map(async ([siteId, siteRows]) => {
1202
+ countsBySite.set(siteId, await attachWaitlistCounts(siteId, siteRows));
1203
+ }),
1204
+ );
1205
+
1206
+ const now = new Date();
1207
+ // `featuredEnabled: true` — the founder's own row, like the operator's
1208
+ // dashboard row, reports the flag as stored. It is the PUBLIC shelf the
1209
+ // site's config switch governs, not what the row's owner may see.
1210
+ return rows.map((row) =>
1211
+ toResponseRow(
1212
+ row,
1213
+ waitlistCountFor(row, countsBySite.get(row.siteId) ?? new Map()),
1214
+ now,
1215
+ true,
1216
+ ),
1217
+ );
1218
+ }
1219
+
1220
+ // ─── Patch (tenant scope; the featured flag) ───────────────────────────────
1221
+
1222
+ export type SetListingFeaturedResult =
1223
+ | { ok: true; value: ProjectListing }
1224
+ | { ok: false; error: { kind: "not_found"; message: string } };
1225
+
1226
+ /**
1227
+ * Set the operator's `featured` flag (#5033 D6).
1228
+ *
1229
+ * This is the only non-status write the PATCH route makes. STATUS
1230
+ * transitions do not come through here at all — since the lifecycle
1231
+ * collapsed to five states with `closed` computed, `requested -> active |
1232
+ * rejected` is the only transition there is, and `decideListing` owns it for
1233
+ * both the PATCH route and the Slack button. The old transition table (which
1234
+ * also carried `approved -> live` and `live -> closed`) has no states left to
1235
+ * describe.
1236
+ */
1237
+ export async function setListingFeatured(
1238
+ siteId: string,
1239
+ id: string,
1240
+ featured: boolean,
1241
+ ): Promise<SetListingFeaturedResult> {
1242
+ const updated = await prisma.projectListing.updateMany({
1243
+ where: { id, siteId },
1244
+ data: { featured },
1245
+ });
1246
+ if (updated.count === 0) {
1247
+ return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1248
+ }
1249
+
1250
+ const row = await prisma.projectListing.findFirst({
1251
+ where: { id, siteId },
1252
+ select: PROJECT_LISTING_SELECT,
1253
+ });
1254
+ if (!row) {
1255
+ return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1256
+ }
1257
+
1258
+ const counts = await attachWaitlistCounts(siteId, [row]);
1259
+ return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
1260
+ }
1261
+
1262
+ // ─── Patch (tenant scope; the operator's copy edit) ─────────────────────────
1263
+
1264
+ /**
1265
+ * The five PUBLISHED copy fields an operator copy edit may rewrite.
1266
+ *
1267
+ * The set is deliberately narrow. `questions` is IN because its prompts and
1268
+ * option labels render on the public page, which is precisely where abusive
1269
+ * language would land. `name` is OUT because the slug is derived from it at
1270
+ * submit and stored, so editing it would desync the public URL from the name.
1271
+ * `url`, `product`, `commitment`, `teamComposition`, `address` and
1272
+ * `founderCompany` are OUT because they are facts rather than copy — a wrong
1273
+ * fact is a question to the founder or a reject, not an edit. `anythingElse`
1274
+ * is OUT because it is a note to the reviewer and never renders.
1275
+ *
1276
+ * The request body is derived from the SAME package schema these keys name
1277
+ * (`projectListingApplicationFieldsSchema`) at the route boundary, so every
1278
+ * bound the application itself carries — the word caps, the fixed FAQ count,
1279
+ * the question refinements — holds here for free.
1280
+ */
1281
+ export type ProjectListingCopyEdit = Pick<
1282
+ ProjectListingDraftPatchInput,
1283
+ "headline" | "oneLiner" | "problem" | "faqs" | "questions"
1284
+ >;
1285
+
1286
+ /**
1287
+ * The WHERE terms that admit a copy edit: `requested`, or `active` as the row
1288
+ * actually READS.
1289
+ *
1290
+ * `draft` is the applicant's own, held through the resume token, and stays
1291
+ * theirs. `rejected` is terminal. `closed` is the awkward one, because nothing
1292
+ * stores it: `computeListingStatus` derives it from a stored `active` whose
1293
+ * window has elapsed, so scoping on the stored string alone would leave every
1294
+ * closed listing editable. These three terms are that function's three
1295
+ * accepting branches, transcribed — a featured row (window-exempt, #5036 D6b),
1296
+ * or a window still in force. A non-featured row with a null or elapsed window
1297
+ * matches none of them and is refused, exactly as it reads.
1298
+ *
1299
+ * `now` is a parameter so the one instant the caller renders the response
1300
+ * against is the same instant the write is guarded by.
1301
+ *
1302
+ * This lives in the WHERE clause rather than in a read-then-write check, so a
1303
+ * concurrent decision cannot slip the row out of the editable set between the
1304
+ * check and the write — the same compare-and-swap discipline
1305
+ * `patchProjectListingDraft` and `decideListing` use.
1306
+ */
1307
+ function copyEditableWhere(now: Date): Prisma.ProjectListingWhereInput[] {
1308
+ return [
1309
+ { status: "requested" },
1310
+ { status: "active", featured: true },
1311
+ { status: "active", windowEndsAt: { gt: now } },
1312
+ ];
1313
+ }
1314
+
1315
+ export type PatchListingCopyResult =
1316
+ | { ok: true; value: ProjectListing }
1317
+ | { ok: false; error: { kind: "not_found"; message: string } };
1318
+
1319
+ /**
1320
+ * Rewrite a listing's published copy, preserving what the applicant submitted.
1321
+ *
1322
+ * The third review outcome beside approve and reject: a listing whose copy
1323
+ * needs a trim was previously publishable only exactly as written, because the
1324
+ * legal transitions out of `requested` are `active` and `rejected`, there is no
1325
+ * route back to `draft`, and `patchProjectListingDraft` is scoped to the
1326
+ * applicant's own resume token.
1327
+ *
1328
+ * `submittedCopy` is WRITE-ONCE, on the first edit only. The read below is what
1329
+ * resolves the pre-edit copy; it is NOT the write's guard, which stays the
1330
+ * status-scoped `updateMany` — the same compare-and-swap shape
1331
+ * `patchProjectListingDraft` and `decideListing` use. Because that read is one
1332
+ * consistent snapshot, a row it saw with a null `submittedCopy` also carries
1333
+ * the copy as the applicant submitted it, so two concurrent first edits write
1334
+ * the same snapshot value rather than racing to a different one. Nothing
1335
+ * clears the column, so the submitted copy stays recoverable for the life of
1336
+ * the row.
1337
+ *
1338
+ * DELIBERATELY silent on the notification surfaces, unlike a decision. No
1339
+ * founder email is sent, and the application's Slack card is not re-rendered,
1340
+ * so a card posted before an edit keeps showing the pre-edit copy. Both are the
1341
+ * decision path's concerns (`sendListingDecisionEmail`, `postListingApplication`)
1342
+ * and neither is touched here. Worth revisiting if operators start editing
1343
+ * before deciding often enough that the card misleads.
1344
+ *
1345
+ * The three audit columns are WRITE-ONLY at present: nothing reads them back,
1346
+ * because the tenant read's response contract is `.strict()` and surfacing them
1347
+ * would mean widening it. "Recoverable" therefore means recoverable from the
1348
+ * row, not through an API.
1349
+ *
1350
+ * PII boundary: as everywhere else in this module, nothing here is logged.
1351
+ */
1352
+ export async function patchListingCopy(
1353
+ siteId: string,
1354
+ id: string,
1355
+ copy: ProjectListingCopyEdit,
1356
+ editedBy: string,
1357
+ ): Promise<PatchListingCopyResult> {
1358
+ const before = await prisma.projectListing.findFirst({
1359
+ where: { id, siteId },
1360
+ select: {
1361
+ submittedCopy: true,
1362
+ headline: true,
1363
+ oneLiner: true,
1364
+ problem: true,
1365
+ faqs: true,
1366
+ questions: true,
1367
+ },
1368
+ });
1369
+ if (!before) {
1370
+ return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1371
+ }
1372
+
1373
+ const now = new Date();
1374
+ const updated = await prisma.projectListing.updateMany({
1375
+ where: { id, siteId, OR: copyEditableWhere(now) },
1376
+ data: {
1377
+ // Only the keys the operator actually sent are written; an absent key is
1378
+ // `undefined`, which Prisma omits from the UPDATE, so trimming the
1379
+ // headline cannot blank the FAQs.
1380
+ ...(copy.headline !== undefined && { headline: copy.headline }),
1381
+ ...(copy.oneLiner !== undefined && { oneLiner: copy.oneLiner }),
1382
+ ...(copy.problem !== undefined && { problem: copy.problem }),
1383
+ ...(copy.faqs !== undefined && { faqs: copy.faqs as unknown as Prisma.InputJsonValue }),
1384
+ ...(copy.questions !== undefined && {
1385
+ questions: copy.questions as unknown as Prisma.InputJsonValue,
1386
+ }),
1387
+ ...(before.submittedCopy === null && {
1388
+ submittedCopy: {
1389
+ headline: before.headline,
1390
+ oneLiner: before.oneLiner,
1391
+ problem: before.problem,
1392
+ faqs: before.faqs,
1393
+ questions: before.questions,
1394
+ } as unknown as Prisma.InputJsonValue,
1395
+ }),
1396
+ copyEditedAt: now,
1397
+ copyEditedBy: editedBy,
1398
+ },
1399
+ });
1400
+ if (updated.count === 0) {
1401
+ return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1402
+ }
1403
+
1404
+ const row = await prisma.projectListing.findFirst({
1405
+ where: { id, siteId },
1406
+ select: PROJECT_LISTING_SELECT,
1407
+ });
1408
+ if (!row) {
1409
+ return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1410
+ }
1411
+
1412
+ const counts = await attachWaitlistCounts(siteId, [row]);
1413
+ return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), now, true) };
1414
+ }
1415
+
1416
+ // ─── Public read (anonymous) ────────────────────────────────────────────────
1417
+
1418
+ /**
1419
+ * The page-render read (#4998 AC4, reshaped by #5033): every `active` row for
1420
+ * the site, the THREE newest by `decidedAt` first, then the rest by
1421
+ * `waitlistCount` desc, then `decidedAt` desc. `founderEmail` never leaves
1422
+ * this function.
1423
+ *
1424
+ * Rows whose window has elapsed are returned and read `closed` — computed,
1425
+ * per row, against one instant, with no job having run. They are not filtered
1426
+ * out: a consumer partitions its shelves on the status it is handed, and a
1427
+ * listing that quietly vanished at midnight would be a worse answer than one
1428
+ * that says it is closed.
1429
+ */
1430
+ export async function publicProjectListings(siteId: string): Promise<ProjectListingPublic[]> {
1431
+ const listingConfig = await listingConfigFor(siteId);
1432
+ const rows = await prisma.projectListing.findMany({
1433
+ where: { siteId, status: "active" },
1434
+ select: PROJECT_LISTING_SELECT,
1435
+ });
1436
+ const counts = await attachWaitlistCounts(siteId, rows);
1437
+ const withCounts = rows.map((row) => ({ row, waitlistCount: waitlistCountFor(row, counts) }));
1438
+
1439
+ const decidedAtMs = (row: ProjectListingRow): number => row.decidedAt?.getTime() ?? -Infinity;
1440
+
1441
+ const newestFirst = [...withCounts].sort((a, b) => decidedAtMs(b.row) - decidedAtMs(a.row));
1442
+ const top3 = newestFirst.slice(0, 3);
1443
+ const top3Ids = new Set(top3.map((t) => t.row.id));
1444
+
1445
+ const rest = withCounts
1446
+ .filter((t) => !top3Ids.has(t.row.id))
1447
+ .sort((a, b) => {
1448
+ if (b.waitlistCount !== a.waitlistCount) return b.waitlistCount - a.waitlistCount;
1449
+ return decidedAtMs(b.row) - decidedAtMs(a.row);
1450
+ });
1451
+
1452
+ const now = new Date();
1453
+ return [...top3, ...rest].map(({ row, waitlistCount }) => {
1454
+ const { founderEmail: _founderEmail, ...publicRow } = toResponseRow(
1455
+ row,
1456
+ waitlistCount,
1457
+ now,
1458
+ listingConfig.featuredEnabled,
1459
+ );
1460
+ // #5285 — the `public`-visibility answers ONLY. An `operator-only` answer
1461
+ // is something a site asked for its own triage, and a founder who was
1462
+ // told it would not be published has to be right about that; an answer
1463
+ // whose id the site no longer declares is dropped for the same reason,
1464
+ // because "no declaration" cannot be read as "public" on the read that
1465
+ // publishes. The tenant dashboard and the review payload are the audience
1466
+ // that sees the rest, and they do not come through here.
1467
+ return {
1468
+ ...publicRow,
1469
+ siteAnswers: publicSiteAnswers(publicRow.siteAnswers, listingConfig.siteAnswers),
1470
+ };
1471
+ });
1472
+ }