create-ailk 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -63,6 +63,27 @@ model Lead {
|
|
|
63
63
|
promotedUserId String? @map("promoted_user_id")
|
|
64
64
|
promotedAt DateTime? @map("promoted_at")
|
|
65
65
|
|
|
66
|
+
// #7869 (roadmap D5) — the caller's `Idempotency-Key` header on
|
|
67
|
+
// `POST /v1/leads/submit`, stored verbatim so a retried write finds the
|
|
68
|
+
// first lead. Written only by `apps/api/src/services/lead-routing.ts`'s
|
|
69
|
+
// submit branch and read only by its two scoped lookups. Untrusted,
|
|
70
|
+
// client-supplied text — never echo it. Nullable, no default: every
|
|
71
|
+
// pre-#7869 row and every keyless lead reads NULL, and Postgres treats
|
|
72
|
+
// NULLs as distinct under the unique below. Same shape as
|
|
73
|
+
// `FlowCheckout.idempotencyKey` (#4912).
|
|
74
|
+
idempotencyKey String? @map("idempotency_key")
|
|
75
|
+
|
|
76
|
+
// #852 D1 — the Person this lead belongs to: one client of this workspace,
|
|
77
|
+
// stamped at creation by `apps/api/src/services/lead-routing.ts` through
|
|
78
|
+
// `findOrCreatePerson`. Plain scalar, no @relation (the inbox house style,
|
|
79
|
+
// `FlowCheckout.leadId`). NULL only when the email is not a storable
|
|
80
|
+
// address or the Person step failed — a Person step never refuses a lead.
|
|
81
|
+
// Backfilled by database/migrations/20261010121000_person_backfill.
|
|
82
|
+
personId String? @map("person_id")
|
|
83
|
+
|
|
84
|
+
// #7869 — the idempotency guarantee, scoped per workspace so a key can
|
|
85
|
+
// never reach another tenant's row.
|
|
86
|
+
@@unique([workspaceId, idempotencyKey])
|
|
66
87
|
@@index([siteId])
|
|
67
88
|
@@index([createdAt])
|
|
68
89
|
@@index([email])
|
|
@@ -78,9 +99,93 @@ model Lead {
|
|
|
78
99
|
// `'flow:' + <magnet slug>` value, not the `handledAt` index's allowlist
|
|
79
100
|
// `IN` — a distinct query shape that composite does not serve.
|
|
80
101
|
@@index([workspaceId, source])
|
|
102
|
+
@@index([personId])
|
|
81
103
|
@@map("leads")
|
|
82
104
|
}
|
|
83
105
|
|
|
106
|
+
/// Person — one client of one workspace (#852). Created only by
|
|
107
|
+
/// apps/api/src/services/person.ts's findOrCreatePerson. Plain scalars, no
|
|
108
|
+
/// @relation (ADR 0009): workspaceId and userId name rows in the auth
|
|
109
|
+
/// namespace.
|
|
110
|
+
///
|
|
111
|
+
/// `email` is the lowercased, trimmed address (the `people_email_normalized`
|
|
112
|
+
/// CHECK holds it to printable ASCII). `userId` is written only on a verified
|
|
113
|
+
/// email match (`stampPersonUser`) and never overwritten. `billingCustomerId`
|
|
114
|
+
/// is NOT unique — the spokes share one Stripe account — and the first
|
|
115
|
+
/// writer wins. `planOffering` is written by a later phase (Epic 2's
|
|
116
|
+
/// checkout-lead rule); this slice only carries the column.
|
|
117
|
+
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")
|
|
128
|
+
|
|
129
|
+
pieces Piece[]
|
|
130
|
+
interactions Interaction[]
|
|
131
|
+
|
|
132
|
+
@@unique([workspaceId, email])
|
|
133
|
+
@@unique([workspaceId, userId])
|
|
134
|
+
// #7877 D1 — redundant with the PK, load-bearing as the target of the
|
|
135
|
+
// composite (person_id, workspace_id) FKs from pieces and interactions:
|
|
136
|
+
// "a Person of the same workspace" is a database fact.
|
|
137
|
+
@@unique([id, workspaceId])
|
|
138
|
+
@@index([workspaceId, billingCustomerId])
|
|
139
|
+
@@index([workspaceId, createdAt])
|
|
140
|
+
@@map("people")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Piece — one deliverable for one client (#7877). Written only through
|
|
144
|
+
/// tenantDb(tenant).memberArea; read by a client only through
|
|
145
|
+
/// lib/member-store.ts. `stage` is one of the workspace's declared stages
|
|
146
|
+
/// (WorkspacePieceStages), checked at write time, never an enum.
|
|
147
|
+
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")
|
|
157
|
+
|
|
158
|
+
person Person @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "pieces_person_fk")
|
|
159
|
+
|
|
160
|
+
@@index([workspaceId, personId, createdAt])
|
|
161
|
+
@@map("pieces")
|
|
162
|
+
}
|
|
163
|
+
|
|
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.
|
|
169
|
+
model Interaction {
|
|
170
|
+
id String @id @default(dbgenerated("('intx_'::text || (uuidv7())::text)")) @db.Text
|
|
171
|
+
workspaceId String @map("workspace_id")
|
|
172
|
+
personId String @map("person_id")
|
|
173
|
+
channel String @db.VarChar(16)
|
|
174
|
+
direction String @db.VarChar(16)
|
|
175
|
+
subject String @db.VarChar(200)
|
|
176
|
+
body String @db.Text
|
|
177
|
+
assetId String? @map("asset_id")
|
|
178
|
+
createdBy String @map("created_by")
|
|
179
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
180
|
+
readAt DateTime? @map("read_at")
|
|
181
|
+
|
|
182
|
+
person Person @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "interactions_person_fk")
|
|
183
|
+
|
|
184
|
+
@@index([workspaceId, personId, createdAt])
|
|
185
|
+
@@index([assetId])
|
|
186
|
+
@@map("interactions")
|
|
187
|
+
}
|
|
188
|
+
|
|
84
189
|
/// Subscriber — newsletter subscription (Phase B subscribe_newsletter tool / B6 route)
|
|
85
190
|
model Subscriber {
|
|
86
191
|
id String @id @default(dbgenerated("('subscr_'::text || (uuidv7())::text)")) @db.Text
|
|
@@ -1124,11 +1229,17 @@ model Appointment {
|
|
|
1124
1229
|
noShowEventAt DateTime? @map("no_show_event_at")
|
|
1125
1230
|
createdAt DateTime @default(now()) @map("created_at")
|
|
1126
1231
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
1232
|
+
/// #852 D1 — the Person of the attendee, in this row's workspace. Stamped
|
|
1233
|
+
/// at create (and on a spoke bind's move) by appointments.ts through
|
|
1234
|
+
/// findOrCreatePerson. Plain scalar, no @relation. NULL when the attendee
|
|
1235
|
+
/// email is not a storable address.
|
|
1236
|
+
personId String? @map("person_id")
|
|
1127
1237
|
|
|
1128
1238
|
@@unique([siteId, provider, providerBookingUid])
|
|
1129
1239
|
@@index([siteId, startTime])
|
|
1130
1240
|
@@index([createdAt])
|
|
1131
1241
|
@@index([workspaceId])
|
|
1242
|
+
@@index([personId])
|
|
1132
1243
|
@@map("appointments")
|
|
1133
1244
|
}
|
|
1134
1245
|
|
|
@@ -6,26 +6,22 @@ module.exports = {
|
|
|
6
6
|
testPathIgnorePatterns: ["/node_modules/", "/generated/"],
|
|
7
7
|
moduleFileExtensions: ["ts", "js", "json"],
|
|
8
8
|
moduleNameMapper: {
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// identical mapping. The published dist is ESM-only and this package's
|
|
12
|
-
// CJS ts-jest transform can't load it; database/flows/presets.ts's test
|
|
13
|
-
// needs the real flowConfigSchema value at runtime.
|
|
14
|
-
"^@working-theory/validation$": "<rootDir>/../packages/validation/src/index.ts",
|
|
15
|
-
// ../tenancy.ts's `isSentinel` import from @working-theory/ids became a
|
|
16
|
-
// VALUE import (#5876 spec D2/D11) — no longer type-only, so it DOES need
|
|
17
|
-
// a mapping now, same reason as the validation mapping above.
|
|
18
|
-
"^@working-theory/ids$": "<rootDir>/../packages/ids/src/index.ts",
|
|
9
|
+
// @working-theory/validation and @working-theory/ids resolve from the
|
|
10
|
+
// installed packages; their ESM dist is converted by the transform below.
|
|
19
11
|
"^(\\.{1,2}/.*)\\.js$": "$1",
|
|
20
12
|
},
|
|
13
|
+
transformIgnorePatterns: [
|
|
14
|
+
"/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))",
|
|
15
|
+
],
|
|
21
16
|
transform: {
|
|
22
|
-
"^.+\\.
|
|
17
|
+
"^.+\\.[cm]?[tj]sx?$": [
|
|
23
18
|
"ts-jest",
|
|
24
19
|
{
|
|
25
20
|
tsconfig: {
|
|
26
21
|
target: "ES2020",
|
|
27
22
|
module: "commonjs",
|
|
28
23
|
lib: ["ES2020"],
|
|
24
|
+
allowJs: true,
|
|
29
25
|
declaration: false,
|
|
30
26
|
strict: true,
|
|
31
27
|
esModuleInterop: true,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- #7803 — sign-in lands in the user's last workspace (spec
|
|
2
|
+
-- docs/specs/7803-sign-in-last-workspace-spec.md D1).
|
|
3
|
+
--
|
|
4
|
+
-- users.last_workspace_id (auth) — the workspace this person last activated.
|
|
5
|
+
-- Written only by activateWorkspace after its membership
|
|
6
|
+
-- grant; read only by listTenancy, which reports it only
|
|
7
|
+
-- when the workspace is in the caller's own listing
|
|
8
|
+
-- (apps/api/src/services/tenant-context.ts). Deleting the
|
|
9
|
+
-- workspace sets it null.
|
|
10
|
+
--
|
|
11
|
+
-- PURELY ADDITIVE: one nullable column with no default, its index, one FK to
|
|
12
|
+
-- "workspaces" and the ADR-0031 format CHECK on the new reference column. No
|
|
13
|
+
-- backfill runs and no existing row changes, so there is no pre-assertion
|
|
14
|
+
-- block — this migration cannot lose a row. A NULL passes the CHECK.
|
|
15
|
+
--
|
|
16
|
+
-- Rollout: deploy this migration BEFORE the API code that reads the column
|
|
17
|
+
-- (SEC-7803-3). Being nullable, it is safe to apply ahead of the code.
|
|
18
|
+
--
|
|
19
|
+
-- Rollback: ALTER TABLE "users" DROP COLUMN "last_workspace_id"; (this drops
|
|
20
|
+
-- the index, the FK and the CHECK with it). Safe while nothing but #7803's own
|
|
21
|
+
-- code reads it.
|
|
22
|
+
|
|
23
|
+
-- AlterTable
|
|
24
|
+
ALTER TABLE "users" ADD COLUMN "last_workspace_id" TEXT;
|
|
25
|
+
|
|
26
|
+
-- CreateIndex
|
|
27
|
+
CREATE INDEX "users_last_workspace_id_idx" ON "users"("last_workspace_id");
|
|
28
|
+
|
|
29
|
+
-- AddForeignKey
|
|
30
|
+
-- A deleted workspace clears every pointer to it.
|
|
31
|
+
ALTER TABLE "users" ADD CONSTRAINT "users_last_workspace_id_fkey" FOREIGN KEY ("last_workspace_id") REFERENCES "workspaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
32
|
+
|
|
33
|
+
-- ADR-0031 rule 3 — the reference column carries the format CHECK of the
|
|
34
|
+
-- entity it names (wksp_), in the exact pattern the agents migration uses.
|
|
35
|
+
ALTER TABLE "users" ADD CONSTRAINT "users_last_workspace_id_format" CHECK ("last_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}$');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- #7869 (spoke-pattern roadmap Phase 1, D5) — Lead idempotency key: the
|
|
2
|
+
-- caller's optional `Idempotency-Key` header on POST /v1/leads/submit, so a
|
|
3
|
+
-- retried lead write (a client retry, a spoke's fulfilment replay) finds the
|
|
4
|
+
-- first lead instead of writing a second. Mirrors the column-add shape of
|
|
5
|
+
-- 20260906000000_add_flow_checkout_idempotency (one AlterTable, one unique
|
|
6
|
+
-- index under the Prisma section headers).
|
|
7
|
+
--
|
|
8
|
+
-- The column is nullable with no default, so no pre-existing row needs a
|
|
9
|
+
-- backfill and none collides on the new unique — Postgres treats NULLs as
|
|
10
|
+
-- distinct. The unique is scoped by workspace_id: one key in two workspaces
|
|
11
|
+
-- is two leads.
|
|
12
|
+
--
|
|
13
|
+
-- Rollback: ALTER TABLE "leads" DROP COLUMN "idempotency_key"; (the index
|
|
14
|
+
-- goes with it).
|
|
15
|
+
|
|
16
|
+
-- AlterTable
|
|
17
|
+
ALTER TABLE "leads" ADD COLUMN "idempotency_key" TEXT;
|
|
18
|
+
|
|
19
|
+
-- CreateIndex
|
|
20
|
+
CREATE UNIQUE INDEX "leads_workspace_id_idempotency_key_key" ON "leads"("workspace_id", "idempotency_key");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- #7864 — grant an agent key five named abilities (spec
|
|
2
|
+
-- docs/specs/7864-agent-key-grants-spec.md D2).
|
|
3
|
+
--
|
|
4
|
+
-- agents.grants (auth) — the permissions an owner or admin granted this
|
|
5
|
+
-- agent one by one. Written only by setServedAgentGrants
|
|
6
|
+
-- (apps/api/src/lib/agent-store.ts, the PUT
|
|
7
|
+
-- …/agents/:agentId/grants route); read by the auth
|
|
8
|
+
-- preHandler's key lookup and the agent list. The tenant
|
|
9
|
+
-- guards hold an agent to the grants its person's own role
|
|
10
|
+
-- also holds, so a stored grant never outranks the person.
|
|
11
|
+
--
|
|
12
|
+
-- The CHECK names the same five permissions as @working-theory/validation's
|
|
13
|
+
-- AGENT_GRANTABLE_PERMISSIONS; database/__tests__/agents.db.test.ts reads
|
|
14
|
+
-- this file and fails if the two lists differ (SEC-7864-G).
|
|
15
|
+
--
|
|
16
|
+
-- PURELY ADDITIVE: one NOT NULL column with an empty-array default and its
|
|
17
|
+
-- CHECK. No backfill runs: every existing agent takes the default, no
|
|
18
|
+
-- grants, and so behaves exactly as before. No existing row changes beyond
|
|
19
|
+
-- the default fill, so there is no pre-assertion block.
|
|
20
|
+
--
|
|
21
|
+
-- Rollout: deploy this migration BEFORE the API code that reads the column.
|
|
22
|
+
-- Code without the column fails closed — its key lookup errors and every
|
|
23
|
+
-- agent is answered 401 (SEC-7864-F).
|
|
24
|
+
--
|
|
25
|
+
-- Rollback: ALTER TABLE "agents" DROP COLUMN "grants"; (this drops the CHECK
|
|
26
|
+
-- with it). Safe while nothing but #7864's own code reads it.
|
|
27
|
+
|
|
28
|
+
-- AlterTable
|
|
29
|
+
ALTER TABLE "agents" ADD COLUMN "grants" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
|
30
|
+
|
|
31
|
+
-- Only the five grantable permissions may be stored.
|
|
32
|
+
ALTER TABLE "agents" ADD CONSTRAINT "agents_grants_allowed"
|
|
33
|
+
CHECK ("grants" <@ ARRAY['content-edit','content-publish','assets-write','magnets-manage','meeting-types-manage']::TEXT[]);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
-- #852 — Person, the per-workspace client record (spec
|
|
2
|
+
-- docs/specs/852-person-the-per-workspace-client-spec.md D1, D2).
|
|
3
|
+
--
|
|
4
|
+
-- people (inbox) — one client of one workspace, keyed on a lowercased,
|
|
5
|
+
-- trimmed email unique per workspace. Created only by
|
|
6
|
+
-- apps/api/src/services/person.ts's findOrCreatePerson.
|
|
7
|
+
-- workspace_id and user_id are plain columns with no
|
|
8
|
+
-- foreign key (ADR 0009): they name auth-namespace rows.
|
|
9
|
+
-- leads.person_id, appointments.person_id — the Person each row belongs
|
|
10
|
+
-- to, stamped at creation by the intake paths.
|
|
11
|
+
--
|
|
12
|
+
-- PURELY ADDITIVE, DDL ONLY: one new table with its indexes and CHECKs, and
|
|
13
|
+
-- one nullable column with no default on each of "leads" and "appointments",
|
|
14
|
+
-- so Postgres changes only the catalog for those two. No existing row's
|
|
15
|
+
-- values change here — the backfill is the next migration,
|
|
16
|
+
-- 20261010121000_person_backfill, split out so its row updates never run
|
|
17
|
+
-- under the exclusive locks these ALTER TABLEs take (SEC-852-E). Prisma runs
|
|
18
|
+
-- this file as one transaction, so each ALTER TABLE's ACCESS EXCLUSIVE lock
|
|
19
|
+
-- is held until the file commits — through the plain CREATE INDEX on the new
|
|
20
|
+
-- column — blocking reads AND writes of "leads" and "appointments" (lead
|
|
21
|
+
-- capture included) for one heap scan of each. Accepted: the scan is short
|
|
22
|
+
-- next to the backfill it is split from.
|
|
23
|
+
--
|
|
24
|
+
-- Rollout: deploy this migration (and the backfill) BEFORE the API code that
|
|
25
|
+
-- writes person_id; code without the column fails every lead write.
|
|
26
|
+
--
|
|
27
|
+
-- Rollback: ALTER TABLE "leads" DROP COLUMN "person_id"; ALTER TABLE
|
|
28
|
+
-- "appointments" DROP COLUMN "person_id"; DROP TABLE "people"; (the indexes
|
|
29
|
+
-- and CHECKs go with them). Safe while nothing but #852's own code reads them.
|
|
30
|
+
|
|
31
|
+
-- AlterTable
|
|
32
|
+
ALTER TABLE "appointments" ADD COLUMN "person_id" TEXT;
|
|
33
|
+
|
|
34
|
+
-- AlterTable
|
|
35
|
+
ALTER TABLE "leads" ADD COLUMN "person_id" TEXT;
|
|
36
|
+
|
|
37
|
+
-- CreateTable
|
|
38
|
+
CREATE TABLE "people" (
|
|
39
|
+
"id" TEXT NOT NULL DEFAULT ('person_'::text || (uuidv7())::text),
|
|
40
|
+
"workspace_id" TEXT NOT NULL,
|
|
41
|
+
"email" VARCHAR(254) NOT NULL,
|
|
42
|
+
"name" VARCHAR(200),
|
|
43
|
+
"user_id" TEXT,
|
|
44
|
+
"billing_provider" VARCHAR(32),
|
|
45
|
+
"billing_customer_id" VARCHAR(255),
|
|
46
|
+
"plan_offering" VARCHAR(100),
|
|
47
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
48
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
49
|
+
|
|
50
|
+
CONSTRAINT "people_pkey" PRIMARY KEY ("id")
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- CreateIndex
|
|
54
|
+
-- The webhook's first match: a customer id within one workspace (D3 step 2).
|
|
55
|
+
-- NOT unique — the spokes share one Stripe account.
|
|
56
|
+
CREATE INDEX "people_workspace_id_billing_customer_id_idx" ON "people"("workspace_id", "billing_customer_id");
|
|
57
|
+
|
|
58
|
+
-- CreateIndex
|
|
59
|
+
-- The People list: workspace_id = $1 ORDER BY created_at DESC (D7).
|
|
60
|
+
CREATE INDEX "people_workspace_id_created_at_idx" ON "people"("workspace_id", "created_at");
|
|
61
|
+
|
|
62
|
+
-- CreateIndex
|
|
63
|
+
-- One Person per address per workspace — what findOrCreatePerson's
|
|
64
|
+
-- INSERT … ON CONFLICT DO NOTHING absorbs a concurrent duplicate on.
|
|
65
|
+
CREATE UNIQUE INDEX "people_workspace_id_email_key" ON "people"("workspace_id", "email");
|
|
66
|
+
|
|
67
|
+
-- CreateIndex
|
|
68
|
+
-- One Person per User per workspace. NULLs are distinct, so any number of
|
|
69
|
+
-- unlinked Persons fit.
|
|
70
|
+
CREATE UNIQUE INDEX "people_workspace_id_user_id_key" ON "people"("workspace_id", "user_id");
|
|
71
|
+
|
|
72
|
+
-- CreateIndex
|
|
73
|
+
CREATE INDEX "appointments_person_id_idx" ON "appointments"("person_id");
|
|
74
|
+
|
|
75
|
+
-- CreateIndex
|
|
76
|
+
CREATE INDEX "leads_person_id_idx" ON "leads"("person_id");
|
|
77
|
+
|
|
78
|
+
-- ADR-0031 rule 3 — people: person_ (registered in packages/ids/src/registry.ts;
|
|
79
|
+
-- this migration's path is added to migration-sync.test.ts's
|
|
80
|
+
-- PK_CHECK_MIGRATIONS). Each reference column carries the format CHECK of the
|
|
81
|
+
-- entity it names, in the agents-migration shape.
|
|
82
|
+
ALTER TABLE "people" ALTER COLUMN "id" TYPE text COLLATE "C";
|
|
83
|
+
ALTER TABLE "people" ALTER COLUMN "id" SET DEFAULT ('person_'::text || (uuidv7())::text);
|
|
84
|
+
ALTER TABLE "people" ADD CONSTRAINT "people_id_format" CHECK ("id" ~ '^person_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
|
|
85
|
+
ALTER TABLE "people" ADD CONSTRAINT "people_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}$');
|
|
86
|
+
ALTER TABLE "people" ADD CONSTRAINT "people_user_id_format" CHECK ("user_id" ~ '^user_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
|
|
87
|
+
|
|
88
|
+
-- The stored address is already normalized: printable ASCII only (no
|
|
89
|
+
-- whitespace, no control or invisible character), exactly one @ with a
|
|
90
|
+
-- non-empty side each, lower case. findOrCreatePerson's
|
|
91
|
+
-- isStorablePersonEmail applies the same rule before any write (D3).
|
|
92
|
+
ALTER TABLE "people" ADD CONSTRAINT "people_email_normalized"
|
|
93
|
+
CHECK ("email" = lower("email" COLLATE "C") AND "email" ~ '^[!-?A-~]+@[!-?A-~]+$');
|
|
94
|
+
|
|
95
|
+
-- A provider and its customer id are written together or not at all — the
|
|
96
|
+
-- same provider-agnostic pair User and Organization carry.
|
|
97
|
+
ALTER TABLE "people" ADD CONSTRAINT "people_billing_pair"
|
|
98
|
+
CHECK (("billing_provider" IS NULL) = ("billing_customer_id" IS NULL));
|
|
99
|
+
|
|
100
|
+
-- The two new reference columns carry the person_ format CHECK, added NOT
|
|
101
|
+
-- VALID and then validated: the file runs as one transaction, so the ALTER
|
|
102
|
+
-- TABLE's lock is still held, and the scan is of an all-NULL column.
|
|
103
|
+
ALTER TABLE "leads" ADD CONSTRAINT "leads_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}$') NOT VALID;
|
|
104
|
+
ALTER TABLE "leads" VALIDATE CONSTRAINT "leads_person_id_format";
|
|
105
|
+
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_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}$') NOT VALID;
|
|
106
|
+
ALTER TABLE "appointments" VALIDATE CONSTRAINT "appointments_person_id_format";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
-- #852 — the Person backfill (spec
|
|
2
|
+
-- docs/specs/852-person-the-per-workspace-client-spec.md D2, D4).
|
|
3
|
+
--
|
|
4
|
+
-- Fills "people" from the rows the hub already holds and stamps
|
|
5
|
+
-- leads.person_id and appointments.person_id, so the People list is not
|
|
6
|
+
-- empty on the day it ships. Set-based and idempotent: every insert is
|
|
7
|
+
-- ON CONFLICT DO NOTHING and every update is guarded by IS NULL, so running
|
|
8
|
+
-- this text twice changes nothing the first run wrote.
|
|
9
|
+
--
|
|
10
|
+
-- THE SAME TEXT, byte for byte, is database/scripts/person-backfill.sql. The
|
|
11
|
+
-- previous deployment keeps writing leads and appointments without a
|
|
12
|
+
-- person_id between this migration and the cutover to the new code; run
|
|
13
|
+
-- that script ONCE against the hub database after the cutover to stamp them.
|
|
14
|
+
-- database/__tests__/person.db.test.ts asserts the two files are identical
|
|
15
|
+
-- and that a second run changes no row.
|
|
16
|
+
--
|
|
17
|
+
-- Normalization — the SQL twin of apps/api/src/services/person.ts's
|
|
18
|
+
-- normalizePersonEmail / isStorablePersonEmail:
|
|
19
|
+
-- N(x) = lower(btrim(x, E' \t\n\r\f\x0B') COLLATE "C")
|
|
20
|
+
-- (\x0B is vertical tab; an E-string has no \v escape — E'\v'
|
|
21
|
+
-- is the letter v, which would strip the v of every .dev)
|
|
22
|
+
-- VALID(e) = e ~ '^[!-?A-~]+@[!-?A-~]+$' AND length(e) <= 254
|
|
23
|
+
-- NAME(x) = C0/C1 controls and bidi controls removed, trimmed, cut to
|
|
24
|
+
-- 200 characters; empty → NULL
|
|
25
|
+
-- A row whose email is not VALID makes no Person and keeps person_id NULL.
|
|
26
|
+
--
|
|
27
|
+
-- Sources, in order: leads (oldest first), then completed hub FlowCheckouts
|
|
28
|
+
-- through their lead (the customer id, first writer wins), then appointments
|
|
29
|
+
-- by attendee email. Subscriber is NOT a source; FlowSession, ProjectListing
|
|
30
|
+
-- and DeliverableGrant reach a Person through their lead at read time.
|
|
31
|
+
-- The join to "users" reads the auth namespace inside this one-off
|
|
32
|
+
-- statement only; it adds no foreign key (ADR 0009 invariant 1).
|
|
33
|
+
--
|
|
34
|
+
-- Rollback: nothing to undo on its own — dropping 20261010120000_person's
|
|
35
|
+
-- table and columns removes everything this wrote.
|
|
36
|
+
|
|
37
|
+
-- 1. Leads, oldest first: one Person per (workspace, normalized email). The
|
|
38
|
+
-- name is the oldest lead's payload.name when it is a JSON string.
|
|
39
|
+
INSERT INTO "people" ("workspace_id", "email", "name", "created_at", "updated_at")
|
|
40
|
+
SELECT DISTINCT ON (l."workspace_id", lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"))
|
|
41
|
+
l."workspace_id",
|
|
42
|
+
lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"),
|
|
43
|
+
CASE WHEN jsonb_typeof(l."payload" -> 'name') = 'string'
|
|
44
|
+
THEN NULLIF(left(btrim(regexp_replace(l."payload" ->> 'name', '[\x01-\x1F\x7F-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069]', '', 'g')), 200), '')
|
|
45
|
+
END,
|
|
46
|
+
l."created_at",
|
|
47
|
+
now()
|
|
48
|
+
FROM "leads" l
|
|
49
|
+
WHERE lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C") ~ '^[!-?A-~]+@[!-?A-~]+$'
|
|
50
|
+
AND length(lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C")) <= 254
|
|
51
|
+
ORDER BY l."workspace_id", lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"), l."created_at" ASC, l."id" ASC
|
|
52
|
+
ON CONFLICT ("workspace_id", "email") DO NOTHING;
|
|
53
|
+
|
|
54
|
+
-- 2. Hub FlowCheckouts, through their lead (same workspace): the oldest
|
|
55
|
+
-- completed checkout with a customer id writes it onto a Person that has
|
|
56
|
+
-- none. First writer wins — oldest by completed_at, then created_at, then id.
|
|
57
|
+
UPDATE "people" p
|
|
58
|
+
SET "billing_provider" = 'stripe',
|
|
59
|
+
"billing_customer_id" = c."stripe_customer_id",
|
|
60
|
+
"updated_at" = now()
|
|
61
|
+
FROM (
|
|
62
|
+
SELECT DISTINCT ON (fc."workspace_id", lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"))
|
|
63
|
+
fc."workspace_id",
|
|
64
|
+
lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C") AS "email",
|
|
65
|
+
fc."stripe_customer_id"
|
|
66
|
+
FROM "flow_checkouts" fc
|
|
67
|
+
JOIN "leads" l ON l."id" = fc."lead_id" AND l."workspace_id" = fc."workspace_id"
|
|
68
|
+
WHERE fc."status" = 'completed'
|
|
69
|
+
AND fc."stripe_customer_id" IS NOT NULL
|
|
70
|
+
AND fc."stripe_customer_id" <> ''
|
|
71
|
+
AND length(fc."stripe_customer_id") <= 255
|
|
72
|
+
ORDER BY fc."workspace_id", lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"),
|
|
73
|
+
fc."completed_at" ASC NULLS LAST, fc."created_at" ASC, fc."id" ASC
|
|
74
|
+
) c
|
|
75
|
+
WHERE p."workspace_id" = c."workspace_id"
|
|
76
|
+
AND p."email" = c."email"
|
|
77
|
+
AND p."billing_customer_id" IS NULL;
|
|
78
|
+
|
|
79
|
+
-- 3. Appointments, by attendee email: a Person a lead already made keeps its
|
|
80
|
+
-- lead-derived name and created_at (ON CONFLICT DO NOTHING).
|
|
81
|
+
INSERT INTO "people" ("workspace_id", "email", "name", "created_at", "updated_at")
|
|
82
|
+
SELECT DISTINCT ON (a."workspace_id", lower(btrim(a."attendee_email", E' \t\n\r\f\x0B') COLLATE "C"))
|
|
83
|
+
a."workspace_id",
|
|
84
|
+
lower(btrim(a."attendee_email", E' \t\n\r\f\x0B') COLLATE "C"),
|
|
85
|
+
NULLIF(left(btrim(regexp_replace(a."attendee_name", '[\x01-\x1F\x7F-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069]', '', 'g')), 200), ''),
|
|
86
|
+
a."created_at",
|
|
87
|
+
now()
|
|
88
|
+
FROM "appointments" a
|
|
89
|
+
WHERE lower(btrim(a."attendee_email", E' \t\n\r\f\x0B') COLLATE "C") ~ '^[!-?A-~]+@[!-?A-~]+$'
|
|
90
|
+
AND length(lower(btrim(a."attendee_email", E' \t\n\r\f\x0B') COLLATE "C")) <= 254
|
|
91
|
+
ORDER BY a."workspace_id", lower(btrim(a."attendee_email", E' \t\n\r\f\x0B') COLLATE "C"), a."created_at" ASC, a."id" ASC
|
|
92
|
+
ON CONFLICT ("workspace_id", "email") DO NOTHING;
|
|
93
|
+
|
|
94
|
+
-- 4. Stamps: every lead and appointment with a storable email points at its
|
|
95
|
+
-- workspace's Person; a row already stamped is left alone.
|
|
96
|
+
UPDATE "leads"
|
|
97
|
+
SET "person_id" = p."id"
|
|
98
|
+
FROM "people" p
|
|
99
|
+
WHERE "leads"."person_id" IS NULL
|
|
100
|
+
AND p."workspace_id" = "leads"."workspace_id"
|
|
101
|
+
AND p."email" = lower(btrim("leads"."email", E' \t\n\r\f\x0B') COLLATE "C");
|
|
102
|
+
|
|
103
|
+
UPDATE "appointments"
|
|
104
|
+
SET "person_id" = p."id"
|
|
105
|
+
FROM "people" p
|
|
106
|
+
WHERE "appointments"."person_id" IS NULL
|
|
107
|
+
AND p."workspace_id" = "appointments"."workspace_id"
|
|
108
|
+
AND p."email" = lower(btrim("appointments"."attendee_email", E' \t\n\r\f\x0B') COLLATE "C");
|
|
109
|
+
|
|
110
|
+
-- 5. user_id, verified only: the oldest promoted lead (by promoted_at, then
|
|
111
|
+
-- created_at, then id) whose user is email-verified and whose verified
|
|
112
|
+
-- address normalizes to the Person's. An unverified or differing user is
|
|
113
|
+
-- never copied — the rule stampPersonUser applies to new rows.
|
|
114
|
+
UPDATE "people" p
|
|
115
|
+
SET "user_id" = c."user_id",
|
|
116
|
+
"updated_at" = now()
|
|
117
|
+
FROM (
|
|
118
|
+
SELECT DISTINCT ON (l."workspace_id", lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"))
|
|
119
|
+
l."workspace_id",
|
|
120
|
+
lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C") AS "email",
|
|
121
|
+
l."promoted_user_id" AS "user_id"
|
|
122
|
+
FROM "leads" l
|
|
123
|
+
JOIN "users" u ON u."id" = l."promoted_user_id"
|
|
124
|
+
WHERE l."promoted_user_id" IS NOT NULL
|
|
125
|
+
AND u."email_verified" = true
|
|
126
|
+
AND lower(btrim(u."email", E' \t\n\r\f\x0B') COLLATE "C") = lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C")
|
|
127
|
+
ORDER BY l."workspace_id", lower(btrim(l."email", E' \t\n\r\f\x0B') COLLATE "C"),
|
|
128
|
+
l."promoted_at" ASC NULLS LAST, l."created_at" ASC, l."id" ASC
|
|
129
|
+
) c
|
|
130
|
+
WHERE p."workspace_id" = c."workspace_id"
|
|
131
|
+
AND p."email" = c."email"
|
|
132
|
+
AND p."user_id" IS NULL;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- #852 — an agent key may be granted people-read (spec
|
|
2
|
+
-- docs/specs/852-person-the-per-workspace-client-spec.md D9).
|
|
3
|
+
--
|
|
4
|
+
-- Widens the agents_grants_allowed CHECK from the five #7864 permissions to
|
|
5
|
+
-- six. The CHECK names the same six as @working-theory/validation's
|
|
6
|
+
-- AGENT_GRANTABLE_PERMISSIONS; database/__tests__/agents.db.test.ts reads
|
|
7
|
+
-- this file and fails if the two lists differ (SEC-7864-G).
|
|
8
|
+
--
|
|
9
|
+
-- PURELY ADDITIVE in effect: the new CHECK admits every row the old one
|
|
10
|
+
-- admitted, so re-adding it cannot fail on existing data. No row changes.
|
|
11
|
+
--
|
|
12
|
+
-- Rollback: ALTER TABLE "agents" DROP CONSTRAINT "agents_grants_allowed";
|
|
13
|
+
-- then re-add the five-id CHECK from 20261009120000_agent_grants. That
|
|
14
|
+
-- re-add FAILS while any row holds people-read — remove those grants first.
|
|
15
|
+
|
|
16
|
+
ALTER TABLE "agents" DROP CONSTRAINT "agents_grants_allowed";
|
|
17
|
+
|
|
18
|
+
-- Only the six grantable permissions may be stored.
|
|
19
|
+
ALTER TABLE "agents" ADD CONSTRAINT "agents_grants_allowed"
|
|
20
|
+
CHECK ("grants" <@ ARRAY['content-edit','content-publish','assets-write','magnets-manage','meeting-types-manage','people-read']::TEXT[]);
|