create-ailk 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,413 @@
1
+ /**
2
+ * @file member-contract.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the member contract (#8168, spec
5
+ * docs/specs/8168-member-area-data-model-spec.md D1–D3, D8, D9; AC-2, AC-4).
6
+ * Requires DATABASE_URL pointing at a Postgres with the migrations applied —
7
+ * the suite SKIPS when DATABASE_URL is unset, keeping the workspace Jest run
8
+ * DB-free (the repo convention; the `apps/api Route Tests (Postgres)`
9
+ * workflow exercises it).
10
+ *
11
+ * Uses the `pg` driver directly, as `member-area.db.test.ts` does. What it
12
+ * proves that a mock cannot:
13
+ * 1. Every CHECK the migration adds to `interactions` and `piece_versions`
14
+ * refuses its named shape, and an inbound `message` lands.
15
+ * 2. A version can only name a piece of ITS OWN Person and workspace
16
+ * (piece_versions_piece_fk), and a version number is unique per piece.
17
+ * 3. The request-quote backfill, re-executed VERBATIM from the migration
18
+ * file (the `flow-workspace-key.db.test.ts` technique), seeds a workspace
19
+ * that has no magnet, leaves a workspace's own `request-quote` row
20
+ * byte-for-byte, and adds nothing when run again. It runs inside a
21
+ * transaction that is rolled back, so no other suite sharing the
22
+ * database ever sees its rows.
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ import { requestQuoteFlowConfig } from "@working-theory/validation";
28
+ import { Client } from "pg";
29
+
30
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
31
+
32
+ const MIGRATION_SQL = readFileSync(
33
+ join(
34
+ __dirname,
35
+ "..",
36
+ "migrations",
37
+ "20261012120000_member_contract",
38
+ "migration.sql",
39
+ ),
40
+ "utf8",
41
+ );
42
+
43
+ /** The text between the `-- BEGIN request-quote backfill` / `-- END …` markers. */
44
+ function backfillBlock(sql: string): string {
45
+ const match =
46
+ /-- BEGIN request-quote backfill\n([\s\S]*?)-- END request-quote backfill/.exec(
47
+ sql,
48
+ );
49
+ if (!match?.[1]) throw new Error("request-quote backfill markers not found");
50
+ return match[1];
51
+ }
52
+
53
+ describeDb("the member contract (#8168 D1–D3, AC-2, live DB)", () => {
54
+ let db: Client;
55
+ const WS_1 = "wksp_01920000-8168-7000-8000-0000000000b1";
56
+ const USER = "user_01920000-8168-7000-8000-0000000000b1";
57
+ const ASSET = "asset_01920000-8168-7000-8000-0000000000b1";
58
+ let person1: string;
59
+ let person2: string;
60
+ let piece1: string;
61
+
62
+ const cleanup = async () => {
63
+ await db.query(`DELETE FROM "piece_versions" WHERE "workspace_id" = $1`, [
64
+ WS_1,
65
+ ]);
66
+ await db.query(`DELETE FROM "interactions" WHERE "workspace_id" = $1`, [
67
+ WS_1,
68
+ ]);
69
+ await db.query(`DELETE FROM "pieces" WHERE "workspace_id" = $1`, [WS_1]);
70
+ await db.query(`DELETE FROM "people" WHERE "workspace_id" = $1`, [WS_1]);
71
+ };
72
+
73
+ beforeAll(async () => {
74
+ db = new Client({ connectionString: process.env.DATABASE_URL });
75
+ await db.connect();
76
+ await cleanup();
77
+ const insertPerson = (email: string) =>
78
+ db.query<{ id: string }>(
79
+ `INSERT INTO "people" ("workspace_id", "email", "updated_at") VALUES ($1, $2, now()) RETURNING "id"`,
80
+ [WS_1, email],
81
+ );
82
+ person1 = (await insertPerson("one@example.com")).rows[0]!.id;
83
+ person2 = (await insertPerson("two@example.com")).rows[0]!.id;
84
+ piece1 = (
85
+ await db.query<{ id: string }>(
86
+ `INSERT INTO "pieces" ("workspace_id", "person_id", "title", "stage", "updated_by", "updated_at")
87
+ VALUES ($1, $2, 'Homepage copy', 'writing', $3, now()) RETURNING "id"`,
88
+ [WS_1, person1, USER],
89
+ )
90
+ ).rows[0]!.id;
91
+ });
92
+
93
+ afterAll(async () => {
94
+ await cleanup();
95
+ await db.end();
96
+ });
97
+
98
+ type InteractionCols = {
99
+ direction?: string;
100
+ kind?: string;
101
+ subject?: string;
102
+ body?: string;
103
+ payload?: unknown;
104
+ };
105
+ const insertInteraction = (cols: InteractionCols = {}) =>
106
+ db.query<{ id: string; kind: string }>(
107
+ `INSERT INTO "interactions" ("workspace_id", "person_id", "channel", "direction", "kind", "subject", "body", "payload", "created_by")
108
+ VALUES ($1, $2, 'member', $3, $4, $5, $6, $7, $8) RETURNING "id", "kind"`,
109
+ [
110
+ WS_1,
111
+ person1,
112
+ cols.direction ?? "inbound",
113
+ cols.kind ?? "message",
114
+ cols.subject ?? "A question",
115
+ cols.body ?? "Can we talk?",
116
+ cols.payload === undefined ? null : JSON.stringify(cols.payload),
117
+ USER,
118
+ ],
119
+ );
120
+
121
+ const quotePayload = {
122
+ flowSlug: "request-quote",
123
+ items: [{ name: "need", label: "What do you need?", value: "A homepage" }],
124
+ };
125
+
126
+ it("an existing outbound row keeps kind `message` by default, and an inbound message lands", async () => {
127
+ const outbound = await db.query<{ kind: string }>(
128
+ `INSERT INTO "interactions" ("workspace_id", "person_id", "channel", "direction", "subject", "body", "created_by")
129
+ VALUES ($1, $2, 'member', 'outbound', 'Draft 1', 'Here it is', $3) RETURNING "kind"`,
130
+ [WS_1, person1, USER],
131
+ );
132
+ expect(outbound.rows[0]!.kind).toBe("message");
133
+ const inbound = await insertInteraction();
134
+ expect(inbound.rows[0]!.id).toMatch(/^intx_/);
135
+ const withLink = await insertInteraction({
136
+ payload: { link: "https://example.com" },
137
+ });
138
+ expect(withLink.rows[0]!.kind).toBe("message");
139
+ });
140
+
141
+ it("an inbound quote and review with a payload and an empty body land", async () => {
142
+ await insertInteraction({
143
+ kind: "quote",
144
+ subject: "Request a quote",
145
+ body: "",
146
+ payload: quotePayload,
147
+ });
148
+ await insertInteraction({
149
+ kind: "review",
150
+ subject: "Homepage copy",
151
+ body: "",
152
+ payload: { pieceId: piece1, version: 1, review: "approved" },
153
+ });
154
+ });
155
+
156
+ it.each([
157
+ [
158
+ "an outbound quote",
159
+ { direction: "outbound", kind: "quote", payload: quotePayload },
160
+ "interactions_outbound_shape",
161
+ ],
162
+ [
163
+ "an outbound message with a payload",
164
+ { direction: "outbound", payload: { link: "https://example.com" } },
165
+ "interactions_outbound_shape",
166
+ ],
167
+ [
168
+ "a quote with no payload",
169
+ { kind: "quote", body: "" },
170
+ "interactions_payload_by_kind",
171
+ ],
172
+ [
173
+ "a review with no payload",
174
+ { kind: "review", body: "" },
175
+ "interactions_payload_by_kind",
176
+ ],
177
+ ["an empty-body message", { body: "" }, "interactions_body_length"],
178
+ ["an all-space message body", { body: " " }, "interactions_body_length"],
179
+ [
180
+ "a quote body over 10000 characters",
181
+ { kind: "quote", body: "b".repeat(10001), payload: quotePayload },
182
+ "interactions_body_length",
183
+ ],
184
+ ["kind `note`", { kind: "note" }, "interactions_kind_allowed"],
185
+ [
186
+ "direction `sideways`",
187
+ { direction: "sideways" },
188
+ "interactions_direction_allowed",
189
+ ],
190
+ [
191
+ "a quote whose payload is over 262144 bytes",
192
+ {
193
+ kind: "quote",
194
+ body: "",
195
+ payload: { ...quotePayload, pad: "x".repeat(262144) },
196
+ },
197
+ "interactions_payload_size",
198
+ ],
199
+ ])("refuses %s", async (_label, cols, constraint) => {
200
+ await expect(insertInteraction(cols)).rejects.toMatchObject({
201
+ code: "23514",
202
+ constraint,
203
+ });
204
+ });
205
+
206
+ type VersionCols = {
207
+ person_id?: string;
208
+ piece_id?: string;
209
+ version?: number;
210
+ asset_id?: string | null;
211
+ link?: string | null;
212
+ note?: string | null;
213
+ review?: string | null;
214
+ review_comment?: string | null;
215
+ reviewed_at?: string | null;
216
+ };
217
+ const insertVersion = (cols: VersionCols = {}) =>
218
+ db.query<{ id: string }>(
219
+ `INSERT INTO "piece_versions" ("workspace_id", "person_id", "piece_id", "version", "asset_id", "link", "note", "delivered_by", "review", "review_comment", "reviewed_at")
220
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING "id"`,
221
+ [
222
+ WS_1,
223
+ cols.person_id ?? person1,
224
+ cols.piece_id ?? piece1,
225
+ cols.version ?? 99,
226
+ cols.asset_id === undefined ? null : cols.asset_id,
227
+ cols.link === undefined ? "https://example.com/draft" : cols.link,
228
+ cols.note ?? null,
229
+ USER,
230
+ cols.review ?? null,
231
+ cols.review_comment ?? null,
232
+ cols.reviewed_at ?? null,
233
+ ],
234
+ );
235
+
236
+ it("mints a pver_ id and accepts a file version and a reviewed link version", async () => {
237
+ const file = await insertVersion({
238
+ version: 1,
239
+ asset_id: ASSET,
240
+ link: null,
241
+ });
242
+ expect(file.rows[0]!.id).toMatch(/^pver_[0-9a-f-]{36}$/);
243
+ await insertVersion({
244
+ version: 2,
245
+ note: "Second pass",
246
+ review: "changes-requested",
247
+ review_comment: "Shorter, please",
248
+ reviewed_at: new Date().toISOString(),
249
+ });
250
+ });
251
+
252
+ it.each([
253
+ [
254
+ "both a file and a link",
255
+ { asset_id: ASSET },
256
+ "piece_versions_one_target",
257
+ ],
258
+ ["neither a file nor a link", { link: null }, "piece_versions_one_target"],
259
+ [
260
+ "an http:// link",
261
+ { link: "http://example.com" },
262
+ "piece_versions_link_https",
263
+ ],
264
+ [
265
+ "an https://user@host link",
266
+ { link: "https://user@example.com" },
267
+ "piece_versions_link_https",
268
+ ],
269
+ [
270
+ "a link containing a space",
271
+ { link: "https://example.com/a b" },
272
+ "piece_versions_link_https",
273
+ ],
274
+ ["version 0", { version: 0 }, "piece_versions_version_positive"],
275
+ ["an all-space note", { note: " " }, "piece_versions_note_length"],
276
+ [
277
+ "review `rejected`",
278
+ { review: "rejected", reviewed_at: "2026-10-10T00:00:00Z" },
279
+ "piece_versions_review_allowed",
280
+ ],
281
+ [
282
+ "changes-requested without a comment",
283
+ { review: "changes-requested", reviewed_at: "2026-10-10T00:00:00Z" },
284
+ "piece_versions_changes_need_comment",
285
+ ],
286
+ [
287
+ "a review without reviewed_at",
288
+ { review: "approved" },
289
+ "piece_versions_review_stamped",
290
+ ],
291
+ [
292
+ "a comment without a review",
293
+ { review_comment: "Hmm" },
294
+ "piece_versions_comment_needs_review",
295
+ ],
296
+ [
297
+ "a 5001-character comment",
298
+ {
299
+ review: "approved",
300
+ review_comment: "c".repeat(5001),
301
+ reviewed_at: "2026-10-10T00:00:00Z",
302
+ },
303
+ "piece_versions_comment_length",
304
+ ],
305
+ [
306
+ "a lead_ asset id",
307
+ { asset_id: "lead_01920000-8168-7000-8000-0000000000b1", link: null },
308
+ "piece_versions_asset_id_format",
309
+ ],
310
+ ])("refuses a version with %s", async (_label, cols, constraint) => {
311
+ await expect(insertVersion(cols)).rejects.toMatchObject({
312
+ code: "23514",
313
+ constraint,
314
+ });
315
+ });
316
+
317
+ it("refuses a version naming another Person's piece (the three-column FK)", async () => {
318
+ await expect(
319
+ insertVersion({ person_id: person2, version: 50 }),
320
+ ).rejects.toMatchObject({
321
+ code: "23503",
322
+ constraint: "piece_versions_piece_fk",
323
+ });
324
+ });
325
+
326
+ it("refuses a second version 1 of one piece", async () => {
327
+ await expect(insertVersion({ version: 1 })).rejects.toMatchObject({
328
+ code: "23505",
329
+ constraint: "piece_versions_piece_id_version_key",
330
+ });
331
+ });
332
+
333
+ it("refuses to delete a piece that has a version (RESTRICT)", async () => {
334
+ await expect(
335
+ db.query(`DELETE FROM "pieces" WHERE "id" = $1`, [piece1]),
336
+ ).rejects.toMatchObject({
337
+ code: "23503",
338
+ constraint: "piece_versions_piece_fk",
339
+ });
340
+ });
341
+ });
342
+
343
+ describeDb("the request-quote backfill (#8168 D8, D9, AC-4, live DB)", () => {
344
+ let db: Client;
345
+ const ORG = "org_01920000-8168-7000-8000-0000000000c1";
346
+ const WS_NEW = "wksp_01920000-8168-7000-8000-0000000000c1";
347
+ const WS_OWNS = "wksp_01920000-8168-7000-8000-0000000000c2";
348
+ const block = backfillBlock(MIGRATION_SQL);
349
+
350
+ beforeAll(async () => {
351
+ db = new Client({ connectionString: process.env.DATABASE_URL });
352
+ await db.connect();
353
+ });
354
+
355
+ afterAll(async () => {
356
+ await db.end();
357
+ });
358
+
359
+ const magnets = (workspaceId: string) =>
360
+ db.query<Record<string, unknown>>(
361
+ `SELECT * FROM "flow_definitions" WHERE "workspace_id" = $1 AND "slug" = 'request-quote'`,
362
+ [workspaceId],
363
+ );
364
+
365
+ it("seeds a workspace with no magnet, leaves a workspace's own row untouched, and adds nothing on re-run", async () => {
366
+ await db.query("BEGIN");
367
+ try {
368
+ await db.query(
369
+ `INSERT INTO "organizations" ("id", "name", "slug", "updated_at") VALUES ($1, 'O', $2, now())`,
370
+ [ORG, `o-8168-${Date.now()}`],
371
+ );
372
+ await db.query(
373
+ `INSERT INTO "workspaces" ("id", "organization_id", "name", "slug", "updated_at")
374
+ VALUES ($1, $3, 'New', 'new', now()), ($2, $3, 'Owns', 'owns', now())`,
375
+ [WS_NEW, WS_OWNS, ORG],
376
+ );
377
+ await db.query(
378
+ `INSERT INTO "flow_definitions" ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "updated_at")
379
+ VALUES ($1, $1, 'request-quote', '{"mine":true}', 'diagnostic', 'Theirs', 'live', now())`,
380
+ [WS_OWNS],
381
+ );
382
+ const before = (await magnets(WS_OWNS)).rows;
383
+
384
+ await db.query(block);
385
+
386
+ const seeded = (await magnets(WS_NEW)).rows;
387
+ expect(seeded).toHaveLength(1);
388
+ expect(seeded[0]).toMatchObject({
389
+ site_id: WS_NEW,
390
+ workspace_id: WS_NEW,
391
+ kind: "contact",
392
+ title: "Request a quote",
393
+ status: "draft",
394
+ customers_enabled: true,
395
+ config: requestQuoteFlowConfig,
396
+ });
397
+ expect((await magnets(WS_OWNS)).rows).toEqual(before);
398
+
399
+ const { rows: total } = await db.query<{ n: string }>(
400
+ `SELECT count(*)::text AS "n" FROM "flow_definitions" WHERE "slug" = 'request-quote'`,
401
+ );
402
+ await db.query(block);
403
+ const { rows: again } = await db.query<{ n: string }>(
404
+ `SELECT count(*)::text AS "n" FROM "flow_definitions" WHERE "slug" = 'request-quote'`,
405
+ );
406
+ expect(again[0]!.n).toBe(total[0]!.n);
407
+ expect((await magnets(WS_NEW)).rows).toEqual(seeded);
408
+ expect((await magnets(WS_OWNS)).rows).toEqual(before);
409
+ } finally {
410
+ await db.query("ROLLBACK");
411
+ }
412
+ });
413
+ });