@teamscala/service-boot 0.23.362 → 0.23.363
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
|
@@ -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),
|