create-ailk 0.15.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.
Files changed (127) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +42 -0
  29. package/dist/spoke-checkout.js +359 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +449 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +15 -0
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +27 -0
  93. package/templates/apps/web/lib/account-config.ts +130 -1
  94. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  95. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  96. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  97. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  98. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  99. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  100. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  101. package/templates/apps/web/lib/listings-config.ts +31 -0
  102. package/templates/apps/web/lib/listings.tsx +7 -3
  103. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  104. package/templates/apps/web/lib/next-path.ts +35 -0
  105. package/templates/apps/web/lib/relay.ts +3 -1
  106. package/templates/apps/web/lib/server-api.ts +11 -1
  107. package/templates/apps/web/lib/topology.generated.ts +15 -5
  108. package/templates/apps/web/messages/en.json +32 -3
  109. package/templates/database/CHANGELOG.md +26 -0
  110. package/templates/database/__tests__/agents.db.test.ts +25 -5
  111. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  112. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  113. package/templates/database/__tests__/person.db.test.ts +584 -0
  114. package/templates/database/auth/schema.prisma +3 -2
  115. package/templates/database/config/schema.prisma +13 -0
  116. package/templates/database/inbox/schema.prisma +98 -0
  117. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  118. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  119. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  120. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  121. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/person-backfill.sql +132 -0
  124. package/templates/database/tenancy.ts +53 -0
  125. package/templates/tools/check-declared-env.ts +37 -0
  126. package/templates/tools/declared-env.ts +1163 -0
  127. package/versions.json +35 -35
