@teamscala/service-boot 0.23.524 → 0.23.526
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/package.json +1 -11
- package/src/agreement-gate/authorized-emails.ts +0 -7
- package/src/agreement-gate/placeholder-strip.ts +0 -2
- package/src/agreement-gate/settings.ts +0 -15
- package/src/agreement-gate.ts +0 -34
- package/src/attach-proposal-accept.ts +1 -1
- package/src/attach-proposal-magic.ts +1 -1
- package/src/auth/sign-out.ts +0 -2
- package/src/auth-gate/public-paths.ts +0 -86
- package/src/auth-gate.ts +0 -19
- package/src/bind-page-handler.ts +0 -76
- package/src/build-client-assets/hydrate-bundle.ts +0 -126
- package/src/build-client-assets/manifest.ts +0 -18
- package/src/build-client-assets/source-candidates.ts +0 -34
- package/src/build-client-assets/sourcemaps-upload.ts +0 -42
- package/src/build-client-assets/styles-build.ts +0 -50
- package/src/capability-slug.ts +0 -10
- package/src/compose-page-title.ts +0 -9
- package/src/content-gate/access.ts +0 -26
- package/src/content-gate/catalog.ts +0 -29
- package/src/content-gate/config-types.ts +3 -84
- package/src/content-gate/gate.ts +0 -20
- package/src/content-page-floor.ts +0 -11
- package/src/error-page-logo.ts +2 -12
- package/src/expand-roster.ts +0 -9
- package/src/gpt-gate.ts +1 -1
- package/src/hono-error-handler.ts +0 -4
- package/src/install-logging.ts +0 -32
- package/src/load-page-manifest.ts +0 -6
- package/src/load-service-install-row.ts +41 -4
- package/src/module-not-found.ts +0 -11
- package/src/mount-orpc-router.ts +1 -88
- package/src/mount-server-packages.ts +0 -29
- package/src/mutable-app-ref.ts +0 -11
- package/src/orpc-contract-rows.json +4102 -61
- package/src/orpc-openapi-document.ts +0 -6
- package/src/orpc-openapi-gate.ts +0 -10
- package/src/pg-boss-handler-registry.ts +0 -12
- package/src/pg-boss-mount.ts +0 -101
- package/src/proposal-gate.ts +1 -41
- package/src/quiz-gate.ts +1 -9
- package/src/register-page-server-client.ts +0 -53
- package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
- package/src/register-registry-page-route/apply-page-branding.ts +0 -90
- package/src/register-registry-page-route/captured-loader-config.ts +0 -1
- package/src/register-registry-page-route/live-loader-config.ts +0 -16
- package/src/register-registry-page-route/meta-title.ts +0 -24
- package/src/register-registry-page-route/page-data-shared.ts +0 -6
- package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
- package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
- package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
- package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
- package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
- package/src/register-registry-page-route/run-content-gates.ts +2 -54
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
- package/src/register-registry-page-route.ts +0 -85
- package/src/register-static-assets.ts +0 -45
- package/src/resolve-brand-favicon.ts +0 -9
- package/src/resolve-service-port.ts +0 -11
- package/src/resolve-theme-mode.ts +0 -8
- package/src/resolvers/app-header.ts +0 -1
- package/src/resolvers/fetch-org-brand.ts +0 -1
- package/src/resolvers/portal-route-paths.ts +0 -17
- package/src/resource-not-found.ts +0 -24
- package/src/select-service-routes.ts +0 -14
- package/src/theme-fonts-link.ts +0 -5
- package/src/two-phase-full-app.ts +0 -76
- package/src/two-phase-minimal-app.ts +1 -105
- package/src/types.ts +0 -14
- package/src/ui-service.ts +1 -42
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -197
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -10
- package/src/page-server-orpc/hub-mcp-client.ts +0 -14
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/register-registry-page-route/graph-cache.test.ts +0 -81
- package/src/register-registry-page-route/graph-cache.ts +0 -71
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- package/src/resource-chat-inject.ts +0 -357
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
11
|
-
|
|
12
|
-
/** The ONE active launcher block_schema slug — the Puck registry key. */
|
|
13
|
-
const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
|
|
14
|
-
|
|
15
|
-
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
16
|
-
loadRegistryConfig: async () => ({ agentId: "agent-1" }),
|
|
17
|
-
}));
|
|
18
|
-
mock.module("./page-server-orpc/server-client.ts", () => ({
|
|
19
|
-
getServerClient: () => ({
|
|
20
|
-
ai_agents: {
|
|
21
|
-
findFirst: async () => ({
|
|
22
|
-
name: "Agreement Assistant",
|
|
23
|
-
welcome_message: "Hi! Ask me anything about this agreement.",
|
|
24
|
-
conversation_starters: ["What are the key terms?", "How do I sign this?"],
|
|
25
|
-
primary_color: "#6366f1",
|
|
26
|
-
}),
|
|
27
|
-
},
|
|
28
|
-
}),
|
|
29
|
-
}));
|
|
30
|
-
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
31
|
-
getAppLogger: () => ({ error: () => {} }),
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
|
|
35
|
-
|
|
36
|
-
function makeResolved(resourceKey: string): Record<string, unknown> {
|
|
37
|
-
return {
|
|
38
|
-
[resourceKey]: { organisation_id: "org-1", name: "Demo" },
|
|
39
|
-
brand: { primaryColor: "#123456" },
|
|
40
|
-
page: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function pushedBlocks(resolved: Record<string, unknown>) {
|
|
45
|
-
const pd = (
|
|
46
|
-
resolved.page as {
|
|
47
|
-
puck_data: { content: Array<{ type: string; props?: Record<string, unknown> }> };
|
|
48
|
-
}
|
|
49
|
-
).puck_data;
|
|
50
|
-
return pd.content;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe("chat injectors — pushed block name resolves to a registered block", () => {
|
|
54
|
-
test("the generic resource injector pushes the registered launcher slug", async () => {
|
|
55
|
-
const resolved = makeResolved("agreement");
|
|
56
|
-
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
57
|
-
resourceKey: "agreement",
|
|
58
|
-
agentConfigSlug: "agreement-assistant-agent",
|
|
59
|
-
launchLabel: "Ask about this agreement",
|
|
60
|
-
});
|
|
61
|
-
const blocks = pushedBlocks(resolved);
|
|
62
|
-
expect(blocks).toHaveLength(1);
|
|
63
|
-
expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("neither injector pushes a pre-consolidation launcher name", async () => {
|
|
67
|
-
const a = makeResolved("agreement");
|
|
68
|
-
await injectResourceChatLauncher(a, "viewer@example.com", {
|
|
69
|
-
resourceKey: "agreement",
|
|
70
|
-
agentConfigSlug: "agreement-assistant-agent",
|
|
71
|
-
launchLabel: "Ask about this agreement",
|
|
72
|
-
});
|
|
73
|
-
const p = makeResolved("proposal");
|
|
74
|
-
await injectResourceChatLauncher(p, "viewer@example.com", {
|
|
75
|
-
resourceKey: "proposal",
|
|
76
|
-
agentConfigSlug: "proposal-assistant-agent",
|
|
77
|
-
launchLabel: "Ask about this proposal",
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const dead = ["ResourceChatLauncher", "ChatLauncherBlock"];
|
|
81
|
-
for (const blocks of [pushedBlocks(a), pushedBlocks(p)]) {
|
|
82
|
-
for (const block of blocks) {
|
|
83
|
-
expect(dead).not.toContain(block.type);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("the assistant's words come from the agent row, not the caller", async () => {
|
|
89
|
-
// THE REGRESSION THIS PINS: title, welcome message and starters used to
|
|
90
|
-
// be string literals in the injectors while ai_agents held a blank
|
|
91
|
-
// column for each. Whoever changed the row saw no change on the page.
|
|
92
|
-
const resolved = makeResolved("agreement");
|
|
93
|
-
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
94
|
-
resourceKey: "agreement",
|
|
95
|
-
agentConfigSlug: "agreement-assistant-agent",
|
|
96
|
-
launchLabel: "Ask about this agreement",
|
|
97
|
-
});
|
|
98
|
-
const props = pushedBlocks(resolved)[0].props ?? {};
|
|
99
|
-
expect(props.title).toBe("Agreement Assistant");
|
|
100
|
-
expect(props.welcomeMessage).toBe("Hi! Ask me anything about this agreement.");
|
|
101
|
-
expect(props.conversationStarters).toEqual([
|
|
102
|
-
"What are the key terms?",
|
|
103
|
-
"How do I sign this?",
|
|
104
|
-
]);
|
|
105
|
-
// launchLabel has no column yet, so it is still the caller's.
|
|
106
|
-
expect(props.launchLabel).toBe("Ask about this agreement");
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
/** A region-slot page — the shape a quiz report resolves (no `page` source). */
|
|
111
|
-
function makeAssessmentResolved(): Record<string, unknown> {
|
|
112
|
-
return {
|
|
113
|
-
response: {
|
|
114
|
-
organisation_id: "org-1",
|
|
115
|
-
email: "reader@example.com",
|
|
116
|
-
total_score: 95,
|
|
117
|
-
category_scores: { "Be Seen": 100, "Be Chosen": 83 },
|
|
118
|
-
},
|
|
119
|
-
quiz: { id: "quiz-1", report_title: "The Launcha Authority Index" },
|
|
120
|
-
reportData: { narrative: "You are strongest at being seen." },
|
|
121
|
-
brand: { primaryColor: "#123456" },
|
|
122
|
-
header: { puck_data: { content: [{ type: "SiteHeader" }] } },
|
|
123
|
-
body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
124
|
-
footer: { puck_data: { content: [{ type: "SiteFooter" }] } },
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The launcher's props, wherever in the graph it was attached. */
|
|
129
|
-
function launcherProps(resolved: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
-
for (const value of Object.values(resolved)) {
|
|
131
|
-
const content = (value as { puck_data?: { content?: unknown } } | null)?.puck_data
|
|
132
|
-
?.content;
|
|
133
|
-
if (!Array.isArray(content)) continue;
|
|
134
|
-
const launcher = (content as Array<{ type: string; props?: Record<string, unknown> }>).find(
|
|
135
|
-
(block) => block.type === REGISTERED_LAUNCHER_SLUG,
|
|
136
|
-
);
|
|
137
|
-
if (launcher) return launcher.props ?? {};
|
|
138
|
-
}
|
|
139
|
-
throw new Error("no launcher block was attached to any region");
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function bodyBlocks(resolved: Record<string, unknown>) {
|
|
143
|
-
return (
|
|
144
|
-
resolved.body as {
|
|
145
|
-
puck_data: { content: Array<{ type: string; props?: Record<string, unknown> }> };
|
|
146
|
-
}
|
|
147
|
-
).puck_data.content;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* REGION SLOTS COME FROM THE LOADER ROW, exactly as the caller reads them —
|
|
152
|
-
* the injector is never allowed to guess which resolved keys hold content.
|
|
153
|
-
*/
|
|
154
|
-
const assessmentOpts = {
|
|
155
|
-
resourceKey: "response",
|
|
156
|
-
agentConfigSlug: "assessment-assistant-agent" as never,
|
|
157
|
-
launchLabel: "Ask about your results",
|
|
158
|
-
regionSlots: ["header", "body", "footer"],
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
describe("region-slot pages (a generated report, not an authored document)", () => {
|
|
162
|
-
test("attaches to a declared region when there is no `page` source", async () => {
|
|
163
|
-
// THE DEFECT THIS PINS: the injector read resolved.page.puck_data.content
|
|
164
|
-
// and returned early when absent. A generated report resolves region
|
|
165
|
-
// slots instead, so the early return fired and the assistant was
|
|
166
|
-
// silently missing — fail-soft, so nothing was logged.
|
|
167
|
-
const resolved = makeAssessmentResolved();
|
|
168
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
169
|
-
// The first declared slot holding a content tree. The launcher is
|
|
170
|
-
// position:fixed, so any rendered region shows it identically.
|
|
171
|
-
const header = (
|
|
172
|
-
resolved.header as { puck_data: { content: Array<{ type: string }> } }
|
|
173
|
-
).puck_data.content;
|
|
174
|
-
expect(header.map((block) => block.type)).toContain(REGISTERED_LAUNCHER_SLUG);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("follows the loader's slot order rather than a hardcoded key", async () => {
|
|
178
|
-
// Reordering the row must move the launcher. If this passes while the
|
|
179
|
-
// code names "body", the derivation is a literal wearing a costume.
|
|
180
|
-
const resolved = makeAssessmentResolved();
|
|
181
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", {
|
|
182
|
-
...assessmentOpts,
|
|
183
|
-
regionSlots: ["body", "header", "footer"],
|
|
184
|
-
});
|
|
185
|
-
expect(bodyBlocks(resolved).map((block) => block.type)).toContain(
|
|
186
|
-
REGISTERED_LAUNCHER_SLUG,
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("sends the assessment RESULT, not just the rendered page", async () => {
|
|
191
|
-
// A report's body page is a shared template — one builda_pages row serves
|
|
192
|
-
// every respondent — so the content tree holds zero personal facts. An
|
|
193
|
-
// assistant given only the page would know nothing about this reader.
|
|
194
|
-
const resolved = makeAssessmentResolved();
|
|
195
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
196
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
197
|
-
expect(context).toContain("total_score");
|
|
198
|
-
expect(context).toContain("95");
|
|
199
|
-
expect(context).toContain("Be Chosen");
|
|
200
|
-
expect(context).toContain("You are strongest at being seen.");
|
|
201
|
-
expect(context).toContain("The Launcha Authority Index");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("sends the rendered document too, not just the result", async () => {
|
|
205
|
-
// The mirror of the test above: the reader is asking about what is on
|
|
206
|
-
// screen, so the assistant needs the document as well as the data.
|
|
207
|
-
const resolved = makeAssessmentResolved();
|
|
208
|
-
(
|
|
209
|
-
resolved.body as { puck_data: { content: unknown[] } }
|
|
210
|
-
).puck_data.content.push({ type: "ScoreDial", props: { label: "Authority" } });
|
|
211
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
212
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
213
|
-
expect(context).toContain("ScoreDial");
|
|
214
|
-
expect(context).toContain("Authority");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("names no resolved key — it sends whatever the loader produced", async () => {
|
|
218
|
-
// THE REGRESSION THIS PINS: a draft excluded brand/website/header/footer
|
|
219
|
-
// by name, which is a loader row copied into TypeScript. Those keys are
|
|
220
|
-
// database facts, so the serialiser must not have opinions about them —
|
|
221
|
-
// it walks the graph and a loader edit propagates with no code change.
|
|
222
|
-
const resolved = makeAssessmentResolved();
|
|
223
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
224
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
225
|
-
for (const key of Object.keys(resolved)) {
|
|
226
|
-
expect(context).toContain(key);
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("carries a source a future loader adds, with no edit here", async () => {
|
|
231
|
-
// The serialiser walks the graph rather than naming keys, so a loader
|
|
232
|
-
// resolving a new source reaches the assistant for free.
|
|
233
|
-
const resolved = makeAssessmentResolved();
|
|
234
|
-
resolved.benchmarks = { industryMedian: 62 };
|
|
235
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
236
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
237
|
-
expect(context).toContain("industryMedian");
|
|
238
|
-
expect(context).toContain("62");
|
|
239
|
-
});
|
|
240
|
-
});
|
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
|
|
8
|
-
*
|
|
9
|
-
* `ai_agents` already carries a column for every one of them -- name,
|
|
10
|
-
* welcome_message, conversation_starters, primary_color -- and on 2026-08-19
|
|
11
|
-
* both live agents had welcome_message NULL and conversation_starters empty
|
|
12
|
-
* while the copy sat spelled in TypeScript here and in proposal-chat-inject.
|
|
13
|
-
* The intended home existed, sat blank, and the hardcoded copy won, so nobody
|
|
14
|
-
* could change a word the assistant says without a code change and a deploy.
|
|
15
|
-
* Two copies of one fact with the stale one winning silently is exactly
|
|
16
|
-
* `information-exists-in-one-location`.
|
|
17
|
-
*
|
|
18
|
-
* `launchLabel` and `placeholder` remain caller arguments only because no
|
|
19
|
-
* column holds them yet.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The rendered content tree to append the launcher to.
|
|
24
|
-
*
|
|
25
|
-
* WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
|
|
26
|
-
* sources and its region slots are declared on the `resource_loader` row, and
|
|
27
|
-
* the caller has already read that row — so it passes `regionSlots` in. An
|
|
28
|
-
* earlier draft of this function named "page", "body", "header" and "footer" as
|
|
29
|
-
* literals, which is a loader row copied into TypeScript, free to drift from it
|
|
30
|
-
* the moment a loader is edited (`identity-is-imported-never-spelled`).
|
|
31
|
-
*
|
|
32
|
-
* A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
|
|
33
|
-
* Proposals and agreements resolve one `page` source, so the original injector
|
|
34
|
-
* read `resolved.page.puck_data.content` and returned early when it was absent.
|
|
35
|
-
* A generated report resolves region slots instead, so that early return fired
|
|
36
|
-
* and the assistant was silently missing — the same failure shape as the dead
|
|
37
|
-
* block name, since fail-soft injection logs nothing and the page looks fine.
|
|
38
|
-
*
|
|
39
|
-
* ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
|
|
40
|
-
* renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
|
|
41
|
-
* to its container, so it appears identically whichever region carries it. That
|
|
42
|
-
* is what makes "first declared region holding a content tree" a derivation
|
|
43
|
-
* rather than a guess dressed up as one: there is no better region to prefer.
|
|
44
|
-
*
|
|
45
|
-
* `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
|
|
46
|
-
* source — the same convention `decideContentPageFloor` already keys on in the
|
|
47
|
-
* caller. One shared convention, not a per-resource list.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
|
|
52
|
-
*
|
|
53
|
-
* BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
|
|
54
|
-
* The original serialiser sent the resource record plus the page's content
|
|
55
|
-
* tree, which is exactly right for a proposal — the proposal's substance IS in
|
|
56
|
-
* its blocks. It is wrong for anything the platform GENERATES a document from.
|
|
57
|
-
* A report's body page is a shared template (one `builda_pages` row serves
|
|
58
|
-
* every respondent) so the content tree holds headings and layout and not one
|
|
59
|
-
* personal fact, while the substance — the answers, the category scores, the
|
|
60
|
-
* computed narrative and bands — sits in sibling resolved keys the serialiser
|
|
61
|
-
* never looked at. Sending only the page would give the assistant a form with
|
|
62
|
-
* no answers; sending only the record would leave it unable to discuss what the
|
|
63
|
-
* reader is actually looking at. An assessment result GENERATES a report, so
|
|
64
|
-
* the assistant needs both halves of that.
|
|
65
|
-
*
|
|
66
|
-
* NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
|
|
67
|
-
* resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
|
|
68
|
-
* exclusion list dropping brand, website, header and footer — every one of them
|
|
69
|
-
* a loader source key, which is to say database facts hardcoded here to save a
|
|
70
|
-
* few hundred tokens of colours and navigation markup. Not a trade worth a
|
|
71
|
-
* literal that goes stale in silence. A loader resolving a new source reaches
|
|
72
|
-
* the assistant with no edit here (`offered-is-derived`).
|
|
73
|
-
*
|
|
74
|
-
* IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
|
|
75
|
-
* gate, and a gate withholds by REMOVING content from this same graph. So
|
|
76
|
-
* "everything in the graph" and "everything this viewer may see" are the same
|
|
77
|
-
* set, and they cannot drift — which is what makes serialising the whole graph
|
|
78
|
-
* safe rather than reckless.
|
|
79
|
-
*/
|
|
80
|
-
|
|
81
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
82
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
83
|
-
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
84
|
-
import * as v from "valibot";
|
|
85
|
-
import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
|
|
86
|
-
import type { ConfigKey } from "@teamscala/db/registry/generated/config";
|
|
87
|
-
|
|
88
|
-
const AgentConfigSchema = v.object({ agentId: v.string() });
|
|
89
|
-
|
|
90
|
-
// THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
|
|
91
|
-
// discover-blocks keys the Puck registry off block_schema.slug alone
|
|
92
|
-
// (`components[row.slug] = …`), so a type that is not a live slug resolves to
|
|
93
|
-
// nothing — and because injection is fail-soft, the page renders unchanged with
|
|
94
|
-
// no error. That is exactly how the bubble went missing from every gated
|
|
95
|
-
// proposal and agreement: the block was consolidated to ResourceLauncher and
|
|
96
|
-
// these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
|
|
97
|
-
// the generated union of live slugs, so annotating it here makes a stale name a
|
|
98
|
-
// COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
|
|
99
|
-
const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
|
|
100
|
-
|
|
101
|
-
/** Per-resource-type injection config — the only thing that varies. */
|
|
102
|
-
export interface ResourceChatOptions {
|
|
103
|
-
/** Key in `resolved` holding the record (e.g. "proposal", "agreement"). */
|
|
104
|
-
resourceKey: string;
|
|
105
|
-
/** `config` row slug carrying { agentId } (e.g. "agreement-assistant-agent"). */
|
|
106
|
-
agentConfigSlug: ConfigKey;
|
|
107
|
-
/** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
|
|
108
|
-
* (proposals); agreements pass it explicitly (org isn't on the resolved record). */
|
|
109
|
-
organisationId?: string;
|
|
110
|
-
/** Floating button label / speech bubble (e.g. "Ask about this agreement"). */
|
|
111
|
-
launchLabel: string;
|
|
112
|
-
/** Input placeholder. */
|
|
113
|
-
placeholder?: string;
|
|
114
|
-
/**
|
|
115
|
-
* The loader row's `region_slots`, when this page has them — passed in rather
|
|
116
|
-
* than guessed, so which keys hold rendered content stays a database fact.
|
|
117
|
-
* Absent means a flat page whose content is under the `page` source.
|
|
118
|
-
*/
|
|
119
|
-
regionSlots?: readonly string[];
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface AgentPresentation {
|
|
123
|
-
title?: string;
|
|
124
|
-
welcomeMessage?: string;
|
|
125
|
-
conversationStarters?: string[];
|
|
126
|
-
primaryColor?: string;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export async function loadAgentPresentation(agentId: string): Promise<AgentPresentation> {
|
|
130
|
-
// THROUGH ORPC, NEVER PRISMA DIRECTLY (`no-direct-prisma-outside-orpc`).
|
|
131
|
-
// This is the same service-scoped client the ProductTiers read above uses;
|
|
132
|
-
// `ai_agents` is already in the declared models of both services that mount
|
|
133
|
-
// a launcher, so no schema change is needed to read it here.
|
|
134
|
-
const client = getServerClient() as {
|
|
135
|
-
ai_agents?: {
|
|
136
|
-
findFirst: (args: {
|
|
137
|
-
where: { id: string };
|
|
138
|
-
select: {
|
|
139
|
-
name: true;
|
|
140
|
-
welcome_message: true;
|
|
141
|
-
conversation_starters: true;
|
|
142
|
-
primary_color: true;
|
|
143
|
-
};
|
|
144
|
-
}) => Promise<{
|
|
145
|
-
name: string | null;
|
|
146
|
-
welcome_message: string | null;
|
|
147
|
-
conversation_starters: unknown;
|
|
148
|
-
primary_color: string | null;
|
|
149
|
-
} | null>;
|
|
150
|
-
};
|
|
151
|
-
};
|
|
152
|
-
try {
|
|
153
|
-
const agent = await client.ai_agents?.findFirst({
|
|
154
|
-
where: { id: agentId },
|
|
155
|
-
select: {
|
|
156
|
-
name: true,
|
|
157
|
-
welcome_message: true,
|
|
158
|
-
conversation_starters: true,
|
|
159
|
-
primary_color: true,
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
if (!agent) return {};
|
|
163
|
-
const starters = Array.isArray(agent.conversation_starters)
|
|
164
|
-
? (agent.conversation_starters as unknown[]).filter(
|
|
165
|
-
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
|
166
|
-
)
|
|
167
|
-
: [];
|
|
168
|
-
return {
|
|
169
|
-
title: agent.name ?? undefined,
|
|
170
|
-
welcomeMessage: agent.welcome_message ?? undefined,
|
|
171
|
-
conversationStarters: starters.length > 0 ? starters : undefined,
|
|
172
|
-
primaryColor: agent.primary_color ?? undefined,
|
|
173
|
-
};
|
|
174
|
-
} catch (err) {
|
|
175
|
-
// FAIL SOFT, LIKE THE REST OF THE INJECTION. A presentation read that
|
|
176
|
-
// fails must not remove the bubble: the launcher carries its own
|
|
177
|
-
// default for every one of these fields, so an unreadable row costs
|
|
178
|
-
// wording, never the assistant itself.
|
|
179
|
-
getAppLogger().error(
|
|
180
|
-
`[resource-chat] agent presentation read failed (${agentId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
-
);
|
|
182
|
-
return {};
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** A resolved source that carries a Puck content tree. */
|
|
187
|
-
function puckContentOf(value: unknown): unknown[] | null {
|
|
188
|
-
const content = (value as { puck_data?: { content?: unknown } } | null | undefined)
|
|
189
|
-
?.puck_data?.content;
|
|
190
|
-
return Array.isArray(content) ? content : null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const PAGE_SOURCE_KEY = "page";
|
|
194
|
-
|
|
195
|
-
function findContentHost(
|
|
196
|
-
resolved: Record<string, unknown>,
|
|
197
|
-
regionSlots?: readonly string[],
|
|
198
|
-
): unknown[] | null {
|
|
199
|
-
if (regionSlots?.length) {
|
|
200
|
-
for (const slot of regionSlots) {
|
|
201
|
-
const content = puckContentOf(resolved[slot]);
|
|
202
|
-
if (content) return content;
|
|
203
|
-
}
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
return puckContentOf(resolved[PAGE_SOURCE_KEY]);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function buildResourceContext(
|
|
210
|
-
resolved: Record<string, unknown>,
|
|
211
|
-
resourceKey: string,
|
|
212
|
-
regionSlots?: readonly string[],
|
|
213
|
-
): Promise<string> {
|
|
214
|
-
const record = resolved[resourceKey] as Record<string, unknown> | undefined;
|
|
215
|
-
const parts: string[] = [];
|
|
216
|
-
|
|
217
|
-
if (typeof record?.name === "string" && record.name) {
|
|
218
|
-
parts.push(`Title: ${record.name}`);
|
|
219
|
-
}
|
|
220
|
-
if (typeof record?.client_name === "string" && record.client_name) {
|
|
221
|
-
parts.push(`For: ${record.client_name}`);
|
|
222
|
-
}
|
|
223
|
-
if (typeof record?.description === "string" && record.description) {
|
|
224
|
-
parts.push(`Description: ${record.description}`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
for (const [key, value] of Object.entries(resolved)) {
|
|
228
|
-
if (value == null || typeof value === "function") continue;
|
|
229
|
-
const content = puckContentOf(value);
|
|
230
|
-
if (content) {
|
|
231
|
-
// A rendered document: every block, every prop, verbatim.
|
|
232
|
-
parts.push(`\n--- Document content (${key}) ---`);
|
|
233
|
-
parts.push(JSON.stringify(content, null, 2));
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
parts.push(`\n--- ${key} ---`);
|
|
237
|
-
parts.push(JSON.stringify(value, null, 2));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Resolve DB-backed product tiers when present (proposals) so the GPT sees
|
|
241
|
-
// real pricing. Harmless for resources with no ProductTiers blocks.
|
|
242
|
-
const client = getServerClient() as {
|
|
243
|
-
products?: {
|
|
244
|
-
findFirst: (args: {
|
|
245
|
-
where: { id: string };
|
|
246
|
-
select: { id: true; billing_type: true; tiers: true; components: true };
|
|
247
|
-
}) => Promise<{ id: string; billing_type: string; tiers: unknown; components: unknown } | null>;
|
|
248
|
-
};
|
|
249
|
-
};
|
|
250
|
-
for (const block of (findContentHost(resolved, regionSlots) ?? []) as Array<
|
|
251
|
-
Record<string, unknown>
|
|
252
|
-
>) {
|
|
253
|
-
if (block?.type !== "ProductTiers") continue;
|
|
254
|
-
const props = block.props as Record<string, unknown> | undefined;
|
|
255
|
-
if (!props) continue;
|
|
256
|
-
const productIds = [props.productId, props.productId2, props.productId3].filter(
|
|
257
|
-
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
|
258
|
-
);
|
|
259
|
-
for (const pid of productIds) {
|
|
260
|
-
try {
|
|
261
|
-
const product = await client.products?.findFirst({
|
|
262
|
-
where: { id: pid },
|
|
263
|
-
select: { id: true, billing_type: true, tiers: true, components: true },
|
|
264
|
-
});
|
|
265
|
-
if (product) {
|
|
266
|
-
parts.push(`\n--- Product tiers (productId: ${pid}) ---`);
|
|
267
|
-
parts.push(JSON.stringify(product, null, 2));
|
|
268
|
-
}
|
|
269
|
-
} catch {
|
|
270
|
-
// best-effort
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return parts.join("\n");
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function brandPrimaryColor(resolved: Record<string, unknown>): string {
|
|
279
|
-
const brand = resolved.brand as { primaryColor?: unknown } | undefined;
|
|
280
|
-
return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Inject the generic ResourceChatLauncher behind the gate. `opts` is the only
|
|
285
|
-
* per-resource-type variation; everything else is shared.
|
|
286
|
-
*/
|
|
287
|
-
export async function injectResourceChatLauncher(
|
|
288
|
-
resolved: Record<string, unknown>,
|
|
289
|
-
viewerEmail: string,
|
|
290
|
-
opts: ResourceChatOptions,
|
|
291
|
-
): Promise<void> {
|
|
292
|
-
// Whichever region actually renders — `page` on a flat document, `body` on a
|
|
293
|
-
// region-slot one. No rendered content at all → nothing to attach to.
|
|
294
|
-
const hostContent = findContentHost(resolved, opts.regionSlots);
|
|
295
|
-
if (!hostContent) return;
|
|
296
|
-
|
|
297
|
-
const record = resolved[opts.resourceKey] as Record<string, unknown> | undefined;
|
|
298
|
-
const organisationId =
|
|
299
|
-
opts.organisationId ||
|
|
300
|
-
(typeof record?.organisation_id === "string" ? record.organisation_id : "");
|
|
301
|
-
if (!organisationId) {
|
|
302
|
-
// THIS BAIL USED TO BE SILENT, AND A SILENT DROP IS THE WORST FAILURE
|
|
303
|
-
// SHAPE A BLOCK CAN HAVE (measured 2026-09-08 on the quiz report): the
|
|
304
|
-
// page returns 200 and looks complete while the assistant is simply
|
|
305
|
-
// absent — resource_key named a resolved key that is null on some link
|
|
306
|
-
// shapes (a research-anchor link has no raw response match), and the
|
|
307
|
-
// page rendered a full report with no bubble and no error anywhere.
|
|
308
|
-
// The page must stay usable (fail-soft for the RENDER is the contract),
|
|
309
|
-
// but a declared assistant that did not inject is never "fine": name
|
|
310
|
-
// the key and the reason so the absence is observable.
|
|
311
|
-
getAppLogger().error(
|
|
312
|
-
`[resource-chat] assistant declared but NOT injected: resolved["${opts.resourceKey}"] is ${record === undefined ? "absent" : "null"} or carries no organisation_id — the launcher is silently missing from this page`,
|
|
313
|
-
);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
let agentId = "";
|
|
318
|
-
try {
|
|
319
|
-
agentId = (
|
|
320
|
-
await loadRegistryConfig("config", opts.agentConfigSlug, AgentConfigSchema)
|
|
321
|
-
).agentId;
|
|
322
|
-
} catch {
|
|
323
|
-
return; // no agent configured → no bubble (fail-soft, page unchanged)
|
|
324
|
-
}
|
|
325
|
-
if (!agentId) return;
|
|
326
|
-
|
|
327
|
-
try {
|
|
328
|
-
const runtimeContext = await buildResourceContext(
|
|
329
|
-
resolved,
|
|
330
|
-
opts.resourceKey,
|
|
331
|
-
opts.regionSlots,
|
|
332
|
-
);
|
|
333
|
-
const presentation = await loadAgentPresentation(agentId);
|
|
334
|
-
(hostContent as Array<Record<string, unknown>>).push({
|
|
335
|
-
type: LAUNCHER_BLOCK,
|
|
336
|
-
props: {
|
|
337
|
-
id: `${opts.resourceKey}-chat-launcher`,
|
|
338
|
-
agentId,
|
|
339
|
-
organisationId,
|
|
340
|
-
viewerEmail,
|
|
341
|
-
runtimeContext,
|
|
342
|
-
// The page's own brand still wins over the agent's stored colour:
|
|
343
|
-
// the bubble sits ON this document and must match it.
|
|
344
|
-
primaryColor: brandPrimaryColor(resolved) || presentation.primaryColor,
|
|
345
|
-
title: presentation.title,
|
|
346
|
-
welcomeMessage: presentation.welcomeMessage,
|
|
347
|
-
launchLabel: opts.launchLabel,
|
|
348
|
-
placeholder: opts.placeholder,
|
|
349
|
-
conversationStarters: presentation.conversationStarters,
|
|
350
|
-
},
|
|
351
|
-
});
|
|
352
|
-
} catch (err) {
|
|
353
|
-
getAppLogger().error(
|
|
354
|
-
`[resource-chat] launcher injection failed (${opts.resourceKey}): ${err instanceof Error ? err.message : String(err)}`,
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
}
|