create-ailk 0.17.0 → 0.19.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 (193) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  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/lib/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. 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
+ });
@@ -10,6 +10,12 @@
10
10
  * `createAuth({ prisma })` DI pattern in apps-paid/admin/__tests__.
11
11
  */
12
12
 
13
+ import {
14
+ REQUEST_QUOTE_FLOW_SLUG,
15
+ REQUEST_QUOTE_FLOW_TITLE,
16
+ requestQuoteFlowConfig,
17
+ } from "@working-theory/validation";
18
+
13
19
  import {
14
20
  DEFAULT_ORG_ID,
15
21
  DEFAULT_ORG_SLUG,
@@ -23,8 +29,9 @@ function makeStubPrisma(workspaceCount: number) {
23
29
  const organizationUpsert = jest.fn().mockResolvedValue({});
24
30
  const workspaceUpsert = jest.fn().mockResolvedValue({});
25
31
  const workspaceCountFn = jest.fn().mockResolvedValue(workspaceCount);
32
+ const flowDefinitionUpsert = jest.fn().mockResolvedValue({});
26
33
 
27
- // Minimal Prisma delegate stub — only the two delegates
34
+ // Minimal Prisma delegate stub — only the three delegates
28
35
  // provisionDefaultWorkspace touches. The full PrismaClient delegate method
29
36
  // signatures are impractical to satisfy for a hand-built DB-free stub, so a
30
37
  // double assertion through `unknown` is the sanctioned boundary here.
@@ -32,9 +39,16 @@ function makeStubPrisma(workspaceCount: number) {
32
39
  const prisma = {
33
40
  organization: { upsert: organizationUpsert },
34
41
  workspace: { count: workspaceCountFn, upsert: workspaceUpsert },
42
+ flowDefinition: { upsert: flowDefinitionUpsert },
35
43
  } as unknown as DefaultWorkspacePrismaClient;
36
44
 
37
- return { prisma, organizationUpsert, workspaceUpsert, workspaceCountFn };
45
+ return {
46
+ prisma,
47
+ organizationUpsert,
48
+ workspaceUpsert,
49
+ workspaceCountFn,
50
+ flowDefinitionUpsert,
51
+ };
38
52
  }
39
53
 
40
54
  describe("provisionDefaultWorkspace (#3622)", () => {
@@ -86,3 +100,41 @@ describe("provisionDefaultWorkspace (#3622)", () => {
86
100
  expect(workspaceUpsert).not.toHaveBeenCalled();
87
101
  });
88
102
  });
103
+
104
+ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4, AC-5)", () => {
105
+ it("upserts the default workspace's draft request-quote magnet, insert-if-absent, after the workspace", async () => {
106
+ const { prisma, workspaceUpsert, flowDefinitionUpsert } = makeStubPrisma(0);
107
+
108
+ await provisionDefaultWorkspace(prisma);
109
+
110
+ expect(flowDefinitionUpsert).toHaveBeenCalledTimes(1);
111
+ expect(flowDefinitionUpsert).toHaveBeenCalledWith({
112
+ where: {
113
+ siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: REQUEST_QUOTE_FLOW_SLUG },
114
+ },
115
+ create: {
116
+ siteId: DEFAULT_WORKSPACE_ID,
117
+ workspaceId: DEFAULT_WORKSPACE_ID,
118
+ slug: "request-quote",
119
+ config: requestQuoteFlowConfig,
120
+ kind: "contact",
121
+ title: REQUEST_QUOTE_FLOW_TITLE,
122
+ status: "draft",
123
+ customersEnabled: true,
124
+ },
125
+ // Empty: an existing (site_id, slug) row is never overwritten (D8, C6).
126
+ update: {},
127
+ });
128
+ expect(workspaceUpsert.mock.invocationCallOrder[0]).toBeLessThan(
129
+ flowDefinitionUpsert.mock.invocationCallOrder[0] ?? 0,
130
+ );
131
+ });
132
+
133
+ it("makes no flowDefinition call when a workspace already exists", async () => {
134
+ const { prisma, flowDefinitionUpsert } = makeStubPrisma(1);
135
+
136
+ await provisionDefaultWorkspace(prisma);
137
+
138
+ expect(flowDefinitionUpsert).not.toHaveBeenCalled();
139
+ });
140
+ });
@@ -81,6 +81,18 @@ model Lead {
81
81
  // Backfilled by database/migrations/20261010121000_person_backfill.
82
82
  personId String? @map("person_id")
83
83
 
84
+ // #8185 D1 — the hub's conclusion, at write time, that this lead is a
85
+ // spoke's LIVE paid checkout: the submit carried a registered spoke's key,
86
+ // was not relayed for a visitor, and passed the #7877 checkout plan rule's
87
+ // own predicate (a `cs_live_` Idempotency-Key and the checkout shape).
88
+ // Written only by `apps/api/src/services/lead-routing.ts`'s submit create
89
+ // and never updated; read only by `apps/api/src/lib/member-store.ts`'s
90
+ // `listMemberPurchases`, which serves a marked lead as a purchase. A lead
91
+ // written before the column existed reads false: `viaSpoke` was never
92
+ // stored, so its provenance cannot be proven (no backfill). No index: the
93
+ // read filters by the already-indexed `personId`.
94
+ spokeCheckout Boolean @default(false) @map("spoke_checkout")
95
+
84
96
  // #7869 — the idempotency guarantee, scoped per workspace so a key can
85
97
  // never reach another tenant's row.
86
98
  @@unique([workspaceId, idempotencyKey])
@@ -144,36 +156,92 @@ model Person {
144
156
  /// tenantDb(tenant).memberArea; read by a client only through
145
157
  /// lib/member-store.ts. `stage` is one of the workspace's declared stages
146
158
  /// (WorkspacePieceStages), checked at write time, never an enum.
159
+ ///
160
+ /// #8168 D5 — `approvedAt` is set in the same transaction as an `approved`
161
+ /// review of the latest PieceVersion and freezes review: once set, no further
162
+ /// review lands. Approval is a piece-level fact; the version's own `review`
163
+ /// records which version was approved.
147
164
  model Piece {
148
- id String @id @default(dbgenerated("('piece_'::text || (uuidv7())::text)")) @db.Text
149
- workspaceId String @map("workspace_id")
150
- personId String @map("person_id")
151
- title String @db.VarChar(200)
152
- stage String @db.VarChar(40)
153
- needsClient Boolean @default(false) @map("needs_client")
154
- updatedBy String @map("updated_by")
155
- createdAt DateTime @default(now()) @map("created_at")
156
- updatedAt DateTime @updatedAt @map("updated_at")
165
+ id String @id @default(dbgenerated("('piece_'::text || (uuidv7())::text)")) @db.Text
166
+ workspaceId String @map("workspace_id")
167
+ personId String @map("person_id")
168
+ title String @db.VarChar(200)
169
+ stage String @db.VarChar(40)
170
+ needsClient Boolean @default(false) @map("needs_client")
171
+ updatedBy String @map("updated_by")
172
+ approvedAt DateTime? @map("approved_at")
173
+ createdAt DateTime @default(now()) @map("created_at")
174
+ updatedAt DateTime @updatedAt @map("updated_at")
157
175
 
158
- person Person @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "pieces_person_fk")
176
+ person Person @relation(fields: [personId, workspaceId], references: [id, workspaceId], onDelete: Restrict, map: "pieces_person_fk")
177
+ versions PieceVersion[]
159
178
 
160
179
  @@index([workspaceId, personId, createdAt])
180
+ // #8168 D1 — redundant with the PK, load-bearing as the target of
181
+ // piece_versions_piece_fk: a version names a piece of THIS Person in THIS
182
+ // workspace, as one database fact.
183
+ @@unique([id, personId, workspaceId])
161
184
  @@map("pieces")
162
185
  }
163
186
 
164
- /// Interaction — one message on a client's record (#7877): the original CRM
165
- /// spec's Interaction cut to one channel and one direction. An inbound reply
166
- /// is a later second `direction` value on this table. `readAt` is when a
167
- /// member read FIRST served the row to the client's spoke — a delivery
168
- /// stamp, not proof a human read it.
187
+ /// PieceVersion — one delivery of a piece to its client (#8168 D1, D2): a
188
+ /// file (`assetId`, a private library asset of the same workspace) or a link
189
+ /// (`link`, https only), never both, numbered from 1 per piece. The client's
190
+ /// review of it (`review`, `reviewComment`, `reviewedAt`) is recorded on the
191
+ /// version itself. There is NO round count, limit or remaining-count column,
192
+ /// by design (#8140 disposition 3).
193
+ ///
194
+ /// Its one relation is the three-column FK onto `pieces(id, person_id,
195
+ /// workspace_id)`, so the piece, its Person and the workspace are all one
196
+ /// database fact. `assetId` and `deliveredBy` name rows of other namespaces:
197
+ /// plain columns with format CHECKs, no foreign key (ADR 0009).
198
+ model PieceVersion {
199
+ id String @id @default(dbgenerated("('pver_'::text || (uuidv7())::text)")) @db.Text
200
+ workspaceId String @map("workspace_id")
201
+ personId String @map("person_id")
202
+ pieceId String @map("piece_id")
203
+ version Int
204
+ assetId String? @map("asset_id")
205
+ link String? @db.VarChar(2048)
206
+ note String? @db.Text
207
+ deliveredBy String @map("delivered_by")
208
+ deliveredAt DateTime @default(now()) @map("delivered_at")
209
+ review String? @db.VarChar(24)
210
+ reviewComment String? @map("review_comment") @db.Text
211
+ reviewedAt DateTime? @map("reviewed_at")
212
+
213
+ piece Piece @relation(fields: [pieceId, personId, workspaceId], references: [id, personId, workspaceId], onDelete: Restrict, map: "piece_versions_piece_fk")
214
+
215
+ @@unique([pieceId, version])
216
+ // A client's artifacts: workspace_id = $1 AND person_id = $2 ORDER BY delivered_at DESC.
217
+ @@index([workspaceId, personId, deliveredAt])
218
+ // The public asset read's "is this asset a version file" check (AC-MAA-21).
219
+ @@index([assetId])
220
+ @@map("piece_versions")
221
+ }
222
+
223
+ /// Interaction — one entry on a client's record (#7877): the original CRM
224
+ /// spec's Interaction cut to one channel. `readAt` is when a member read
225
+ /// FIRST served the row to the client's spoke — a delivery stamp, not proof a
226
+ /// human read it.
227
+ ///
228
+ /// #8168 D3/D4 — `direction` is `outbound` (an operator's message, #7877) or
229
+ /// `inbound` (a client's own write); `kind` is `message`, `quote` or
230
+ /// `review`. An outbound row is always a `message` with no `payload`
231
+ /// (`interactions_outbound_shape`); a `quote` or `review` row carries the
232
+ /// stored payload `packages/validation`'s member.ts types. Every existing
233
+ /// reader filters to `direction: "outbound"`, so an inbound row is invisible
234
+ /// to them until a reader opts in.
169
235
  model Interaction {
170
236
  id String @id @default(dbgenerated("('intx_'::text || (uuidv7())::text)")) @db.Text
171
237
  workspaceId String @map("workspace_id")
172
238
  personId String @map("person_id")
173
239
  channel String @db.VarChar(16)
174
240
  direction String @db.VarChar(16)
241
+ kind String @default("message") @db.VarChar(16)
175
242
  subject String @db.VarChar(200)
176
243
  body String @db.Text
244
+ payload Json?
177
245
  assetId String? @map("asset_id")
178
246
  createdBy String @map("created_by")
179
247
  createdAt DateTime @default(now()) @map("created_at")
@@ -338,6 +406,13 @@ model FlowDefinition {
338
406
  // across the namespace boundary, so this is a plain string — but a stated
339
407
  // one rather than a coincidence.
340
408
  listingId String? @map("listing_id")
409
+ /// #8168 D7 — whether a signed-in client may submit this magnet from the
410
+ /// member area. A column, not a config block, because a spoke seed refuses
411
+ /// non-seedable config (#7876); `upsertFlowDefinition` never names it, so a
412
+ /// seed never changes it. Independent of `status`: the seeded
413
+ /// `request-quote` magnet is `draft` (never served anonymously) with
414
+ /// `customersEnabled = true`.
415
+ customersEnabled Boolean @default(false) @map("customers_enabled")
341
416
  createdAt DateTime @default(now()) @map("created_at")
342
417
  updatedAt DateTime @updatedAt @map("updated_at")
343
418