@@ -73,6 +73,14 @@ model Lead {
73
73
  // `FlowCheckout.idempotencyKey` (#4912).
74
74
  idempotencyKey String? @map("idempotency_key")
75
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
+
76
84
  // #7869 — the idempotency guarantee, scoped per workspace so a key can
77
85
  // never reach another tenant's row.
78
86
  @@unique([workspaceId, idempotencyKey])
@@ -91,9 +99,93 @@ model Lead {
91
99
  // `'flow:' + <magnet slug>` value, not the `handledAt` index's allowlist
92
100
  // `IN` — a distinct query shape that composite does not serve.
93
101
  @@index([workspaceId, source])
102
+ @@index([personId])
94
103
  @@map("leads")
95
104
  }
96
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
+
97
189
  /// Subscriber — newsletter subscription (Phase B subscribe_newsletter tool / B6 route)
98
190
  model Subscriber {
99
191
  id String @id @default(dbgenerated("('subscr_'::text || (uuidv7())::text)")) @db.Text
@@ -1137,11 +1229,17 @@ model Appointment {
1137
1229
  noShowEventAt DateTime? @map("no_show_event_at")
1138
1230
  createdAt DateTime @default(now()) @map("created_at")
1139
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")
1140
1237
 
1141
1238
  @@unique([siteId, provider, providerBookingUid])
1142
1239
  @@index([siteId, startTime])
1143
1240
  @@index([createdAt])
1144
1241
  @@index([workspaceId])
1242
+ @@index([personId])
1145
1243
  @@map("appointments")
1146
1244
  }
1147
1245
 
@@ -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[]);
@@ -0,0 +1,158 @@
1
+ -- #7877 — the member area contract (spec
2
+ -- docs/specs/7877-the-member-area-contract-a-spec.md D1, D2, D3).
3
+ --
4
+ -- pieces (inbox) — one deliverable for one client: a title, a stage
5
+ -- drawn from the workspace's declared list, a
6
+ -- needs-client flag. Written only through
7
+ -- apps/api's tenantDb(tenant).memberArea.
8
+ -- interactions (inbox) — one outbound message on the 'member' channel to
9
+ -- one client, optionally carrying a workspace
10
+ -- library asset. The original CRM spec's
11
+ -- Interaction table cut to one channel and one
12
+ -- direction; an inbound reply is a later second
13
+ -- direction value on this table.
14
+ -- workspace_piece_stages (config) — a workspace's declared piece stages,
15
+ -- in order, one row per workspace. No row means the
16
+ -- product default, which lives in apps/api only.
17
+ -- people_id_workspace_id_key — a unique index over a pair the PK already
18
+ -- makes unique: the target of the composite
19
+ -- (person_id, workspace_id) foreign keys, so "a
20
+ -- Person of the same workspace" is a database fact.
21
+ --
22
+ -- Both foreign keys stay inside the inbox namespace (ADR 0009 invariant 1).
23
+ -- workspace_id, asset_id, created_by and updated_by name rows of other
24
+ -- namespaces: plain columns with format CHECKs, no foreign key. Neither new
25
+ -- inbox table carries an email.
26
+ --
27
+ -- PURELY ADDITIVE, DDL ONLY: three new tables and one unique index on
28
+ -- "people". No existing row changes. The unique index builds under the
29
+ -- file's transaction, holding a SHARE lock on "people" (writes wait, reads do
30
+ -- not) for one scan of a table that is one row per client.
31
+ --
32
+ -- Rollout: deploy this migration BEFORE the API code that reads or writes
33
+ -- these tables; code without them fails every member-area read and write.
34
+ --
35
+ -- Rollback: DROP TABLE "interactions"; DROP TABLE "pieces"; DROP TABLE
36
+ -- "workspace_piece_stages"; DROP INDEX "people_id_workspace_id_key"; (the
37
+ -- indexes, foreign keys and CHECKs go with them). Safe while nothing but
38
+ -- #7877's own code reads them.
39
+
40
+ -- CreateTable
41
+ CREATE TABLE "workspace_piece_stages" (
42
+ "id" TEXT NOT NULL DEFAULT ('pstages_'::text || (uuidv7())::text),
43
+ "workspace_id" TEXT NOT NULL,
44
+ "stages" TEXT[],
45
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+ "updated_at" TIMESTAMPTZ NOT NULL,
47
+
48
+ CONSTRAINT "workspace_piece_stages_pkey" PRIMARY KEY ("id")
49
+ );
50
+
51
+ -- CreateTable
52
+ CREATE TABLE "pieces" (
53
+ "id" TEXT NOT NULL DEFAULT ('piece_'::text || (uuidv7())::text),
54
+ "workspace_id" TEXT NOT NULL,
55
+ "person_id" TEXT NOT NULL,
56
+ "title" VARCHAR(200) NOT NULL,
57
+ "stage" VARCHAR(40) NOT NULL,
58
+ "needs_client" BOOLEAN NOT NULL DEFAULT false,
59
+ "updated_by" TEXT NOT NULL,
60
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
61
+ "updated_at" TIMESTAMP(3) NOT NULL,
62
+
63
+ CONSTRAINT "pieces_pkey" PRIMARY KEY ("id")
64
+ );
65
+
66
+ -- CreateTable
67
+ CREATE TABLE "interactions" (
68
+ "id" TEXT NOT NULL DEFAULT ('intx_'::text || (uuidv7())::text),
69
+ "workspace_id" TEXT NOT NULL,
70
+ "person_id" TEXT NOT NULL,
71
+ "channel" VARCHAR(16) NOT NULL,
72
+ "direction" VARCHAR(16) NOT NULL,
73
+ "subject" VARCHAR(200) NOT NULL,
74
+ "body" TEXT NOT NULL,
75
+ "asset_id" TEXT,
76
+ "created_by" TEXT NOT NULL,
77
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
78
+ "read_at" TIMESTAMP(3),
79
+
80
+ CONSTRAINT "interactions_pkey" PRIMARY KEY ("id")
81
+ );
82
+
83
+ -- CreateIndex
84
+ -- One stage list per workspace — the WorkspaceMeetings shape.
85
+ CREATE UNIQUE INDEX "workspace_piece_stages_workspace_id_key" ON "workspace_piece_stages"("workspace_id");
86
+
87
+ -- CreateIndex
88
+ -- A client's pieces: workspace_id = $1 AND person_id = $2 ORDER BY created_at DESC.
89
+ CREATE INDEX "pieces_workspace_id_person_id_created_at_idx" ON "pieces"("workspace_id", "person_id", "created_at");
90
+
91
+ -- CreateIndex
92
+ -- A client's messages, the same read shape as pieces.
93
+ CREATE INDEX "interactions_workspace_id_person_id_created_at_idx" ON "interactions"("workspace_id", "person_id", "created_at");
94
+
95
+ -- CreateIndex
96
+ -- The public asset read's "is this asset attached to any message" count (D18).
97
+ CREATE INDEX "interactions_asset_id_idx" ON "interactions"("asset_id");
98
+
99
+ -- CreateIndex
100
+ -- Redundant with the PK; the composite foreign keys below need it as their target.
101
+ CREATE UNIQUE INDEX "people_id_workspace_id_key" ON "people"("id", "workspace_id");
102
+
103
+ -- AddForeignKey
104
+ -- A piece names a Person of ITS OWN workspace; deleting a Person with a piece is refused.
105
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_person_fk" FOREIGN KEY ("person_id", "workspace_id") REFERENCES "people"("id", "workspace_id") ON DELETE RESTRICT ON UPDATE CASCADE;
106
+
107
+ -- AddForeignKey
108
+ -- The same rule for a message.
109
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_person_fk" FOREIGN KEY ("person_id", "workspace_id") REFERENCES "people"("id", "workspace_id") ON DELETE RESTRICT ON UPDATE CASCADE;
110
+
111
+ -- ADR-0031 rule 3 — pieces: piece_, interactions: intx_,
112
+ -- workspace_piece_stages: pstages_ (registered in
113
+ -- packages/ids/src/registry.ts; this migration's path is added to
114
+ -- migration-sync.test.ts's PK_CHECK_MIGRATIONS). Each reference column
115
+ -- carries the format CHECK of the entity it names, in the person-migration
116
+ -- shape.
117
+ ALTER TABLE "pieces" ALTER COLUMN "id" TYPE text COLLATE "C";
118
+ ALTER TABLE "pieces" ALTER COLUMN "id" SET DEFAULT ('piece_'::text || (uuidv7())::text);
119
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_id_format" CHECK ("id" ~ '^piece_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
120
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_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}$');
121
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_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}$');
122
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_updated_by_format" CHECK ("updated_by" ~ '^user_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
123
+
124
+ ALTER TABLE "interactions" ALTER COLUMN "id" TYPE text COLLATE "C";
125
+ ALTER TABLE "interactions" ALTER COLUMN "id" SET DEFAULT ('intx_'::text || (uuidv7())::text);
126
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_id_format" CHECK ("id" ~ '^intx_[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 "interactions" ADD CONSTRAINT "interactions_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}$');
128
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_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}$');
129
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_created_by_format" CHECK ("created_by" ~ '^user_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
130
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_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}$');
131
+
132
+ ALTER TABLE "workspace_piece_stages" ALTER COLUMN "id" TYPE text COLLATE "C";
133
+ ALTER TABLE "workspace_piece_stages" ALTER COLUMN "id" SET DEFAULT ('pstages_'::text || (uuidv7())::text);
134
+ ALTER TABLE "workspace_piece_stages" ADD CONSTRAINT "workspace_piece_stages_id_format" CHECK ("id" ~ '^pstages_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
135
+ ALTER TABLE "workspace_piece_stages" ADD CONSTRAINT "workspace_piece_stages_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}$');
136
+
137
+ -- A piece's title is 1–200 characters once trimmed; a stage is a slug. Which
138
+ -- slugs a workspace accepts is its declared list, checked at write time by
139
+ -- the API — never an enum here or in packages/validation.
140
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_title_length"
141
+ CHECK (char_length(btrim("title")) BETWEEN 1 AND 200);
142
+ ALTER TABLE "pieces" ADD CONSTRAINT "pieces_stage_format"
143
+ CHECK ("stage" ~ '^[a-z0-9][a-z0-9-]{0,39}$');
144
+
145
+ -- One channel and one direction in this slice. An inbound reply widens
146
+ -- interactions_direction_allowed in its own migration.
147
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_channel_allowed"
148
+ CHECK ("channel" = 'member');
149
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_direction_allowed"
150
+ CHECK ("direction" = 'outbound');
151
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_subject_length"
152
+ CHECK (char_length(btrim("subject")) BETWEEN 1 AND 200);
153
+ ALTER TABLE "interactions" ADD CONSTRAINT "interactions_body_length"
154
+ CHECK (char_length(btrim("body")) BETWEEN 1 AND 10000);
155
+
156
+ -- A declared list holds 1–20 stages (never NULL: no row is the default).
157
+ ALTER TABLE "workspace_piece_stages" ADD CONSTRAINT "workspace_piece_stages_count"
158
+ CHECK ("stages" IS NOT NULL AND cardinality("stages") BETWEEN 1 AND 20);
@@ -0,0 +1,21 @@
1
+ -- #7878 — an agent key may be granted member-pieces-manage: a client's
2
+ -- pieces, plan and outbound messages (#7877's operator writes).
3
+ --
4
+ -- Widens the agents_grants_allowed CHECK from the six #852 permissions to
5
+ -- seven. The CHECK names the same seven 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 six-id CHECK from 20261010130000_agent_grants_people_read.
14
+ -- That re-add FAILS while any row holds member-pieces-manage — remove those
15
+ -- grants first.
16
+
17
+ ALTER TABLE "agents" DROP CONSTRAINT "agents_grants_allowed";
18
+
19
+ -- Only the seven grantable permissions may be stored.
20
+ ALTER TABLE "agents" ADD CONSTRAINT "agents_grants_allowed"
21
+ CHECK ("grants" <@ ARRAY['content-edit','content-publish','assets-write','magnets-manage','meeting-types-manage','people-read','member-pieces-manage']::TEXT[]);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"