create-ailk 0.4.0 → 0.6.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -8,16 +8,53 @@
8
8
  //
9
9
  // Generator + datasource blocks live in database/schema.prisma (the root schema
10
10
  // file). This file holds only model definitions for the inbox namespace.
11
+ //
12
+ // ── The fence fan-out (#6374) — why `workspaceId` sits where it does ────────
13
+ // Eleven models in this file carry the tenancy fence: a NOT NULL
14
+ // `workspaceId` mapped to `workspace_id`, an `@@index([workspaceId])`, and a
15
+ // `<table>_workspace_site_agree` CHECK added by
16
+ // database/migrations/20260922120000_tenancy_fence. No Prisma relation is
17
+ // declared for it (no Prisma relation directive) and no raw-SQL foreign key
18
+ // is added (ADR-0009 invariant #1) — the CHECK is the fence, and it is
19
+ // strictly tighter than an FK, because `site_id` IS the resolved workspace id.
11
20
 
12
21
  /// Lead — anonymous lead submission (Phase B submit_lead tool / B6 route)
13
22
  model Lead {
14
23
  id String @id @default(dbgenerated("('lead_'::text || (uuidv7())::text)")) @db.Text
15
24
  siteId String @map("site_id")
25
+ // #6203 — the isolation key `tenantDb(tenant).leads` filters on. Plain
26
+ // scalar, no @relation (ADR 0009 D1, the same reasoning `promotedUserId`
27
+ // below already applies). Backfilled by
28
+ // database/migrations/20260919140000_inbox_workspace_key.
29
+ //
30
+ // #6254 AC-3/AC-4/AC-7 — NOT NULL as of
31
+ // database/migrations/20260922120000_tenancy_fence, which also normalizes
32
+ // `siteId` to the resolved workspace id and adds
33
+ // `leads_workspace_site_agree CHECK ("workspace_id" = "site_id")`: since
34
+ // `siteId` IS the workspace id (never a WorkspaceSiteIdentity id — see
35
+ // that model's docblock), Postgres itself now rejects a row whose two
36
+ // columns disagree, in place of the issue's literal (and unbuildable,
37
+ // ADR-0009-crossing) composite FK — docs/database/namespaces.md. Every
38
+ // write site derives this value server-side from the resolved site
39
+ // alias; never from a request body (AC-6).
40
+ workspaceId String @map("workspace_id")
16
41
  source String
17
42
  email String
18
43
  payload Json
19
44
  createdAt DateTime @default(now()) @map("created_at")
20
45
 
46
+ // #6204 — the triage stamp. NOT `promotedAt`'s sibling: `promotedAt` below
47
+ // is the lead→user PROMOTION stamp, written by the email-verification
48
+ // reconciler and true of a lead regardless of whether anyone has answered
49
+ // it. `handledAt` is written by an operator (or an agent acting for one)
50
+ // clearing a MESSAGE out of the Needs-you queue (spec D8). Nullable, no
51
+ // default — every existing row reads NULL, i.e. unhandled (spec D5).
52
+ // `handledBy` is the actor, mirroring `ProjectListing.decidedBy` below
53
+ // (spec D4/S-7) — plain scalar, no @relation (ADR 0009 D2), the same
54
+ // posture `promotedUserId` already takes.
55
+ handledAt DateTime? @map("handled_at")
56
+ handledBy String? @map("handled_by")
57
+
21
58
  // Lead → user promotion (#3586). Plain scalars — no cross-namespace
22
59
  // @relation into auth.User (ADR 0009 invariant #1). Set by
23
60
  // apps/api/src/services/lead-promotion.ts once the reconciling user's
@@ -29,6 +66,18 @@ model Lead {
29
66
  @@index([siteId])
30
67
  @@index([createdAt])
31
68
  @@index([email])
69
+ @@index([workspaceId])
70
+ // #6204 — the message-inbox read: workspaceId = $1 AND handledAt IS NULL
71
+ // AND source = ANY($2), ordered by createdAt desc. Two columns, not three
72
+ // (spec D10): `source` is an IN over four values Postgres applies cheaply
73
+ // after this narrowing, so it is not worth widening the index on every
74
+ // write.
75
+ @@index([workspaceId, handledAt])
76
+ // #6246 — the per-magnet read: workspaceId = $1 AND source = $2, ordered
77
+ // by createdAt desc (spec § 1.5/D1). `source` here is an EXACT-equality
78
+ // `'flow:' + <magnet slug>` value, not the `handledAt` index's allowlist
79
+ // `IN` — a distinct query shape that composite does not serve.
80
+ @@index([workspaceId, source])
32
81
  @@map("leads")
33
82
  }
34
83
 
@@ -36,27 +85,91 @@ model Lead {
36
85
  model Subscriber {
37
86
  id String @id @default(dbgenerated("('subscr_'::text || (uuidv7())::text)")) @db.Text
38
87
  siteId String @map("site_id")
88
+ // #6374 — the tenancy fence, extended to this model (spec
89
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
90
+ // Prisma relation directive (ADR-0009 invariant #1), and
91
+ // subscribers_workspace_site_agree CHECK (workspace_id = site_id) — since
92
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
93
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
94
+ // value, never one from a request body (AC-6).
95
+ workspaceId String @map("workspace_id")
39
96
  email String
40
97
  source String
41
98
  createdAt DateTime @default(now()) @map("created_at")
42
99
 
43
100
  @@unique([siteId, email])
44
101
  @@index([siteId])
102
+ @@index([workspaceId])
45
103
  @@map("subscribers")
46
104
  }
47
105
 
48
- /// Callback — callback request (Phase B request_callback tool / B6 route)
106
+ /// Callback — callback request (Phase B request_callback tool / B6 route),
107
+ /// plus the request-and-confirm lifecycle (#5970, D-67 / SUB-100).
108
+ ///
109
+ /// The eleven trailing members are the confirm/pay lifecycle. They are
110
+ /// deliberately ON THIS MODEL rather than on a second `ServiceRequest` row
111
+ /// (spec #5970 D1), and the cost of that choice is stated rather than hidden:
112
+ /// every ORDINARY callback row — the overwhelming majority — now carries
113
+ /// eleven NULL columns it will never use. The alternative duplicates
114
+ /// siteId/name/phone/preferredTime/payload or carries a cross-row join for a
115
+ /// one-to-one relationship, and it puts a SECOND write inside the anonymous
116
+ /// lead-capture path, which is a never-gate surface. Eleven nullable columns
117
+ /// is the cheaper side.
49
118
  model Callback {
50
119
  id String @id @default(dbgenerated("('callbk_'::text || (uuidv7())::text)")) @db.Text
51
120
  siteId String @map("site_id")
121
+ // #6374 — the tenancy fence, extended to this model (spec
122
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
123
+ // Prisma relation directive (ADR-0009 invariant #1), and
124
+ // callbacks_workspace_site_agree CHECK (workspace_id = site_id) — since
125
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
126
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
127
+ // value, never one from a request body (AC-6).
128
+ workspaceId String @map("workspace_id")
52
129
  name String
53
130
  phone String
54
131
  preferredTime String? @map("preferred_time")
55
132
  payload Json
56
133
  createdAt DateTime @default(now()) @map("created_at")
57
134
 
135
+ // ── request-and-confirm lifecycle (#5970) ──────────────────────────────
136
+ /// sha-256 hex digest of the tenant's confirm token. The token itself is
137
+ /// emailed once and never stored; NULLED when the credential is spent,
138
+ /// which is the single-use mechanism. `@unique` mirrors `resumeTokenHash` —
139
+ /// a digest collision is a bug, not a state.
140
+ confirmTokenHash String? @unique @map("confirm_token_hash")
141
+ /// When the credential stops resolving (mint + 7 days).
142
+ confirmTokenExpiresAt DateTime? @map("confirm_token_expires_at")
143
+ /// Stamped by the tenant's confirm POST. Also the "already confirmed" predicate.
144
+ confirmedAt DateTime? @map("confirmed_at")
145
+ /// What the tenant agreed to, as they typed it — a string like
146
+ /// `preferredTime` above, not a parsed instant.
147
+ confirmedTime String? @map("confirmed_time")
148
+ /// Where the hosted payment URL is sent. Typed on the confirm form,
149
+ /// prefilled from `payload.email` when the capture form carried one.
150
+ buyerEmail String? @map("buyer_email")
151
+ /// MINOR units, never major — Stripe's own `unit_amount` convention.
152
+ amountMinor Int? @map("amount_minor")
153
+ /// Lowercase ISO-4217.
154
+ currency String?
155
+ /// The Payment Link the confirm step minted — THE artifact id the webhook
156
+ /// stamp matches on (`session.payment_link`).
157
+ stripePaymentLinkId String? @map("stripe_payment_link_id")
158
+ /// The session written from the same event, for support lookups. NOT a
159
+ /// match key, which is why it carries no index.
160
+ stripeCheckoutSessionId String? @map("stripe_checkout_session_id")
161
+ /// NULL-forever at #5970's boundary — present so the hosted-invoice rung
162
+ /// lands additively, with no second migration.
163
+ stripeInvoiceId String? @map("stripe_invoice_id")
164
+ /// Stamped exactly once by the Stripe webhook; the `paidAt: null` guard on
165
+ /// that update IS the durable idempotency key.
166
+ paidAt DateTime? @map("paid_at")
167
+
58
168
  @@index([siteId])
59
169
  @@index([createdAt])
170
+ @@index([stripePaymentLinkId])
171
+ @@index([stripeInvoiceId])
172
+ @@index([workspaceId])
60
173
  @@map("callbacks")
61
174
  }
62
175
 
@@ -67,8 +180,51 @@ model Callback {
67
180
  model FlowDefinition {
68
181
  id String @id @default(dbgenerated("('flow_'::text || (uuidv7())::text)")) @db.Text
69
182
  siteId String @map("site_id")
183
+ /// #6155 AD-2 — the workspace isolation key. Backfilled from `siteId`: the
184
+ /// literal "default" resolves to the seeded sentinel workspace
185
+ /// (wksp_00000000-0000-7000-8000-000000000001); any other value resolves
186
+ /// only by exact `workspaces.id` match. Plain string, no `@relation`: ADR
187
+ /// 0009 forbids a Prisma relation across the inbox -> auth (and, per
188
+ /// #6254, inbox -> config) namespace boundary, the same reason
189
+ /// `Asset.workspaceId` (database/content/schema.prisma) carries none.
190
+ ///
191
+ /// #6254 AC-3/AC-4/AC-7 — NOT NULL as of
192
+ /// database/migrations/20260922120000_tenancy_fence, which also
193
+ /// normalizes `siteId` to the resolved workspace id and adds
194
+ /// `flow_definitions_workspace_site_agree CHECK ("workspace_id" =
195
+ /// "site_id")` — see `Lead.workspaceId` above for the full rationale
196
+ /// (identical here). Every write site derives this value server-side;
197
+ /// never from a request body (AC-6).
198
+ workspaceId String @map("workspace_id")
70
199
  slug String
71
200
  config Json
201
+ /// #6156 D1/D3 — the magnet's classification, derived structurally (never
202
+ /// from slug) by `deriveFlowKind` on CREATE only: special-report |
203
+ /// diagnostic | listicle | waitlist | contact | newsletter. Keeps its
204
+ /// column default ('contact') — unlike `status`, a mis-classified magnet
205
+ /// is a mislabel, not a publication or security control.
206
+ kind String @default("contact")
207
+ /// #6156 D1 — the operator-facing name; the slug is not a name. NOT NULL
208
+ /// with NO column default (D1): a nameless magnet must never enter the
209
+ /// table silently, so every future writer is required by `tsc` to state
210
+ /// one, exactly as `status` below is required to state a status.
211
+ title String
212
+ /// #6156 D1/D4 — an operator-declared publish address, site-relative only
213
+ /// (`flowLandingPageSchema`, packages/validation/src/schemas/flow.ts).
214
+ /// NULL means "no operator-declared landing page; the canonical
215
+ /// `/{locale}/flows/{slug}` route serves it" — never a derived guess, since
216
+ /// a magnet has THREE public addresses (the indexed landing route, the
217
+ /// walk route one segment deeper, and the results route), and only the
218
+ /// operator can say which one they are publishing.
219
+ landingPage String? @db.Text @map("landing_page")
220
+ /// #6156 D1/D2/D10 — draft | live. NOT NULL with NO column default (D1,
221
+ /// SEC-08): silence must never publish, so every future writer is required
222
+ /// by `tsc` to name a status. Existing rows are backfilled 'live' by the
223
+ /// migration (every row that exists today is already serving). The
224
+ /// `flow_definitions_status_domain` CHECK (D2) closes the deny-list gate in
225
+ /// `resolveSession` (D10): an unknown value is unstorable, so "not
226
+ /// 'draft'" and "'live'" are the same set at the database level.
227
+ status String
72
228
  // #5036 D6d — the listing this definition was projected from, when it was.
73
229
  // Null for every flow that is not a listing diagnostic. Before this column
74
230
  // the only link was that the generated flow's slug HAPPENED to equal the
@@ -81,6 +237,7 @@ model FlowDefinition {
81
237
  updatedAt DateTime @updatedAt @map("updated_at")
82
238
 
83
239
  @@unique([siteId, slug])
240
+ @@index([workspaceId])
84
241
  @@map("flow_definitions")
85
242
  }
86
243
 
@@ -94,6 +251,18 @@ model FlowDefinition {
94
251
  model FlowSession {
95
252
  id String @id @default(dbgenerated("('flsess_'::text || (uuidv7())::text)")) @db.Text
96
253
  siteId String @map("site_id")
254
+ /// #6155 AD-2 — the workspace isolation key, same backfill contract as
255
+ /// `FlowDefinition.workspaceId` above; #6254 makes it NOT NULL plus the
256
+ /// same `flow_sessions_workspace_site_agree` equality CHECK — see
257
+ /// `Lead.workspaceId` (database/inbox/schema.prisma, top of file) for the
258
+ /// full rationale. Declared here, immediately after `siteId` and before
259
+ /// `state`'s empty-object-literal default below: the drift guard's model
260
+ /// regex (`tests/architecture/tenant-scope-pattern.test.ts`) truncates a
261
+ /// model's captured body at its first closing brace — including one
262
+ /// inside a comment or a string literal, not only in code — so a field
263
+ /// placed after that point (or a stray brace pair written ABOVE it, as
264
+ /// this very note must avoid) would be invisible to it.
265
+ workspaceId String @map("workspace_id")
97
266
  // Informational only — never joined on (ADR 0009 D1: logical, not FK,
98
267
  // isolation). Session resumption + completion key on flowSlug, not this
99
268
  // column; deliberate, per code-review finding on #3956's acceptance PR.
@@ -138,6 +307,7 @@ model FlowSession {
138
307
 
139
308
  @@index([siteId])
140
309
  @@index([siteId, completedAt])
310
+ @@index([workspaceId])
141
311
  @@index([email])
142
312
  // Backs the abandon-email sweep's per-definition selection query
143
313
  // (apps/api/src/services/abandon-email.ts — flowId equality + completedAt
@@ -151,16 +321,32 @@ model FlowSession {
151
321
  @@map("flow_sessions")
152
322
  }
153
323
 
154
- /// DeliverableAsset — an operator-uploaded asset the funnel's `deliverables`
155
- /// config block references by `key` (#3961 D1). Bytes live IN THE DATABASE
156
- /// (Postgres bytea) behind one internal resolver — zero new infrastructure;
157
- /// reversibility is structural (a later blob-storage swap changes only the
158
- /// resolver + the upload CLI, never the config contract, grant model, or
159
- /// email link shape). Uploaded via the `deliverable:upload` CLI
160
- /// (apps/api/src/bin/deliverable-upload.ts) only — no HTTP upload route.
324
+ /// DeliverableAsset — LEGACY (#6310 D3/D11). An operator-uploaded asset the
325
+ /// funnel's `deliverables` config block references by `key` (#3961 D1).
326
+ /// Bytes live IN THE DATABASE (Postgres bytea) behind one internal
327
+ /// resolver — zero new infrastructure; reversibility is structural (a later
328
+ /// blob-storage swap changes only the resolver + the upload CLI, never the
329
+ /// config contract, grant model, or email link shape). Uploaded via the
330
+ /// `deliverable:upload` CLI (apps/api/src/bin/deliverable-upload.ts) only —
331
+ /// no HTTP upload route.
332
+ ///
333
+ /// Since #6310: a magnet deliverable resolves against the workspace-scoped
334
+ /// `Asset` model (database/content/schema.prisma) FIRST; this store is the
335
+ /// FALLBACK, tried only on an `Asset` miss (D2). No existing row is
336
+ /// migrated — D3 grounds why in the two server-internal write/read paths —
337
+ /// so every row here keeps delivering, unmigrated, until an operator
338
+ /// confirms none remain (§2.3's exit condition).
161
339
  model DeliverableAsset {
162
340
  id String @id @default(dbgenerated("('dasset_'::text || (uuidv7())::text)")) @db.Text
163
341
  siteId String @map("site_id")
342
+ // #6374 — the tenancy fence, extended to this model (spec
343
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
344
+ // Prisma relation directive (ADR-0009 invariant #1), and
345
+ // deliverable_assets_workspace_site_agree CHECK (workspace_id = site_id) — since
346
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
347
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
348
+ // value, never one from a request body (AC-6).
349
+ workspaceId String @map("workspace_id")
164
350
  // The assetKey a flow config's deliverables.assets entry references.
165
351
  key String
166
352
  // Download filename (Content-Disposition). Stored RAW at upload time
@@ -178,6 +364,7 @@ model DeliverableAsset {
178
364
  updatedAt DateTime @updatedAt @map("updated_at")
179
365
 
180
366
  @@unique([siteId, key])
367
+ @@index([workspaceId])
181
368
  @@map("deliverable_assets")
182
369
  }
183
370
 
@@ -188,12 +375,63 @@ model DeliverableAsset {
188
375
  /// value — only the uuidv7-random grant id, delivered once via the lead's
189
376
  /// own email. `assetId`/`leadId` are stable-id refs (ADR 0009 invariant 1
190
377
  /// posture) — no cross-model `@relation`.
378
+ ///
379
+ /// `assetId`'s three id classes (#6310): `asset_` (a workspace `Asset`,
380
+ /// scoped serve via `workspaceId` below), `dasset_` (legacy
381
+ /// `DeliverableAsset`, scoped by `siteId` above), `playbook:` (the FEAT-074
382
+ /// U4b virtual asset — no store row at all).
191
383
  model DeliverableGrant {
192
384
  id String @id @default(dbgenerated("('dgrant_'::text || (uuidv7())::text)")) @db.Text
193
385
  siteId String @map("site_id")
194
386
  assetId String @map("asset_id")
195
387
  leadId String @map("lead_id")
196
388
  sessionId String @map("session_id")
389
+ /// The workspace that authorized this grant's asset resolution (#6310
390
+ /// D15). Plain string, no
391
+ /// @relation (ADR 0009 invariant 1) — the same shape
392
+ /// FlowDefinition.workspaceId carries. The serve path reads it ONLY for
393
+ /// the `asset_` class, and a NULL there is a 404, never an unscoped read.
394
+ ///
395
+ /// #6374 CHANGED WHO WRITES IT, AND THE SAFETY ARGUMENT MOVED WITH IT.
396
+ /// Before #6374 this was written ONLY when `assetId` was an `asset_` id,
397
+ /// and stayed NULL for a legacy `dasset_` grant and a `playbook:` virtual
398
+ /// one. As of #6374 BOTH mint sites stamp it unconditionally
399
+ /// (services/deliverable-fulfillment.ts, services/listing-csv-sweep.ts),
400
+ /// so NO newly-minted grant is NULL and the fail-closed sentinel below
401
+ /// now protects PRE-EXISTING rows only — the migration still touches none
402
+ /// of them.
403
+ ///
404
+ /// That is safe because of a DIFFERENT invariant than the one that used to
405
+ /// carry it, and the new one is more fragile, so it is written down here
406
+ /// rather than left implicit. What makes stamping safe is NOT "the column
407
+ /// is NULL on legacy grants" — it no longer is. It is that
408
+ /// routes/deliverables/index.ts branches on the `assetId` PREFIX, and only
409
+ /// the `asset_` arm ever reads this column: the `playbook:` arm scopes by
410
+ /// siteId + leadId, and the legacy `dasset_` arm scopes by
411
+ /// `asset.siteId === grant.siteId`. Neither reads `workspaceId`, so a
412
+ /// stamped legacy grant gains no reach it did not already have.
413
+ /// DO NOT make the legacy or playbook arm consult this column without
414
+ /// re-deriving that argument — doing so WOULD be an authorization
415
+ /// widening, which is exactly what the pre-#6374 NULL used to prevent.
416
+ ///
417
+ /// #6254 DELIBERATELY DOES NOT TOUCH THIS FIELD. It stays nullable, with
418
+ /// no equality CHECK — unlike Lead/ProjectListing/FlowDefinition/
419
+ /// FlowSession above, this column has no source column to derive from
420
+ /// (its own migration says so: "no source column to derive from ... a
421
+ /// wholly new fact, captured only at a FUTURE mint"), and `NULL` is the
422
+ /// serve path's fail-closed sentinel (`deliverable-asset-store.ts`
423
+ /// `readDeliverableAsset` opens `if (workspaceId === null) return null`).
424
+ /// Stamping `workspace_id := site_id` would hand a legacy grant an
425
+ /// authorization scope no mint ever granted, on a bearer-token download
426
+ /// path — a security regression, not a tightening. That reasoning still
427
+ /// holds for EXISTING rows, which is why the column stays nullable and why
428
+ /// #6374's migration adds no column and rewrites no row. Carved out of the
429
+ /// #6254 retrofit by the operator (spec §10.1 Q2 -> carve out); #6374
430
+ /// fences it at mint grain, where the workspace is actually known, and
431
+ /// adds the NULL-TOLERANT
432
+ /// deliverable_grants_workspace_site_agree CHECK — so a STAMPED grant can
433
+ /// never disagree with its site while a legacy grant stays storable.
434
+ workspaceId String? @map("workspace_id")
197
435
  createdAt DateTime @default(now()) @map("created_at")
198
436
  // Post-mint send-outcome stamp (#3998 D1) — set at most once, by the
199
437
  // send-and-stamp tail shared between fulfillDeliverable and the
@@ -223,6 +461,14 @@ model DeliverableGrant {
223
461
  model WaitlistExperiment {
224
462
  id String @id @default(dbgenerated("('wlexp_'::text || (uuidv7())::text)")) @db.Text
225
463
  siteId String @map("site_id")
464
+ // #6374 — the tenancy fence, extended to this model (spec
465
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
466
+ // Prisma relation directive (ADR-0009 invariant #1), and
467
+ // waitlist_experiments_workspace_site_agree CHECK (workspace_id = site_id) — since
468
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
469
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
470
+ // value, never one from a request body (AC-6).
471
+ workspaceId String @map("workspace_id")
226
472
  key String
227
473
  mode String // lead-capture | qualifying | paid
228
474
  status String // draft | live | closed
@@ -231,6 +477,7 @@ model WaitlistExperiment {
231
477
  updatedAt DateTime @updatedAt @map("updated_at")
232
478
 
233
479
  @@unique([siteId, key])
480
+ @@index([workspaceId])
234
481
  @@map("waitlist_experiments")
235
482
  }
236
483
 
@@ -240,6 +487,14 @@ model WaitlistExperiment {
240
487
  model WaitlistScore {
241
488
  id String @id @default(dbgenerated("('wlscore_'::text || (uuidv7())::text)")) @db.Text
242
489
  siteId String @map("site_id")
490
+ // #6374 — the tenancy fence, extended to this model (spec
491
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
492
+ // Prisma relation directive (ADR-0009 invariant #1), and
493
+ // waitlist_scores_workspace_site_agree CHECK (workspace_id = site_id) — since
494
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
495
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
496
+ // value, never one from a request body (AC-6).
497
+ workspaceId String @map("workspace_id")
243
498
  leadId String @map("lead_id")
244
499
  experimentKey String @map("experiment_key")
245
500
  waitlistKey String @map("waitlist_key")
@@ -261,6 +516,7 @@ model WaitlistScore {
261
516
  // that `scoreCompletedSignup` relies on, per project rather than globally.
262
517
  @@unique([leadId, experimentKey])
263
518
  @@index([siteId, experimentKey, waitlistKey])
519
+ @@index([workspaceId])
264
520
  @@map("waitlist_scores")
265
521
  }
266
522
 
@@ -277,6 +533,14 @@ model WaitlistScore {
277
533
  model PlaybookInstance {
278
534
  id String @id @default(dbgenerated("('pbk_'::text || (uuidv7())::text)")) @db.Text
279
535
  siteId String @map("site_id")
536
+ // #6374 — the tenancy fence, extended to this model (spec
537
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
538
+ // Prisma relation directive (ADR-0009 invariant #1), and
539
+ // playbook_instances_workspace_site_agree CHECK (workspace_id = site_id) — since
540
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
541
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
542
+ // value, never one from a request body (AC-6).
543
+ workspaceId String @map("workspace_id")
280
544
  leadId String @map("lead_id")
281
545
  experimentKey String @map("experiment_key")
282
546
  scoreId String @map("score_id")
@@ -288,6 +552,7 @@ model PlaybookInstance {
288
552
  createdAt DateTime @default(now()) @map("created_at")
289
553
 
290
554
  @@index([siteId, leadId, createdAt])
555
+ @@index([workspaceId])
291
556
  @@map("playbook_instances")
292
557
  }
293
558
 
@@ -306,6 +571,14 @@ model PlaybookInstance {
306
571
  model WaitlistFollowUpSend {
307
572
  id String @id @default(dbgenerated("('wlfu_'::text || (uuidv7())::text)")) @db.Text
308
573
  siteId String @map("site_id")
574
+ // #6374 — the tenancy fence, extended to this model (spec
575
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
576
+ // Prisma relation directive (ADR-0009 invariant #1), and
577
+ // waitlist_followup_sends_workspace_site_agree CHECK (workspace_id = site_id) — since
578
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
579
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
580
+ // value, never one from a request body (AC-6).
581
+ workspaceId String @map("workspace_id")
309
582
  leadId String @map("lead_id")
310
583
  followUpKey String @map("follow_up_key")
311
584
  sentAt DateTime @map("sent_at")
@@ -313,6 +586,7 @@ model WaitlistFollowUpSend {
313
586
 
314
587
  @@unique([leadId, followUpKey])
315
588
  @@index([siteId])
589
+ @@index([workspaceId])
316
590
  @@map("waitlist_followup_sends")
317
591
  }
318
592
 
@@ -327,12 +601,21 @@ model WaitlistFollowUpSend {
327
601
  /// visible in the data, not only in the sweep's control flow.
328
602
  model ListingCsvSend {
329
603
  id String @id @default(dbgenerated("('lcsv_'::text || (uuidv7())::text)")) @db.Text
604
+ // #6374 D2 — the tenancy fence, in the shape this model's ABSENT site_id
605
+ // forces (operator disposition, 2026-09-20). It gets NOT NULL plus the
606
+ // listing_csv_sends_workspace_id_format CHECK and NOTHING else: with no
607
+ // site_id column there is nothing for an equality CHECK to compare, and a
608
+ // composite FK to ProjectListing — legal, both models are inbox — was
609
+ // offered and declined in favour of ONE fence shape. Backfilled and
610
+ // stamped from the parent listing row, never from a request body (AC-6).
611
+ workspaceId String @map("workspace_id")
330
612
  listingId String @map("listing_id")
331
613
  sentAt DateTime @map("sent_at")
332
614
  rowCount Int @map("row_count")
333
615
  grantId String @map("grant_id")
334
616
 
335
617
  @@index([listingId, sentAt])
618
+ @@index([workspaceId])
336
619
  @@map("listing_csv_sends")
337
620
  }
338
621
 
@@ -350,6 +633,14 @@ model ListingCsvSend {
350
633
  model FlowCheckout {
351
634
  id String @id @default(dbgenerated("('flchk_'::text || (uuidv7())::text)")) @db.Text
352
635
  siteId String @map("site_id")
636
+ // #6374 — the tenancy fence, extended to this model (spec
637
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
638
+ // Prisma relation directive (ADR-0009 invariant #1), and
639
+ // flow_checkouts_workspace_site_agree CHECK (workspace_id = site_id) — since
640
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
641
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
642
+ // value, never one from a request body (AC-6).
643
+ workspaceId String @map("workspace_id")
353
644
  flowSlug String @map("flow_slug")
354
645
  experimentKey String @map("experiment_key")
355
646
  sessionId String @map("session_id")
@@ -388,6 +679,7 @@ model FlowCheckout {
388
679
  @@index([siteId, experimentKey, status])
389
680
  // Onboarding's read: everything a lead bought.
390
681
  @@index([leadId])
682
+ @@index([workspaceId])
391
683
  @@map("flow_checkouts")
392
684
  }
393
685
 
@@ -430,6 +722,18 @@ model FlowCheckout {
430
722
  model ProjectListing {
431
723
  id String @id @default(dbgenerated("('prjl_'::text || (uuidv7())::text)")) @db.Text
432
724
  siteId String @map("site_id")
725
+ // #6203 — the isolation key `tenantDb(tenant).projectListings` filters on.
726
+ // Plain scalar, no @relation (ADR 0009 D1, same reasoning as ownerUserId
727
+ // below).
728
+ //
729
+ // #6254 AC-3/AC-4/AC-7 — NOT NULL as of
730
+ // database/migrations/20260922120000_tenancy_fence, which also normalizes
731
+ // `siteId` to the resolved workspace id and adds
732
+ // `project_listings_workspace_site_agree CHECK ("workspace_id" =
733
+ // "site_id")` — see `Lead.workspaceId` (top of database/inbox/schema.prisma)
734
+ // for the full rationale. Every write site derives this value
735
+ // server-side; never from a request body (AC-6).
736
+ workspaceId String @map("workspace_id")
433
737
  // #5036 — written at SUBMIT, not at start. The founder's contact details are
434
738
  // already captured on THIS row at step one, so a `Lead` at step one adds no
435
739
  // capture; what it does add is an operator notification email per abandoned
@@ -445,6 +749,10 @@ model ProjectListing {
445
749
  name String?
446
750
  oneLiner String? @map("one_liner")
447
751
  problem String? @db.Text
752
+ // #5096 — `problem`'s narrative other half ("how are you solving it?").
753
+ // OPT-IN, not required: it is absent from the platform's default
754
+ // `collectedFields` mask, so a site collects it only by naming it.
755
+ solution String? @db.Text
448
756
  url String?
449
757
  // #5036 D2 — the six-rung product ladder that replaced the four-value
450
758
  // `stage`. The migration remaps every old value by an explicit map; there
@@ -574,7 +882,7 @@ model ProjectListing {
574
882
  status String
575
883
  // #5036 D1/D6 — when the founder ticked step one's consent box. Never null:
576
884
  // a row cannot exist without it, because `start` requires it.
577
- consentAt DateTime @default(now()) @map("consent_at")
885
+ consentAt DateTime @default(now()) @map("consent_at") @db.Timestamptz(3)
578
886
  // #5036 D1 — sha-256 of the opaque resume token, never the token itself.
579
887
  // The token is the ONLY credential on an anonymous surface, so the column
580
888
  // holds a digest an attacker who reads the table still cannot present.
@@ -636,6 +944,64 @@ model ProjectListing {
636
944
  // a link nobody proved, which is the bug this column exists to remove.
637
945
  ownerUserId String? @map("owner_user_id")
638
946
  ownerLinkedAt DateTime? @map("owner_linked_at")
947
+ // #6314 — the listicle-kind `FlowDefinition` that CAPTURED this
948
+ // application: the magnet the visitor arrived through, so a magnet's own
949
+ // Applications list can be scoped to its own submissions rather than to the
950
+ // whole workspace (nothing constrains a workspace to one listicle magnet —
951
+ // `FlowDefinition` carries `@@unique([siteId, slug])` and no
952
+ // `@@unique([siteId, kind])`, so workspace-only scoping would conflate two).
953
+ //
954
+ // NOT `FlowDefinition.listingId` reversed: that column is written at
955
+ // APPROVAL time by `services/project-listing-flow.ts`, when an approved
956
+ // listing is projected into its own per-listing diagnostic sub-flow. A
957
+ // different link, at a different time, in the other direction.
958
+ //
959
+ // AN ID, NOT A SLUG (operator ruling 2026-09-19): a slug is a natural key
960
+ // `@@unique([siteId, slug])` implies is mutable, and `Lead.source`'s
961
+ // `'flow:' + slug` pattern is the weaker one, used there only because that
962
+ // write path has no resolve step. This one does — the wire carries a slug
963
+ // (no public surface hands a visitor a `flow_<uuidv7>`), and
964
+ // `startProjectListingDraft` resolves it server-side against `siteId` +
965
+ // `workspaceId` + `kind: "listicle"` before storing the id.
966
+ //
967
+ // PLAIN SCALAR REF — no `@relation`, no foreign key, no `onDelete`
968
+ // (operator decision 2026-09-20, withdrawing the ruling's "on-delete
969
+ // set-null" clause). Both models live in `inbox/`, so ADR 0009 invariant #1
970
+ // (which bans CROSS-namespace relations) does not reach this column and
971
+ // permission was never the question — the convention is: every
972
+ // intra-`inbox` reference on this model and its siblings (`leadId`,
973
+ // `listingId`, `grantId`, `flowSessionId`) is a plain scalar, and of the 24
974
+ // FOREIGN KEY constraints in `database/migrations/**` every one targets
975
+ // `auth`. The composite shape specifically was offered and declined on
976
+ // `ListingCsvSend` below.
977
+ //
978
+ // WHAT THAT GIVES UP, stated rather than argued away: the database does not
979
+ // constrain this to name a real magnet, nor one in the same workspace. Three
980
+ // things bound it — the resolve carries `siteId` and `kind`, so storing a
981
+ // foreign id needs the service layer to actively misbehave; nothing deletes
982
+ // a `FlowDefinition` (`routes/magnets/index.ts` D5: "No DELETE"), so
983
+ // `SET NULL` would have described no live code path; and the read carries
984
+ // the injected `workspaceId` alongside this id, so a dangling or foreign
985
+ // value yields NO rows, never another workspace's.
986
+ //
987
+ // WRITE-ONCE, at START. The magnet a visitor arrived through is knowable
988
+ // only on arrival — by submit, ten steps and possibly a resume-email
989
+ // round-trip later, the referring context is gone. `draftUpdateData` never
990
+ // learns this key, so no patch body can re-attribute a draft after the fact,
991
+ // the same shape `emailVerifiedAt`'s write-once stamp has.
992
+ //
993
+ // PERMANENTLY NULLABLE, and NOT a backfill target. Every row written before
994
+ // this column has no evidence naming the magnet that captured it — no
995
+ // referrer, no session link, no slug — so a backfill would have to guess
996
+ // "the workspace's only listicle magnet", which is exactly the conflation
997
+ // this column exists to remove, made permanent and unauditable. It is the
998
+ // reason `founder_name` parts (#5407), the address parts (#5279) and
999
+ // `owner_user_id` (#5295) each backfill nothing: a value that can only be
1000
+ // guessed must not be asserted. NULL means "not attributed" — never
1001
+ // "attribution failed" and never "captured directly": an application
1002
+ // started through a direct link AFTER this ships is NULL for the same
1003
+ // reason and is indistinguishable from a legacy row.
1004
+ capturedByFlowId String? @map("captured_by_flow_id")
639
1005
  createdAt DateTime @default(now()) @map("created_at")
640
1006
  updatedAt DateTime @updatedAt @map("updated_at")
641
1007
 
@@ -650,6 +1016,15 @@ model ProjectListing {
650
1016
  // design: the promotion match (`founderEmail` + `siteId` + null owner) is
651
1017
  // already served by the (site_id, founder_email) index above.
652
1018
  @@index([ownerUserId])
1019
+ // #6203 — backs a per-workspace list (tenantDb(tenant).projectListings.list).
1020
+ @@index([workspaceId])
1021
+ // #6314 — backs the per-magnet Applications read
1022
+ // (tenantDb(tenant).projectListings.listByCapturingFlow), whose WHERE is
1023
+ // exactly these two columns. Leading on `workspaceId`, matching the
1024
+ // `@@index([workspaceId])` convention the fence set on this table. ONE
1025
+ // index: no read reaches `capturedByFlowId` without a tenant, and with no
1026
+ // foreign key there is no cascade wanting a referencing-side index either.
1027
+ @@index([workspaceId, capturedByFlowId])
653
1028
  @@map("project_listings")
654
1029
  }
655
1030
 
@@ -673,6 +1048,14 @@ model ProjectListing {
673
1048
  model ConsentGrant {
674
1049
  id String @id @default(dbgenerated("('cnsnt_'::text || (uuidv7())::text)")) @db.Text
675
1050
  siteId String @map("site_id")
1051
+ // #6374 — the tenancy fence, extended to this model (spec
1052
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
1053
+ // Prisma relation directive (ADR-0009 invariant #1), and
1054
+ // consent_grants_workspace_site_agree CHECK (workspace_id = site_id) — since
1055
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
1056
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
1057
+ // value, never one from a request body (AC-6).
1058
+ workspaceId String @map("workspace_id")
676
1059
  subjectType String @map("subject_type")
677
1060
  subjectId String @map("subject_id")
678
1061
  purposeId String @map("purpose_id")
@@ -686,5 +1069,65 @@ model ConsentGrant {
686
1069
  @@index([siteId, subjectType, subjectId])
687
1070
  // "Who agreed to this purpose" — the cross-surface count the id exists for (D1/D2).
688
1071
  @@index([siteId, purposeId])
1072
+ @@index([workspaceId])
689
1073
  @@map("consent_grants")
690
1074
  }
1075
+
1076
+ /// Appointment — a Cal.com booking, normalized from the webhook receiver (#6085, brief H7).
1077
+ ///
1078
+ /// siteId-scoped with no FK, like every other inbox model (ADR 0009 D2). The
1079
+ /// (siteId, provider, providerBookingUid) unique constraint IS the idempotency
1080
+ /// key: every write is a guarded updateMany, and the create that follows a zero
1081
+ /// match races on THIS index — which is what makes "exactly one row per booking
1082
+ /// uid" a DATABASE property rather than a property of a process that may have
1083
+ /// restarted. `provider` is in the key because a deployment may one day run two
1084
+ /// providers against one receiver and their uid spaces do not coordinate (#4288).
1085
+ model Appointment {
1086
+ id String @id @default(dbgenerated("('appt_'::text || (uuidv7())::text)")) @db.Text
1087
+ siteId String @map("site_id") @db.VarChar(200)
1088
+ // #6374 — the tenancy fence, extended to this model (spec
1089
+ // docs/specs/6374-the-fence-schema-wave-spec.md D1). NOT NULL, no
1090
+ // Prisma relation directive (ADR-0009 invariant #1), and
1091
+ // appointments_workspace_site_agree CHECK (workspace_id = site_id) — since
1092
+ // siteId IS the resolved workspace id, Postgres itself rejects a row
1093
+ // whose two columns disagree. Every write site stamps a SERVER-RESOLVED
1094
+ // value, never one from a request body (AC-6).
1095
+ workspaceId String @map("workspace_id")
1096
+ /// Cal.com's own booking `uid` — stable across reschedules of the same booking.
1097
+ /// Bounded: a value above Postgres's btree entry limit cannot be indexed at all.
1098
+ providerBookingUid String @map("provider_booking_uid") @db.VarChar(200)
1099
+ /// Provider identifier, part of the unique key (#4288).
1100
+ provider String @default("cal") @db.VarChar(32)
1101
+ startTime DateTime @map("start_time")
1102
+ endTime DateTime @map("end_time")
1103
+ /// The event's own title as the provider stated it. PII-adjacent: never logged (NFR-1).
1104
+ title String? @db.VarChar(300)
1105
+ /// Free text typed by an anonymous prospect — bounded at the same 120/254 the
1106
+ /// outbound seam already bounds them at (packages/validation SchedulingAttendeeSchema).
1107
+ attendeeName String @map("attendee_name") @db.VarChar(120)
1108
+ attendeeEmail String @map("attendee_email") @db.VarChar(254)
1109
+ attendeeTimeZone String? @map("attendee_time_zone") @db.VarChar(64)
1110
+ /// Normalized lifecycle state — see § D5. One of: pending | confirmed | cancelled.
1111
+ status String @db.VarChar(32)
1112
+ /// The payment flag. BOOKING_PAID sets it true; nothing else in this issue reads it.
1113
+ paid Boolean @default(false)
1114
+ /// Set by BOOKING_PAYMENT_INITIATED; a checkout was opened on Cal's side.
1115
+ paymentInitiatedAt DateTime? @map("payment_initiated_at")
1116
+ /// Set by BOOKING_NO_SHOW_UPDATED.
1117
+ noShow Boolean @default(false) @map("no_show")
1118
+ cancelledAt DateTime? @map("cancelled_at")
1119
+ /// Three INDEPENDENT watermarks, one per event family (§ D7). One shared column
1120
+ /// forced a choice between dropping a late payment fact and letting a replayed
1121
+ /// one revert state; three columns make each family monotonic on its own terms.
1122
+ lifecycleEventAt DateTime @map("lifecycle_event_at")
1123
+ paymentEventAt DateTime? @map("payment_event_at")
1124
+ noShowEventAt DateTime? @map("no_show_event_at")
1125
+ createdAt DateTime @default(now()) @map("created_at")
1126
+ updatedAt DateTime @updatedAt @map("updated_at")
1127
+
1128
+ @@unique([siteId, provider, providerBookingUid])
1129
+ @@index([siteId, startTime])
1130
+ @@index([createdAt])
1131
+ @@index([workspaceId])
1132
+ @@map("appointments")
1133
+ }