create-ailk 0.24.0 → 0.26.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 (638) hide show
  1. package/README.md +53 -22
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +2 -1
  14. package/dist/lib/fetch-template-snapshot.js +8 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +36 -15
  19. package/dist/lib/module-tier.d.ts +2 -8
  20. package/dist/lib/module-tier.js +13 -12
  21. package/dist/module-architecture.d.ts +20 -3
  22. package/dist/module-architecture.js +129 -552
  23. package/dist/paid-sections.d.ts +25 -1
  24. package/dist/paid-sections.js +88 -3
  25. package/dist/starter-grid.d.ts +6 -6
  26. package/dist/starter-grid.js +12 -5
  27. package/dist/surfaces.js +39 -2
  28. package/dist/template-record.d.ts +21 -3
  29. package/dist/template-record.js +38 -7
  30. package/dist/topology.d.ts +16 -3
  31. package/dist/topology.js +54 -23
  32. package/dist/update.d.ts +10 -1
  33. package/dist/update.js +715 -21
  34. package/package.json +3 -3
  35. package/release-scope.json +2 -2
  36. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  37. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  38. package/site-templates/marketing-site/ailk-template.json +7 -1
  39. package/site-templates/marketing-site/provenance.json +7 -1
  40. package/site-templates/saas-product/ailk-template.json +2 -1
  41. package/site-templates/saas-product/provenance.json +1 -0
  42. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  43. package/templates/.claude/hooks/template-guard.sh +137 -0
  44. package/templates/.claude/rules/extending.md +106 -0
  45. package/templates/.claude/rules/loop-contract.md +2 -2
  46. package/templates/.claude/settings.json +11 -0
  47. package/templates/.env.example +20 -5
  48. package/templates/.github/workflows/ci.yml +7 -4
  49. package/templates/CLAUDE.md +11 -1
  50. package/templates/CONVENTIONS.md +8 -10
  51. package/templates/README.md +14 -13
  52. package/templates/apps/api/.env.example +23 -2
  53. package/templates/apps/api/CLAUDE.md +119 -83
  54. package/templates/apps/api/jest.config.cjs +15 -5
  55. package/templates/apps/api/package.json +16 -20
  56. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  57. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  58. package/templates/apps/api/src/index.ts +1 -1
  59. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  61. package/templates/apps/api/src/modules/commerce.ts +10 -0
  62. package/templates/apps/api/src/modules/content-write.ts +10 -0
  63. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  64. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  65. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  66. package/templates/apps/api/src/modules/workspace.ts +10 -0
  67. package/templates/apps/api/src/server.ts +117 -1882
  68. package/templates/apps/api/vercel.json +2 -2
  69. package/templates/apps/mcp/CLAUDE.md +90 -26
  70. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  71. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  72. package/templates/apps/mcp/jest.config.cjs +6 -1
  73. package/templates/apps/mcp/package.json +5 -13
  74. package/templates/apps/mcp/src/app.ts +8 -7
  75. package/templates/apps/mcp/src/index.ts +6 -34
  76. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  77. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  78. package/templates/apps/mcp/src/site.ts +12 -0
  79. package/templates/apps/web/.env.example +1 -1
  80. package/templates/apps/web/CLAUDE.md +31 -3
  81. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  82. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  83. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  84. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
  85. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
  86. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  87. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  88. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
  89. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  90. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
  91. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  92. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  95. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  96. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  97. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  98. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +33 -13
  101. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  102. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  103. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  104. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  105. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  106. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  107. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  108. package/templates/apps/web/auth.ts +10 -6
  109. package/templates/apps/web/i18n/request.ts +49 -1
  110. package/templates/apps/web/jest.config.cjs +1 -0
  111. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  112. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  113. package/templates/apps/web/lib/auth-email.ts +103 -21
  114. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
  115. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
  116. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  117. package/templates/apps/web/lib/relay.ts +2 -2
  118. package/templates/apps/web/lib/release-scope.ts +47 -0
  119. package/templates/apps/web/lib/server-api.ts +1 -1
  120. package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
  121. package/templates/apps/web/lib/site-contributions.ts +18 -0
  122. package/templates/apps/web/messages/en.json +186 -20
  123. package/templates/apps/web/messages/site/en.json +1 -0
  124. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  125. package/templates/customizations.yaml +37 -0
  126. package/templates/database/prisma.config.ts +10 -14
  127. package/templates/database/seed-test.ts +2 -2
  128. package/templates/database/seed.ts +5 -5
  129. package/templates/package.json +12 -6
  130. package/templates/pnpm-workspace.yaml +0 -1
  131. package/templates/project.yaml +16 -8
  132. package/templates/tools/jest/reporters.cjs +65 -0
  133. package/templates/tools/lint-architecture.ts +553 -7
  134. package/templates/tsconfig.json +1 -2
  135. package/versions.json +49 -36
  136. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  137. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  138. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  139. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  140. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  141. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  142. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  143. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  144. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  145. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  146. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  147. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  148. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  149. package/templates/apps/api/src/bin/spokes.ts +0 -198
  150. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  151. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  152. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  153. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  154. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  155. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  156. package/templates/apps/api/src/config/index.ts +0 -596
  157. package/templates/apps/api/src/config/modules.ts +0 -83
  158. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  159. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  160. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  161. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  162. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  163. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  164. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  165. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  166. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  167. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  168. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  169. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  170. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  171. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  172. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  173. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  174. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  175. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  176. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  177. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  178. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  179. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  180. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  181. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  182. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  183. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  184. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  185. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  186. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  187. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  188. package/templates/apps/api/src/lib/errors.ts +0 -95
  189. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  190. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  191. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  192. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  193. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  194. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  195. package/templates/apps/api/src/lib/locales.ts +0 -201
  196. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  197. package/templates/apps/api/src/lib/prisma.ts +0 -21
  198. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  199. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  200. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  201. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  202. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  203. package/templates/apps/api/src/lib/site.ts +0 -257
  204. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  205. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  206. package/templates/apps/api/src/lib/stripe.ts +0 -321
  207. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  208. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  209. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  210. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  211. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  212. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  213. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  214. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  215. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  216. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  217. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  218. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  219. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  220. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  221. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  222. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  223. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  224. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  225. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  226. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  227. package/templates/apps/api/src/middleware/auth.ts +0 -864
  228. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  229. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  230. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  231. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  232. package/templates/apps/api/src/openapi/index.ts +0 -18
  233. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  234. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  235. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  236. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  237. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  238. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  239. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  240. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  241. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  242. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  243. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  244. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  245. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  246. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  247. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  248. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  249. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  250. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  251. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  252. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  253. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  254. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  255. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  256. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  257. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  258. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  259. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  260. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  261. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  262. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  263. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  264. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  265. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  266. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  267. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  268. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  269. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  270. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  271. package/templates/apps/api/src/routes/content/index.ts +0 -49
  272. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  273. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  274. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  275. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  276. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  277. package/templates/apps/api/src/routes/content/search.ts +0 -100
  278. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  279. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  280. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  281. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  282. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  283. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  284. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  285. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  286. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  287. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  288. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  289. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  290. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  291. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  292. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  293. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  294. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  295. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  296. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  297. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  298. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  299. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  300. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  301. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  302. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  303. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  304. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  305. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  306. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  307. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  308. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  309. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  310. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  311. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  312. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  313. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  314. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  315. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  316. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  317. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  318. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  319. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  320. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  321. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  322. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  323. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  324. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  325. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  326. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  327. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  328. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  329. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  330. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  331. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  332. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  333. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  334. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  335. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  336. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  337. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  338. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  339. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  340. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  341. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  342. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  343. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  344. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  345. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  346. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  347. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  348. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  349. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  350. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  351. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  352. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  353. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  354. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  355. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  356. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  357. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  358. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  359. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  360. package/templates/apps/api/src/services/audit-log.ts +0 -961
  361. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  362. package/templates/apps/api/src/services/consent.ts +0 -834
  363. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  364. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  365. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  366. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  367. package/templates/apps/api/src/services/entitlements.ts +0 -338
  368. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  369. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  370. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  371. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  372. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  373. package/templates/apps/api/src/services/listing-config.ts +0 -178
  374. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  375. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  376. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  377. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  378. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  379. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  380. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  381. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  382. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  383. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  384. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  385. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  386. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  387. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  388. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  389. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  390. package/templates/apps/api/src/services/person.ts +0 -1211
  391. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  392. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  393. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  394. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  395. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  396. package/templates/apps/api/src/services/revalidation.ts +0 -235
  397. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  398. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  399. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  400. package/templates/apps/api/src/services/verified-email.ts +0 -71
  401. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  402. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  403. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  404. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  405. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  406. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  407. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  408. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  409. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  410. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  411. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  412. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  413. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  414. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  415. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  416. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  417. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  418. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  419. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  420. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  421. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  422. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  423. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  424. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  425. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  426. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  427. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  428. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  429. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  430. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  431. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  432. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  433. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  434. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  435. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  436. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  437. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  438. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  439. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  440. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  441. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  442. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  443. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  444. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  445. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  446. package/templates/apps/mcp/src/boot.ts +0 -167
  447. package/templates/apps/mcp/src/config/modules.ts +0 -43
  448. package/templates/apps/mcp/src/http-transport.ts +0 -564
  449. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  450. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  451. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  452. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  453. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  454. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  455. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  456. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  457. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  458. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  459. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  460. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  461. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  462. package/templates/apps/mcp/src/request-context.ts +0 -52
  463. package/templates/apps/mcp/src/server.ts +0 -343
  464. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  465. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  466. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  467. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  468. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  469. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  470. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  471. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  472. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  473. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  474. package/templates/apps/mcp/src/tools/index.ts +0 -138
  475. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  476. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  477. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  478. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  479. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  480. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  481. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  482. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  483. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  484. package/templates/database/CHANGELOG.md +0 -981
  485. package/templates/database/LICENSE +0 -202
  486. package/templates/database/NOTICE +0 -2
  487. package/templates/database/README.md +0 -243
  488. package/templates/database/__tests__/agents.db.test.ts +0 -320
  489. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  490. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  491. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  492. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  493. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  494. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  495. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  496. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  497. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  498. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  499. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  500. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  501. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  502. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  503. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  504. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  505. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  506. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  507. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  508. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  509. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  510. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  511. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  512. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  513. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  514. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  515. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  516. package/templates/database/__tests__/person.db.test.ts +0 -586
  517. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  518. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  519. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  520. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  521. package/templates/database/__tests__/seed.test.ts +0 -272
  522. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  523. package/templates/database/__tests__/tenancy.test.ts +0 -141
  524. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  525. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  526. package/templates/database/_gitignore +0 -8
  527. package/templates/database/auth/.gitkeep +0 -0
  528. package/templates/database/auth/schema.prisma +0 -641
  529. package/templates/database/config/.gitkeep +0 -0
  530. package/templates/database/config/schema.prisma +0 -441
  531. package/templates/database/content/.gitkeep +0 -0
  532. package/templates/database/content/schema.prisma +0 -339
  533. package/templates/database/eslint.config.mjs +0 -69
  534. package/templates/database/flows/listing-diagnostic.ts +0 -182
  535. package/templates/database/flows/presets.ts +0 -315
  536. package/templates/database/inbox/.gitkeep +0 -0
  537. package/templates/database/inbox/README.md +0 -3
  538. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  539. package/templates/database/inbox/schema.prisma +0 -1447
  540. package/templates/database/jest.config.cjs +0 -36
  541. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  542. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  543. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  544. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  545. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  546. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  547. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  548. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  549. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  550. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  551. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  552. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  553. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  554. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  555. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  556. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  557. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  558. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  559. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  560. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  561. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  562. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  563. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  564. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  565. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  566. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  567. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  568. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  569. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  570. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  571. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  572. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  573. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  574. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  575. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  576. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  577. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  578. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  579. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  580. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  581. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  582. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  583. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  584. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  585. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  586. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  587. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  588. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  589. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  590. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  591. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  592. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  593. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  594. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  595. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  596. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  597. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  598. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  599. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  600. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  601. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  602. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  603. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  604. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  605. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  606. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  607. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  608. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  609. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  610. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  611. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  612. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  613. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  614. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  615. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  616. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  617. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  618. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  619. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  620. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  621. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  622. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  623. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  624. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  625. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  626. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  627. package/templates/database/migrations/migration_lock.toml +0 -3
  628. package/templates/database/ops/.gitkeep +0 -0
  629. package/templates/database/ops/schema.prisma +0 -43
  630. package/templates/database/package.json +0 -89
  631. package/templates/database/schema.prisma +0 -71
  632. package/templates/database/scripts/db-generate-locked.sh +0 -80
  633. package/templates/database/scripts/person-backfill.sql +0 -132
  634. package/templates/database/tenancy.ts +0 -183
  635. package/templates/database/tsconfig.build.json +0 -16
  636. package/templates/database/tsconfig.flows.json +0 -15
  637. package/templates/database/tsconfig.json +0 -16
  638. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,2085 +0,0 @@
