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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- 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
|
|
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 {
|
|
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
|
|
149
|
-
workspaceId String
|
|
150
|
-
personId String
|
|
151
|
-
title String
|
|
152
|
-
stage String
|
|
153
|
-
needsClient Boolean
|
|
154
|
-
updatedBy String
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
///
|
|
165
|
-
///
|
|
166
|
-
///
|
|
167
|
-
///
|
|
168
|
-
///
|
|
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
|
|