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.
Files changed (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,584 @@
1
+ /**
2
+ * @file person.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the Person record (#852, spec
5
+ * docs/specs/852-person-the-per-workspace-client-spec.md D1, D2, D4, D7;
6
+ * AC-1, AC-2's concurrency leg, AC-4's database half and AC-6's fallback
7
+ * semantics). Requires DATABASE_URL pointing at a Postgres with the
8
+ * migrations applied — every live suite SKIPS when DATABASE_URL is unset,
9
+ * keeping the workspace Jest run DB-free (the repo convention; the
10
+ * `apps/api Route Tests (Postgres)` workflow exercises it).
11
+ *
12
+ * Uses the `pg` driver directly, as `agents.db.test.ts` does. What it proves
13
+ * that a mock cannot:
14
+ * 1. The people table's CHECKs and unique indexes (AC-1): a normalized,
15
+ * printable-ASCII email unique per workspace; a user unique per
16
+ * workspace with NULLs distinct; a NON-unique customer id; the billing
17
+ * pair; the id format CHECKs on people, leads.person_id and
18
+ * appointments.person_id.
19
+ * 2. INSERT … ON CONFLICT DO NOTHING absorbs a concurrent duplicate inside
20
+ * two open transactions: one row, one id, neither aborts (AC-2).
21
+ * 3. The backfill (AC-4): the committed fixture, backfilled by the
22
+ * migration's own text, yields exactly the expected Persons and stamps;
23
+ * the post-cutover script is byte-identical, changes nothing on a
24
+ * second run, and stamps a lead written after the first.
25
+ * 4. The timeline's email fallback (AC-6): the very SQL constants
26
+ * `apps/api/src/lib/tenant-db.ts` runs match only pre-cutoff, lead-less
27
+ * rows of this workspace by EXACT normalized equality — no wildcard, no
28
+ * non-ASCII case fold.
29
+ *
30
+ * Two cases need no database and always run: the script and the migration
31
+ * are byte-identical, and the normalization pairs the fixture lists are what
32
+ * the fixture claims (their SQL twin runs live below).
33
+ */
34
+ import { readFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ import { Client } from "pg";
38
+
39
+ import {
40
+ PERSON_BACKFILL_MIGRATION,
41
+ PERSON_CUTOFF_SQL,
42
+ PERSON_FALLBACK_FLOW_SESSIONS_SQL,
43
+ PERSON_FALLBACK_PROJECT_LISTINGS_SQL,
44
+ } from "../tenancy";
45
+
46
+ type Fixture = {
47
+ workspaces: Record<string, string>;
48
+ input: {
49
+ users: Array<{ key: string; email: string; emailVerified: boolean }>;
50
+ leads: Array<{
51
+ key: string;
52
+ workspace: string;
53
+ email: string;
54
+ payload: Record<string, unknown>;
55
+ createdAt: string;
56
+ promotedUser?: string;
57
+ promotedAt?: string;
58
+ }>;
59
+ flowCheckouts: Array<{
60
+ key: string;
61
+ workspace: string;
62
+ lead: string;
63
+ status: string;
64
+ stripeCustomerId: string;
65
+ completedAt: string | null;
66
+ createdAt: string;
67
+ }>;
68
+ appointments: Array<{
69
+ key: string;
70
+ workspace: string;
71
+ attendeeEmail: string;
72
+ attendeeName: string;
73
+ createdAt: string;
74
+ }>;
75
+ subscribers: Array<{ workspace: string; email: string }>;
76
+ };
77
+ emails: Array<{ raw: string; normalized: string; storable: boolean }>;
78
+ expected: {
79
+ people: Array<{
80
+ workspace: string;
81
+ email: string;
82
+ name: string | null;
83
+ billingCustomerId: string | null;
84
+ user: string | null;
85
+ }>;
86
+ unstamped: string[];
87
+ };
88
+ };
89
+
90
+ const fixture = JSON.parse(
91
+ readFileSync(join(__dirname, "fixtures", "person-backfill.json"), "utf8"),
92
+ ) as Fixture;
93
+
94
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
95
+
96
+ const BACKFILL_MIGRATION = join(
97
+ __dirname,
98
+ "..",
99
+ "migrations",
100
+ "20261010121000_person_backfill",
101
+ "migration.sql",
102
+ );
103
+ const BACKFILL_SCRIPT = join(__dirname, "..", "scripts", "person-backfill.sql");
104
+ const PERSON_MIGRATION = join(
105
+ __dirname,
106
+ "..",
107
+ "migrations",
108
+ "20261010120000_person",
109
+ "migration.sql",
110
+ );
111
+
112
+ const N_SQL = `lower(btrim($1::text, E' \\t\\n\\r\\f\\x0B') COLLATE "C")`;
113
+ const ID_TAIL = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
114
+ const PERSON_ID = new RegExp(`^person_${ID_TAIL}$`);
115
+
116
+ describe("the Person backfill files (#852 D2)", () => {
117
+ it("the post-cutover script is byte-identical to the backfill migration", () => {
118
+ expect(readFileSync(BACKFILL_SCRIPT, "utf8")).toBe(
119
+ readFileSync(BACKFILL_MIGRATION, "utf8"),
120
+ );
121
+ });
122
+
123
+ it("the cutoff key names the backfill migration's folder", () => {
124
+ expect(PERSON_BACKFILL_MIGRATION).toBe("20261010121000_person_backfill");
125
+ expect(PERSON_CUTOFF_SQL).toContain(`'${PERSON_BACKFILL_MIGRATION}'`);
126
+ });
127
+
128
+ it("the Person model carries no phone, traits, brief, merge pointer or secondary email", () => {
129
+ const schema = readFileSync(join(__dirname, "..", "inbox", "schema.prisma"), "utf8");
130
+ const block = /^model Person \{[\s\S]*?^\}/m.exec(schema)?.[0] ?? "";
131
+ expect(block).not.toBe("");
132
+ expect(block).not.toMatch(/phone|traits|brief|merge|secondary/);
133
+ expect(readFileSync(PERSON_MIGRATION, "utf8")).not.toMatch(/"(phone|traits|brief)"/);
134
+ });
135
+ });
136
+
137
+ describeDb("the people table (#852 D1/D2, AC-1, live DB)", () => {
138
+ let db: Client;
139
+ const WS_1 = "wksp_01920000-0852-7000-8000-0000000000c1";
140
+ const WS_2 = "wksp_01920000-0852-7000-8000-0000000000c2";
141
+ const USER = "user_01920000-0852-7000-8000-0000000000c1";
142
+
143
+ beforeAll(async () => {
144
+ db = new Client({ connectionString: process.env.DATABASE_URL });
145
+ await db.connect();
146
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
147
+ });
148
+
149
+ afterAll(async () => {
150
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
151
+ await db.end();
152
+ });
153
+
154
+ type PersonCols = {
155
+ id?: string;
156
+ workspace_id?: string;
157
+ email: string;
158
+ user_id?: string | null;
159
+ billing_provider?: string | null;
160
+ billing_customer_id?: string | null;
161
+ };
162
+
163
+ const insertPerson = (cols: PersonCols) =>
164
+ db.query<{ id: string }>(
165
+ `INSERT INTO "people" ("id", "workspace_id", "email", "user_id", "billing_provider", "billing_customer_id", "updated_at")
166
+ VALUES (COALESCE($1, 'person_' || uuidv7()::text), $2, $3, $4, $5, $6, now()) RETURNING "id"`,
167
+ [
168
+ cols.id ?? null,
169
+ cols.workspace_id ?? WS_1,
170
+ cols.email,
171
+ cols.user_id ?? null,
172
+ cols.billing_provider ?? null,
173
+ cols.billing_customer_id ?? null,
174
+ ],
175
+ );
176
+
177
+ it("mints a person_ id and accepts a normalized address", async () => {
178
+ const { rows } = await insertPerson({ email: "first@example.com" });
179
+ expect(rows[0]!.id).toMatch(PERSON_ID);
180
+ });
181
+
182
+ it.each([
183
+ ["a mixed-case email", "Mixed@Example.com"],
184
+ ["a leading tab", "\ttab@example.com"],
185
+ ["a zero-width space inside", "zero\u200Bwidth@example.com"],
186
+ ["a non-ASCII letter", "zoë@example.com"],
187
+ ["two @", "a@b@example.com"],
188
+ ["an empty local part", "@example.com"],
189
+ ])("refuses %s (people_email_normalized)", async (_label, email) => {
190
+ await expect(insertPerson({ email })).rejects.toMatchObject({
191
+ code: "23514",
192
+ constraint: "people_email_normalized",
193
+ });
194
+ });
195
+
196
+ it("refuses a second Person with the same (workspace_id, email), admits it in another workspace", async () => {
197
+ await insertPerson({ email: "dup@example.com" });
198
+ await expect(insertPerson({ email: "dup@example.com" })).rejects.toMatchObject({
199
+ code: "23505",
200
+ constraint: "people_workspace_id_email_key",
201
+ });
202
+ await expect(insertPerson({ workspace_id: WS_2, email: "dup@example.com" })).resolves.toBeDefined();
203
+ });
204
+
205
+ it("refuses two Persons of one workspace with one user_id; admits any number of NULL user_ids", async () => {
206
+ await insertPerson({ email: "u1@example.com", user_id: USER });
207
+ await expect(insertPerson({ email: "u2@example.com", user_id: USER })).rejects.toMatchObject({
208
+ code: "23505",
209
+ constraint: "people_workspace_id_user_id_key",
210
+ });
211
+ await insertPerson({ email: "null-a@example.com" });
212
+ await expect(insertPerson({ email: "null-b@example.com" })).resolves.toBeDefined();
213
+ });
214
+
215
+ it("admits one customer id on two Persons (the spokes share one Stripe account)", async () => {
216
+ await insertPerson({ email: "c1@example.com", billing_provider: "stripe", billing_customer_id: "cus_shared" });
217
+ await expect(
218
+ insertPerson({ email: "c2@example.com", billing_provider: "stripe", billing_customer_id: "cus_shared" }),
219
+ ).resolves.toBeDefined();
220
+ });
221
+
222
+ it("refuses a customer id without a provider (people_billing_pair)", async () => {
223
+ await expect(
224
+ insertPerson({ email: "pair@example.com", billing_customer_id: "cus_alone" }),
225
+ ).rejects.toMatchObject({ code: "23514", constraint: "people_billing_pair" });
226
+ });
227
+
228
+ it.each([
229
+ ["id", { id: "lead_01920000-0852-7000-8000-0000000000c1", email: "f1@example.com" }, "people_id_format"],
230
+ ["workspace_id", { workspace_id: "org_01920000-0852-7000-8000-0000000000c1", email: "f2@example.com" }, "people_workspace_id_format"],
231
+ ["user_id", { user_id: "member_01920000-0852-7000-8000-0000000000c1", email: "f3@example.com" }, "people_user_id_format"],
232
+ ] as const)("refuses a malformed %s", async (_column, cols, constraint) => {
233
+ await expect(insertPerson(cols)).rejects.toMatchObject({ code: "23514", constraint });
234
+ });
235
+
236
+ it("leads.person_id and appointments.person_id accept a person_ id and refuse a lead_ one", async () => {
237
+ const good = "person_01920000-0852-7000-8000-0000000000c9";
238
+ const bad = "lead_01920000-0852-7000-8000-0000000000c9";
239
+ await db.query("BEGIN");
240
+ try {
241
+ await db.query(
242
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "person_id")
243
+ VALUES ($1, $1, 'p852-format', 'x@example.com', '{}', $2)`,
244
+ [WS_1, good],
245
+ );
246
+ await db.query(
247
+ `INSERT INTO "appointments" ("site_id", "workspace_id", "provider_booking_uid", "start_time", "end_time",
248
+ "attendee_name", "attendee_email", "status", "lifecycle_event_at", "updated_at", "person_id")
249
+ VALUES ($1::text, $1::text, 'p852-format', now(), now(), 'X', 'x@example.com', 'confirmed', now(), now(), $2)`,
250
+ [WS_1, good],
251
+ );
252
+ } finally {
253
+ await db.query("ROLLBACK");
254
+ }
255
+ await expect(
256
+ db.query(
257
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "person_id")
258
+ VALUES ($1, $1, 'p852-format', 'x@example.com', '{}', $2)`,
259
+ [WS_1, bad],
260
+ ),
261
+ ).rejects.toMatchObject({ code: "23514", constraint: "leads_person_id_format" });
262
+ await expect(
263
+ db.query(
264
+ `INSERT INTO "appointments" ("site_id", "workspace_id", "provider_booking_uid", "start_time", "end_time",
265
+ "attendee_name", "attendee_email", "status", "lifecycle_event_at", "updated_at", "person_id")
266
+ VALUES ($1::text, $1::text, 'p852-format', now(), now(), 'X', 'x@example.com', 'confirmed', now(), now(), $2)`,
267
+ [WS_1, bad],
268
+ ),
269
+ ).rejects.toMatchObject({ code: "23514", constraint: "appointments_person_id_format" });
270
+ });
271
+
272
+ it("two concurrent transactions inserting one (workspace_id, email) with ON CONFLICT DO NOTHING yield one row and one id, and neither aborts (AC-2)", async () => {
273
+ const a = new Client({ connectionString: process.env.DATABASE_URL });
274
+ const b = new Client({ connectionString: process.env.DATABASE_URL });
275
+ await a.connect();
276
+ await b.connect();
277
+ const insert = `INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, 'race@example.com', now())
278
+ ON CONFLICT ("workspace_id", "email") DO NOTHING`;
279
+ const reread = `SELECT "id" FROM "people" WHERE "workspace_id" = $1 AND "email" = 'race@example.com'`;
280
+ try {
281
+ await a.query("BEGIN");
282
+ await b.query("BEGIN");
283
+ const insertedA = await a.query(insert, [WS_1]);
284
+ // B's insert waits on A's uncommitted row, then does nothing.
285
+ const pendingB = b.query(insert, [WS_1]);
286
+ const idA = (await a.query<{ id: string }>(reread, [WS_1])).rows[0]!.id;
287
+ await a.query("COMMIT");
288
+ const insertedB = await pendingB;
289
+ const idB = (await b.query<{ id: string }>(reread, [WS_1])).rows[0]!.id;
290
+ await b.query("COMMIT");
291
+
292
+ expect(insertedA.rowCount).toBe(1);
293
+ expect(insertedB.rowCount).toBe(0);
294
+ expect(idB).toBe(idA);
295
+ const { rows } = await db.query(reread, [WS_1]);
296
+ expect(rows).toHaveLength(1);
297
+ } finally {
298
+ await a.end();
299
+ await b.end();
300
+ }
301
+ });
302
+ });
303
+
304
+ describeDb("the Person backfill (#852 D4, AC-4, live DB)", () => {
305
+ let db: Client;
306
+ const WS = fixture.workspaces;
307
+ const WORKSPACES = Object.values(WS);
308
+ const userIds = new Map<string, string>();
309
+ const leadIds = new Map<string, string>();
310
+ const appointmentIds = new Map<string, string>();
311
+ const userEmails = fixture.input.users.map((u) => u.email);
312
+
313
+ async function cleanup(): Promise<void> {
314
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
315
+ await db.query(`DELETE FROM "flow_checkouts" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
316
+ await db.query(`DELETE FROM "leads" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
317
+ await db.query(`DELETE FROM "appointments" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
318
+ await db.query(`DELETE FROM "subscribers" WHERE "workspace_id" = ANY($1)`, [WORKSPACES]);
319
+ await db.query(`DELETE FROM "users" WHERE "email" = ANY($1)`, [userEmails]);
320
+ }
321
+
322
+ beforeAll(async () => {
323
+ db = new Client({ connectionString: process.env.DATABASE_URL });
324
+ await db.connect();
325
+ await cleanup();
326
+
327
+ for (const u of fixture.input.users) {
328
+ const { rows } = await db.query<{ id: string }>(
329
+ `INSERT INTO "users" ("email", "email_verified", "updated_at") VALUES ($1, $2, now()) RETURNING "id"`,
330
+ [u.email, u.emailVerified],
331
+ );
332
+ userIds.set(u.key, rows[0]!.id);
333
+ }
334
+ for (const lead of fixture.input.leads) {
335
+ const { rows } = await db.query<{ id: string }>(
336
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload", "created_at", "promoted_user_id", "promoted_at")
337
+ VALUES ($1, $1, 'p852', $2, $3, $4, $5, $6) RETURNING "id"`,
338
+ [
339
+ WS[lead.workspace],
340
+ lead.email,
341
+ JSON.stringify(lead.payload),
342
+ lead.createdAt,
343
+ lead.promotedUser ? userIds.get(lead.promotedUser) : null,
344
+ lead.promotedAt ?? null,
345
+ ],
346
+ );
347
+ leadIds.set(lead.key, rows[0]!.id);
348
+ }
349
+ for (const c of fixture.input.flowCheckouts) {
350
+ await db.query(
351
+ `INSERT INTO "flow_checkouts" ("site_id", "workspace_id", "flow_slug", "experiment_key", "session_id", "lead_id",
352
+ "offer_key", "seats", "period", "credit_stop", "amount_minor", "currency", "quote",
353
+ "permalink", "status", "stripe_customer_id", "completed_at", "created_at", "updated_at")
354
+ VALUES ($1, $1, 'p852', 'p852', 'p852-session', $2, 'pro', 1, 'month', '{}', 1900, 'usd', '{}',
355
+ 'p852-permalink', $3, $4, $5, $6, now())`,
356
+ [WS[c.workspace], leadIds.get(c.lead), c.status, c.stripeCustomerId, c.completedAt, c.createdAt],
357
+ );
358
+ }
359
+ for (const a of fixture.input.appointments) {
360
+ const { rows } = await db.query<{ id: string }>(
361
+ `INSERT INTO "appointments" ("site_id", "workspace_id", "provider_booking_uid", "start_time", "end_time",
362
+ "attendee_name", "attendee_email", "status", "lifecycle_event_at", "created_at", "updated_at")
363
+ VALUES ($1::text, $1::text, $2, $3::timestamp, $3::timestamp, $4, $5, 'confirmed', $3::timestamp, $3::timestamp, now()) RETURNING "id"`,
364
+ [WS[a.workspace], `p852-${a.key}`, a.createdAt, a.attendeeName, a.attendeeEmail],
365
+ );
366
+ appointmentIds.set(a.key, rows[0]!.id);
367
+ }
368
+ for (const s of fixture.input.subscribers) {
369
+ await db.query(
370
+ `INSERT INTO "subscribers" ("site_id", "workspace_id", "email", "source") VALUES ($1, $1, $2, 'p852')`,
371
+ [WS[s.workspace], s.email],
372
+ );
373
+ }
374
+
375
+ await db.query(readFileSync(BACKFILL_MIGRATION, "utf8"));
376
+ });
377
+
378
+ afterAll(async () => {
379
+ await cleanup();
380
+ await db.end();
381
+ });
382
+
383
+ type PersonRow = {
384
+ id: string;
385
+ workspace_id: string;
386
+ email: string;
387
+ name: string | null;
388
+ billing_provider: string | null;
389
+ billing_customer_id: string | null;
390
+ user_id: string | null;
391
+ };
392
+
393
+ const readPeople = async () =>
394
+ (
395
+ await db.query<PersonRow>(
396
+ `SELECT "id", "workspace_id", "email", "name", "billing_provider", "billing_customer_id", "user_id"
397
+ FROM "people" WHERE "workspace_id" = ANY($1) ORDER BY "workspace_id", "email"`,
398
+ [WORKSPACES],
399
+ )
400
+ ).rows;
401
+
402
+ const snapshot = async () => ({
403
+ people: (
404
+ await db.query(
405
+ `SELECT * FROM "people" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
406
+ [WORKSPACES],
407
+ )
408
+ ).rows,
409
+ leads: (
410
+ await db.query(
411
+ `SELECT "id", "person_id" FROM "leads" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
412
+ [WORKSPACES],
413
+ )
414
+ ).rows,
415
+ appointments: (
416
+ await db.query(
417
+ `SELECT * FROM "appointments" WHERE "workspace_id" = ANY($1) ORDER BY "id"`,
418
+ [WORKSPACES],
419
+ )
420
+ ).rows,
421
+ });
422
+
423
+ it("the TypeScript-side normalization pairs match the SQL N(x) and VALID(e) on every fixture email", async () => {
424
+ for (const e of fixture.emails) {
425
+ const { rows } = await db.query<{ n: string; valid: boolean }>(
426
+ `SELECT ${N_SQL} AS n,
427
+ (${N_SQL} ~ '^[!-?A-~]+@[!-?A-~]+$' AND length(${N_SQL}) <= 254) AS valid`,
428
+ [e.raw],
429
+ );
430
+ expect({ raw: e.raw, n: rows[0]!.n, valid: rows[0]!.valid }).toEqual({
431
+ raw: e.raw,
432
+ n: e.normalized,
433
+ valid: e.storable,
434
+ });
435
+ }
436
+ });
437
+
438
+ it("makes exactly the expected Persons (email, name, customer id, user)", async () => {
439
+ const people = await readPeople();
440
+ expect(
441
+ people.map((p) => ({
442
+ workspace: Object.keys(WS).find((k) => WS[k] === p.workspace_id),
443
+ email: p.email,
444
+ name: p.name,
445
+ billingCustomerId: p.billing_customer_id,
446
+ user: p.user_id === null ? null : [...userIds].find(([, id]) => id === p.user_id)?.[0],
447
+ })),
448
+ ).toEqual(fixture.expected.people);
449
+ for (const p of people) {
450
+ expect(p.billing_provider).toBe(p.billing_customer_id === null ? null : "stripe");
451
+ }
452
+ });
453
+
454
+ it("stamps every lead and appointment with a storable email with its own workspace's Person; the rest stay NULL", async () => {
455
+ const people = await readPeople();
456
+ const personOf = (workspace: string, raw: string) =>
457
+ people.find(
458
+ (p) => p.workspace_id === WS[workspace] && p.email === fixture.emails.find((e) => e.raw === raw)?.normalized,
459
+ )?.id ?? null;
460
+ for (const l of fixture.input.leads) {
461
+ const { rows } = await db.query<{ person_id: string | null }>(
462
+ `SELECT "person_id" FROM "leads" WHERE "id" = $1`,
463
+ [leadIds.get(l.key)],
464
+ );
465
+ const expected = fixture.expected.unstamped.includes(l.key) ? null : personOf(l.workspace, l.email);
466
+ expect({ key: l.key, personId: rows[0]!.person_id }).toEqual({ key: l.key, personId: expected });
467
+ if (expected === null) expect(fixture.expected.unstamped).toContain(l.key);
468
+ }
469
+ for (const a of fixture.input.appointments) {
470
+ const { rows } = await db.query<{ person_id: string | null }>(
471
+ `SELECT "person_id" FROM "appointments" WHERE "id" = $1`,
472
+ [appointmentIds.get(a.key)],
473
+ );
474
+ const expected = fixture.expected.unstamped.includes(a.key) ? null : personOf(a.workspace, a.attendeeEmail);
475
+ expect({ key: a.key, personId: rows[0]!.person_id }).toEqual({ key: a.key, personId: expected });
476
+ }
477
+ });
478
+
479
+ it("makes no Person from a Subscriber", async () => {
480
+ const { rows } = await db.query(
481
+ `SELECT 1 FROM "people" WHERE "email" = $1`,
482
+ [fixture.input.subscribers[0]!.email],
483
+ );
484
+ expect(rows).toHaveLength(0);
485
+ });
486
+
487
+ it("the post-cutover script changes no row on a second run, then stamps a lead written after the first", async () => {
488
+ const before = await snapshot();
489
+ await db.query(readFileSync(BACKFILL_SCRIPT, "utf8"));
490
+ expect(await snapshot()).toEqual(before);
491
+
492
+ const { rows } = await db.query<{ id: string }>(
493
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload")
494
+ VALUES ($1, $1, 'p852', ' EVE@x.io', '{}') RETURNING "id"`,
495
+ [WS["A"]],
496
+ );
497
+ await db.query(readFileSync(BACKFILL_SCRIPT, "utf8"));
498
+ const stamped = await db.query<{ person_id: string | null }>(
499
+ `SELECT "person_id" FROM "leads" WHERE "id" = $1`,
500
+ [rows[0]!.id],
501
+ );
502
+ const eve = (await readPeople()).find((p) => p.workspace_id === WS["A"] && p.email === "eve@x.io");
503
+ expect(stamped.rows[0]!.person_id).toBe(eve!.id);
504
+ expect((await readPeople()).length).toBe(fixture.expected.people.length);
505
+ });
506
+ });
507
+
508
+ describeDb("the timeline's pre-migration email fallback (#852 D7, AC-6, live DB)", () => {
509
+ let db: Client;
510
+ const WS_1 = "wksp_01920000-0852-7000-8000-0000000000d1";
511
+ const WS_2 = "wksp_01920000-0852-7000-8000-0000000000d2";
512
+ const LEAD = "lead_01920000-0852-7000-8000-0000000000d1";
513
+ const T = new Date("2026-06-01T12:00:00.000Z");
514
+ const at = (offsetMs: number) => new Date(T.getTime() + offsetMs).toISOString();
515
+
516
+ async function cleanup(): Promise<void> {
517
+ await db.query(`DELETE FROM "flow_sessions" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
518
+ await db.query(`DELETE FROM "project_listings" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
519
+ }
520
+
521
+ const insertSession = async (workspaceId: string, email: string, createdAt: string, leadId: string | null = null) =>
522
+ (
523
+ await db.query<{ id: string }>(
524
+ `INSERT INTO "flow_sessions" ("site_id", "workspace_id", "flow_id", "flow_slug", "config_snapshot", "email", "lead_id", "created_at", "updated_at")
525
+ VALUES ($1, $1, 'p852-flow', 'p852', '{}', $2, $3, $4, now()) RETURNING "id"`,
526
+ [workspaceId, email, leadId, createdAt],
527
+ )
528
+ ).rows[0]!.id;
529
+
530
+ const insertListing = async (workspaceId: string, email: string, createdAt: string, leadId: string | null = null) =>
531
+ (
532
+ await db.query<{ id: string }>(
533
+ `INSERT INTO "project_listings" ("site_id", "workspace_id", "founder_name", "founder_email", "status", "lead_id", "created_at", "updated_at")
534
+ VALUES ($1, $1, 'Founder', $2, 'pending', $3, $4, now()) RETURNING "id"`,
535
+ [workspaceId, email, leadId, createdAt],
536
+ )
537
+ ).rows[0]!.id;
538
+
539
+ const fallback = async (sql: string, email: string, workspaceId: string) =>
540
+ (await db.query<{ id: string }>(sql, [email, workspaceId, T])).rows.map((r) => r.id);
541
+
542
+ beforeAll(async () => {
543
+ db = new Client({ connectionString: process.env.DATABASE_URL });
544
+ await db.connect();
545
+ await cleanup();
546
+ });
547
+
548
+ afterAll(async () => {
549
+ await cleanup();
550
+ await db.end();
551
+ });
552
+
553
+ it.each([
554
+ ["flow_sessions", PERSON_FALLBACK_FLOW_SESSIONS_SQL, insertSession],
555
+ ["project_listings", PERSON_FALLBACK_PROJECT_LISTINGS_SQL, insertListing],
556
+ ] as const)("%s: only a lead-less, pre-cutoff row of this workspace with the exact normalized address", async (_t, sql, insert) => {
557
+ const person = `ada-${_t}@x.io`;
558
+ const before = await insert(WS_1, ` ADA-${_t.toUpperCase()}@X.IO\t`, at(-1000));
559
+ await insert(WS_1, person, at(0)); // exactly T
560
+ await insert(WS_1, person, at(1000)); // after T
561
+ await insert(WS_1, person, at(-2000), LEAD); // has a lead
562
+ await insert(WS_2, person, at(-1000)); // another workspace
563
+ expect(await fallback(sql, person, WS_1)).toEqual([before]);
564
+
565
+ // No wildcard: `_` is a literal character.
566
+ await insert(WS_1, `john-${_t}@x.io`, at(-1000));
567
+ expect(await fallback(sql, `j_hn-${_t}@x.io`, WS_1)).toEqual([]);
568
+
569
+ // No non-ASCII case fold: a KELVIN SIGN (U+212A) is not `k`.
570
+ await insert(WS_1, `Kate-${_t}@x.io`, at(-1000));
571
+ expect(await fallback(sql, `kate-${_t}@x.io`, WS_1)).toEqual([]);
572
+ });
573
+
574
+ it("PERSON_CUTOFF_SQL returns the backfill migration's finished_at", async () => {
575
+ const { rows } = await db.query<{ finished_at: Date }>(PERSON_CUTOFF_SQL);
576
+ const direct = await db.query<{ finished_at: Date }>(
577
+ `SELECT "finished_at" FROM "_prisma_migrations" WHERE "migration_name" = $1`,
578
+ [PERSON_BACKFILL_MIGRATION],
579
+ );
580
+ expect(rows).toHaveLength(1);
581
+ expect(rows[0]!.finished_at).toBeInstanceOf(Date);
582
+ expect(rows[0]!.finished_at.getTime()).toBe(direct.rows[0]!.finished_at.getTime());
583
+ });
584
+ });
@@ -106,6 +106,9 @@ model Workspace {
106
106
  spokes Spoke[]
107
107
  // #3591 — the agents bound to this workspace.
108
108
  agents Agent[]
109
+ // #7803 — the people who last activated this workspace (User.lastWorkspaceId).
110
+ // Read and written only by apps/api/src/services/tenant-context.ts.
111
+ lastActiveUsers User[] @relation("UserLastWorkspace")
109
112
 
110
113
  @@unique([organizationId, slug])
111
114
  @@index([organizationId])
@@ -167,6 +170,17 @@ enum AgentPermissionLevel {
167
170
  /// deleting the creator does not delete the agents they made for others.
168
171
  /// `apps/api/src/lib/agent-store.ts` is the only file that names
169
172
  /// `prisma.agent`.
173
+ ///
174
+ /// `grants` (#7864, spec docs/specs/7864-agent-key-grants-spec.md D2, D3)
175
+ /// lists the permissions an owner or admin granted this agent one by one —
176
+ /// only the seven `@working-theory/validation` names in
177
+ /// `AGENT_GRANTABLE_PERMISSIONS` (content-edit, content-publish, assets-write,
178
+ /// magnets-manage, meeting-types-manage, people-read since #852,
179
+ /// member-pieces-manage since #7878), held to that set by the
180
+ /// `agents_grants_allowed` CHECK. A grant never raises the ceiling above. The
181
+ /// agent holds a stored grant only while its person's own role holds it too:
182
+ /// the tenant guards clamp it on every request, so a demoted person's agent
183
+ /// keeps the row but loses the permission.
170
184
  model Agent {
171
185
  id String @id @default(dbgenerated("('agent_'::text || (uuidv7())::text)")) @db.Text
172
186
  organizationId String @map("organization_id")
@@ -174,6 +188,7 @@ model Agent {
174
188
  responsibleMembershipId String @map("responsible_membership_id")
175
189
  name String @db.VarChar(80)
176
190
  permissionLevel AgentPermissionLevel @default(member) @map("permission_level")
191
+ grants String[] @default([]) @map("grants")
177
192
  enabled Boolean @default(true)
178
193
  credentialHash String @unique @map("credential_hash") @db.Char(64)
179
194
  credentialPrefix String @map("credential_prefix") @db.VarChar(24)
@@ -374,7 +389,16 @@ model User {
374
389
  memberships Membership[]
375
390
  sentInvitations Invitation[] @relation("sent_invitations")
376
391
 
392
+ // #7803 — the workspace this person last activated, so a new sign-in lands
393
+ // there. Written only by activateWorkspace after its membership grant; read
394
+ // only by listTenancy, which reports it only when the workspace is in the
395
+ // caller's own listing (apps/api/src/services/tenant-context.ts). Deleting
396
+ // the workspace clears it. Never a Better Auth field, so no client can set it.
397
+ lastWorkspaceId String? @map("last_workspace_id")
398
+ lastWorkspace Workspace? @relation("UserLastWorkspace", fields: [lastWorkspaceId], references: [id], onDelete: SetNull)
399
+
377
400
  @@unique([billingProvider, billingCustomerId])
401
+ @@index([lastWorkspaceId])
378
402
  @@map("users")
379
403
  }
380
404
 
@@ -136,6 +136,19 @@ model WorkspaceMeetings {
136
136
  @@map("workspace_meetings")
137
137
  }
138
138
 
139
+ /// WorkspacePieceStages — a workspace's declared piece stages, in order
140
+ /// (#7877). One row per workspace, the WorkspaceMeetings shape; no row means
141
+ /// the product default (apps/api, tenant-db.ts DEFAULT_PIECE_STAGES).
142
+ model WorkspacePieceStages {
143
+ id String @id @default(dbgenerated("('pstages_'::text || (uuidv7())::text)")) @db.Text
144
+ workspaceId String @unique @map("workspace_id")
145
+ stages String[]
146
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
147
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
148
+
149
+ @@map("workspace_piece_stages")
150
+ }
151
+
139
152
  /// SchedulingConnection — a workspace's own scheduling provider account
140
153
  /// (#7533 D1): the provider id, its credential and the per-connection webhook
141
154
  /// entry point + secret, so each workspace reads and receives bookings through