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