1
- /**
2
- * NOT marked `oss: false` (D-48/#5366 escalation — see
3
- * `routes/project-listings/me.ts`'s header comment): imported by
4
- * `routes/project-listings/{start,patch-draft,submit,public,me}.ts`, each of
5
- * which ships unconditionally because `routes/project-listings/index.ts`'s
6
- * multi-tenant literal `import()` forces it. Registration of the routes that
7
- * call into this service stays gated (`isModuleEnabled("signal-collectors")`).
8
- *
9
- * @file project-listings.ts
10
- * @description The founder's-application-row service (#4998, reshaped by
11
- * #5036) — writes, reads, and transitions `ProjectListing` (ADR 0009 D3
12
- * service-layer convention, mirroring `lead-routing.ts` and
13
- * `waitlist-dashboard.ts`).
14
- *
15
- * The single anonymous create became THREE operations when the application
16
- * became a ten-step form that saves as it goes (#5036 D1):
17
- *
18
- * start name + email + consent -> a `draft` row and a resume token
19
- * patch merges a partial payload; asserts no completeness
20
- * submit validates the WHOLE application, `draft` -> `requested`
21
- *
22
- * Validation therefore moved from write-time to submit-time, and that is why
23
- * every application field is nullable on the row: a half-filled draft is a
24
- * legitimate row, and only `submit` asserts it is finished.
25
- *
26
- * `submitProjectListingDraft` calls `routeLead` (kind `submit`, source
27
- * `projects-apply`) for the Lead write — never a duplicate Lead-shaped insert.
28
- * The Lead is written at SUBMIT rather than at start: the founder's contact
29
- * details are already captured on the listing row itself at step one, so a
30
- * Lead at step one adds no capture, and what it DOES add is an operator
31
- * notification email per abandoned first step on an anonymous route (PR #5042
32
- * security review, finding 4). Every tenant read/write
33
- * is scoped by `siteId` (`request.tenant.workspaceId`) — there is no
34
- * client-supplied site id on any tenant route. `waitlistCount` joins THROUGH
35
- * `experimentId` (`WaitlistExperiment.id` -> `.key` -> `WaitlistScore.experimentKey`,
36
- * scoped by `siteId`), 0 when a listing carries no `experimentKey`.
37
- *
38
- * PII boundary: this module never logs `founderEmail`, `founderName`, or
39
- * `questions` — the same discipline `lead-routing.ts` documents. The resume
40
- * token is never logged either, at any level: it is the ONLY credential on
41
- * the anonymous draft surface.
42
- */
43
-
44
- import { createHash, randomBytes } from "node:crypto";
45
-
46
- import type { Prisma } from "@working-theory/database";
47
- import {
48
- DEFAULT_WORKSPACE_SLUG,
49
- resolveSentinelWorkspaceRow,
50
- } from "@working-theory/database/tenancy";
51
- import { logger } from "@working-theory/observability";
52
- import {
53
- clampWindowDays,
54
- buildProjectListingDraftPatchSchema,
55
- buildProjectListingSubmitSchema,
56
- composeAddressLine,
57
- composeFounderName,
58
- flowAddressValueSchema,
59
- flowConfigSchema,
60
- listicleSiteAnswerQuestions,
61
- listingProfile,
62
- publicSiteAnswers,
63
- withListicleQuestions,
64
- LISTING_DIRECTORY_MAX_ENTRIES,
65
- LISTING_ENTRY_TYPE_DEFAULT,
66
- PROJECT_LISTING_CONCURRENT_LIMIT,
67
- PROJECT_LISTING_NON_TERMINAL_STATUSES,
68
- RESERVED_FLOW_SLUGS,
69
- } from "@working-theory/validation";
70
- import type {
71
- FlowAddressValue,
72
- FlowConfig,
73
- ListingDirectoryResponse,
74
- ListingEntryPublic,
75
- ListingSiteAnswersConfig,
76
- ProjectListing,
77
- ProjectListingDraftPatchInput,
78
- ProjectListingFaqs,
79
- ProjectListingPublic,
80
- ProjectListingQuestions,
81
- ProjectListingStartInput,
82
- } from "@working-theory/validation";
83
-
84
- import { prisma } from "../lib/prisma.js";
85
-
86
- import {
87
- consentConfigForSite,
88
- MAX_CONSENT_KEYS,
89
- recordConsent,
90
- recordLegacyConsent,
91
- requiredConsentUnmet,
92
- } from "./consent.js";
93
- import { routeLead, type SourceMeta } from "./lead-routing.js";
94
- import { listingConfigFor } from "./listing-config.js";
95
-
96
- // ─── The computed lifecycle state ───────────────────────────────────────────
97
-
98
- type ProjectListingStatus = ProjectListing["status"];
99
-
100
- /**
101
- * Resolve a row's LIFECYCLE status from its STORED status (#5033 D3).
102
- *
103
- * `closed` is the one state nothing writes. An `active` listing whose window
104
- * end has passed reads `closed`, here, on every read, with no job having run.
105
- * Storing it instead would need a scheduler whose failure leaves expired
106
- * listings publicly open — a silent, wrong-way failure. This makes that
107
- * unrepresentable.
108
- *
109
- * A FEATURED row is exempt (#5036 D6b). The operator's own products become
110
- * listing rows carrying `featured: true`, and such a row is not a time-boxed
111
- * validation experiment: it has no meaningful window and must not vanish from
112
- * the shelf because a date passed. Its `windowEndsAt` may be null, and it
113
- * reads `active` either way.
114
- *
115
- * A null window on a NON-featured `active` row is an ERROR, not a licence to
116
- * stay open forever: every real listing is time-boxed by design, and the whole
117
- * point of computing `closed` rather than storing it is that an expired
118
- * listing cannot linger. It is logged and read `closed` — failing toward the
119
- * closed shelf rather than toward a publicly-open listing nothing bounds.
120
- * `draft`, `requested` and `rejected` rows legitimately carry no window and
121
- * are not touched by any of this.
122
- */
123
- export function computeListingStatus(
124
- storedStatus: string,
125
- windowEndsAt: Date | null,
126
- now: Date,
127
- featured: boolean,
128
- ): ProjectListingStatus {
129
- if (storedStatus !== "active") return storedStatus as ProjectListingStatus;
130
- if (featured) return "active";
131
- if (windowEndsAt === null) {
132
- logger.error(
133
- { kind: "active_listing_without_window" },
134
- "[project-listings] a non-featured active listing carries no window end; reading it closed",
135
- );
136
- return "closed";
137
- }
138
- return windowEndsAt.getTime() <= now.getTime() ? "closed" : "active";
139
- }
140
-
141
- // ─── Slug derivation (#5033 D2) ─────────────────────────────────────────────
142
-
143
- /** The `name`-derived slug's length cap. */
144
- const SLUG_MAX_LENGTH = 48;
145
-
146
- /** The slug a name with no alphanumeric content falls back to before suffixing. */
147
- const SLUG_FALLBACK = "listing";
148
-
149
- /**
150
- * Derive a slug from a listing `name`: lowercase, every non-alphanumeric run
151
- * to a single hyphen, trimmed of leading/trailing hyphens, truncated to 48.
152
- * A name carrying no alphanumerics at all (the payload allows it — `name` is
153
- * only bounded by length) falls back to `listing`, which the site-scoped
154
- * suffixing below then disambiguates like any other collision.
155
- */
156
- export function deriveListingSlug(name: string): string {
157
- const base = name
158
- .toLowerCase()
159
- .replace(/[^a-z0-9]+/g, "-")
160
- .replace(/^-+|-+$/g, "")
161
- .slice(0, SLUG_MAX_LENGTH)
162
- .replace(/-+$/, "");
163
- return base.length > 0 ? base : SLUG_FALLBACK;
164
- }
165
-
166
- /**
167
- * The most same-prefix slugs one create will read before giving up.
168
- *
169
- * The collision read is a prefix scan, and `base` derives from a fully
170
- * caller-controlled `name` on an ANONYMOUS route — a one-character name makes
171
- * the prefix match most of the site's slugs, and every accepted application
172
- * adds another row to that family. Unbounded, the cost of a create would grow
173
- * with the number of listings an attacker had already filed. Bounded, a
174
- * pathological family degrades to the same `slug_taken` 409 a genuine race
175
- * already produces, rather than to an ever-slower table scan.
176
- */
177
- const SLUG_COLLISION_SCAN_LIMIT = 200;
178
-
179
- /**
180
- * The most active rows the anonymous page-render read returns (#9243). No
181
- * consumer in this repo names a shelf size, so the bound is the one the other
182
- * anonymous listing read already carries — the directory's
183
- * `LISTING_DIRECTORY_MAX_ENTRIES` — rather than a second number to keep in step.
184
- */
185
- const PUBLIC_LISTINGS_MAX_ROWS = LISTING_DIRECTORY_MAX_ENTRIES;
186
-
187
- /**
188
- * The first free slug for `base` on this site. Uniqueness is already scoped
189
- * `@@unique([siteId, slug])`, so the suffix is too: a second "Atlas" on this
190
- * site becomes `atlas-2` even if `atlas` is taken on another.
191
- *
192
- * Two concurrent creates of the same name can still both resolve the same
193
- * free slug and race to the insert; the loser hits the unique constraint and
194
- * is reported as `slug_taken`, exactly as it was when the client supplied the
195
- * slug itself.
196
- *
197
- * #8661 D4 — a listing's slug becomes its diagnostic flow's slug on approval,
198
- * so the standard magnets' reserved slugs are treated as taken: a project
199
- * named "Contact" is stored at `contact-2`.
200
- */
201
- async function firstFreeSlug(siteId: string, base: string): Promise<string> {
202
- const rows = await prisma.projectListing.findMany({
203
- where: { siteId, slug: { startsWith: base } },
204
- select: { slug: true },
205
- take: SLUG_COLLISION_SCAN_LIMIT,
206
- });
207
- const taken = new Set<string | null>([...RESERVED_FLOW_SLUGS, ...rows.map((r) => r.slug)]);
208
- if (!taken.has(base)) return base;
209
-
210
- // Bounded by `taken.size + 1` iterations — one of the first `n` candidates
211
- // is necessarily free.
212
- let suffix = 2;
213
- while (taken.has(`${base}-${suffix}`)) suffix += 1;
214
- return `${base}-${suffix}`;
215
- }
216
-
217
- // ─── Row shape ──────────────────────────────────────────────────────────────
218
-
219
- const PROJECT_LISTING_SELECT = {
220
- id: true,
221
- siteId: true,
222
- leadId: true,
223
- slug: true,
224
- name: true,
225
- oneLiner: true,
226
- problem: true,
227
- solution: true,
228
- url: true,
229
- product: true,
230
- commitment: true,
231
- teamComposition: true,
232
- faqs: true,
233
- anythingElse: true,
234
- address: true,
235
- // #5279 — the structured parts beside the display string.
236
- addressLine1: true,
237
- addressLine2: true,
238
- addressCity: true,
239
- addressRegion: true,
240
- addressPostalCode: true,
241
- addressCountry: true,
242
- questions: true,
243
- // #5285 — the site's own answers, and the window the applicant asked for.
244
- // `windowDays` rides the select because `submit` re-validates the assembled
245
- // application, and `requestedWindowDays` is one of its fields now.
246
- siteAnswers: true,
247
- windowDays: true,
248
- founderName: true,
249
- // #5407 — the name parts beside the composed display string, the phone, and
250
- // the resume-link verification stamp.
251
- founderFirstName: true,
252
- founderLastName: true,
253
- founderEmail: true,
254
- founderPhone: true,
255
- emailVerifiedAt: true,
256
- founderCompany: true,
257
- headline: true,
258
- logoMarkUrl: true,
259
- logoWordmarkUrl: true,
260
- featured: true,
261
- status: true,
262
- consentAt: true,
263
- decidedAt: true,
264
- decidedBy: true,
265
- issueUrl: true,
266
- experimentKey: true,
267
- windowEndsAt: true,
268
- createdAt: true,
269
- updatedAt: true,
270
- } as const satisfies Prisma.ProjectListingSelect;
271
-
272
- type ProjectListingRow = Prisma.ProjectListingGetPayload<{
273
- select: typeof PROJECT_LISTING_SELECT;
274
- }>;
275
-
276
- /**
277
- * The structured address parts of a row, or null (#5279).
278
- *
279
- * Exported because `project-listing-decision.ts` renders the same row shape
280
- * and must answer the same way — two readers guessing separately at what
281
- * "has parts" means is exactly how one of them starts emitting a half-filled
282
- * object.
283
- *
284
- * The rule is the composite's REQUIRED set: the five non-optional parts are
285
- * all present, or there are no parts. `addressLine2` is optional in the
286
- * fieldset, so it rides along when present and is simply absent when not. A
287
- * row written before #5279 has all six columns null and answers null here —
288
- * never a partial object reconstructed from `address`, which nothing in this
289
- * repo parses.
290
- *
291
- * `.safeParse` rather than a cast: these are six nullable TEXT columns, and
292
- * the emitted object must satisfy `flowAddressValueSchema` or the row fails
293
- * its own response schema downstream. A shape that somehow does not (a
294
- * `country` that is not an alpha-2 code, say) answers null rather than
295
- * putting an invalid address on the PUBLIC read.
296
- */
297
- export function addressPartsFromRow(row: {
298
- addressLine1: string | null;
299
- addressLine2: string | null;
300
- addressCity: string | null;
301
- addressRegion: string | null;
302
- addressPostalCode: string | null;
303
- addressCountry: string | null;
304
- }): FlowAddressValue | null {
305
- if (
306
- row.addressLine1 === null ||
307
- row.addressCity === null ||
308
- row.addressRegion === null ||
309
- row.addressPostalCode === null ||
310
- row.addressCountry === null
311
- ) {
312
- return null;
313
- }
314
- const parsed = flowAddressValueSchema.safeParse({
315
- addressLine1: row.addressLine1,
316
- ...(row.addressLine2 !== null && { addressLine2: row.addressLine2 }),
317
- city: row.addressCity,
318
- region: row.addressRegion,
319
- postalCode: row.addressPostalCode,
320
- country: row.addressCountry,
321
- });
322
- return parsed.success ? parsed.data : null;
323
- }
324
-
325
- /**
326
- * The site-answers object of a row, or null (#5285).
327
- *
328
- * Exported for the same reason `addressPartsFromRow` is: the operator's
329
- * decision payload renders the same row shape and must answer the same way.
330
- *
331
- * The column is `Json?`, so Prisma types it as `JsonValue` — an array, a
332
- * number and a string all satisfy it. Only ever written by
333
- * `draftUpdateData` from a body the site's own declarations validated, so a
334
- * row holding anything else is a row somebody wrote around this service.
335
- * A non-object answers null rather than being cast: a value of the wrong
336
- * SHAPE reaching the response schema would fail the whole read, and one
337
- * malformed row must not take a listing page down.
338
- *
339
- * Non-string members are dropped for the same reason — the store is
340
- * `Record<string, string>`, and what is handed back has to actually be one.
341
- */
342
- export function siteAnswersFromRow(value: Prisma.JsonValue | null): Record<string, string> | null {
343
- if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
344
- return Object.fromEntries(
345
- Object.entries(value).filter(
346
- (entry): entry is [string, string] => typeof entry[1] === "string",
347
- ),
348
- );
349
- }
350
-
351
- /**
352
- * `now` is passed rather than read inside so one read renders every row of a
353
- * list against a single instant. `featuredEnabled` is the site's config
354
- * switch (#5033 D6): where a site has not enabled a featured shelf, the flag
355
- * never surfaces. The tenant dashboard passes `true` — an operator manages
356
- * the flag and must see what they set.
357
- */
358
- function toResponseRow(
359
- row: ProjectListingRow,
360
- waitlistCount: number,
361
- now: Date,
362
- featuredEnabled: boolean,
363
- ): ProjectListing {
364
- return {
365
- id: row.id,
366
- siteId: row.siteId,
367
- leadId: row.leadId,
368
- slug: row.slug,
369
- name: row.name,
370
- headline: row.headline,
371
- oneLiner: row.oneLiner,
372
- problem: row.problem,
373
- solution: row.solution,
374
- url: row.url,
375
- product: row.product as ProjectListing["product"],
376
- commitment: row.commitment as ProjectListing["commitment"],
377
- teamComposition: row.teamComposition as ProjectListing["teamComposition"],
378
- faqs: row.faqs as unknown as ProjectListingFaqs | null,
379
- anythingElse: row.anythingElse,
380
- address: row.address,
381
- addressParts: addressPartsFromRow(row),
382
- founderName: row.founderName,
383
- founderFirstName: row.founderFirstName,
384
- founderLastName: row.founderLastName,
385
- founderEmail: row.founderEmail,
386
- founderPhone: row.founderPhone,
387
- // #5407 — the operator's verification filter at review. Null means "no
388
- // opening of the resume link recorded", never "verification failed".
389
- emailVerifiedAt: row.emailVerifiedAt ? row.emailVerifiedAt.toISOString() : null,
390
- founderCompany: row.founderCompany,
391
- questions: row.questions as unknown as ProjectListingQuestions | null,
392
- logoMarkUrl: row.logoMarkUrl,
393
- logoWordmarkUrl: row.logoWordmarkUrl,
394
- // #5285 — UNFILTERED here, deliberately. This row shape serves the tenant
395
- // dashboard and the founder's own draft read, both of which are entitled
396
- // to every answer. The PUBLIC read is the one that must not carry an
397
- // `operator-only` answer, and it narrows this through `publicSiteAnswers`
398
- // in `publicProjectListings` — one filter, on the one read that publishes.
399
- siteAnswers: siteAnswersFromRow(row.siteAnswers),
400
- featured: featuredEnabled && row.featured,
401
- // The row's OWN flag decides the closed exemption (#5036 D6b), not the
402
- // site's `featuredEnabled` switch: that switch governs whether the flag
403
- // SURFACES on a read, not whether the listing is time-boxed.
404
- status: computeListingStatus(row.status, row.windowEndsAt, now, row.featured),
405
- consentAt: row.consentAt.toISOString(),
406
- decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
407
- decidedBy: row.decidedBy,
408
- issueUrl: row.issueUrl,
409
- experimentKey: row.experimentKey,
410
- windowEndsAt: row.windowEndsAt ? row.windowEndsAt.toISOString() : null,
411
- waitlistCount,
412
- createdAt: row.createdAt.toISOString(),
413
- updatedAt: row.updatedAt.toISOString(),
414
- };
415
- }
416
-
417
- /**
418
- * `waitlistCount` per row, keyed by `experimentKey`.
419
- *
420
- * Since #5036 D6d the listing stores the experiment's KEY rather than its id,
421
- * and that key is the same string `WaitlistScore.experimentKey` holds — so the
422
- * `WaitlistExperiment` read that existed purely to translate one into the
423
- * other is gone. ONE `WaitlistScore.groupBy` over every DISTINCT key remains
424
- * (#9243): one read across every returned row (`waitlist-dashboard.ts`'s
425
- * discipline), however many experiments the page carries — the per-key
426
- * `count()` fan-out this replaced ran on the anonymous public read.
427
- */
428
- async function attachWaitlistCounts(
429
- siteId: string,
430
- rows: readonly Pick<ProjectListingRow, "experimentKey">[],
431
- ): Promise<Map<string, number>> {
432
- const experimentKeys = [
433
- ...new Set(rows.map((r) => r.experimentKey).filter((v): v is string => v != null)),
434
- ];
435
- if (experimentKeys.length === 0) return new Map();
436
-
437
- // fence: workspaceId === the scope id this query already filters siteId by
438
- // (workspace_site_agree CHECK, 20260922120000_tenancy_fence). Filtering the
439
- // CONSTRAINED column makes the scope correct by construction; the format
440
- // CHECK makes a raw alias match nothing rather than by accident.
441
- //
442
- // What that id IS depends on the caller, and only two of the six are
443
- // route-resolved: `listProjectListings` and `getProjectListing` receive
444
- // `tenant.workspaceId`, so for them this is an isolation control.
445
- // `listMyProjectListings` and the two `publicProjectListings` call sites
446
- // pass a ProjectListing row's OWN `siteId`, so for them it is a
447
- // CONSISTENCY control — the count agrees with the row it is reported
448
- // against — exactly as `project-listing-decision.ts#waitlistCountFor`
449
- // spells out (#6258 § 1.6 / § 5 D8, security review L-2).
450
- //
451
- // A key with no score rows is absent from a GROUP BY result; `waitlistCountFor`
452
- // reads it as 0, the same value the per-key `count()` produced.
453
- const grouped = await prisma.waitlistScore.groupBy({
454
- by: ["experimentKey"],
455
- where: { siteId, workspaceId: siteId, experimentKey: { in: experimentKeys } },
456
- _count: { _all: true },
457
- });
458
- return new Map(grouped.map((g) => [g.experimentKey, g._count._all]));
459
- }
460
-
461
- function waitlistCountFor(
462
- row: Pick<ProjectListingRow, "experimentKey">,
463
- counts: Map<string, number>,
464
- ): number {
465
- return row.experimentKey ? (counts.get(row.experimentKey) ?? 0) : 0;
466
- }
467
-
468
- // ─── The founder's key (#5036 D7) ──────────────────────────────────────────
469
-
470
- /**
471
- * Normalize the email a founder is counted under.
472
- *
473
- * Trim and lowercase, so `Ada@Example.com ` and `ada@example.com` are one
474
- * founder rather than two. Postgres text equality is case-sensitive, so
475
- * without this the concurrent-listing limit is evaded by holding down shift.
476
- *
477
- * Deliberately NOT stripping plus-tags: `ada+two@example.com` still counts as
478
- * a different founder. Plus-addressing is a legitimate, widely-used feature
479
- * and treating it as evasion would refuse real applicants; closing that gap
480
- * properly needs a verified email, which this anonymous surface does not have.
481
- */
482
- function normalizeFounderEmail(email: string): string {
483
- return email.trim().toLowerCase();
484
- }
485
-
486
- /**
487
- * How long an unsubmitted draft occupies one of a founder's two slots
488
- * (#5036 D7).
489
- *
490
- * Drafts MUST count, or the limit is evaded by never submitting. But
491
- * `founderEmail` is unverified on an anonymous surface, so anyone can open a
492
- * draft in a stranger's name — and a draft that counted forever would let two
493
- * anonymous requests lock a named founder out of ever applying, with no
494
- * operator remedy (there is no route that deletes a listing; PR #5042 security
495
- * review, finding 1).
496
- *
497
- * Bounding the window keeps D7's intent — an application in progress holds a
498
- * slot, so not submitting evades nothing — while making that lockout expire on
499
- * its own. Fourteen days is far longer than a ten-step form takes and far
500
- * shorter than "forever".
501
- *
502
- * This bounds the QUOTA impact, not retention: an abandoned draft still holds
503
- * the founder's name, email and address in the table. A retention sweep is a
504
- * separate unit and is not in this change.
505
- */
506
- const DRAFT_HOLDS_A_SLOT_DAYS = 14;
507
-
508
- // ─── The resume token (#5036 D1) ───────────────────────────────────────────
509
-
510
- /**
511
- * The token's entropy, in bytes. 256 bits — the same floor the repo's other
512
- * capability tokens carry, and far beyond guessing at any request rate the
513
- * `leadsScope` rate limiter permits.
514
- */
515
- const RESUME_TOKEN_BYTES = 32;
516
-
517
- /**
518
- * Mint an opaque resume token. Returned to the caller EXACTLY once, at
519
- * `start`: only its digest is stored, so the server cannot hand it back a
520
- * second time and an attacker who reads the table still cannot present one.
521
- */
522
- function mintResumeToken(): string {
523
- return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
524
- }
525
-
526
- /**
527
- * What the column holds. A plain sha-256 with no salt and no stretching is
528
- * correct here and a KDF would not be: the input is 256 bits of CSPRNG output,
529
- * not a human-chosen secret, so there is no dictionary to precompute and
530
- * nothing for a work factor to slow down.
531
- */
532
- function hashResumeToken(token: string): string {
533
- return createHash("sha256").update(token).digest("hex");
534
- }
535
-
536
- // ─── The email-verification nonce (#5407) ──────────────────────────────────
537
-
538
- /**
539
- * Mint the nonce that PROVES the founder read mail sent to `founderEmail`.
540
- *
541
- * WHY A SECOND CREDENTIAL EXISTS AT ALL, since the obvious design is to reuse
542
- * the resume token the emailed link already carries. That design cannot work:
543
- * `start` returns the resume token in its own 201 body, to whoever posted the
544
- * form. The emailed link is a SECOND COPY of a credential the applicant
545
- * already holds, so keying verification on it would let two HTTP calls — open
546
- * a draft under a stranger's address, take the token out of the response,
547
- * present it back — forge the stamp with no mailbox involved. Found by this
548
- * change's own security review, before it shipped.
549
- *
550
- * This nonce closes that because of where it does NOT go. It is minted here,
551
- * digested into `emailVerifyNonceHash`, and returned to exactly one caller —
552
- * `start`, which hands it to `sendListingResumeLink` and to nothing else. It
553
- * is in no response body, no log line, and no browser that filled the form.
554
- * The only way to hold it is to have read the email.
555
- *
556
- * Same 256 bits as the resume token, for the same reason.
557
- */
558
- function mintVerifyNonce(): string {
559
- return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
560
- }
561
-
562
- /**
563
- * What `emailVerifyNonceHash` holds. The same plain sha-256 as
564
- * `hashResumeToken`, for the same reason — the input is CSPRNG output, not a
565
- * human-chosen secret — and kept as its own function rather than aliased so
566
- * no call site has to read "resume token" while meaning the nonce.
567
- */
568
- function hashVerifyNonce(nonce: string): string {
569
- return createHash("sha256").update(nonce).digest("hex");
570
- }
571
-
572
- /**
573
- * Resolve the ONE draft a token grants access to (#5036 D1).
574
- *
575
- * Both the id and the token's digest are in the `where`, so a valid token
576
- * presented against a different listing's id resolves to nothing: a token is
577
- * bound to one listing and grants access to no other. `status: "draft"` is in
578
- * there too — `submit` clears the digest, so a token that leaks after
579
- * submission is inert twice over.
580
- *
581
- * The comparison is an equality test on a 256-bit digest inside Postgres.
582
- * There is no application-level secret compare to make constant-time, and no
583
- * practical timing signal in matching digests of that width.
584
- */
585
- async function findDraftByToken(
586
- id: string,
587
- resumeToken: string,
588
- ): Promise<ProjectListingRow | null> {
589
- return prisma.projectListing.findFirst({
590
- where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
591
- select: PROJECT_LISTING_SELECT,
592
- });
593
- }
594
-
595
- // ─── start (POST, anonymous) ────────────────────────────────────────────────
596
-
597
- /**
598
- * `start` has one failure mode of its own to report (#5275): a `consents`
599
- * map that does not GRANT every purpose the site's registry marks required —
600
- * declined, or simply absent. The legacy literal says the founder consented;
601
- * a `false` (or nothing) beside it for the required purpose says they did
602
- * not — the body contradicts itself, and the row must not be created on it
603
- * (it would go on to publish the founder's address against a recorded
604
- * decline, or with no evidence row at all). An oversized map is the same
605
- * 400. Everything else is as before: the site's config falls back to the
606
- * platform defaults rather than erroring, and a Prisma failure is a 500
607
- * through the framework. The Lead write — the step that used to give this
608
- * operation a `persist_failed`/`config_missing` surface — moved to `submit`.
609
- */
610
- export type StartProjectListingDraftResult =
611
- | {
612
- ok: true;
613
- id: string;
614
- resumeToken: string;
615
- /**
616
- * #5407 — the email-verification nonce, for the RESUME-EMAIL BUILDER
617
- * and for nothing else.
618
- *
619
- * A sibling of the result rather than a field of anything the route
620
- * hands to a response schema, deliberately (lesson 05): `start`'s 201
621
- * body is `projectListingStartResponseSchema`, which is `.strict()` and
622
- * carries `id` and `resumeToken` only. That route's request and
623
- * response shapes are a declared hard floor and are unchanged — this
624
- * value reaches `sendListingResumeLink` from the route's local
625
- * variable and never touches the wire.
626
- */
627
- verifyNonce: string;
628
- }
629
- | { ok: false; error: { kind: "invalid_input"; message: string } }
630
- // #6203 — the resolved `siteId` names no workspace this database holds.
631
- // Distinct from `invalid_input` (a body problem): this is a server-side
632
- // tenancy misconfiguration, and the route answers 503, never 400.
633
- | { ok: false; error: { kind: "tenant_unresolved"; message: string } };
634
-
635
- /**
636
- * Resolves an ALREADY-RESOLVED `siteId` (never a caller-supplied string — see
637
- * `startProjectListingDraft`'s own header) to its workspace id (#6203).
638
- *
639
- * Mirrors `apps/api/src/lib/site.ts`'s `resolveSiteWorkspaceId()` — the SAME
640
- * two-branch alias mapping the #6203 migration's own backfill `CASE`
641
- * encodes: the literal alias `"default"` resolves to the seeded sentinel
642
- * workspace IFF that row exists and hangs off the sentinel Organization;
643
- * any other alias resolves only by exact `workspaces.id` equality. Kept
644
- * LOCAL and PARAMETERIZED rather than reusing `resolveSiteWorkspaceId()`
645
- * directly: that function is hardwired to `currentSiteId()`
646
- * (`AILK_SITE_ID`), while a `ProjectListing`'s `siteId` can legitimately
647
- * name a DIFFERENT site under a multi-key deployment
648
- * (`listing-config.ts#siteIdForPublicKey`'s `SiteListingConfig.publicKey`
649
- * path) — the workspace stamped here must correspond to the SAME site the
650
- * row's own `siteId` already names, not the deployment's default. NOT
651
- * memoized (unlike `resolveSiteWorkspaceId()`): a different `siteId` per
652
- * call means the sentinel-only cache that function keeps would be wrong
653
- * here.
654
- */
655
- async function resolveWorkspaceIdForSite(siteId: string): Promise<string | null> {
656
- if (siteId === DEFAULT_WORKSPACE_SLUG) {
657
- const row = await resolveSentinelWorkspaceRow(prisma);
658
- return row?.id ?? null;
659
- }
660
- const row = await prisma.workspace.findUnique({ where: { id: siteId } });
661
- return row ? siteId : null;
662
- }
663
-
664
- /**
665
- * #6314 — resolve the capturing listicle magnet, SERVER-SIDE, from the slug
666
- * the body named.
667
- *
668
- * All four terms in ONE query, and only `slug` came from the caller:
669
- * `siteId` and `workspaceId` are the value `startProjectListingDraft` has
670
- * already resolved from the site's PUBLIC KEY (`siteIdForPublicKey`, then
671
- * `resolveWorkspaceIdForSite`), never from the body. `@@unique([siteId,
672
- * slug])` makes the match at most one row.
673
- *
674
- * `kind: "listicle"` is LOAD-BEARING, not cosmetic: without it a visitor
675
- * could name a contact or newsletter magnet's slug — both are public path
676
- * segments — and file an application against it.
677
- *
678
- * NO `status` term, deliberately. A magnet can be flipped `live -> draft`
679
- * while a visitor is mid-application, and dropping attribution on that flip
680
- * would silently punish the operator's own edit. The cost is bounded: a draft
681
- * magnet's slug is the operator's own, the resulting row lands only in that
682
- * operator's own workspace, and the Applications wire row carries `status`
683
- * so a never-submitted row stays legible as one.
684
- *
685
- * Returns `unresolved` for a slug that names nothing, names another
686
- * workspace's magnet, or names a magnet of the wrong kind — those three are
687
- * deliberately indistinguishable, and all three write NULL.
688
- *
689
- * `failed` is a FOURTH outcome and is NOT one of them: the lookup itself threw.
690
- * It also writes NULL, so the wire is unchanged, but the caller must not
691
- * describe it as "no such magnet" — during a database incident that would send
692
- * an operator hunting a magnet-configuration problem that does not exist. This
693
- * function has already logged the accurate line by the time it returns `failed`.
694
- */
695
- type CapturingFlowResolution =
696
- | { kind: "resolved"; id: string }
697
- | { kind: "unresolved" }
698
- | { kind: "failed" };
699
-
700
- async function resolveCapturingFlowId(
701
- workspaceId: string,
702
- slug: string,
703
- ): Promise<CapturingFlowResolution> {
704
- try {
705
- const row = await prisma.flowDefinition.findFirst({
706
- where: {
707
- slug,
708
- // Both scope terms, both from the value resolved above. They are equal
709
- // by the `flow_definitions_workspace_site_agree` CHECK the fence added;
710
- // carrying both is what makes the scope explicit at the query rather
711
- // than implied by a constraint a future migration could move.
712
- siteId: workspaceId,
713
- workspaceId,
714
- kind: "listicle",
715
- },
716
- select: { id: true },
717
- });
718
- return row === null ? { kind: "unresolved" } : { kind: "resolved", id: row.id };
719
- } catch (err) {
720
- // The lookup is OPTIONAL attribution on a never-gate anonymous route: an
721
- // unavailable read (statement timeout, connection blip) must degrade
722
- // exactly like a slug that names nothing — NULL, and the application is
723
- // kept — rather than turn into a 500 that loses a real founder's
724
- // submission. Both outcomes are NULL on the row, so this line is the only
725
- // thing that tells an operator "lookup failed" from "no such magnet".
726
- // PII-SAFE, same idiom as the unresolved-slug line below: routing
727
- // metadata plus the caught error; never the founder's identity, the
728
- // resume token or the verify nonce (none is in scope here).
729
- logger.warn(
730
- { workspaceId, capturedByFlowSlug: slug, err },
731
- "[project-listings] capturedByFlowSlug lookup failed; application stored unattributed",
732
- );
733
- return { kind: "failed" };
734
- }
735
- }
736
-
737
- /**
738
- * Create a `draft` from step one — the founder's name, email and consent.
739
- *
740
- * `siteId` is a PARAMETER, not a field of `input` (#5048 D2). The payload
741
- * names the site by its public key and the route resolves it through
742
- * `siteIdForPublicKey` before calling here, so no request body carries a
743
- * tenant identifier and nothing a caller sends can choose which site an
744
- * application is filed against.
745
- *
746
- * The site's own terms are read SERVER-SIDE and stamped now, so a listing is
747
- * granted the terms that were in force when it applied; nothing in `input`
748
- * can reach them. The applicant's *requested* window is clamped here, not
749
- * rejected, because a founder who could post their own bound could post an
750
- * unbounded one.
751
- *
752
- * No slug yet: it is derived from `name` at SUBMIT (#5033 D2), which is the
753
- * first moment a name exists.
754
- *
755
- * WORKSPACE STAMP (#6203). The row's `workspaceId` is resolved from `siteId`
756
- * itself (`resolveWorkspaceIdForSite` above) — never a client-supplied
757
- * value, and never a fixed deployment default. An unresolved result rejects
758
- * the write before any row is created (spec AC-4).
759
- */
760
- export async function startProjectListingDraft(
761
- siteId: string,
762
- input: ProjectListingStartInput,
763
- ): Promise<StartProjectListingDraftResult> {
764
- const workspaceId = await resolveWorkspaceIdForSite(siteId);
765
- if (workspaceId === null) {
766
- return {
767
- ok: false,
768
- error: {
769
- kind: "tenant_unresolved",
770
- message: "This site is not configured to accept applications.",
771
- },
772
- };
773
- }
774
-
775
- const { requestedWindowDays } = input;
776
- // #5407 — the NOT NULL display column. `composeFounderName` builds it from
777
- // the PARTS wherever both are written (even beside a `founderName` the body
778
- // also sent, so the stored line and the stored parts can never disagree),
779
- // and stores a line sent on its own verbatim. ONE WAY ONLY: nothing here or
780
- // anywhere else derives parts back out of a line. The schema has already
781
- // refused a body carrying neither shape, so this always produces a string.
782
- const founderName = composeFounderName(input);
783
- const founderEmail = normalizeFounderEmail(input.founderEmail);
784
-
785
- const listingConfig = await listingConfigFor(siteId);
786
- const windowDays = clampWindowDays(requestedWindowDays, listingConfig);
787
-
788
- // #5275 — refuse BEFORE the row exists: a required purpose not granted
789
- // (declined or absent) is a body that contradicts its own `consent: true`,
790
- // and an oversized map is not a form's answer.
791
- const consentConfig = await consentConfigForSite(siteId);
792
- if (input.consents) {
793
- if (Object.keys(input.consents).length > MAX_CONSENT_KEYS) {
794
- return {
795
- ok: false,
796
- error: {
797
- kind: "invalid_input",
798
- message: `consents carries more than ${MAX_CONSENT_KEYS} purposes`,
799
- },
800
- };
801
- }
802
- const unmet = requiredConsentUnmet(consentConfig, input.consents);
803
- if (unmet !== undefined) {
804
- return {
805
- ok: false,
806
- error: {
807
- kind: "invalid_input",
808
- message: `required consent purpose "${unmet}" was not granted`,
809
- },
810
- };
811
- }
812
- }
813
-
814
- const resumeToken = mintResumeToken();
815
- // #5407 — minted on EVERY draft, whether or not the site sends the resume
816
- // email. `start` cannot know: the send is decided asynchronously inside
817
- // `sendListingResumeLink`, against the site's own config. A site running no
818
- // resume email therefore stores a nonce nobody is ever handed, and nothing
819
- // on that row can ever verify — which is the correct outcome rather than a
820
- // gap, because no one has read anything at that address.
821
- const verifyNonce = mintVerifyNonce();
822
- // #5275 — the row and its grant record land in ONE transaction: from here
823
- // on `consentAt` and the ConsentGrant row are stamped by the SAME
824
- // affirmative act, and a grant write that fails takes the row with it
825
- // rather than stranding a "consented" listing with no evidence (security
826
- // review, Medium 2). A body carrying `consents` records the wording the
827
- // founder read (resolved server-side from the same registry the renderer
828
- // used, D7); a body carrying only the legacy `consent: true` literal
829
- // records one grant marked unrecoverable (D11) rather than today's
830
- // wording, which nobody was shown.
831
- const consents = input.consents;
832
- const locale = input.locale ?? "en";
833
- // #6314 — the capturing magnet, resolved SERVER-SIDE from the slug the body
834
- // named. Only runs when the key is present, so an existing caller costs
835
- // nothing; when it is, it is one probe of `@@unique([siteId, slug])`.
836
- const capture =
837
- input.capturedByFlowSlug === undefined
838
- ? undefined
839
- : await resolveCapturingFlowId(workspaceId, input.capturedByFlowSlug);
840
- const capturedByFlowId = capture?.kind === "resolved" ? capture.id : null;
841
- const row = await prisma.$transaction(async (tx) => {
842
- const created = await tx.projectListing.create({
843
- data: {
844
- // #6254 AC-3/AC-6/AC-10 — the RESOLVED workspace id, not the raw
845
- // `siteId` parameter: `project_listings` now requires
846
- // `workspace_id = site_id`
847
- // (database/migrations/20260922120000_tenancy_fence), and this
848
- // column is the isolation key every downstream reader (including
849
- // `submitProjectListingDraft`'s `routeLead` call) reads back as
850
- // `draft.siteId`. `siteId` (the public-facing param, possibly the
851
- // raw `"default"` alias) is still used above to look up
852
- // `listingConfigFor`/`consentConfigForSite` — those are independent,
853
- // unfenced reads keyed on the site concept, not this row.
854
- siteId: workspaceId,
855
- // #6203 — the isolation key, resolved above from this SAME siteId.
856
- workspaceId,
857
- founderName,
858
- // #5407 — the parts as sent, beside the composed line. Absent where a
859
- // caller sent only a display line, which is the shape every row
860
- // written before #5407 carries; nothing here ever splits that line.
861
- ...(input.founderFirstName !== undefined && {
862
- founderFirstName: input.founderFirstName,
863
- }),
864
- ...(input.founderLastName !== undefined && {
865
- founderLastName: input.founderLastName,
866
- }),
867
- founderEmail,
868
- ...(input.founderPhone !== undefined && { founderPhone: input.founderPhone }),
869
- status: "draft",
870
- consentAt: new Date(),
871
- resumeTokenHash: hashResumeToken(resumeToken),
872
- emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
873
- windowDays,
874
- // #6314 — WRITE-ONCE, and only here. `draftUpdateData` never learns
875
- // this key, so no patch body and no submit can re-attribute a draft
876
- // to a different magnet after the fact — not by its holder, and not
877
- // by anyone who obtains its resume token.
878
- ...(capturedByFlowId !== null && { capturedByFlowId }),
879
- },
880
- select: { id: true },
881
- });
882
- const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
883
- // #8438 D3 — a listing names no magnet slug (ProjectListing carries no
884
- // link to its flow), so `magnet` would be unprovable: the source is `api`.
885
- if (consents) {
886
- await recordConsent(
887
- { ...subject, personId: null, source: "api", locale, config: consentConfig, consents },
888
- tx,
889
- );
890
- } else {
891
- await recordLegacyConsent({ ...subject, source: "api", config: consentConfig }, tx);
892
- }
893
- return created;
894
- });
895
-
896
- // #6314 — an unresolved slug is the operator's ONLY signal, precisely
897
- // because the response deliberately carries none (a distinguishable error
898
- // would be a cross-workspace magnet-slug oracle on an anonymous route).
899
- // Without this line, a mis-wired or stale consuming site yields a
900
- // permanently empty Applications list an operator cannot tell from "no
901
- // applications yet".
902
- //
903
- // PII-SAFE, in this route family's existing idiom: routing metadata only.
904
- // Never `founderEmail`, `founderName`, the resume token or the verify
905
- // nonce — the last two never leave this function except inside the email.
906
- // ONLY the `unresolved` outcome, never `failed`. Both store NULL and both
907
- // return the same 201, but only one of them establishes "no listicle magnet
908
- // of that slug in this site". Asserting it on the throw path would put a
909
- // false magnet-configuration signal into the log during a database incident,
910
- // which is exactly when an operator is grepping for a cause. `failed` has its
911
- // own, accurate line, emitted inside `resolveCapturingFlowId`.
912
- if (capture?.kind === "unresolved") {
913
- logger.warn(
914
- {
915
- siteId,
916
- listingId: row.id,
917
- capturedByFlowSlug: input.capturedByFlowSlug,
918
- },
919
- "[project-listings] capturedByFlowSlug resolved to no listicle magnet in this site; application stored unattributed",
920
- );
921
- }
922
-
923
- return { ok: true, id: row.id, resumeToken, verifyNonce };
924
- }
925
-
926
- // ─── verify-email (POST, anonymous, nonce-gated) ───────────────────────────
927
-
928
- /**
929
- * THE NONCE HAS NO EXPIRY, and shares the resume token's lifetime exactly: it
930
- * lives until `submit` clears both digests, and an unsubmitted draft is never
931
- * swept (`DRAFT_HOLDS_A_SLOT_DAYS` bounds the QUOTA a draft occupies, not the
932
- * row). A resume email sitting in a mailbox for a year still verifies that
933
- * draft a year later.
934
- *
935
- * That is acceptable rather than overlooked, and for one reason: the same
936
- * message carries the resume token, so anyone who can read it can already
937
- * resume and submit the draft outright. A TTL would have to cover both
938
- * credentials to mean anything, and that is a change to #5048's resume
939
- * contract rather than to this column.
940
- *
941
- * It is also NOT cleared on a successful stamp — the fallback read below
942
- * needs it to answer a repeat call idempotently.
943
- */
944
- export type VerifyProjectListingDraftEmailResult =
945
- | { ok: true; value: { id: string; emailVerifiedAt: Date } }
946
- | { ok: false; error: { kind: "not_found"; message: string } };
947
-
948
- /**
949
- * Record that somebody who can read mail at `founderEmail` opened this
950
- * draft's emailed link (#5407).
951
- *
952
- * THE PRECISION IN THAT SENTENCE IS DELIBERATE. It is evidence about the
953
- * ADDRESS, not about the person driving the draft. Whoever posted `start`
954
- * chose the address and holds the resume token, so an attacker can open a
955
- * draft under a stranger's address and the platform will mail that stranger a
956
- * link; if they click it, the attacker's draft is stamped. Binding the stamp
957
- * to the application's CONTENT, or to `ownerUserId`, is what would close that,
958
- * and neither is in this issue. What the stamp rules out is the case it was
959
- * built for: a stamp obtained with no mailbox involved at all.
960
- *
961
- * WHAT IT PROVES, and why it takes TWO credentials. The caller must present
962
- * both the draft's resume token and its verification NONCE, and only the
963
- * second one carries the proof:
964
- *
965
- * - the RESUME TOKEN says "you opened this draft". `start` returns it in
966
- * its own 201 body, so anyone who posted the form has it. On its own it
967
- * proves nothing about an address, and a design that stamped on it alone
968
- * would be forgeable in two HTTP calls — open a draft under a stranger's
969
- * address, take the token out of the response, present it back. That is
970
- * the design this one replaced, caught by its own security review.
971
- * - the NONCE says "you read mail sent to `founderEmail`". It is minted at
972
- * `start`, digested onto the row, and handed to nothing but
973
- * `sendListingResumeLink`. It is in no response body and no log line, and
974
- * the only place it exists outside this server is the `?verify=`
975
- * parameter of the link inside the email.
976
- *
977
- * The token stays in the guard anyway: it costs one term in a `where` and it
978
- * means a nonce that somehow leaked alone still opens nothing.
979
- *
980
- * What it does NOT say is who the founder is. `ownerUserId` (#5295) is the
981
- * column that carries an identity claim and still needs a signed-in user
982
- * whose own address the auth system verified.
983
- *
984
- * WRITE-ONCE, BY COMPARE-AND-SWAP. `emailVerifiedAt: null` is in the `where`,
985
- * so the stamp is the atomic transition and two concurrent openings of the
986
- * same link produce one stamp between them rather than a last-writer-wins
987
- * race. A repeat opening reads back the FIRST stamp, so the call is
988
- * idempotent in its effect and in its answer. It is not indistinguishable —
989
- * a repeat answers an older timestamp than "now" — and does not need to be:
990
- * only a holder of both credentials can make the call at all, and they are
991
- * the one person entitled to know when their own link was first opened.
992
- *
993
- * ONE ANSWER FOR EVERY FAILURE. A wrong token, a wrong nonce, a credential
994
- * bound to a different listing, an unknown id and an already-submitted row
995
- * are all `not_found` — exactly what `patch` and `submit` answer. The
996
- * anonymous surface must not distinguish "exists but not yours" from "does
997
- * not exist", and a verification route least of all: its whole input is a
998
- * guessable-in-principle id and two secrets, so a distinguishable response
999
- * would turn it into an oracle for which drafts exist and, worse, into a
1000
- * nonce-guessing feedback channel.
1001
- *
1002
- * Neither credential is ever logged, returned, or compared in application
1003
- * code — only their sha-256 digests reach the query.
1004
- */
1005
- export async function verifyProjectListingDraftEmail(
1006
- id: string,
1007
- resumeToken: string,
1008
- verifyNonce: string,
1009
- ): Promise<VerifyProjectListingDraftEmailResult> {
1010
- const guard = {
1011
- id,
1012
- status: "draft",
1013
- resumeTokenHash: hashResumeToken(resumeToken),
1014
- emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
1015
- };
1016
- const emailVerifiedAt = new Date();
1017
-
1018
- const stamped = await prisma.projectListing.updateMany({
1019
- where: { ...guard, emailVerifiedAt: null },
1020
- data: { emailVerifiedAt },
1021
- });
1022
- if (stamped.count > 0) return { ok: true, value: { id, emailVerifiedAt } };
1023
-
1024
- // Zero rows means one of two things, and they answer differently: the draft
1025
- // is already stamped (idempotent success, carrying the FIRST stamp), or the
1026
- // credentials do not open this draft (`not_found`). This read is what tells
1027
- // them apart, and it is gated by the same four terms the write was — so it
1028
- // cannot report a draft the caller's credentials do not open.
1029
- const row = await prisma.projectListing.findFirst({
1030
- where: guard,
1031
- select: { id: true, emailVerifiedAt: true },
1032
- });
1033
- if (row?.emailVerifiedAt) {
1034
- return { ok: true, value: { id: row.id, emailVerifiedAt: row.emailVerifiedAt } };
1035
- }
1036
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1037
- }
1038
-
1039
- // ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
1040
-
1041
- export type PatchProjectListingDraftResult =
1042
- | { ok: true; value: ProjectListing }
1043
- | {
1044
- ok: false;
1045
- error:
1046
- | { kind: "not_found"; message: string }
1047
- | { kind: "invalid_input"; message: string };
1048
- };
1049
-
1050
- /**
1051
- * What `draftUpdateData` cannot read off the patch body (#5285).
1052
- *
1053
- * Both members are the SERVER's, and that is why they are a second argument
1054
- * rather than two more payload fields: `windowDays` is the applicant's
1055
- * request already clamped against the site's own maximum, and
1056
- * `storedSiteAnswers` is what is on the row, which the merge needs and a
1057
- * request body must never be able to supply.
1058
- */
1059
- interface DraftUpdateContext {
1060
- /** The clamped window, when the patch asked for one. */
1061
- windowDays?: number;
1062
- /** The answers already stored, for the merge. */
1063
- storedSiteAnswers?: Record<string, string>;
1064
- }
1065
-
1066
- /**
1067
- * Translate a validated partial application into a Prisma update.
1068
- *
1069
- * Only the keys the caller actually sent are written: an absent key is
1070
- * `undefined`, which Prisma omits from the UPDATE, so patching one field
1071
- * cannot blank the nine the founder filled in on earlier steps.
1072
- *
1073
- * #5279 — THIS IS WHERE THE DISPLAY ADDRESS IS COMPOSED, and the only place.
1074
- * `address` is a union of the legacy display STRING and the structured PARTS
1075
- * (`flowAddressValueSchema`), and the two branches write different columns:
1076
- *
1077
- * string → `address` verbatim, no part column touched. A founder who typed
1078
- * a line gets that line stored exactly as typed, and a draft
1079
- * written before #5279 keeps patching as it always did.
1080
- * parts → the six part columns, PLUS `address` composed from them by
1081
- * `composeAddressLine`.
1082
- *
1083
- * The derivation runs ONE WAY ONLY. Nothing anywhere derives parts from a
1084
- * line: address parsing guesses, and a wrong guess silently corrupts the
1085
- * party identification a listing's privacy notice points at (#5036 D6), with
1086
- * nothing on the row to mark it as invented.
1087
- */
1088
- function draftUpdateData(
1089
- patch: ProjectListingDraftPatchInput,
1090
- context: DraftUpdateContext,
1091
- ): Prisma.ProjectListingUpdateInput {
1092
- return {
1093
- ...(patch.name !== undefined && { name: patch.name }),
1094
- ...(patch.headline !== undefined && { headline: patch.headline }),
1095
- ...(patch.oneLiner !== undefined && { oneLiner: patch.oneLiner }),
1096
- ...(patch.problem !== undefined && { problem: patch.problem }),
1097
- ...(patch.solution !== undefined && { solution: patch.solution }),
1098
- ...(patch.url !== undefined && { url: patch.url }),
1099
- ...(patch.product !== undefined && { product: patch.product }),
1100
- ...(patch.commitment !== undefined && { commitment: patch.commitment }),
1101
- ...(patch.teamComposition !== undefined && { teamComposition: patch.teamComposition }),
1102
- ...(patch.faqs !== undefined && { faqs: patch.faqs as unknown as Prisma.InputJsonValue }),
1103
- ...(patch.anythingElse !== undefined && { anythingElse: patch.anythingElse }),
1104
- ...(patch.address !== undefined &&
1105
- (typeof patch.address === "string"
1106
- ? { address: patch.address }
1107
- : {
1108
- address: composeAddressLine(patch.address),
1109
- addressLine1: patch.address.addressLine1,
1110
- addressLine2: patch.address.addressLine2 ?? null,
1111
- addressCity: patch.address.city,
1112
- addressRegion: patch.address.region,
1113
- addressPostalCode: patch.address.postalCode,
1114
- addressCountry: patch.address.country,
1115
- })),
1116
- ...(patch.founderCompany !== undefined && { founderCompany: patch.founderCompany }),
1117
- ...(patch.questions !== undefined && {
1118
- questions: patch.questions as unknown as Prisma.InputJsonValue,
1119
- }),
1120
- ...(patch.logoMarkUrl !== undefined && { logoMarkUrl: patch.logoMarkUrl }),
1121
- ...(patch.logoWordmarkUrl !== undefined && {
1122
- logoWordmarkUrl: patch.logoWordmarkUrl,
1123
- }),
1124
- // #5285 — the applicant's REQUEST, already clamped by the caller against
1125
- // the site's own `maxWindowDays`. It is written to `windowDays`, the same
1126
- // column `start` writes, because there is one window per listing and the
1127
- // screen that asks for it is the site's choice of screen, not a second
1128
- // field. Clamped and not refused, for the reason `startProjectListingDraft`
1129
- // gives: a founder who could post their own bound could post an unbounded
1130
- // one.
1131
- ...(context.windowDays !== undefined && { windowDays: context.windowDays }),
1132
- // #5285 — MERGED, never replaced. A founder patches one screen at a time,
1133
- // so a body carrying screen four's two answers must not blank screen
1134
- // two's. The incoming keys win; everything already stored is carried.
1135
- // Every incoming id was checked against the site's declarations by the
1136
- // compiled patch schema, so nothing undeclared can enter here.
1137
- ...(patch.siteAnswers !== undefined && {
1138
- siteAnswers: {
1139
- ...context.storedSiteAnswers,
1140
- ...patch.siteAnswers,
1141
- } as Prisma.InputJsonValue,
1142
- }),
1143
- };
1144
- }
1145
-
1146
- /**
1147
- * The site-answer declarations a CAPTURED draft is checked against (#7521 D2):
1148
- * the site's own, plus the eligible Application fields of the LIVE listicle
1149
- * that captured it (`listicleSiteAnswerQuestions`). A draft listicle accepts
1150
- * no answers (SEC-7521-B1).
1151
- *
1152
- * Fails CLOSED: a missing listicle or a config that no longer parses logs at
1153
- * warn and answers the site's declarations alone, so an id nobody declared
1154
- * is still a 400.
1155
- */
1156
- async function capturedDraftSiteAnswers(
1157
- siteId: string,
1158
- listingId: string,
1159
- capturedByFlowId: string,
1160
- siteDeclared: ListingSiteAnswersConfig,
1161
- ): Promise<ListingSiteAnswersConfig> {
1162
- const listicle = await prisma.flowDefinition.findFirst({
1163
- where: {
1164
- id: capturedByFlowId,
1165
- siteId,
1166
- workspaceId: siteId,
1167
- kind: "listicle",
1168
- status: "live",
1169
- },
1170
- select: { config: true },
1171
- });
1172
- const config = listicle ? flowConfigSchema.safeParse(listicle.config) : null;
1173
- if (!config?.success) {
1174
- logger.warn(
1175
- { siteId, listingId },
1176
- "[project-listings] a captured draft's listicle is not live or its config does not parse; accepting the site's own answers only",
1177
- );
1178
- return siteDeclared;
1179
- }
1180
- return withListicleQuestions(siteDeclared, listicleSiteAnswerQuestions(config.data));
1181
- }
1182
-
1183
- /**
1184
- * Merge a partial application into a draft and hand back the merged row.
1185
- *
1186
- * Enforces NO completeness — that is `submit`'s job alone, and the split is
1187
- * the whole point of the lifecycle. Returning the merged row is what makes a
1188
- * step's save verifiable by the client that wrote it.
1189
- */
1190
- export async function patchProjectListingDraft(
1191
- id: string,
1192
- resumeToken: string,
1193
- body: unknown,
1194
- ): Promise<PatchProjectListingDraftResult> {
1195
- // The word caps are the SITE's (#5048 D6), so the body cannot be validated
1196
- // until the draft's site is known. This read resolves that and nothing
1197
- // else — it is not the write's guard, which stays the compare-and-swap
1198
- // below. A caller holding no valid token never gets past it, so a bad token
1199
- // still answers `not_found` before any body is inspected.
1200
- const owner = await prisma.projectListing.findFirst({
1201
- where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
1202
- // #5285 — `siteAnswers` rides this read because the write MERGES into it
1203
- // (a founder patches one screen at a time). It is still not the write's
1204
- // guard: the compare-and-swap below is, and a concurrent patch of a
1205
- // DIFFERENT screen can still lose its answers to this one, exactly as it
1206
- // can already lose a column. The store is one row's worth of one
1207
- // founder's own form, not a shared document.
1208
- //
1209
- // #7521 D2 — `capturedByFlowId` rides it too: a draft a listicle captured
1210
- // may answer that listicle's own Application fields.
1211
- select: { siteId: true, siteAnswers: true, capturedByFlowId: true },
1212
- });
1213
- if (!owner) {
1214
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1215
- }
1216
-
1217
- const listingConfig = await listingConfigFor(owner.siteId);
1218
- const siteAnswers =
1219
- owner.capturedByFlowId === null
1220
- ? listingConfig.siteAnswers
1221
- : await capturedDraftSiteAnswers(
1222
- owner.siteId,
1223
- id,
1224
- owner.capturedByFlowId,
1225
- listingConfig.siteAnswers,
1226
- );
1227
- const parsed = buildProjectListingDraftPatchSchema(
1228
- listingConfig.wordCaps,
1229
- listingConfig.collectedFields,
1230
- siteAnswers,
1231
- ).safeParse(body);
1232
- if (!parsed.success) {
1233
- return {
1234
- ok: false,
1235
- error: {
1236
- kind: "invalid_input",
1237
- message: parsed.error.issues[0]?.message ?? "invalid input",
1238
- },
1239
- };
1240
- }
1241
- const patch: ProjectListingDraftPatchInput = parsed.data;
1242
-
1243
- const stored = siteAnswersFromRow(owner.siteAnswers);
1244
- const context: DraftUpdateContext = {
1245
- // THE CLAMP, and the reason `requestedWindowDays` is safe to accept from
1246
- // an anonymous body at all (#5285). The site's `maxWindowDays` is read
1247
- // server-side and caps everything; the payload carries a REQUEST. A
1248
- // founder who could post their own bound could post an unbounded one, and
1249
- // this is the only place a patched window reaches a column.
1250
- ...(patch.requestedWindowDays !== undefined && {
1251
- windowDays: clampWindowDays(patch.requestedWindowDays, listingConfig),
1252
- }),
1253
- // What is on the row already, so the merge below adds to it rather than
1254
- // replacing it. It comes from the ROW and never from the body.
1255
- ...(stored !== null && { storedSiteAnswers: stored }),
1256
- };
1257
-
1258
- // A GUARDED `updateMany`, not a read-then-write: the token check and the
1259
- // write are one statement, so a concurrent `submit` cannot flip the row to
1260
- // `requested` in between and let this patch land on an application the
1261
- // operator is already reviewing (PR #5042 security review, finding 3). The
1262
- // same compare-and-swap shape `submit` and `decideListing` already use.
1263
- const updated = await prisma.projectListing.updateMany({
1264
- where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
1265
- data: draftUpdateData(patch, context),
1266
- });
1267
- if (updated.count === 0) {
1268
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1269
- }
1270
-
1271
- const row = await prisma.projectListing.findFirst({
1272
- where: { id },
1273
- select: PROJECT_LISTING_SELECT,
1274
- });
1275
- if (!row) {
1276
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1277
- }
1278
-
1279
- return { ok: true, value: toResponseRow(row, 0, new Date(), true) };
1280
- }
1281
-
1282
- // ─── submit (POST, anonymous, token-gated) ──────────────────────────────────
1283
-
1284
- export type SubmitProjectListingDraftResult =
1285
- | { ok: true; id: string; slug: string; listing: ProjectListing }
1286
- | {
1287
- ok: false;
1288
- error:
1289
- | { kind: "not_found"; message: string }
1290
- | { kind: "incomplete"; message: string; missingFields: string[] }
1291
- | { kind: "listing_limit_reached"; message: string }
1292
- | { kind: "slug_taken"; message: string };
1293
- };
1294
-
1295
- /**
1296
- * The application, assembled from the stored row for `submit` to validate.
1297
- *
1298
- * Narrowed to the site's COLLECTED fields (#5048 D5). The built submit schema
1299
- * is `.strict()` over exactly that set, so a stored value for a field the site
1300
- * does not collect would otherwise fail as an unrecognised key — which is the
1301
- * right shape for a REQUEST body and the wrong one for a row that may carry a
1302
- * value written before the site narrowed its step set.
1303
- */
1304
- function applicationFromRow(
1305
- row: ProjectListingRow,
1306
- collectedFields: readonly string[],
1307
- ): Record<string, unknown> {
1308
- const collected = new Set(collectedFields);
1309
- const all: Record<string, unknown> = {
1310
- name: row.name,
1311
- headline: row.headline,
1312
- oneLiner: row.oneLiner,
1313
- problem: row.problem,
1314
- ...(row.url !== null && { url: row.url }),
1315
- product: row.product,
1316
- commitment: row.commitment,
1317
- teamComposition: row.teamComposition,
1318
- faqs: row.faqs,
1319
- ...(row.anythingElse !== null && { anythingElse: row.anythingElse }),
1320
- address: row.address,
1321
- ...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
1322
- questions: row.questions,
1323
- // #5285/#5096 — the four fields that became writable. Each is optional in
1324
- // the application schema, so a row that never carried one simply omits it
1325
- // and `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
1326
- // `windowDays` the row actually holds, not what was asked for: the
1327
- // request is not stored anywhere, and re-asserting it would be asserting
1328
- // a number nothing ever agreed to.
1329
- ...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
1330
- ...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
1331
- ...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
1332
- ...(row.solution !== null && { solution: row.solution }),
1333
- };
1334
- return Object.fromEntries(
1335
- Object.entries(all).filter(([key]) => collected.has(key)),
1336
- );
1337
- }
1338
-
1339
- /**
1340
- * Count the founder's OTHER non-terminal listings on this site (#5036 D7).
1341
- *
1342
- * Drafts count. A limit drafts escaped would be evaded by never submitting,
1343
- * which is exactly the state a ten-step form leaves a founder in by default.
1344
- *
1345
- * The stored-status filter is a first pass, not the answer: `closed` is
1346
- * computed, so an `active` row whose window has elapsed is terminal and must
1347
- * not count. `computeListingStatus` is the one place that knows this, and
1348
- * consulting it here is the same discipline the CSV sweep learned in #5033.
1349
- */
1350
- async function countOtherNonTerminalListings(
1351
- siteId: string,
1352
- founderEmail: string,
1353
- exceptId: string,
1354
- ): Promise<number> {
1355
- const now = new Date();
1356
- const draftCutoff = new Date(now.getTime() - DRAFT_HOLDS_A_SLOT_DAYS * 24 * 60 * 60 * 1000);
1357
-
1358
- const rows = await prisma.projectListing.findMany({
1359
- where: {
1360
- siteId,
1361
- founderEmail,
1362
- id: { not: exceptId },
1363
- OR: [
1364
- // Every non-terminal state except `draft`, which the clause below
1365
- // handles on its own terms. Derived from the shared constant rather
1366
- // than typed out, so a fifth non-terminal state is counted the day it
1367
- // is added.
1368
- {
1369
- status: {
1370
- in: PROJECT_LISTING_NON_TERMINAL_STATUSES.filter((s) => s !== "draft"),
1371
- },
1372
- },
1373
- // A draft holds a slot only while it is live — see
1374
- // `DRAFT_HOLDS_A_SLOT_DAYS`. It still counts, so not submitting evades
1375
- // nothing; it just stops counting once the application is plainly
1376
- // abandoned, which is what keeps an unverified email from locking a
1377
- // named founder out permanently.
1378
- { status: "draft", createdAt: { gt: draftCutoff } },
1379
- ],
1380
- },
1381
- select: { status: true, windowEndsAt: true, featured: true },
1382
- });
1383
- return rows.filter(
1384
- (r) => computeListingStatus(r.status, r.windowEndsAt, now, r.featured) !== "closed",
1385
- ).length;
1386
- }
1387
-
1388
- /**
1389
- * Validate the whole application and move the row `draft` -> `requested`.
1390
- *
1391
- * Three things happen here and nowhere else: completeness is asserted (an
1392
- * incomplete draft comes back with the offending fields NAMED, so the form can
1393
- * send the founder to the step that owns each one); the concurrent-listing
1394
- * limit binds; and the slug is derived, which is the first moment a `name`
1395
- * exists to derive it from.
1396
- *
1397
- * The transition write is a GUARDED `updateMany` on `status: "draft"`, so two
1398
- * concurrent submits of the same draft cannot both succeed — the loser reads
1399
- * as `not_found`, the same answer a spent token gets. The token's digest is
1400
- * cleared in that same write: a token that leaks after submission grants
1401
- * nothing.
1402
- *
1403
- * The Lead write and the Slack post are both side effects of that committed
1404
- * transition and both fail soft, in that order — never before it, or a submit
1405
- * that lost the race would still have put an application in the operator's
1406
- * pipeline with no listing behind it.
1407
- */
1408
- export async function submitProjectListingDraft(
1409
- id: string,
1410
- resumeToken: string,
1411
- sourceMeta: SourceMeta,
1412
- ): Promise<SubmitProjectListingDraftResult> {
1413
- const draft = await findDraftByToken(id, resumeToken);
1414
- if (!draft) {
1415
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1416
- }
1417
-
1418
- // #5048 D5 — completeness derives from the site's CONFIGURED collected-field
1419
- // set, never from a fixed schema. Steps are individually optional, so a
1420
- // field whose step the site never rendered was never asked for; requiring it
1421
- // would refuse a valid application naming a question the founder never saw.
1422
- //
1423
- // The set comes from the site's own server-side `SiteListingConfig`. It is
1424
- // never read from the request: a caller that could post its own required-
1425
- // field set could post an empty one and submit a blank listing.
1426
- const listingConfig = await listingConfigFor(draft.siteId);
1427
- const submitSchema = buildProjectListingSubmitSchema(
1428
- listingConfig.wordCaps,
1429
- listingConfig.collectedFields,
1430
- );
1431
- const parsed = submitSchema.safeParse(
1432
- applicationFromRow(draft, listingConfig.collectedFields),
1433
- );
1434
- if (!parsed.success) {
1435
- const missingFields = [
1436
- ...new Set(parsed.error.issues.map((issue) => String(issue.path[0]))),
1437
- ].sort();
1438
- return {
1439
- ok: false,
1440
- error: {
1441
- kind: "incomplete",
1442
- message: `application is incomplete: ${missingFields.join(", ")}`,
1443
- missingFields,
1444
- },
1445
- };
1446
- }
1447
-
1448
- const concurrent = await countOtherNonTerminalListings(
1449
- draft.siteId,
1450
- draft.founderEmail,
1451
- draft.id,
1452
- );
1453
- if (concurrent >= PROJECT_LISTING_CONCURRENT_LIMIT) {
1454
- return {
1455
- ok: false,
1456
- error: {
1457
- kind: "listing_limit_reached",
1458
- message: `a founder may hold at most ${PROJECT_LISTING_CONCURRENT_LIMIT} listings at once`,
1459
- },
1460
- };
1461
- }
1462
-
1463
- // `name` may be absent from the picked schema if a site does not collect it
1464
- // (#5048 D5), so the slug derives from the ROW rather than from the parsed
1465
- // payload. A site that collects no name has none to derive from, and the
1466
- // founder's own name is the only other stable string on the row.
1467
- const base = deriveListingSlug(draft.name ?? draft.founderName);
1468
- const slug = await firstFreeSlug(draft.siteId, base);
1469
-
1470
- const transition = async (candidate: string): Promise<number | "taken"> => {
1471
- try {
1472
- const updated = await prisma.projectListing.updateMany({
1473
- where: { id, status: "draft" },
1474
- // #5407 — the verification nonce is cleared here too, for the reason
1475
- // the resume-token digest is: a credential that leaks after the draft
1476
- // stops being a draft is inert twice over.
1477
- data: {
1478
- status: "requested",
1479
- slug: candidate,
1480
- resumeTokenHash: null,
1481
- emailVerifyNonceHash: null,
1482
- },
1483
- });
1484
- return updated.count;
1485
- } catch (err) {
1486
- if (isUniqueViolation(err)) return "taken";
1487
- throw err;
1488
- }
1489
- };
1490
-
1491
- let count = await transition(slug);
1492
- if (count === "taken") {
1493
- // `firstFreeSlug` samples at most 200 same-prefix rows, so a family larger
1494
- // than that can hand back a slug that is in fact taken — and a plain retry
1495
- // resamples and fails the same way, which would let a squatted prefix wall
1496
- // off every later applicant whose name derives to it (PR #5042 security
1497
- // review, finding 2). One random suffix ends that: an ugly slug beats an
1498
- // application that cannot be submitted at all.
1499
- count = await transition(`${base}-${randomBytes(3).toString("hex")}`);
1500
- }
1501
- if (count === "taken") {
1502
- return {
1503
- ok: false,
1504
- error: { kind: "slug_taken", message: "slug already in use for this site" },
1505
- };
1506
- }
1507
- if (count === 0) {
1508
- // A concurrent submit won the race and already spent this draft.
1509
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1510
- }
1511
-
1512
- // The Lead write reuses routeLead — never a duplicate persistence path — and
1513
- // happens at SUBMIT rather than at start, so an anonymous stranger cannot
1514
- // cause an operator notification by opening a draft they never finish.
1515
- //
1516
- // It runs AFTER the transition, and fails soft. Before the transition it
1517
- // would write a Lead (and mail the operator) for every submit that then lost
1518
- // the race or hit a taken slug — an application in the operator's pipeline
1519
- // with no listing behind it, and two Leads for the one submit that did land.
1520
- // After it, the worst case is a `requested` listing whose `leadId` is null,
1521
- // which the model allows and the weekly CSV sweep already skips explicitly.
1522
- // The submit itself has landed either way, and that is what the founder
1523
- // asked for; reporting it as a failure would be the wrong answer.
1524
- const leadResult = await routeLead(
1525
- {
1526
- kind: "submit",
1527
- siteId: draft.siteId,
1528
- source: "projects-apply",
1529
- email: draft.founderEmail,
1530
- payload: {
1531
- name: parsed.data.name,
1532
- oneLiner: parsed.data.oneLiner,
1533
- problem: parsed.data.problem,
1534
- product: parsed.data.product,
1535
- founderName: draft.founderName,
1536
- questions: parsed.data.questions,
1537
- },
1538
- },
1539
- sourceMeta,
1540
- );
1541
- if (leadResult.ok) {
1542
- await prisma.projectListing.update({
1543
- where: { id },
1544
- data: { leadId: leadResult.id },
1545
- });
1546
- } else {
1547
- logger.error(
1548
- { listingId: id, kind: leadResult.error.kind },
1549
- "[project-listings] the Lead write failed after a submitted listing already landed",
1550
- );
1551
- }
1552
-
1553
- const row = await prisma.projectListing.findFirst({
1554
- where: { id },
1555
- select: PROJECT_LISTING_SELECT,
1556
- });
1557
- if (!row) {
1558
- return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
1559
- }
1560
-
1561
- return {
1562
- ok: true,
1563
- id,
1564
- slug: row.slug!,
1565
- listing: toResponseRow(row, 0, new Date(), true),
1566
- };
1567
- }
1568
-
1569
- function isUniqueViolation(err: unknown): boolean {
1570
- return (
1571
- err != null &&
1572
- typeof err === "object" &&
1573
- "code" in err &&
1574
- (err as { code: string }).code === "P2002"
1575
- );
1576
- }
1577
-
1578
- // ─── List + get (tenant scope) ──────────────────────────────────────────────
1579
-
1580
- export async function listProjectListings(siteId: string): Promise<ProjectListing[]> {
1581
- const rows = await prisma.projectListing.findMany({
1582
- where: { siteId },
1583
- orderBy: { createdAt: "desc" },
1584
- select: PROJECT_LISTING_SELECT,
1585
- });
1586
- const counts = await attachWaitlistCounts(siteId, rows);
1587
- const now = new Date();
1588
- return rows.map((row) => toResponseRow(row, waitlistCountFor(row, counts), now, true));
1589
- }
1590
-
1591
- export type GetProjectListingResult =
1592
- | { ok: true; value: ProjectListing }
1593
- | { ok: false; error: { kind: "not_found"; message: string } };
1594
-
1595
- export async function getProjectListing(siteId: string, id: string): Promise<GetProjectListingResult> {
1596
- const row = await prisma.projectListing.findFirst({
1597
- where: { id, siteId },
1598
- select: PROJECT_LISTING_SELECT,
1599
- });
1600
- if (!row) {
1601
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1602
- }
1603
- const counts = await attachWaitlistCounts(siteId, [row]);
1604
- return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
1605
- }
1606
-
1607
- // ─── The founder's own listings (auth scope; #5295) ─────────────────────────
1608
-
1609
- /**
1610
- * The listings the CALLING USER owns, newest first.
1611
- *
1612
- * The join is the owner link and ONLY the owner link:
1613
- * `ownerUserId === userId`, the scalar `listing-promotion.ts` stamps once that
1614
- * user's own email is verified, matches the row's `founderEmail`, and the row's
1615
- * site resolves to one of the user's own workspaces. `founderEmail` is NEVER
1616
- * compared here, at any stage — an un-linked listing that happens to carry the
1617
- * caller's address is not theirs until a promotion said so, and a read-time
1618
- * email match would reintroduce exactly the cross-founder capture the link
1619
- * exists to remove. (`flow-checkouts.ts` draws the same line for `Lead`.)
1620
- *
1621
- * No `siteId` parameter and no `currentSiteId()` scope, deliberately — and the
1622
- * reason is NOT that the two can never coincide. `siteIdForPublicKey`
1623
- * (`listing-config.ts`) does fall back to `currentSiteId()`, so a listing's
1624
- * `siteId` CAN be `AILK_SITE_ID` or the literal `"default"` on a single-tenant
1625
- * deployment. The reason is that the owner link is already the narrower scope:
1626
- * it is only ever stamped for a site inside the user's own tenancy
1627
- * (`listing-promotion.ts`, condition 3), so adding a site term could only
1628
- * remove rows the caller owns — and on the multi-tenant shape, where a
1629
- * listing's `siteId` is a WORKSPACE id rather than `AILK_SITE_ID`, it would
1630
- * remove all of them. The response omits `siteId` regardless, so no workspace
1631
- * id leaves the read.
1632
- *
1633
- * The one thing this gives up against the sibling `/me` routes: on a database
1634
- * shared by more than one site, a caller's rows from every such site come back
1635
- * together. They are all the caller's own rows, which is why that is acceptable
1636
- * here and not a disclosure.
1637
- *
1638
- * No verified-email re-check either. `flow-checkouts.ts` performs one as
1639
- * defence in depth beside its email-matching sibling
1640
- * (`waitlist-signups.ts`, where the gate is load-bearing because the match IS
1641
- * an email). Here the link is the proof, and it was only issuable under
1642
- * verification — so a re-check adds no safety, while failing a caller whose
1643
- * address later changed to an unverified one would wrongly hide the rows they
1644
- * already proved they own.
1645
- *
1646
- * `waitlistCount` is resolved PER SITE: the count is keyed by
1647
- * `(siteId, experimentKey)`, and a founder may own listings in more than one of
1648
- * their own workspaces, so counting them all under one site id would report
1649
- * another site's number.
1650
- *
1651
- * An empty array for a caller who owns nothing is the normal answer, not an
1652
- * error.
1653
- */
1654
- export async function listMyProjectListings(userId: string): Promise<ProjectListing[]> {
1655
- const rows = await prisma.projectListing.findMany({
1656
- where: { ownerUserId: userId },
1657
- orderBy: { createdAt: "desc" },
1658
- select: PROJECT_LISTING_SELECT,
1659
- });
1660
- if (rows.length === 0) return [];
1661
-
1662
- const rowsBySite = new Map<string, ProjectListingRow[]>();
1663
- for (const row of rows) {
1664
- const forSite = rowsBySite.get(row.siteId);
1665
- if (forSite) forSite.push(row);
1666
- else rowsBySite.set(row.siteId, [row]);
1667
- }
1668
- const countsBySite = new Map<string, Map<string, number>>();
1669
- await Promise.all(
1670
- [...rowsBySite].map(async ([siteId, siteRows]) => {
1671
- countsBySite.set(siteId, await attachWaitlistCounts(siteId, siteRows));
1672
- }),
1673
- );
1674
-
1675
- const now = new Date();
1676
- // `featuredEnabled: true` — the founder's own row, like the operator's
1677
- // dashboard row, reports the flag as stored. It is the PUBLIC shelf the
1678
- // site's config switch governs, not what the row's owner may see.
1679
- return rows.map((row) =>
1680
- toResponseRow(
1681
- row,
1682
- waitlistCountFor(row, countsBySite.get(row.siteId) ?? new Map()),
1683
- now,
1684
- true,
1685
- ),
1686
- );
1687
- }
1688
-
1689
- // ─── Patch (tenant scope; the featured flag) ───────────────────────────────
1690
-
1691
- export type SetListingFeaturedResult =
1692
- | { ok: true; value: ProjectListing }
1693
- | { ok: false; error: { kind: "not_found"; message: string } };
1694
-
1695
- /**
1696
- * Set the operator's `featured` flag (#5033 D6).
1697
- *
1698
- * This is the only non-status write the PATCH route makes. STATUS
1699
- * transitions do not come through here at all — since the lifecycle
1700
- * collapsed to five states with `closed` computed, `requested -> active |
1701
- * rejected` is the only transition there is, and `decideListing` owns it for
1702
- * both the PATCH route and the Slack button. The old transition table (which
1703
- * also carried `approved -> live` and `live -> closed`) has no states left to
1704
- * describe.
1705
- */
1706
- export async function setListingFeatured(
1707
- siteId: string,
1708
- id: string,
1709
- featured: boolean,
1710
- ): Promise<SetListingFeaturedResult> {
1711
- const updated = await prisma.projectListing.updateMany({
1712
- where: { id, siteId },
1713
- data: { featured },
1714
- });
1715
- if (updated.count === 0) {
1716
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1717
- }
1718
-
1719
- const row = await prisma.projectListing.findFirst({
1720
- where: { id, siteId },
1721
- select: PROJECT_LISTING_SELECT,
1722
- });
1723
- if (!row) {
1724
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1725
- }
1726
-
1727
- const counts = await attachWaitlistCounts(siteId, [row]);
1728
- return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
1729
- }
1730
-
1731
- // ─── Patch (tenant scope; the operator's copy edit) ─────────────────────────
1732
-
1733
- /**
1734
- * The five PUBLISHED copy fields an operator copy edit may rewrite.
1735
- *
1736
- * The set is deliberately narrow. `questions` is IN because its prompts and
1737
- * option labels render on the public page, which is precisely where abusive
1738
- * language would land. `name` is OUT because the slug is derived from it at
1739
- * submit and stored, so editing it would desync the public URL from the name.
1740
- * `url`, `product`, `commitment`, `teamComposition`, `address` and
1741
- * `founderCompany` are OUT because they are facts rather than copy — a wrong
1742
- * fact is a question to the founder or a reject, not an edit. `anythingElse`
1743
- * is OUT because it is a note to the reviewer and never renders.
1744
- *
1745
- * The request body is derived from the SAME package schema these keys name
1746
- * (`projectListingApplicationFieldsSchema`) at the route boundary, so every
1747
- * bound the application itself carries — the word caps, the fixed FAQ count,
1748
- * the question refinements — holds here for free.
1749
- */
1750
- export type ProjectListingCopyEdit = Pick<
1751
- ProjectListingDraftPatchInput,
1752
- "headline" | "oneLiner" | "problem" | "faqs" | "questions"
1753
- >;
1754
-
1755
- /**
1756
- * The WHERE terms that admit a copy edit: `requested`, or `active` as the row
1757
- * actually READS.
1758
- *
1759
- * `draft` is the applicant's own, held through the resume token, and stays
1760
- * theirs. `rejected` is terminal. `closed` is the awkward one, because nothing
1761
- * stores it: `computeListingStatus` derives it from a stored `active` whose
1762
- * window has elapsed, so scoping on the stored string alone would leave every
1763
- * closed listing editable. These three terms are that function's three
1764
- * accepting branches, transcribed — a featured row (window-exempt, #5036 D6b),
1765
- * or a window still in force. A non-featured row with a null or elapsed window
1766
- * matches none of them and is refused, exactly as it reads.
1767
- *
1768
- * `now` is a parameter so the one instant the caller renders the response
1769
- * against is the same instant the write is guarded by.
1770
- *
1771
- * This lives in the WHERE clause rather than in a read-then-write check, so a
1772
- * concurrent decision cannot slip the row out of the editable set between the
1773
- * check and the write — the same compare-and-swap discipline
1774
- * `patchProjectListingDraft` and `decideListing` use.
1775
- */
1776
- function copyEditableWhere(now: Date): Prisma.ProjectListingWhereInput[] {
1777
- return [
1778
- { status: "requested" },
1779
- { status: "active", featured: true },
1780
- { status: "active", windowEndsAt: { gt: now } },
1781
- ];
1782
- }
1783
-
1784
- export type PatchListingCopyResult =
1785
- | { ok: true; value: ProjectListing }
1786
- | { ok: false; error: { kind: "not_found"; message: string } };
1787
-
1788
- /**
1789
- * Rewrite a listing's published copy, preserving what the applicant submitted.
1790
- *
1791
- * The third review outcome beside approve and reject: a listing whose copy
1792
- * needs a trim was previously publishable only exactly as written, because the
1793
- * legal transitions out of `requested` are `active` and `rejected`, there is no
1794
- * route back to `draft`, and `patchProjectListingDraft` is scoped to the
1795
- * applicant's own resume token.
1796
- *
1797
- * `submittedCopy` is WRITE-ONCE, on the first edit only. The read below is what
1798
- * resolves the pre-edit copy; it is NOT the write's guard, which stays the
1799
- * status-scoped `updateMany` — the same compare-and-swap shape
1800
- * `patchProjectListingDraft` and `decideListing` use. Because that read is one
1801
- * consistent snapshot, a row it saw with a null `submittedCopy` also carries
1802
- * the copy as the applicant submitted it, so two concurrent first edits write
1803
- * the same snapshot value rather than racing to a different one. Nothing
1804
- * clears the column, so the submitted copy stays recoverable for the life of
1805
- * the row.
1806
- *
1807
- * DELIBERATELY silent on the notification surfaces, unlike a decision. No
1808
- * founder email is sent, and the application's Slack card is not re-rendered,
1809
- * so a card posted before an edit keeps showing the pre-edit copy. Both are the
1810
- * decision path's concerns (`sendListingDecisionEmail`, `postListingApplication`)
1811
- * and neither is touched here. Worth revisiting if operators start editing
1812
- * before deciding often enough that the card misleads.
1813
- *
1814
- * The three audit columns are WRITE-ONLY at present: nothing reads them back,
1815
- * because the tenant read's response contract is `.strict()` and surfacing them
1816
- * would mean widening it. "Recoverable" therefore means recoverable from the
1817
- * row, not through an API.
1818
- *
1819
- * PII boundary: as everywhere else in this module, nothing here is logged.
1820
- */
1821
- export async function patchListingCopy(
1822
- siteId: string,
1823
- id: string,
1824
- copy: ProjectListingCopyEdit,
1825
- editedBy: string,
1826
- ): Promise<PatchListingCopyResult> {
1827
- const before = await prisma.projectListing.findFirst({
1828
- where: { id, siteId },
1829
- select: {
1830
- submittedCopy: true,
1831
- headline: true,
1832
- oneLiner: true,
1833
- problem: true,
1834
- faqs: true,
1835
- questions: true,
1836
- },
1837
- });
1838
- if (!before) {
1839
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1840
- }
1841
-
1842
- const now = new Date();
1843
- const updated = await prisma.projectListing.updateMany({
1844
- where: { id, siteId, OR: copyEditableWhere(now) },
1845
- data: {
1846
- // Only the keys the operator actually sent are written; an absent key is
1847
- // `undefined`, which Prisma omits from the UPDATE, so trimming the
1848
- // headline cannot blank the FAQs.
1849
- ...(copy.headline !== undefined && { headline: copy.headline }),
1850
- ...(copy.oneLiner !== undefined && { oneLiner: copy.oneLiner }),
1851
- ...(copy.problem !== undefined && { problem: copy.problem }),
1852
- ...(copy.faqs !== undefined && { faqs: copy.faqs as unknown as Prisma.InputJsonValue }),
1853
- ...(copy.questions !== undefined && {
1854
- questions: copy.questions as unknown as Prisma.InputJsonValue,
1855
- }),
1856
- ...(before.submittedCopy === null && {
1857
- submittedCopy: {
1858
- headline: before.headline,
1859
- oneLiner: before.oneLiner,
1860
- problem: before.problem,
1861
- faqs: before.faqs,
1862
- questions: before.questions,
1863
- } as unknown as Prisma.InputJsonValue,
1864
- }),
1865
- copyEditedAt: now,
1866
- copyEditedBy: editedBy,
1867
- },
1868
- });
1869
- if (updated.count === 0) {
1870
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1871
- }
1872
-
1873
- const row = await prisma.projectListing.findFirst({
1874
- where: { id, siteId },
1875
- select: PROJECT_LISTING_SELECT,
1876
- });
1877
- if (!row) {
1878
- return { ok: false, error: { kind: "not_found", message: "Project listing not found" } };
1879
- }
1880
-
1881
- const counts = await attachWaitlistCounts(siteId, [row]);
1882
- return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), now, true) };
1883
- }
1884
-
1885
- // ─── Public read (anonymous) ────────────────────────────────────────────────
1886
-
1887
- /**
1888
- * The public order (#5033): the THREE newest by `decidedAt` first, then the
1889
- * rest by `waitlistCount` desc, then `decidedAt` desc. One function, so the
1890
- * project read and the directory read (#7521 D3) cannot order differently.
1891
- */
1892
- function orderPublicListings<T extends { row: { id: string; decidedAt: Date | null }; waitlistCount: number }>(
1893
- withCounts: readonly T[],
1894
- ): T[] {
1895
- const decidedAtMs = (row: T["row"]): number => row.decidedAt?.getTime() ?? -Infinity;
1896
-
1897
- const newestFirst = [...withCounts].sort((a, b) => decidedAtMs(b.row) - decidedAtMs(a.row));
1898
- const top3 = newestFirst.slice(0, 3);
1899
- const top3Ids = new Set(top3.map((t) => t.row.id));
1900
-
1901
- const rest = withCounts
1902
- .filter((t) => !top3Ids.has(t.row.id))
1903
- .sort((a, b) => {
1904
- if (b.waitlistCount !== a.waitlistCount) return b.waitlistCount - a.waitlistCount;
1905
- return decidedAtMs(b.row) - decidedAtMs(a.row);
1906
- });
1907
-
1908
- return [...top3, ...rest];
1909
- }
1910
-
1911
- /**
1912
- * The page-render read (#4998 AC4, reshaped by #5033, bounded by #9243): the
1913
- * newest-decided `PUBLIC_LISTINGS_MAX_ROWS` `active` rows for the site, the
1914
- * THREE newest by `decidedAt` first, then the rest by `waitlistCount` desc,
1915
- * then `decidedAt` desc. `founderEmail` never leaves this function.
1916
- *
1917
- * Rows whose window has elapsed are returned and read `closed` — computed,
1918
- * per row, against one instant, with no job having run. They are not filtered
1919
- * out: a consumer partitions its shelves on the status it is handed, and a
1920
- * listing that quietly vanished at midnight would be a worse answer than one
1921
- * that says it is closed.
1922
- */
1923
- export async function publicProjectListings(siteId: string): Promise<ProjectListingPublic[]> {
1924
- const listingConfig = await listingConfigFor(siteId);
1925
- const rows = await prisma.projectListing.findMany({
1926
- where: { siteId, status: "active" },
1927
- // Same window as the directory read: with a `take`, the rows kept must be
1928
- // chosen, and "newest decided first, NULLS LAST" is the order the public
1929
- // shelf leads with anyway.
1930
- orderBy: [{ decidedAt: { sort: "desc", nulls: "last" } }, { id: "asc" }],
1931
- take: PUBLIC_LISTINGS_MAX_ROWS,
1932
- select: PROJECT_LISTING_SELECT,
1933
- });
1934
- const counts = await attachWaitlistCounts(siteId, rows);
1935
- const withCounts = rows.map((row) => ({ row, waitlistCount: waitlistCountFor(row, counts) }));
1936
-
1937
- const now = new Date();
1938
- return orderPublicListings(withCounts).map(({ row, waitlistCount }) => {
1939
- // #5407 — `founderPhone` and `emailVerifiedAt` leave here with
1940
- // `founderEmail`. `projectListingPublicSchema` omits all three and
1941
- // `sendValidated` re-parses `.strict()`, so a key that stopped being
1942
- // dropped here would 500 the public read rather than quietly publishing a
1943
- // founder's phone number — which is the failure mode worth having.
1944
- const {
1945
- founderEmail: _founderEmail,
1946
- founderPhone: _founderPhone,
1947
- emailVerifiedAt: _emailVerifiedAt,
1948
- ...publicRow
1949
- } = toResponseRow(
1950
- row,
1951
- waitlistCount,
1952
- now,
1953
- listingConfig.featuredEnabled,
1954
- );
1955
- // #5285 — the `public`-visibility answers ONLY. An `operator-only` answer
1956
- // is something a site asked for its own triage, and a founder who was
1957
- // told it would not be published has to be right about that; an answer
1958
- // whose id the site no longer declares is dropped for the same reason,
1959
- // because "no declaration" cannot be read as "public" on the read that
1960
- // publishes. The tenant dashboard and the review payload are the audience
1961
- // that sees the rest, and they do not come through here.
1962
- return {
1963
- ...publicRow,
1964
- siteAnswers: publicSiteAnswers(publicRow.siteAnswers, listingConfig.siteAnswers),
1965
- };
1966
- });
1967
- }
1968
-
1969
- // ─── Directory read (anonymous, #7521) ──────────────────────────────────────
1970
-
1971
- /** Only what the directory maps — no contact column is ever read here. */
1972
- const DIRECTORY_LISTING_SELECT = {
1973
- id: true,
1974
- slug: true,
1975
- name: true,
1976
- headline: true,
1977
- oneLiner: true,
1978
- url: true,
1979
- logoMarkUrl: true,
1980
- founderCompany: true,
1981
- founderName: true,
1982
- siteAnswers: true,
1983
- featured: true,
1984
- status: true,
1985
- decidedAt: true,
1986
- windowEndsAt: true,
1987
- experimentKey: true,
1988
- capturedByFlowId: true,
1989
- } as const satisfies Prisma.ProjectListingSelect;
1990
-
1991
- /**
1992
- * A landing page's directory (#7521 D3): the entry type of the live listicle
1993
- * that names `landingPage`, and that page's ACCEPTED entries — every `active`
1994
- * listing (an elapsed one reads `closed`, as on `/public`) captured by a live
1995
- * listicle naming it, at most the 200 most recently accepted, in the public
1996
- * order. `null` when no live listicle of this site names the page, which is
1997
- * almost every page.
1998
- *
1999
- * Each entry carries the generic directory shape, never the project-shaped
2000
- * row: name, summary, links, and a `profile` built at read time from the
2001
- * capturing listicle's CURRENT Application fields (`listingProfile`, D4).
2002
- * Contact columns never leave this function.
2003
- *
2004
- * Two live listicles naming one page both contribute entries; the
2005
- * earlier-created one supplies the entry type.
2006
- */
2007
- export async function listingDirectoryFor(
2008
- siteId: string,
2009
- landingPage: string,
2010
- locale: string | undefined,
2011
- ): Promise<ListingDirectoryResponse> {
2012
- // fence: siteId and workspaceId both — the value `siteIdForPublicKey`
2013
- // resolved from the public key, never from the request.
2014
- const listicles = await prisma.flowDefinition.findMany({
2015
- where: { siteId, workspaceId: siteId, kind: "listicle", status: "live", landingPage },
2016
- orderBy: [{ createdAt: "asc" }, { id: "asc" }],
2017
- select: { id: true, config: true },
2018
- });
2019
- if (listicles.length === 0) return null;
2020
-
2021
- const configs = new Map<string, FlowConfig | null>();
2022
- for (const listicle of listicles) {
2023
- const parsed = flowConfigSchema.safeParse(listicle.config);
2024
- if (!parsed.success) {
2025
- logger.warn(
2026
- { siteId, flowId: listicle.id },
2027
- "[project-listings] a live listicle's config does not parse; its entries publish with no profile",
2028
- );
2029
- }
2030
- configs.set(listicle.id, parsed.success ? parsed.data : null);
2031
- }
2032
- const entryType =
2033
- configs.get(listicles[0]!.id)?.listing?.entryType ?? LISTING_ENTRY_TYPE_DEFAULT;
2034
-
2035
- const listingConfig = await listingConfigFor(siteId);
2036
- const rows = await prisma.projectListing.findMany({
2037
- where: {
2038
- siteId,
2039
- workspaceId: siteId,
2040
- status: "active",
2041
- capturedByFlowId: { in: listicles.map((l) => l.id) },
2042
- slug: { not: null },
2043
- },
2044
- // NULLS LAST: Postgres sorts NULL first on DESC, so an undecided row
2045
- // would otherwise take a slot in the window ahead of an accepted one.
2046
- orderBy: [{ decidedAt: { sort: "desc", nulls: "last" } }, { id: "asc" }],
2047
- take: LISTING_DIRECTORY_MAX_ENTRIES,
2048
- select: DIRECTORY_LISTING_SELECT,
2049
- });
2050
- const counts = await attachWaitlistCounts(siteId, rows);
2051
- const withCounts = rows.map((row) => ({ row, waitlistCount: waitlistCountFor(row, counts) }));
2052
-
2053
- const now = new Date();
2054
- const entries = orderPublicListings(withCounts).map(
2055
- ({ row, waitlistCount }): ListingEntryPublic => {
2056
- const config = row.capturedByFlowId ? configs.get(row.capturedByFlowId) : null;
2057
- return {
2058
- id: row.id,
2059
- slug: row.slug!,
2060
- name: row.headline ?? row.name ?? row.founderCompany ?? row.founderName,
2061
- summary: row.oneLiner,
2062
- url: row.url,
2063
- logoMarkUrl: row.logoMarkUrl,
2064
- featured: listingConfig.featuredEnabled && row.featured,
2065
- status:
2066
- computeListingStatus(row.status, row.windowEndsAt, now, row.featured) === "closed"
2067
- ? "closed"
2068
- : "active",
2069
- windowEndsAt: row.windowEndsAt ? row.windowEndsAt.toISOString() : null,
2070
- decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
2071
- waitlistCount,
2072
- profile: config
2073
- ? listingProfile(
2074
- config,
2075
- siteAnswersFromRow(row.siteAnswers),
2076
- locale ?? config.defaultLocale,
2077
- listingConfig.siteAnswers.questions,
2078
- )
2079
- : [],
2080
- };
2081
- },
2082
- );
2083
-
2084
- return { landingPage, entryType, entries };
2085
- }