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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- 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
|
|
119
|
-
workspaceId
|
|
120
|
-
email
|
|
121
|
-
name
|
|
122
|
-
userId
|
|
123
|
-
billingProvider
|
|
124
|
-
billingCustomerId
|
|
125
|
-
planOffering
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
149
|
-
workspaceId String
|
|
150
|
-
personId String
|
|
151
|
-
title String
|
|
152
|
-
stage String
|
|
153
|
-
needsClient Boolean
|
|
154
|
-
updatedBy String
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
///
|
|
165
|
-
///
|
|
166
|
-
///
|
|
167
|
-
///
|
|
168
|
-
///
|
|
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"();
|