create-ailk 0.18.0 → 0.19.1

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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -81,6 +81,18 @@ model Lead {
81
81
  // Backfilled by database/migrations/20261010121000_person_backfill.
82
82
  personId String? @map("person_id")
83
83
 
84
+ // #8185 D1 — the hub's conclusion, at write time, that this lead is a
85
+ // spoke's LIVE paid checkout: the submit carried a registered spoke's key,
86
+ // was not relayed for a visitor, and passed the #7877 checkout plan rule's
87
+ // own predicate (a `cs_live_` Idempotency-Key and the checkout shape).
88
+ // Written only by `apps/api/src/services/lead-routing.ts`'s submit create
89
+ // and never updated; read only by `apps/api/src/lib/member-store.ts`'s
90
+ // `listMemberPurchases`, which serves a marked lead as a purchase. A lead
91
+ // written before the column existed reads false: `viaSpoke` was never
92
+ // stored, so its provenance cannot be proven (no backfill). No index: the
93
+ // read filters by the already-indexed `personId`.
94
+ spokeCheckout Boolean @default(false) @map("spoke_checkout")
95
+
84
96
  // #7869 — the idempotency guarantee, scoped per workspace so a key can
85
97
  // never reach another tenant's row.
86
98
  @@unique([workspaceId, idempotencyKey])
@@ -115,19 +127,41 @@ model Lead {
115
127
  /// writer wins. `planOffering` is written by a later phase (Epic 2's
116
128
  /// checkout-lead rule); this slice only carries the column.
117
129
  model Person {
118
- id String @id @default(dbgenerated("('person_'::text || (uuidv7())::text)")) @db.Text
119
- workspaceId String @map("workspace_id")
120
- email String @db.VarChar(254)
121
- name String? @db.VarChar(200)
122
- userId String? @map("user_id")
123
- billingProvider String? @map("billing_provider") @db.VarChar(32)
124
- billingCustomerId String? @map("billing_customer_id") @db.VarChar(255)
125
- planOffering String? @map("plan_offering") @db.VarChar(100)
126
- createdAt DateTime @default(now()) @map("created_at")
127
- updatedAt DateTime @updatedAt @map("updated_at")
130
+ id String @id @default(dbgenerated("('person_'::text || (uuidv7())::text)")) @db.Text
131
+ workspaceId String @map("workspace_id")
132
+ email String @db.VarChar(254)
133
+ name String? @db.VarChar(200)
134
+ userId String? @map("user_id")
135
+ billingProvider String? @map("billing_provider") @db.VarChar(32)
136
+ billingCustomerId String? @map("billing_customer_id") @db.VarChar(255)
137
+ planOffering String? @map("plan_offering") @db.VarChar(100)
138
+ // #8435 D1 — contact details, to the standard (option A). Every column is
139
+ // nullable: a capture fills only a blank one (D4), an edit overwrites (D9,
140
+ // D11). The address is the magnet's six parts, stored whole or not at all
141
+ // (people_address_complete).
142
+ givenName String? @map("given_name") @db.VarChar(100)
143
+ familyName String? @map("family_name") @db.VarChar(100)
144
+ telephone String? @db.VarChar(16)
145
+ addressLine1 String? @map("address_line1") @db.VarChar(100)
146
+ addressLine2 String? @map("address_line2") @db.VarChar(60)
147
+ addressCity String? @map("address_city") @db.VarChar(60)
148
+ addressRegion String? @map("address_region") @db.VarChar(40)
149
+ addressPostalCode String? @map("address_postal_code") @db.VarChar(20)
150
+ addressCountry String? @map("address_country") @db.Char(2)
151
+ companyName String? @map("company_name") @db.VarChar(200)
152
+ jobTitle String? @map("job_title") @db.VarChar(100)
153
+ // Who last saved the contact by hand — "operator" (the Person page) or
154
+ // "client" (their account) — and when. NULL until a human saves it; while
155
+ // NULL and `userId` is NULL, a capture may fill a blank field (D4).
156
+ contactConfirmedAt DateTime? @map("contact_confirmed_at")
157
+ contactConfirmedBy String? @map("contact_confirmed_by") @db.VarChar(16)
158
+ createdAt DateTime @default(now()) @map("created_at")
159
+ updatedAt DateTime @updatedAt @map("updated_at")
128
160
 
129
161
  pieces Piece[]
130
162
  interactions Interaction[]
163
+ callbacks Callback[]
164
+ subscribers Subscriber[]
131
165
 
132
166
  @@unique([workspaceId, email])
133
167
  @@unique([workspaceId, userId])
@@ -144,36 +178,92 @@ model Person {
144
178
  /// tenantDb(tenant).memberArea; read by a client only through
145
179
  /// lib/member-store.ts. `stage` is one of the workspace's declared stages
146
180
  /// (WorkspacePieceStages), checked at write time, never an enum.
181
+ ///
182
+ /// #8168 D5 — `approvedAt` is set in the same transaction as an `approved`
183
+ /// review of the latest PieceVersion and freezes review: once set, no further
184
+ /// review lands. Approval is a piece-level fact; the version's own `review`
185
+ /// records which version was approved.
147
186
  model Piece {
148
- id String @id @default(dbgenerated("('piece_'::text || (uuidv7())::text)")) @db.Text
149
- workspaceId String @map("workspace_id")
150
- personId String @map("person_id")
151
- title String @db.VarChar(200)
152
- stage String @db.VarChar(40)
153
- needsClient Boolean @default(false) @map("needs_client")
154
- updatedBy String @map("updated_by")
155
- createdAt DateTime @default(now()) @map("created_at")
156
- updatedAt DateTime @updatedAt @map("updated_at")
187
+ id String @id @default(dbgenerated("('piece_'::text || (uuidv7())::text)")) @db.Text
188
+ workspaceId String @map("workspace_id")
189
+ personId String @map("person_id")
190
+ title String @db.VarChar(200)
191
+ stage String @db.VarChar(40)
192
+ needsClient Boolean @default(false) @map("needs_client")
193
+ updatedBy String @map("updated_by")
194
+ approvedAt DateTime? @map("approved_at")
195
+ createdAt DateTime @default(now()) @map("created_at")
196
+ updatedAt DateTime @updatedAt @map("updated_at")
157
197
 
158
- person Person @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "pieces_person_fk")
198
+ person Person @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "pieces_person_fk")
199
+ versions PieceVersion[]
159
200
 
160
201
  @@index([workspaceId, personId, createdAt])
202
+ // #8168 D1 — redundant with the PK, load-bearing as the target of
203
+ // piece_versions_piece_fk: a version names a piece of THIS Person in THIS
204
+ // workspace, as one database fact.
205
+ @@unique([id, personId, workspaceId])
161
206
  @@map("pieces")
162
207
  }
163
208
 
164
- /// Interaction — one message on a client's record (#7877): the original CRM
165
- /// spec's Interaction cut to one channel and one direction. An inbound reply
166
- /// is a later second `direction` value on this table. `readAt` is when a
167
- /// member read FIRST served the row to the client's spoke — a delivery
168
- /// stamp, not proof a human read it.
209
+ /// PieceVersion — one delivery of a piece to its client (#8168 D1, D2): a
210
+ /// file (`assetId`, a private library asset of the same workspace) or a link
211
+ /// (`link`, https only), never both, numbered from 1 per piece. The client's
212
+ /// review of it (`review`, `reviewComment`, `reviewedAt`) is recorded on the
213
+ /// version itself. There is NO round count, limit or remaining-count column,
214
+ /// by design (#8140 disposition 3).
215
+ ///
216
+ /// Its one relation is the three-column FK onto `pieces(id, person_id,
217
+ /// workspace_id)`, so the piece, its Person and the workspace are all one
218
+ /// database fact. `assetId` and `deliveredBy` name rows of other namespaces:
219
+ /// plain columns with format CHECKs, no foreign key (ADR 0009).
220
+ model PieceVersion {
221
+ id String @id @default(dbgenerated("('pver_'::text || (uuidv7())::text)")) @db.Text
222
+ workspaceId String @map("workspace_id")
223
+ personId String @map("person_id")
224
+ pieceId String @map("piece_id")
225
+ version Int
226
+ assetId String? @map("asset_id")
227
+ link String? @db.VarChar(2048)
228
+ note String? @db.Text
229
+ deliveredBy String @map("delivered_by")
230
+ deliveredAt DateTime @default(now()) @map("delivered_at")
231
+ review String? @db.VarChar(24)
232
+ reviewComment String? @map("review_comment") @db.Text
233
+ reviewedAt DateTime? @map("reviewed_at")
234
+
235
+ piece Piece @relation(fields: [pieceId, personId, workspaceId], references: [id, personId, workspaceId], onDelete: Restrict, map: "piece_versions_piece_fk")
236
+
237
+ @@unique([pieceId, version])
238
+ // A client's artifacts: workspace_id = $1 AND person_id = $2 ORDER BY delivered_at DESC.
239
+ @@index([workspaceId, personId, deliveredAt])
240
+ // The public asset read's "is this asset a version file" check (AC-MAA-21).
241
+ @@index([assetId])
242
+ @@map("piece_versions")
243
+ }
244
+
245
+ /// Interaction — one entry on a client's record (#7877): the original CRM
246
+ /// spec's Interaction cut to one channel. `readAt` is when a member read
247
+ /// FIRST served the row to the client's spoke — a delivery stamp, not proof a
248
+ /// human read it.
249
+ ///
250
+ /// #8168 D3/D4 — `direction` is `outbound` (an operator's message, #7877) or
251
+ /// `inbound` (a client's own write); `kind` is `message`, `quote` or
252
+ /// `review`. An outbound row is always a `message` with no `payload`
253
+ /// (`interactions_outbound_shape`); a `quote` or `review` row carries the
254
+ /// stored payload `packages/validation`'s member.ts types. Every existing
255
+ /// reader filters to `direction: "outbound"`, so an inbound row is invisible
256
+ /// to them until a reader opts in.
169
257
  model Interaction {
170
258
  id String @id @default(dbgenerated("('intx_'::text || (uuidv7())::text)")) @db.Text
171
259
  workspaceId String @map("workspace_id")
172
260
  personId String @map("person_id")
173
261
  channel String @db.VarChar(16)
174
262
  direction String @db.VarChar(16)
263
+ kind String @default("message") @db.VarChar(16)
175
264
  subject String @db.VarChar(200)
176
265
  body String @db.Text
266
+ payload Json?
177
267
  assetId String? @map("asset_id")
178
268
  createdBy String @map("created_by")
179
269
  createdAt DateTime @default(now()) @map("created_at")
@@ -201,9 +291,14 @@ model Subscriber {
201
291
  email String
202
292
  source String
203
293
  createdAt DateTime @default(now()) @map("created_at")
294
+ // #8435 D1/D5 — the Person this subscription belongs to; composite FK onto
295
+ // people(id, workspace_id), the #7877 shape. NULL is not checked.
296
+ personId String? @map("person_id")
297
+ person Person? @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "subscribers_person_fk")
204
298
 
205
299
  @@unique([siteId, email])
206
300
  @@index([siteId])
301
+ @@index([personId])
207
302
  @@index([workspaceId])
208
303
  @@map("subscribers")
209
304
  }
@@ -270,7 +365,14 @@ model Callback {
270
365
  /// that update IS the durable idempotency key.
271
366
  paidAt DateTime? @map("paid_at")
272
367
 
368
+ // #8435 D1/D5 — the Person this request belongs to (its payload.email, or
369
+ // the buyer email at confirm). Composite FK onto people(id, workspace_id):
370
+ // "a Person of the same workspace" is a database fact. NULL is not checked.
371
+ personId String? @map("person_id")
372
+ person Person? @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "callbacks_person_fk")
373
+
273
374
  @@index([siteId])
375
+ @@index([personId])
274
376
  @@index([createdAt])
275
377
  @@index([stripePaymentLinkId])
276
378
  @@index([stripeInvoiceId])
@@ -338,6 +440,13 @@ model FlowDefinition {
338
440
  // across the namespace boundary, so this is a plain string — but a stated
339
441
  // one rather than a coincidence.
340
442
  listingId String? @map("listing_id")
443
+ /// #8168 D7 — whether a signed-in client may submit this magnet from the
444
+ /// member area. A column, not a config block, because a spoke seed refuses
445
+ /// non-seedable config (#7876); `upsertFlowDefinition` never names it, so a
446
+ /// seed never changes it. Independent of `status`: the seeded
447
+ /// `request-quote` magnet is `draft` (never served anonymously) with
448
+ /// `customersEnabled = true`.
449
+ customersEnabled Boolean @default(false) @map("customers_enabled")
341
450
  createdAt DateTime @default(now()) @map("created_at")
342
451
  updatedAt DateTime @updatedAt @map("updated_at")
343
452
 
@@ -1168,6 +1277,18 @@ model ConsentGrant {
1168
1277
  locale String
1169
1278
  statement String @db.Text
1170
1279
  statementHash String @map("statement_hash")
1280
+ // #8438 D2 — the Person this row counts toward. A plain scalar with no
1281
+ // foreign key (this table's own D10 posture): a row about a visitor or a
1282
+ // listing has none, and a ledger row must outlive anything it names.
1283
+ personId String? @map("person_id")
1284
+ // #8438 D2 — where the answer came from (CONSENT_SOURCES). NOT NULL with no
1285
+ // default after the migration: every new row must say. Rows stored before
1286
+ // this column read `legacy`.
1287
+ source String @db.Text
1288
+ // The magnet slug when `source` is `magnet`.
1289
+ sourceRef String? @map("source_ref") @db.VarChar(200)
1290
+ // The registry's policy version the person read. NULL only for `legacy` rows.
1291
+ policyVersion Int? @map("policy_version")
1171
1292
  createdAt DateTime @default(now()) @map("created_at")
1172
1293
 
1173
1294
  // "What did this subject agree to" — every row for one subject, newest last.
@@ -1175,9 +1296,29 @@ model ConsentGrant {
1175
1296
  // "Who agreed to this purpose" — the cross-surface count the id exists for (D1/D2).
1176
1297
  @@index([siteId, purposeId])
1177
1298
  @@index([workspaceId])
1299
+ // #8438 D2 — "current state per purpose" for one Person (DISTINCT ON).
1300
+ @@index([workspaceId, personId, purposeId, createdAt])
1178
1301
  @@map("consent_grants")
1179
1302
  }
1180
1303
 
1304
+ // #8438 D2 — links a banner visitor (a random browser-held id) to the Person a
1305
+ // later form or sign-in identifies, so the Person's cookie history can include
1306
+ // the visitor's own rows without ever editing them. Append-only like the
1307
+ // ledger: a database trigger refuses any change to a stored row. No relation
1308
+ // directive (ADR-0009 invariant #1); no IP, user-agent or device column.
1309
+ model ConsentVisitorLink {
1310
+ id String @id @default(dbgenerated("('cvlink_'::text || (uuidv7())::text)")) @db.Text
1311
+ workspaceId String @map("workspace_id")
1312
+ visitorId String @map("visitor_id")
1313
+ personId String @map("person_id")
1314
+ source String @db.Text
1315
+ createdAt DateTime @default(now()) @map("created_at")
1316
+
1317
+ @@unique([workspaceId, visitorId, personId])
1318
+ @@index([workspaceId, personId])
1319
+ @@map("consent_visitor_links")
1320
+ }
1321
+
1181
1322
  /// Appointment — a Cal.com booking, normalized from the webhook receiver (#6085, brief H7).
1182
1323
  ///
1183
1324
  /// siteId-scoped with no FK, like every other inbox model (ADR 0009 D2). The
@@ -0,0 +1,192 @@
1
+ -- #8168 — the member area data model (spec
2
+ -- docs/specs/8168-member-area-data-model-spec.md D1–D3, D5, D7–D9, D12).
3
+ --
4
+ -- piece_versions (inbox) — one delivery of a piece to its client:
5
+ -- a private library file OR an https
6
+ -- link, numbered from 1 per piece, with
7
+ -- the client's review recorded on it.
8
+ -- NO round count, limit or remaining
9
+ -- column (#8140 disposition 3).
10
+ -- pieces.approved_at — set with an `approved` review; freezes
11
+ -- review (D5).
12
+ -- pieces_id_person_id_workspace_id_key — a unique index over a triple the
13
+ -- PK already makes unique: the target of
14
+ -- piece_versions_piece_fk, so "a piece of
15
+ -- THIS Person in THIS workspace" is one
16
+ -- database fact (D1).
17
+ -- interactions.kind / .payload — `message` · `quote` · `review`, and the
18
+ -- stored payload of a quote or review;
19
+ -- `inbound` joins `outbound` (D3). An
20
+ -- outbound row stays a payload-free
21
+ -- `message` — #7877's rows, unchanged.
22
+ -- flow_definitions.customers_enabled — whether a signed-in client may submit
23
+ -- the magnet from the member area (D7).
24
+ -- the `request-quote` magnet — one draft `contact` magnet with
25
+ -- customers_enabled = true, backfilled
26
+ -- into EVERY existing workspace (D8).
27
+ --
28
+ -- The only new foreign key stays inside the inbox namespace (ADR 0009
29
+ -- invariant 1). asset_id and delivered_by name rows of other namespaces:
30
+ -- plain columns with format CHECKs, no foreign key. The backfill reads
31
+ -- "workspaces" (auth namespace) in SQL only — no relation.
32
+ --
33
+ -- ADDITIVE: every ADD COLUMN is nullable or carries a constant default, so no
34
+ -- existing row fails a new CHECK — every existing interaction is an outbound
35
+ -- `message` with no payload, which the replaced and added CHECKs admit
36
+ -- (interactions_body_length keeps its 1–10000 rule for a `message`). The
37
+ -- unique index on "pieces" builds under the file's transaction, holding a
38
+ -- SHARE lock on "pieces" for one scan of a table that is a few rows per
39
+ -- client. The backfill inserts one row per workspace and NEVER overwrites a
40
+ -- workspace's existing (site_id, slug) row (ON CONFLICT DO NOTHING) —
41
+ -- re-running the block adds nothing.
42
+ --
43
+ -- Rollout: deploy this migration BEFORE any code that writes piece_versions,
44
+ -- pieces.approved_at, an inbound interaction, interactions.kind/payload or
45
+ -- flow_definitions.customers_enabled. Code written before it never names the
46
+ -- new columns, so it keeps working against the migrated database.
47
+ --
48
+ -- Rollback (safe while nothing but #8168's siblings use the new surface):
49
+ -- DELETE FROM "flow_definitions" WHERE "slug" = 'request-quote' AND "status" = 'draft' AND "customers_enabled";
50
+ -- ALTER TABLE "flow_definitions" DROP COLUMN "customers_enabled";
51
+ -- DELETE FROM "interactions" WHERE "direction" = 'inbound';
52
+ -- ALTER TABLE "interactions" DROP CONSTRAINT "interactions_kind_allowed",
53
+ -- DROP CONSTRAINT "interactions_outbound_shape", DROP CONSTRAINT "interactions_payload_by_kind",
54
+ -- DROP CONSTRAINT "interactions_payload_size", DROP CONSTRAINT "interactions_body_length",
55
+ -- DROP CONSTRAINT "interactions_direction_allowed";
56
+ -- ALTER TABLE "interactions" DROP COLUMN "kind", DROP COLUMN "payload";
57
+ -- then re-add #7877's interactions_direction_allowed (direction = 'outbound')
58
+ -- and interactions_body_length (1–10000 once trimmed) from
59
+ -- 20261011120000_member_area;
60
+ -- DROP TABLE "piece_versions"; DROP INDEX "pieces_id_person_id_workspace_id_key";
61
+ -- ALTER TABLE "pieces" DROP COLUMN "approved_at";
62
+ -- The first DELETE also removes an operator's own edits to a still-draft
63
+ -- seeded magnet; check for those before rolling back.
64
+
65
+ -- AlterTable
66
+ ALTER TABLE "flow_definitions" ADD COLUMN "customers_enabled" BOOLEAN NOT NULL DEFAULT false;
67
+
68
+ -- AlterTable
69
+ -- Existing rows and #7877's writer (which names no kind) stay `message`.
70
+ ALTER TABLE "interactions" ADD COLUMN "kind" VARCHAR(16) NOT NULL DEFAULT 'message',
71
+ ADD COLUMN "payload" JSONB;
72
+
73
+ -- AlterTable
74
+ ALTER TABLE "pieces" ADD COLUMN "approved_at" TIMESTAMP(3);
75
+
76
+ -- CreateTable
77
+ CREATE TABLE "piece_versions" (
78
+ "id" TEXT NOT NULL DEFAULT ('pver_'::text || (uuidv7())::text),
79
+ "workspace_id" TEXT NOT NULL,
80
+ "person_id" TEXT NOT NULL,
81
+ "piece_id" TEXT NOT NULL,
82
+ "version" INTEGER NOT NULL,
83
+ "asset_id" TEXT,
84
+ "link" VARCHAR(2048),
85
+ "note" TEXT,
86
+ "delivered_by" TEXT NOT NULL,
87
+ "delivered_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
88
+ "review" VARCHAR(24),
89
+ "review_comment" TEXT,
90
+ "reviewed_at" TIMESTAMP(3),
91
+
92
+ CONSTRAINT "piece_versions_pkey" PRIMARY KEY ("id")
93
+ );
94
+
95
+ -- CreateIndex
96
+ -- A client's artifacts: workspace_id = $1 AND person_id = $2 ORDER BY delivered_at DESC.
97
+ CREATE INDEX "piece_versions_workspace_id_person_id_delivered_at_idx" ON "piece_versions"("workspace_id", "person_id", "delivered_at");
98
+
99
+ -- CreateIndex
100
+ -- The public asset read's "is this asset a version file" check (AC-MAA-21).
101
+ CREATE INDEX "piece_versions_asset_id_idx" ON "piece_versions"("asset_id");
102
+
103
+ -- CreateIndex
104
+ -- A version number is a fact per piece; the writer numbers, this is the backstop.
105
+ CREATE UNIQUE INDEX "piece_versions_piece_id_version_key" ON "piece_versions"("piece_id", "version");
106
+
107
+ -- CreateIndex
108
+ -- Redundant with the PK; piece_versions_piece_fk needs it as its target.
109
+ CREATE UNIQUE INDEX "pieces_id_person_id_workspace_id_key" ON "pieces"("id", "person_id", "workspace_id");
110
+
111
+ -- AddForeignKey
112
+ -- A version names a piece of THIS Person in THIS workspace (the piece's own
113
+ -- FK already ties it to a Person of its workspace); deleting a piece with a
114
+ -- version is refused.
115
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_piece_fk" FOREIGN KEY ("piece_id", "person_id", "workspace_id") REFERENCES "pieces"("id", "person_id", "workspace_id") ON DELETE RESTRICT ON UPDATE CASCADE;
116
+
117
+ -- ADR-0031 rule 3 — piece_versions: pver_ (registered in
118
+ -- packages/ids/src/registry.ts; this migration's path is added to
119
+ -- migration-sync.test.ts's PK_CHECK_MIGRATIONS). Each reference column
120
+ -- carries the format CHECK of the entity it names, in the #7877 shape.
121
+ ALTER TABLE "piece_versions" ALTER COLUMN "id" TYPE text COLLATE "C";
122
+ ALTER TABLE "piece_versions" ALTER COLUMN "id" SET DEFAULT ('pver_'::text || (uuidv7())::text);
123
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_id_format" CHECK ("id" ~ '^pver_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
124
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_workspace_id_format" CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
125
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_person_id_format" CHECK ("person_id" ~ '^person_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
126
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_piece_id_format" CHECK ("piece_id" ~ '^piece_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
127
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_asset_id_format" CHECK ("asset_id" ~ '^asset_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
128
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_delivered_by_format" CHECK ("delivered_by" ~ '^user_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
129
+
130
+ -- D2 — a version's shape is held here, not by the writer alone.
131
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_version_positive"
132
+ CHECK ("version" >= 1);
133
+ -- A delivery is a file or a link: exactly one.
134
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_one_target"
135
+ CHECK (("asset_id" IS NULL) <> ("link" IS NULL));
136
+ -- An https scheme, a host with no userinfo, no whitespace anywhere — the
137
+ -- database floor under packages/validation's isHttpsLink (SEC-8168-5).
138
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_link_https"
139
+ CHECK ("link" IS NULL OR ("link" ~ '^https://[^[:space:]/?#@]+([/?#][^[:space:]]*)?$' AND char_length("link") <= 2048));
140
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_note_length"
141
+ CHECK ("note" IS NULL OR char_length(btrim("note")) BETWEEN 1 AND 2000);
142
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_review_allowed"
143
+ CHECK ("review" IS NULL OR "review" IN ('approved', 'changes-requested'));
144
+ -- A review and its timestamp are set together or not at all.
145
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_review_stamped"
146
+ CHECK (("review" IS NULL) = ("reviewed_at" IS NULL));
147
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_comment_needs_review"
148
+ CHECK ("review_comment" IS NULL OR "review" IS NOT NULL);
149
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_changes_need_comment"
150
+ CHECK ("review" IS DISTINCT FROM 'changes-requested' OR char_length(btrim(coalesce("review_comment", ''))) >= 1);
151
+ ALTER TABLE "piece_versions" ADD CONSTRAINT "piece_versions_comment_length"
152
+ CHECK ("review_comment" IS NULL OR char_length("review_comment") <= 5000);
153
+
154
+ -- D3 — the second direction and the kinds. Replaced IN PLACE under the same
155
+ -- names, so #7877's tests keep naming the same constraints.
156
+ ALTER TABLE "interactions" DROP CONSTRAINT "interactions_direction_allowed";
157
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_direction_allowed"
158
+ CHECK ("direction" IN ('outbound', 'inbound'));
159
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_kind_allowed"
160
+ CHECK ("kind" IN ('message', 'quote', 'review'));
161
+ -- An outbound row is a payload-free `message` — #7877's rows, unchanged.
162
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_outbound_shape"
163
+ CHECK ("direction" <> 'outbound' OR ("kind" = 'message' AND "payload" IS NULL));
164
+ -- A quote or a review always carries its stored payload.
165
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_payload_by_kind"
166
+ CHECK ("kind" = 'message' OR "payload" IS NOT NULL);
167
+ -- 64 items of a 200-character label and a 2000-character value fit with room (SEC-8168-4).
168
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_payload_size"
169
+ CHECK ("payload" IS NULL OR octet_length("payload"::text) <= 262144);
170
+ -- A message body stays 1–10000 characters once trimmed; a quote or review
171
+ -- body may be empty; no body exceeds 10000.
172
+ ALTER TABLE "interactions" DROP CONSTRAINT "interactions_body_length";
173
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_body_length"
174
+ CHECK (
175
+ char_length("body") <= 10000
176
+ AND ("kind" <> 'message' OR char_length(btrim("body")) BETWEEN 1 AND 10000)
177
+ );
178
+
179
+ -- D8/D9 — the request-quote magnet in every existing workspace. The config
180
+ -- literal below is test-held to `requestQuoteFlowConfig`
181
+ -- (packages/validation/src/schemas/flow.ts) by
182
+ -- database/__tests__/member-contract-migration.test.ts, and the block is
183
+ -- re-executed verbatim by member-contract.db.test.ts to prove it idempotent.
184
+ -- Do not edit one without the other.
185
+ -- BEGIN request-quote backfill
186
+ INSERT INTO "flow_definitions" ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "updated_at")
187
+ SELECT w."id", w."id", 'request-quote',
188
+ '{"slug":"request-quote","defaultLocale":"en","steps":[{"key":"request","type":"field","copy":{"title":{"en":"Request a quote"}},"fields":[{"name":"need","inputType":"textarea","required":true,"copy":{"label":{"en":"What do you need?"}}},{"name":"timing","inputType":"text","copy":{"label":{"en":"When do you need it?"}}},{"name":"budget","inputType":"text","copy":{"label":{"en":"Budget range"},"help":{"en":"A rough range is fine."}}},{"name":"link","inputType":"url","copy":{"label":{"en":"Link"},"help":{"en":"A brief, a document or an example. Must start with https://."}}}]}]}'::jsonb,
189
+ 'contact', 'Request a quote', 'draft', true, CURRENT_TIMESTAMP
190
+ FROM "workspaces" w
191
+ ON CONFLICT ("site_id", "slug") DO NOTHING;
192
+ -- END request-quote backfill
@@ -0,0 +1,20 @@
1
+ -- #8185 — record, at the moment the hub writes it, that a lead is a spoke's
2
+ -- live paid checkout (spec docs/specs/8185-spoke-checkout-purchases-spec.md
3
+ -- D1, D6, D10).
4
+ --
5
+ -- leads.spoke_checkout — set true by apps/api/src/services/lead-routing.ts
6
+ -- only for a spoke-keyed, unrelayed submit passing
7
+ -- the #7877 checkout plan rule's predicate. Written
8
+ -- at creation, never updated. NOT NULL with a
9
+ -- constant default, so existing rows read false and
10
+ -- nothing is rewritten; they are deliberately NOT
11
+ -- backfilled (D6: no stored fact proves an older
12
+ -- lead's provenance).
13
+ --
14
+ -- Rollout order (D10): apply this migration to a deployment's database BEFORE
15
+ -- deploying the API that writes the column. The old API is unaffected by it;
16
+ -- the new API's marked create fails without it.
17
+ --
18
+ -- Rollback: ALTER TABLE "leads" DROP COLUMN "spoke_checkout";
19
+
20
+ ALTER TABLE "leads" ADD COLUMN "spoke_checkout" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,122 @@
1
+ -- #8438 — the consent ledger (spec docs/specs/8438-consent-registry-and-ledger-step-spec.md D2).
2
+ --
3
+ -- consent_grants (inbox) — the existing append-only table (#5275)
4
+ -- becomes THE ledger. It gains the Person a
5
+ -- row counts toward, the source it came
6
+ -- from, the magnet slug, and the policy
7
+ -- version read. Every row stored before this
8
+ -- migration reads source = 'legacy' with no
9
+ -- policy version, honestly: those rows
10
+ -- recorded neither. Every row written after
11
+ -- it must name its source and version.
12
+ -- consent_visitor_links (inbox) — a banner visitor id linked to the Person a
13
+ -- later form or sign-in identifies. A second
14
+ -- append-only table, so a visitor's own rows
15
+ -- are never edited to point at a Person.
16
+ --
17
+ -- Both tables are APPEND-ONLY, and now the database says so: a trigger refuses
18
+ -- any change to a stored row and any truncation. The triggers bind the
19
+ -- application's database role; a table owner can still disable them. A later
20
+ -- migration that must rewrite or erase rows disables them explicitly inside its
21
+ -- own transaction, which makes the act a reviewed migration rather than
22
+ -- something application code can do.
23
+ --
24
+ -- person_id is a plain scalar with no foreign key, the table's own posture
25
+ -- (ADR 0009 invariant 1, #5275 D10): a row about a visitor or a listing has no
26
+ -- Person, and a ledger row must outlive anything it names. Neither table
27
+ -- carries any network or browser evidence column (spec section 2.3).
28
+ --
29
+ -- ADDITIVE: ADD COLUMN with a constant default, then the default dropped, so no
30
+ -- existing row fails a new CHECK. Deploy this migration BEFORE any code that
31
+ -- writes the new columns; code written before it never names them.
32
+ --
33
+ -- Rollback (safe while nothing but #8438 writes the new surface):
34
+ -- DROP TABLE "consent_visitor_links";
35
+ -- ALTER TABLE "consent_grants" DISABLE TRIGGER USER;
36
+ -- ALTER TABLE "consent_grants"
37
+ -- DROP COLUMN "person_id", DROP COLUMN "source", DROP COLUMN "source_ref", DROP COLUMN "policy_version";
38
+ -- ALTER TABLE "consent_grants" ENABLE TRIGGER USER; -- after dropping the three triggers and the function
39
+ -- DROP FUNCTION "consent_ledger_append_only"();
40
+
41
+ -- AlterTable
42
+ -- source: every row already stored reads 'legacy'; the default is dropped
43
+ -- straight after, so every new row has to say where it came from.
44
+ ALTER TABLE "consent_grants" ADD COLUMN "person_id" TEXT,
45
+ ADD COLUMN "source" TEXT NOT NULL DEFAULT 'legacy',
46
+ ADD COLUMN "source_ref" VARCHAR(200),
47
+ ADD COLUMN "policy_version" INTEGER;
48
+ ALTER TABLE "consent_grants" ALTER COLUMN "source" DROP DEFAULT;
49
+
50
+ -- CreateIndex
51
+ -- "Current state per purpose" for one Person: DISTINCT ON (purpose_id) over
52
+ -- workspace_id = $1 AND person_id = $2 ORDER BY purpose_id, created_at DESC.
53
+ CREATE INDEX "consent_grants_workspace_id_person_id_purpose_id_created_at_idx" ON "consent_grants"("workspace_id", "person_id", "purpose_id", "created_at");
54
+
55
+ -- The source vocabulary is packages/validation CONSENT_SOURCES. 'legacy' is
56
+ -- read-only in practice: only the default above ever writes it.
57
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_source_known"
58
+ CHECK ("source" IN ('cookie_banner', 'sign_up', 'magnet', 'account_settings', 'email_unsubscribe', 'api', 'legacy'));
59
+ -- A new row records the policy version it was read at; only a legacy row may not.
60
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_policy_version_present"
61
+ CHECK ("source" = 'legacy' OR "policy_version" IS NOT NULL);
62
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_policy_version_positive"
63
+ CHECK ("policy_version" IS NULL OR "policy_version" >= 1);
64
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_person_id_format"
65
+ CHECK ("person_id" IS NULL OR "person_id" ~ '^person_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
66
+
67
+ -- CreateTable
68
+ CREATE TABLE "consent_visitor_links" (
69
+ "id" TEXT NOT NULL DEFAULT ('cvlink_'::text || (uuidv7())::text),
70
+ "workspace_id" TEXT NOT NULL,
71
+ "visitor_id" TEXT NOT NULL,
72
+ "person_id" TEXT NOT NULL,
73
+ "source" TEXT NOT NULL,
74
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
75
+
76
+ CONSTRAINT "consent_visitor_links_pkey" PRIMARY KEY ("id")
77
+ );
78
+
79
+ -- CreateIndex
80
+ -- A repeat link is a no-op: the writer inserts and ignores a conflict here.
81
+ CREATE UNIQUE INDEX "consent_visitor_links_workspace_id_visitor_id_person_id_key" ON "consent_visitor_links"("workspace_id", "visitor_id", "person_id");
82
+
83
+ -- CreateIndex
84
+ -- A Person's linked visitors: workspace_id = $1 AND person_id = $2.
85
+ CREATE INDEX "consent_visitor_links_workspace_id_person_id_idx" ON "consent_visitor_links"("workspace_id", "person_id");
86
+
87
+ -- ADR-0031 rule 3 — consent_visitor_links: cvlink_ (registered in
88
+ -- packages/ids/src/registry.ts ENTITY_PREFIX.consentVisitorLink; this
89
+ -- migration's path is added to migration-sync.test.ts's PK_CHECK_MIGRATIONS).
90
+ -- Each reference column carries the format CHECK of the entity it names.
91
+ ALTER TABLE "consent_visitor_links" ALTER COLUMN "id" TYPE text COLLATE "C";
92
+ ALTER TABLE "consent_visitor_links" ALTER COLUMN "id" SET DEFAULT ('cvlink_'::text || (uuidv7())::text);
93
+ ALTER TABLE "consent_visitor_links" ADD CONSTRAINT "consent_visitor_links_id_format" CHECK ("id" ~ '^cvlink_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
94
+ ALTER TABLE "consent_visitor_links" ADD CONSTRAINT "consent_visitor_links_workspace_id_format" CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
95
+ ALTER TABLE "consent_visitor_links" ADD CONSTRAINT "consent_visitor_links_person_id_format" CHECK ("person_id" ~ '^person_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
96
+ -- A visitor id is a random version-4 UUID the site's own server minted.
97
+ ALTER TABLE "consent_visitor_links" ADD CONSTRAINT "consent_visitor_links_visitor_id_format" CHECK ("visitor_id" ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
98
+ ALTER TABLE "consent_visitor_links" ADD CONSTRAINT "consent_visitor_links_source_known"
99
+ CHECK ("source" IN ('magnet', 'api', 'account_settings'));
100
+
101
+ -- SEC-8438-7 — append-only, enforced. One function, three triggers per table.
102
+ CREATE FUNCTION "consent_ledger_append_only"() RETURNS trigger
103
+ LANGUAGE plpgsql AS $$
104
+ BEGIN
105
+ RAISE EXCEPTION '% is append-only', TG_TABLE_NAME
106
+ USING ERRCODE = 'restrict_violation';
107
+ END;
108
+ $$;
109
+
110
+ CREATE TRIGGER "consent_grants_no_update" BEFORE UPDATE ON "consent_grants"
111
+ FOR EACH ROW EXECUTE FUNCTION "consent_ledger_append_only"();
112
+ CREATE TRIGGER "consent_grants_no_delete" BEFORE DELETE ON "consent_grants"
113
+ FOR EACH ROW EXECUTE FUNCTION "consent_ledger_append_only"();
114
+ CREATE TRIGGER "consent_grants_no_truncate" BEFORE TRUNCATE ON "consent_grants"
115
+ FOR EACH STATEMENT EXECUTE FUNCTION "consent_ledger_append_only"();
116
+
117
+ CREATE TRIGGER "consent_visitor_links_no_update" BEFORE UPDATE ON "consent_visitor_links"
118
+ FOR EACH ROW EXECUTE FUNCTION "consent_ledger_append_only"();
119
+ CREATE TRIGGER "consent_visitor_links_no_delete" BEFORE DELETE ON "consent_visitor_links"
120
+ FOR EACH ROW EXECUTE FUNCTION "consent_ledger_append_only"();
121
+ CREATE TRIGGER "consent_visitor_links_no_truncate" BEFORE TRUNCATE ON "consent_visitor_links"
122
+ FOR EACH STATEMENT EXECUTE FUNCTION "consent_ledger_append_only"();