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
@@ -0,0 +1,264 @@
1
+ {
2
+ "_comment": "#852 AC-4 — the Person backfill fixture. person.db.test.ts seeds `input` into live Postgres, runs database/migrations/20261010121000_person_backfill/migration.sql, and asserts the people rows equal `expected.people`; apps/api's person.test.ts seeds `expected.people` into the in-memory double and calls findOrCreatePerson on every raw email in `input`, asserting zero new rows. `emails` pairs each raw address with its normalized form, so the TypeScript and SQL normalizations are checked against the same values. Timestamps are UTC.",
3
+ "workspaces": {
4
+ "A": "wksp_01920000-0852-7000-8000-00000000000a",
5
+ "B": "wksp_01920000-0852-7000-8000-00000000000b"
6
+ },
7
+ "input": {
8
+ "users": [
9
+ { "key": "pat", "email": "Pat@P852.test", "emailVerified": true },
10
+ { "key": "uma", "email": "uma@p852.test", "emailVerified": false },
11
+ { "key": "dee", "email": "someone-else@p852.test", "emailVerified": true }
12
+ ],
13
+ "leads": [
14
+ {
15
+ "key": "L1",
16
+ "workspace": "A",
17
+ "email": " Ada@Example.COM\t",
18
+ "payload": { "name": "Ada Lovelace" },
19
+ "createdAt": "2026-01-01T00:00:00.000Z"
20
+ },
21
+ {
22
+ "key": "L2",
23
+ "workspace": "A",
24
+ "email": "ada@example.com",
25
+ "payload": { "name": "Later Ada" },
26
+ "createdAt": "2026-01-02T00:00:00.000Z"
27
+ },
28
+ {
29
+ "key": "L3",
30
+ "workspace": "A",
31
+ "email": "ADA@EXAMPLE.COM",
32
+ "payload": {},
33
+ "createdAt": "2026-01-03T00:00:00.000Z"
34
+ },
35
+ {
36
+ "key": "L4",
37
+ "workspace": "B",
38
+ "email": "ada@example.com",
39
+ "payload": { "name": "Ada B" },
40
+ "createdAt": "2026-01-01T00:00:00.000Z"
41
+ },
42
+ {
43
+ "key": "L5",
44
+ "workspace": "A",
45
+ "email": "vic@x.dev",
46
+ "payload": { "name": 42 },
47
+ "createdAt": "2026-01-04T00:00:00.000Z"
48
+ },
49
+ {
50
+ "key": "L6",
51
+ "workspace": "A",
52
+ "email": "\u000Bdana@x.io\u000B",
53
+ "payload": { "name": " Dana\u202E " },
54
+ "createdAt": "2026-01-05T00:00:00.000Z"
55
+ },
56
+ {
57
+ "key": "L7",
58
+ "workspace": "A",
59
+ "email": " Eve@X.IO\t",
60
+ "payload": {},
61
+ "createdAt": "2026-01-06T00:00:00.000Z"
62
+ },
63
+ {
64
+ "key": "L8",
65
+ "workspace": "A",
66
+ "email": "pat@p852.test",
67
+ "payload": {},
68
+ "createdAt": "2026-01-07T00:00:00.000Z",
69
+ "promotedUser": "pat",
70
+ "promotedAt": "2026-02-01T00:00:00.000Z"
71
+ },
72
+ {
73
+ "key": "L9",
74
+ "workspace": "A",
75
+ "email": "uma@p852.test",
76
+ "payload": {},
77
+ "createdAt": "2026-01-08T00:00:00.000Z",
78
+ "promotedUser": "uma",
79
+ "promotedAt": "2026-02-02T00:00:00.000Z"
80
+ },
81
+ {
82
+ "key": "L10",
83
+ "workspace": "A",
84
+ "email": "dee@p852.test",
85
+ "payload": {},
86
+ "createdAt": "2026-01-09T00:00:00.000Z",
87
+ "promotedUser": "dee",
88
+ "promotedAt": "2026-02-03T00:00:00.000Z"
89
+ },
90
+ {
91
+ "key": "L11",
92
+ "workspace": "A",
93
+ "email": "not-an-email",
94
+ "payload": {},
95
+ "createdAt": "2026-01-10T00:00:00.000Z"
96
+ }
97
+ ],
98
+ "flowCheckouts": [
99
+ {
100
+ "key": "C1",
101
+ "workspace": "A",
102
+ "lead": "L1",
103
+ "status": "completed",
104
+ "stripeCustomerId": "cus_old",
105
+ "completedAt": "2026-03-01T00:00:00.000Z",
106
+ "createdAt": "2026-03-01T00:00:00.000Z"
107
+ },
108
+ {
109
+ "key": "C2",
110
+ "workspace": "A",
111
+ "lead": "L2",
112
+ "status": "completed",
113
+ "stripeCustomerId": "cus_new",
114
+ "completedAt": "2026-03-05T00:00:00.000Z",
115
+ "createdAt": "2026-03-05T00:00:00.000Z"
116
+ },
117
+ {
118
+ "key": "C3",
119
+ "workspace": "A",
120
+ "lead": "L7",
121
+ "status": "expired",
122
+ "stripeCustomerId": "cus_expired",
123
+ "completedAt": null,
124
+ "createdAt": "2026-03-06T00:00:00.000Z"
125
+ }
126
+ ],
127
+ "appointments": [
128
+ {
129
+ "key": "P1",
130
+ "workspace": "A",
131
+ "attendeeEmail": "ada@example.com",
132
+ "attendeeName": "Appointment Ada",
133
+ "createdAt": "2026-04-01T00:00:00.000Z"
134
+ },
135
+ {
136
+ "key": "P2",
137
+ "workspace": "A",
138
+ "attendeeEmail": "Zed@Example.com",
139
+ "attendeeName": "Zed\u0007 Z",
140
+ "createdAt": "2026-04-02T00:00:00.000Z"
141
+ },
142
+ {
143
+ "key": "P3",
144
+ "workspace": "A",
145
+ "attendeeEmail": "bad email@x.io",
146
+ "attendeeName": "Not Storable",
147
+ "createdAt": "2026-04-03T00:00:00.000Z"
148
+ }
149
+ ],
150
+ "subscribers": [{ "workspace": "A", "email": "subscriber-only@p852.test" }]
151
+ },
152
+ "emails": [
153
+ {
154
+ "raw": " Ada@Example.COM\t",
155
+ "normalized": "ada@example.com",
156
+ "storable": true
157
+ },
158
+ {
159
+ "raw": "ada@example.com",
160
+ "normalized": "ada@example.com",
161
+ "storable": true
162
+ },
163
+ {
164
+ "raw": "ADA@EXAMPLE.COM",
165
+ "normalized": "ada@example.com",
166
+ "storable": true
167
+ },
168
+ { "raw": "vic@x.dev", "normalized": "vic@x.dev", "storable": true },
169
+ {
170
+ "raw": "\u000Bdana@x.io\u000B",
171
+ "normalized": "dana@x.io",
172
+ "storable": true
173
+ },
174
+ { "raw": " Eve@X.IO\t", "normalized": "eve@x.io", "storable": true },
175
+ { "raw": "pat@p852.test", "normalized": "pat@p852.test", "storable": true },
176
+ { "raw": "Pat@P852.test", "normalized": "pat@p852.test", "storable": true },
177
+ { "raw": "uma@p852.test", "normalized": "uma@p852.test", "storable": true },
178
+ { "raw": "dee@p852.test", "normalized": "dee@p852.test", "storable": true },
179
+ { "raw": "not-an-email", "normalized": "not-an-email", "storable": false },
180
+ {
181
+ "raw": "Zed@Example.com",
182
+ "normalized": "zed@example.com",
183
+ "storable": true
184
+ },
185
+ {
186
+ "raw": "bad email@x.io",
187
+ "normalized": "bad email@x.io",
188
+ "storable": false
189
+ },
190
+ {
191
+ "raw": "\r\n\fFeed@X.io \r\n",
192
+ "normalized": "feed@x.io",
193
+ "storable": true
194
+ }
195
+ ],
196
+ "expected": {
197
+ "people": [
198
+ {
199
+ "workspace": "A",
200
+ "email": "ada@example.com",
201
+ "name": "Ada Lovelace",
202
+ "billingCustomerId": "cus_old",
203
+ "user": null
204
+ },
205
+ {
206
+ "workspace": "A",
207
+ "email": "dana@x.io",
208
+ "name": "Dana",
209
+ "billingCustomerId": null,
210
+ "user": null
211
+ },
212
+ {
213
+ "workspace": "A",
214
+ "email": "dee@p852.test",
215
+ "name": null,
216
+ "billingCustomerId": null,
217
+ "user": null
218
+ },
219
+ {
220
+ "workspace": "A",
221
+ "email": "eve@x.io",
222
+ "name": null,
223
+ "billingCustomerId": null,
224
+ "user": null
225
+ },
226
+ {
227
+ "workspace": "A",
228
+ "email": "pat@p852.test",
229
+ "name": null,
230
+ "billingCustomerId": null,
231
+ "user": "pat"
232
+ },
233
+ {
234
+ "workspace": "A",
235
+ "email": "uma@p852.test",
236
+ "name": null,
237
+ "billingCustomerId": null,
238
+ "user": null
239
+ },
240
+ {
241
+ "workspace": "A",
242
+ "email": "vic@x.dev",
243
+ "name": null,
244
+ "billingCustomerId": null,
245
+ "user": null
246
+ },
247
+ {
248
+ "workspace": "A",
249
+ "email": "zed@example.com",
250
+ "name": "Zed Z",
251
+ "billingCustomerId": null,
252
+ "user": null
253
+ },
254
+ {
255
+ "workspace": "B",
256
+ "email": "ada@example.com",
257
+ "name": "Ada B",
258
+ "billingCustomerId": null,
259
+ "user": null
260
+ }
261
+ ],
262
+ "unstamped": ["L11", "P3"]
263
+ }
264
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @file member-area.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the member-area records (#7877, spec
5
+ * docs/specs/7877-the-member-area-contract-a-spec.md D1, D2, D3; AC-1).
6
+ * Requires DATABASE_URL pointing at a Postgres with the migrations applied —
7
+ * the live suite SKIPS when DATABASE_URL is unset, keeping the workspace Jest
8
+ * run DB-free (the repo convention; the `apps/api Route Tests (Postgres)`
9
+ * workflow exercises it).
10
+ *
11
+ * Uses the `pg` driver directly, as `person.db.test.ts` does. What it proves
12
+ * that a mock cannot:
13
+ * 1. A piece or an interaction can only name a Person of ITS OWN workspace
14
+ * (the composite `(person_id, workspace_id)` foreign keys), and a Person
15
+ * holding one cannot be deleted (RESTRICT).
16
+ * 2. Every CHECK the migration declares: the id formats, the title and
17
+ * stage shapes, the one channel and one direction, the subject and body
18
+ * lengths, the stage-list size; and one stage list per workspace.
19
+ * 3. Neither inbox table carries an email column.
20
+ *
21
+ * Two cases need no database and always run: the wire schemas name no stage
22
+ * or offering literal, and the product default stage list lives only in
23
+ * apps/api.
24
+ */
25
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ import { Client } from "pg";
29
+
30
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
31
+
32
+ const REPO_ROOT = join(__dirname, "..", "..");
33
+
34
+ /** create-ailk's generated scaffold tree (gitignored) — a copy, not package source. */
35
+ const GENERATED_TEMPLATES = join(REPO_ROOT, "packages", "create-ailk", "templates");
36
+
37
+ /**
38
+ * Every source `.ts` file under a directory, recursively — skipping build
39
+ * output, tests (this file names the constant it hunts for) and the
40
+ * generated scaffold copy.
41
+ */
42
+ function tsFiles(dir: string): string[] {
43
+ const out: string[] = [];
44
+ for (const name of readdirSync(dir)) {
45
+ if (["node_modules", "dist", "__tests__"].includes(name)) continue;
46
+ if (join(dir, name) === GENERATED_TEMPLATES) continue;
47
+ const path = join(dir, name);
48
+ if (statSync(path).isDirectory()) out.push(...tsFiles(path));
49
+ else if (path.endsWith(".ts")) out.push(path);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * The two no-database cases read the MONOREPO's `packages/` source. A
56
+ * scaffolded site (create-ailk's `verify-scaffold` legs run this file too) has
57
+ * no `packages/`, so they are skipped there rather than thrown (#7920 CI).
58
+ */
59
+ const describeMonorepo = existsSync(join(REPO_ROOT, "packages", "validation"))
60
+ ? describe
61
+ : describe.skip;
62
+
63
+ describeMonorepo("the member-area wire contract (#7877 AC-1, no database)", () => {
64
+ it("packages/validation's pieces and member schemas name no stage literal and no enum", () => {
65
+ for (const file of ["pieces.ts", "member.ts"]) {
66
+ const text = readFileSync(
67
+ join(REPO_ROOT, "packages", "validation", "src", "schemas", file),
68
+ "utf8",
69
+ );
70
+ expect(text).not.toMatch(/requested|in-progress|in-review|delivered|scoping|z\.enum/);
71
+ }
72
+ });
73
+
74
+ it("the product default stage list is named by no package", () => {
75
+ const hits = tsFiles(join(REPO_ROOT, "packages")).filter((file) =>
76
+ readFileSync(file, "utf8").includes("DEFAULT_PIECE_STAGES"),
77
+ );
78
+ expect(hits).toEqual([]);
79
+ });
80
+ });
81
+
82
+ describeDb("the member-area tables (#7877 D1–D3, AC-1, live DB)", () => {
83
+ let db: Client;
84
+ const WS_1 = "wksp_01920000-7877-7000-8000-0000000000a1";
85
+ const WS_2 = "wksp_01920000-7877-7000-8000-0000000000a2";
86
+ const USER = "user_01920000-7877-7000-8000-0000000000a1";
87
+ const ASSET = "asset_01920000-7877-7000-8000-0000000000a1";
88
+ const LEAD_ID = "lead_01920000-7877-7000-8000-0000000000a1";
89
+ let personWs1: string;
90
+ let personWs2: string;
91
+
92
+ const cleanup = async () => {
93
+ await db.query(`DELETE FROM "interactions" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
94
+ await db.query(`DELETE FROM "pieces" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
95
+ await db.query(`DELETE FROM "workspace_piece_stages" WHERE "workspace_id" IN ($1, $2)`, [
96
+ WS_1,
97
+ WS_2,
98
+ ]);
99
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" IN ($1, $2)`, [WS_1, WS_2]);
100
+ };
101
+
102
+ beforeAll(async () => {
103
+ db = new Client({ connectionString: process.env.DATABASE_URL });
104
+ await db.connect();
105
+ await cleanup();
106
+ const insert = (ws: string, email: string) =>
107
+ db.query<{ id: string }>(
108
+ `INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, $2, now()) RETURNING "id"`,
109
+ [ws, email],
110
+ );
111
+ personWs1 = (await insert(WS_1, "client@example.com")).rows[0]!.id;
112
+ personWs2 = (await insert(WS_2, "client@example.com")).rows[0]!.id;
113
+ });
114
+
115
+ afterAll(async () => {
116
+ await cleanup();
117
+ await db.end();
118
+ });
119
+
120
+ type PieceCols = {
121
+ workspace_id?: string;
122
+ person_id?: string;
123
+ title?: string;
124
+ stage?: string;
125
+ updated_by?: string;
126
+ };
127
+ const insertPiece = (cols: PieceCols = {}) =>
128
+ db.query<{ id: string }>(
129
+ `INSERT INTO "pieces" ("workspace_id", "person_id", "title", "stage", "updated_by", "updated_at")
130
+ VALUES ($1, $2, $3, $4, $5, now()) RETURNING "id"`,
131
+ [
132
+ cols.workspace_id ?? WS_1,
133
+ cols.person_id ?? personWs1,
134
+ cols.title ?? "Homepage copy",
135
+ cols.stage ?? "in-progress",
136
+ cols.updated_by ?? USER,
137
+ ],
138
+ );
139
+
140
+ type InteractionCols = {
141
+ workspace_id?: string;
142
+ person_id?: string;
143
+ channel?: string;
144
+ direction?: string;
145
+ subject?: string;
146
+ body?: string;
147
+ asset_id?: string | null;
148
+ };
149
+ const insertInteraction = (cols: InteractionCols = {}) =>
150
+ db.query<{ id: string }>(
151
+ `INSERT INTO "interactions" ("workspace_id", "person_id", "channel", "direction", "subject", "body", "asset_id", "created_by")
152
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING "id"`,
153
+ [
154
+ cols.workspace_id ?? WS_1,
155
+ cols.person_id ?? personWs1,
156
+ cols.channel ?? "member",
157
+ cols.direction ?? "outbound",
158
+ cols.subject ?? "Draft 1",
159
+ cols.body ?? "Here it is",
160
+ cols.asset_id === undefined ? ASSET : cols.asset_id,
161
+ USER,
162
+ ],
163
+ );
164
+
165
+ it("mints piece_ and intx_ ids and accepts a well-formed row of each", async () => {
166
+ const piece = await insertPiece();
167
+ expect(piece.rows[0]!.id).toMatch(/^piece_[0-9a-f-]{36}$/);
168
+ const message = await insertInteraction();
169
+ expect(message.rows[0]!.id).toMatch(/^intx_[0-9a-f-]{36}$/);
170
+ const noAsset = await insertInteraction({ asset_id: null });
171
+ expect(noAsset.rows[0]!.id).toMatch(/^intx_/);
172
+ });
173
+
174
+ it("refuses a piece or a message naming a Person of ANOTHER workspace (the composite FK)", async () => {
175
+ await expect(insertPiece({ person_id: personWs2 })).rejects.toMatchObject({
176
+ code: "23503",
177
+ constraint: "pieces_person_fk",
178
+ });
179
+ await expect(insertInteraction({ person_id: personWs2 })).rejects.toMatchObject({
180
+ code: "23503",
181
+ constraint: "interactions_person_fk",
182
+ });
183
+ });
184
+
185
+ it.each([
186
+ ["a 201-character title", { title: "x".repeat(201) }, "pieces_title_length"],
187
+ ["an all-space title", { title: " " }, "pieces_title_length"],
188
+ ["a stage `In Progress`", { stage: "In Progress" }, "pieces_stage_format"],
189
+ ["a lead_ person id", { person_id: LEAD_ID }, "pieces_person_id_format"],
190
+ ["a lead_ updated_by", { updated_by: LEAD_ID }, "pieces_updated_by_format"],
191
+ ])("refuses a piece with %s", async (_label, cols, constraint) => {
192
+ // A 201-character title overflows VARCHAR(200) before the CHECK runs.
193
+ const expected =
194
+ cols.title?.length === 201 ? { code: "22001" } : { code: "23514", constraint };
195
+ await expect(insertPiece(cols)).rejects.toMatchObject(expected);
196
+ });
197
+
198
+ it.each([
199
+ ["channel `email`", { channel: "email" }, "interactions_channel_allowed"],
200
+ ["direction `inbound`", { direction: "inbound" }, "interactions_direction_allowed"],
201
+ ["a lead_ asset id", { asset_id: LEAD_ID }, "interactions_asset_id_format"],
202
+ ["an empty subject", { subject: "" }, "interactions_subject_length"],
203
+ ["a 10001-character body", { body: "b".repeat(10001) }, "interactions_body_length"],
204
+ ])("refuses an interaction with %s", async (_label, cols, constraint) => {
205
+ await expect(insertInteraction(cols)).rejects.toMatchObject({ code: "23514", constraint });
206
+ });
207
+
208
+ it("holds a stage list to 1–20 stages and one row per workspace", async () => {
209
+ const insert = (ws: string, stages: string[]) =>
210
+ db.query(
211
+ `INSERT INTO "workspace_piece_stages" ("workspace_id", "stages", "updated_at") VALUES ($1, $2, now())`,
212
+ [ws, stages],
213
+ );
214
+ await expect(insert(WS_1, [])).rejects.toMatchObject({
215
+ code: "23514",
216
+ constraint: "workspace_piece_stages_count",
217
+ });
218
+ await expect(
219
+ insert(
220
+ WS_1,
221
+ Array.from({ length: 21 }, (_, i) => `s${i}`),
222
+ ),
223
+ ).rejects.toMatchObject({ code: "23514", constraint: "workspace_piece_stages_count" });
224
+ await insert(WS_1, ["scoping", "brief", "writing", "in-review", "delivered"]);
225
+ await expect(insert(WS_1, ["requested"])).rejects.toMatchObject({
226
+ code: "23505",
227
+ constraint: "workspace_piece_stages_workspace_id_key",
228
+ });
229
+ });
230
+
231
+ it("refuses to delete a Person who holds a piece (RESTRICT)", async () => {
232
+ await insertPiece();
233
+ await expect(
234
+ db.query(`DELETE FROM "people" WHERE "id" = $1`, [personWs1]),
235
+ ).rejects.toMatchObject({ code: "23503", constraint: "pieces_person_fk" });
236
+ });
237
+
238
+ it("neither inbox table has an email column", async () => {
239
+ const { rows } = await db.query<{ table_name: string }>(
240
+ `SELECT "table_name" FROM information_schema.columns
241
+ WHERE "table_name" IN ('pieces', 'interactions') AND "column_name" ILIKE '%email%'`,
242
+ );
243
+ expect(rows).toEqual([]);
244
+ });
245
+ });