@teamscala/service-boot 0.23.362 → 0.23.364
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
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* Authenticated asset upload — POST /api/transfers/upload. Restores the
|
|
7
7
|
* handler deleted with @teamscala/server-files (commit ca5643ef); avatar/logo
|
|
8
8
|
* upload had 404'd on every service since. Writes to S3 via
|
|
9
|
-
* @teamscala/object-storage/create/write-ops and returns the
|
|
10
|
-
* (`/api/
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* @teamscala/object-storage/create/write-ops and returns the SERVABLE URL
|
|
10
|
+
* (`/api/assets/<key>`) the client persists into avatar_url/logo_url — the
|
|
11
|
+
* stored value is the servable value (single-logo-pathway), so display code
|
|
12
|
+
* reads the column with no rewriting. Self-authenticates (getSession) so it
|
|
13
|
+
* mounts BEFORE the auth gate.
|
|
14
14
|
*
|
|
15
15
|
* Key scheme is USER-scoped (`users/<userId>/<type>/<file>-<ts>.<ext>`) per
|
|
16
16
|
* the operator ruling: a profile picture belongs to the individual, not any
|
|
@@ -104,7 +104,7 @@ export async function attachTransfersUpload(app: Hono): Promise<void> {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
const storedUrl = `/api/
|
|
107
|
+
const storedUrl = `/api/assets/${key}`;
|
|
108
108
|
return c.json({ url: storedUrl, thumbUrl: storedUrl });
|
|
109
109
|
});
|
|
110
110
|
}
|
|
@@ -51,6 +51,7 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
|
51
51
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
52
52
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
53
53
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
54
|
+
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
54
55
|
import {
|
|
55
56
|
composeEscalationDescription,
|
|
56
57
|
composeEscalationMessage,
|
|
@@ -116,6 +117,42 @@ async function getSystemIdentity(): Promise<{ userId: string | undefined; organi
|
|
|
116
117
|
return cachedSystemIdentity;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The system-authed in-process ORPC client (set at boot after the router is
|
|
122
|
+
* built — no-arg form carries the system super-admin context, correct for a
|
|
123
|
+
* platform system action). Per `no-direct-prisma-outside-orpc`, every WRITE in
|
|
124
|
+
* this handler routes through it; the single raw read (the event_log
|
|
125
|
+
* recurrence aggregate, a GROUP BY no generated CRUD expresses) uses the
|
|
126
|
+
* assigned-client form with $queryRawUnsafe — the lane-attention-sweep
|
|
127
|
+
* precedent in this same directory. Timestamps are ISO strings, never Date
|
|
128
|
+
* instances (the generated valibot schemas require it).
|
|
129
|
+
*/
|
|
130
|
+
function orpc(): OrpcSurface {
|
|
131
|
+
return getServerClient() as unknown as OrpcSurface;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The router subset this handler uses — mirrors the generated procedure args. */
|
|
135
|
+
interface OrpcSurface {
|
|
136
|
+
platform_fault_escalations: {
|
|
137
|
+
upsert(args: {
|
|
138
|
+
where: { kind: string };
|
|
139
|
+
create: Record<string, unknown>;
|
|
140
|
+
update: Record<string, unknown>;
|
|
141
|
+
}): Promise<unknown>;
|
|
142
|
+
update(args: { where: { kind: string }; data: Record<string, unknown> }): Promise<unknown>;
|
|
143
|
+
findMany(args?: { where?: Record<string, unknown> }): Promise<Array<Record<string, unknown>>>;
|
|
144
|
+
};
|
|
145
|
+
messaging_bots: {
|
|
146
|
+
findFirst(args: Record<string, unknown>): Promise<Record<string, unknown> | null>;
|
|
147
|
+
};
|
|
148
|
+
work_items: {
|
|
149
|
+
create(args: { data: Record<string, unknown>; select?: Record<string, unknown> }): Promise<{ id: string }>;
|
|
150
|
+
};
|
|
151
|
+
comments: {
|
|
152
|
+
create(args: { data: Record<string, unknown> }): Promise<{ id: string }>;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
119
156
|
async function loadConfig(): Promise<FaultEscalationConfig> {
|
|
120
157
|
return loadRegistryConfig("config", CONFIG_SLUG, FaultEscalationConfigSchema);
|
|
121
158
|
}
|
|
@@ -150,15 +187,14 @@ async function fetchAggregates(patterns: string[], windowMinutes: number): Promi
|
|
|
150
187
|
}
|
|
151
188
|
|
|
152
189
|
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
153
|
-
const
|
|
154
|
-
const bot = await prisma.messaging_bots.findFirst({
|
|
190
|
+
const bot = (await orpc().messaging_bots.findFirst({
|
|
155
191
|
where: {
|
|
156
192
|
is_active: true,
|
|
157
193
|
platform: "whatsapp",
|
|
158
194
|
flow_config: { path: ["mode"], equals: "operator-relay" },
|
|
159
195
|
},
|
|
160
196
|
select: { id: true, organisation_id: true, external_id: true, flow_config: true },
|
|
161
|
-
});
|
|
197
|
+
})) as unknown as OperatorRelayBot | null;
|
|
162
198
|
if (!bot) throw new Error("platform-fault-escalation: no active operator-relay WhatsApp bot row");
|
|
163
199
|
const phone = (bot.flow_config as { operator_phone?: string } | null)?.operator_phone;
|
|
164
200
|
if (!phone) throw new Error("platform-fault-escalation: operator-relay bot has no operator_phone");
|
|
@@ -185,22 +221,21 @@ async function deliverEscalation(
|
|
|
185
221
|
config: FaultEscalationConfig,
|
|
186
222
|
): Promise<{ messageId: string; workItemId: string }> {
|
|
187
223
|
const log = getAppLogger();
|
|
188
|
-
const prisma = getPrisma();
|
|
189
224
|
const now = new Date();
|
|
190
225
|
|
|
191
226
|
// 1. Claim the episode BEFORE sending, so a send failure leaves a durable
|
|
192
227
|
// claim the next sweep retries instead of a lost escalation.
|
|
193
|
-
await
|
|
228
|
+
await orpc().platform_fault_escalations.upsert({
|
|
194
229
|
where: { kind },
|
|
195
230
|
create: {
|
|
196
231
|
kind,
|
|
197
|
-
first_seen_at: aggregate.firstSeen,
|
|
198
|
-
last_seen_at: aggregate.lastSeen,
|
|
232
|
+
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
233
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
199
234
|
event_count: aggregate.count,
|
|
200
235
|
},
|
|
201
236
|
update: {
|
|
202
|
-
first_seen_at: aggregate.firstSeen,
|
|
203
|
-
last_seen_at: aggregate.lastSeen,
|
|
237
|
+
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
238
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
204
239
|
event_count: aggregate.count,
|
|
205
240
|
resolved_at: null,
|
|
206
241
|
},
|
|
@@ -244,7 +279,7 @@ async function deliverEscalation(
|
|
|
244
279
|
}
|
|
245
280
|
|
|
246
281
|
// 4. Durable record: the work_items task the PA reads back / the operator answers on.
|
|
247
|
-
const workItem = await
|
|
282
|
+
const workItem = await orpc().work_items.create({
|
|
248
283
|
data: {
|
|
249
284
|
kind: "task",
|
|
250
285
|
parent_id: config.parent_project_id,
|
|
@@ -266,14 +301,14 @@ async function deliverEscalation(
|
|
|
266
301
|
});
|
|
267
302
|
|
|
268
303
|
// 5. Readback-correct the episode: delivered + linked in one update.
|
|
269
|
-
await
|
|
304
|
+
await orpc().platform_fault_escalations.update({
|
|
270
305
|
where: { kind },
|
|
271
306
|
data: {
|
|
272
|
-
escalated_at: now,
|
|
307
|
+
escalated_at: now.toISOString(),
|
|
273
308
|
work_item_id: workItem.id,
|
|
274
309
|
delivered_message_id: messageId,
|
|
275
|
-
last_updated_at: now,
|
|
276
|
-
updated_at: now,
|
|
310
|
+
last_updated_at: now.toISOString(),
|
|
311
|
+
updated_at: now.toISOString(),
|
|
277
312
|
},
|
|
278
313
|
});
|
|
279
314
|
|
|
@@ -290,19 +325,18 @@ async function deliverEscalation(
|
|
|
290
325
|
}
|
|
291
326
|
|
|
292
327
|
async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpisode): Promise<void> {
|
|
293
|
-
const prisma = getPrisma();
|
|
294
328
|
const now = new Date();
|
|
295
|
-
await
|
|
329
|
+
await orpc().platform_fault_escalations.update({
|
|
296
330
|
where: { kind: episode.kind },
|
|
297
331
|
data: {
|
|
298
|
-
last_seen_at: aggregate.lastSeen,
|
|
332
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
299
333
|
event_count: aggregate.count,
|
|
300
|
-
last_updated_at: now,
|
|
301
|
-
updated_at: now,
|
|
334
|
+
last_updated_at: now.toISOString(),
|
|
335
|
+
updated_at: now.toISOString(),
|
|
302
336
|
},
|
|
303
337
|
});
|
|
304
338
|
if (episode.workItemId) {
|
|
305
|
-
await
|
|
339
|
+
await orpc().comments.create({
|
|
306
340
|
data: {
|
|
307
341
|
work_item_id: episode.workItemId,
|
|
308
342
|
content: `Still recurring: ${aggregate.count} events, last at ${aggregate.lastSeen.toISOString()}. No new escalation (already escalated and unresolved).`,
|
|
@@ -318,14 +352,13 @@ async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpiso
|
|
|
318
352
|
}
|
|
319
353
|
|
|
320
354
|
async function resolveEpisode(episode: EscalationEpisode, quietMinutes: number): Promise<void> {
|
|
321
|
-
const prisma = getPrisma();
|
|
322
355
|
const now = new Date();
|
|
323
|
-
await
|
|
356
|
+
await orpc().platform_fault_escalations.update({
|
|
324
357
|
where: { kind: episode.kind },
|
|
325
|
-
data: { resolved_at: now, updated_at: now },
|
|
358
|
+
data: { resolved_at: now.toISOString(), updated_at: now.toISOString() },
|
|
326
359
|
});
|
|
327
360
|
if (episode.workItemId) {
|
|
328
|
-
await
|
|
361
|
+
await orpc().comments.create({
|
|
329
362
|
data: {
|
|
330
363
|
work_item_id: episode.workItemId,
|
|
331
364
|
content: `Fault stopped — no '${episode.kind}' events for ${Math.round(quietMinutes)} min. Episode closed; the fault will re-escalate if it recurs.`,
|
|
@@ -349,9 +382,18 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
|
|
|
349
382
|
// Only OPEN episodes matter: a resolved episode and no episode at all have
|
|
350
383
|
// the same meaning to decideSweep (a recurring sustained fault opens a NEW
|
|
351
384
|
// episode and escalates again).
|
|
352
|
-
const openEpisodes = await
|
|
385
|
+
const openEpisodes = (await orpc().platform_fault_escalations.findMany({
|
|
353
386
|
where: { resolved_at: null },
|
|
354
|
-
})
|
|
387
|
+
})) as Array<{
|
|
388
|
+
kind: string;
|
|
389
|
+
first_seen_at: string | Date;
|
|
390
|
+
last_seen_at: string | Date;
|
|
391
|
+
event_count: number;
|
|
392
|
+
escalated_at: string | Date | null;
|
|
393
|
+
work_item_id: string | null;
|
|
394
|
+
resolved_at: string | Date | null;
|
|
395
|
+
last_updated_at: string | Date | null;
|
|
396
|
+
}>;
|
|
355
397
|
const episodes: EscalationEpisode[] = openEpisodes.map((row) => ({
|
|
356
398
|
kind: row.kind,
|
|
357
399
|
firstSeenAt: new Date(row.first_seen_at),
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Stamp partner and client brand assets onto a proposal page's pageData, after
|
|
7
7
|
* it is built.
|
|
8
|
-
*
|
|
9
8
|
* Split from proposal-post-processors.ts (single-purpose-file: two unrelated
|
|
10
9
|
* export groups). Both helpers here are synchronous, take an already-resolved
|
|
11
10
|
* loader-graph org, and only map a logo/colour onto pageData — no network, no
|
|
@@ -13,7 +12,6 @@
|
|
|
13
12
|
* async ORPC lookup, which is the distinction that was invisible while they sat
|
|
14
13
|
* together.
|
|
15
14
|
*/
|
|
16
|
-
import { toAssetPath } from "@teamscala/formatters/asset-url";
|
|
17
15
|
|
|
18
16
|
/**
|
|
19
17
|
* Stash a proposal's connected partner logo/colour on `pageData` from the
|
|
@@ -37,7 +35,7 @@ export function applyPartnerBrand(
|
|
|
37
35
|
| null,
|
|
38
36
|
): void {
|
|
39
37
|
if (!partnerOrg) return;
|
|
40
|
-
if (partnerOrg.logo_url) pageData.partnerLogo =
|
|
38
|
+
if (partnerOrg.logo_url) pageData.partnerLogo = partnerOrg.logo_url;
|
|
41
39
|
if (partnerOrg.brand_primary_color)
|
|
42
40
|
pageData.partnerBrandColor = partnerOrg.brand_primary_color;
|
|
43
41
|
}
|
|
@@ -56,5 +54,5 @@ export function applyClientLogo(
|
|
|
56
54
|
| null,
|
|
57
55
|
): void {
|
|
58
56
|
if (!clientOrg?.logo_url) return;
|
|
59
|
-
pageData.clientLogo =
|
|
57
|
+
pageData.clientLogo = clientOrg.logo_url;
|
|
60
58
|
}
|
|
@@ -38,7 +38,7 @@ describe("buildResourceNotFoundResponse", () => {
|
|
|
38
38
|
const bytes = new Uint8Array([137, 80, 78, 71]);
|
|
39
39
|
const requested: string[] = [];
|
|
40
40
|
const logo = await inlineLogoAsDataUri(
|
|
41
|
-
"/api/
|
|
41
|
+
"/api/assets/c503629a/logos/cropped.png",
|
|
42
42
|
async () => ({
|
|
43
43
|
file(key) {
|
|
44
44
|
requested.push(key);
|
|
@@ -55,7 +55,7 @@ describe("buildResourceNotFoundResponse", () => {
|
|
|
55
55
|
|
|
56
56
|
it("passes the resolved logo to the waking page instead of its placeholder", async () => {
|
|
57
57
|
const logo = await inlineLogoAsDataUri(
|
|
58
|
-
"/api/
|
|
58
|
+
"/api/assets/c503629a/logos/cropped.png",
|
|
59
59
|
async () => ({
|
|
60
60
|
file() {
|
|
61
61
|
return {
|
|
@@ -33,7 +33,6 @@ import { createCache } from "@teamscala/cache/create-cache";
|
|
|
33
33
|
import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
|
|
34
34
|
import { getServerClient } from "@teamscala/orpc/server-client-registry";
|
|
35
35
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
36
|
-
import { toAssetPath } from "@teamscala/formatters/asset-url";
|
|
37
36
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
38
37
|
import { brandLogoImgTag } from "@teamscala/os/brand/brand-logo-img-tag";
|
|
39
38
|
import { ServiceInstallConfigSchema } from "./load-service-install-row.ts";
|
|
@@ -134,10 +133,10 @@ const ASSETS_PREFIX = "/api/assets/";
|
|
|
134
133
|
|
|
135
134
|
/**
|
|
136
135
|
* Inline a stored logo value as a `data:<mime>;base64,…` URI by fetching its
|
|
137
|
-
* bytes from S3 (the same store `/api/assets/` proxies to).
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
136
|
+
* bytes from S3 (the same store `/api/assets/` proxies to). The stored value
|
|
137
|
+
* IS the servable form — `/api/assets/<key>` — so the bucket key is the path
|
|
138
|
+
* segment after that prefix (single-logo-pathway). Skew-tolerant: any miss →
|
|
139
|
+
* null.
|
|
141
140
|
*/
|
|
142
141
|
type LogoStorage = {
|
|
143
142
|
file(key: string): {
|
|
@@ -151,9 +150,8 @@ export async function inlineLogoAsDataUri(
|
|
|
151
150
|
getStorage: () => Promise<LogoStorage | null> = getClient as unknown as () => Promise<LogoStorage | null>,
|
|
152
151
|
): Promise<string | null> {
|
|
153
152
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const key = decodeURIComponent(assetPath.slice(ASSETS_PREFIX.length));
|
|
153
|
+
if (!stored.startsWith(ASSETS_PREFIX)) return null;
|
|
154
|
+
const key = decodeURIComponent(stored.slice(ASSETS_PREFIX.length));
|
|
157
155
|
const s3 = await getStorage();
|
|
158
156
|
if (!s3) return null;
|
|
159
157
|
const file = s3.file(key);
|