@teamscala/service-boot 0.23.361 → 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
|
@@ -147,6 +147,27 @@ export function formatSpanMinutes(minutes: number): string {
|
|
|
147
147
|
return `${hours >= 10 ? Math.round(hours) : Math.round(hours * 10) / 10} h`;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The WhatsApp message id from a GOWA /send/message result. The canonical
|
|
152
|
+
* shape (from the spec the api-gowa adapter is generated from,
|
|
153
|
+
* SendResponse = { code, message, results: { message_id, status } }) is
|
|
154
|
+
* `results.message_id`; the legacy shape returned the bare id string as
|
|
155
|
+
* `results`. Delivery is only VERIFIED on a real id — `"true"`, a status
|
|
156
|
+
* string, or an empty body is not a message id and must not be recorded as
|
|
157
|
+
* delivered.
|
|
158
|
+
*/
|
|
159
|
+
export function extractGowaMessageId(sendResult: unknown): string | null {
|
|
160
|
+
const results = (sendResult as { results?: unknown } | undefined)?.results;
|
|
161
|
+
if (typeof results === "string") {
|
|
162
|
+
return /^[A-Z0-9]{8,}$/i.test(results) ? results : null;
|
|
163
|
+
}
|
|
164
|
+
if (results && typeof results === "object") {
|
|
165
|
+
const id = (results as { message_id?: unknown }).message_id;
|
|
166
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
/**
|
|
151
172
|
* The escalation message — escalation-routing's three-field contract adapted
|
|
152
173
|
* to an automated detector: what was OBSERVED (evidence), what it MEANS
|
|
@@ -12,6 +12,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
12
12
|
import {
|
|
13
13
|
composeEscalationMessage,
|
|
14
14
|
decideSweep,
|
|
15
|
+
extractGowaMessageId,
|
|
15
16
|
type EscalationEpisode,
|
|
16
17
|
type FaultAggregate,
|
|
17
18
|
type FaultEscalationPolicy,
|
|
@@ -166,6 +167,26 @@ describe("decideSweep — resolution + re-arm", () => {
|
|
|
166
167
|
});
|
|
167
168
|
});
|
|
168
169
|
|
|
170
|
+
describe("extractGowaMessageId — delivery verification", () => {
|
|
171
|
+
test("the canonical GOWA SendResponse shape yields results.message_id", () => {
|
|
172
|
+
expect(extractGowaMessageId({ code: "SUCCESS", results: { message_id: "3EB089B9D6ADD58153C561", status: "sent" } })).toBe(
|
|
173
|
+
"3EB089B9D6ADD58153C561",
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the legacy bare-string results shape yields the id", () => {
|
|
178
|
+
expect(extractGowaMessageId({ results: "3EB089B9D6ADD58153C561" })).toBe("3EB089B9D6ADD58153C561");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a 'true' ack is NOT a message id — delivery stays unverified", () => {
|
|
182
|
+
// A boolean-ish ack string must never be recorded as a delivered id:
|
|
183
|
+
// that would mark the episode delivered with an unfollowable handle.
|
|
184
|
+
expect(extractGowaMessageId({ results: "true" })).toBeNull();
|
|
185
|
+
expect(extractGowaMessageId({ code: "SUCCESS", message: "sent", results: { status: "sent" } })).toBeNull();
|
|
186
|
+
expect(extractGowaMessageId({ code: "SUCCESS" })).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
169
190
|
describe("composeEscalationMessage", () => {
|
|
170
191
|
test("carries the three escalation-routing fields and stays phone-sized", () => {
|
|
171
192
|
const message = composeEscalationMessage({
|
|
@@ -51,10 +51,12 @@ 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,
|
|
57
58
|
decideSweep,
|
|
59
|
+
extractGowaMessageId,
|
|
58
60
|
type EscalationEpisode,
|
|
59
61
|
type FaultAggregate,
|
|
60
62
|
type KindThresholdOverride,
|
|
@@ -115,6 +117,42 @@ async function getSystemIdentity(): Promise<{ userId: string | undefined; organi
|
|
|
115
117
|
return cachedSystemIdentity;
|
|
116
118
|
}
|
|
117
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
|
+
|
|
118
156
|
async function loadConfig(): Promise<FaultEscalationConfig> {
|
|
119
157
|
return loadRegistryConfig("config", CONFIG_SLUG, FaultEscalationConfigSchema);
|
|
120
158
|
}
|
|
@@ -149,15 +187,14 @@ async function fetchAggregates(patterns: string[], windowMinutes: number): Promi
|
|
|
149
187
|
}
|
|
150
188
|
|
|
151
189
|
async function resolveOperatorRelay(): Promise<OperatorRelayBot> {
|
|
152
|
-
const
|
|
153
|
-
const bot = await prisma.messaging_bots.findFirst({
|
|
190
|
+
const bot = (await orpc().messaging_bots.findFirst({
|
|
154
191
|
where: {
|
|
155
192
|
is_active: true,
|
|
156
193
|
platform: "whatsapp",
|
|
157
194
|
flow_config: { path: ["mode"], equals: "operator-relay" },
|
|
158
195
|
},
|
|
159
196
|
select: { id: true, organisation_id: true, external_id: true, flow_config: true },
|
|
160
|
-
});
|
|
197
|
+
})) as unknown as OperatorRelayBot | null;
|
|
161
198
|
if (!bot) throw new Error("platform-fault-escalation: no active operator-relay WhatsApp bot row");
|
|
162
199
|
const phone = (bot.flow_config as { operator_phone?: string } | null)?.operator_phone;
|
|
163
200
|
if (!phone) throw new Error("platform-fault-escalation: operator-relay bot has no operator_phone");
|
|
@@ -178,39 +215,27 @@ async function resolvePaDeviceId(paPhoneDigits: string): Promise<string | undefi
|
|
|
178
215
|
return match?.id;
|
|
179
216
|
}
|
|
180
217
|
|
|
181
|
-
function extractMessageId(sendResult: unknown): string | null {
|
|
182
|
-
const results = (sendResult as { results?: unknown } | undefined)?.results;
|
|
183
|
-
const id =
|
|
184
|
-
typeof results === "string"
|
|
185
|
-
? results
|
|
186
|
-
: Array.isArray(results)
|
|
187
|
-
? ((results[0] as { id?: unknown } | undefined)?.id as string | undefined)
|
|
188
|
-
: ((results as { id?: unknown } | undefined)?.id as string | undefined);
|
|
189
|
-
return typeof id === "string" && id.length > 0 ? id : null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
218
|
async function deliverEscalation(
|
|
193
219
|
kind: string,
|
|
194
220
|
aggregate: FaultAggregate,
|
|
195
221
|
config: FaultEscalationConfig,
|
|
196
222
|
): Promise<{ messageId: string; workItemId: string }> {
|
|
197
223
|
const log = getAppLogger();
|
|
198
|
-
const prisma = getPrisma();
|
|
199
224
|
const now = new Date();
|
|
200
225
|
|
|
201
226
|
// 1. Claim the episode BEFORE sending, so a send failure leaves a durable
|
|
202
227
|
// claim the next sweep retries instead of a lost escalation.
|
|
203
|
-
await
|
|
228
|
+
await orpc().platform_fault_escalations.upsert({
|
|
204
229
|
where: { kind },
|
|
205
230
|
create: {
|
|
206
231
|
kind,
|
|
207
|
-
first_seen_at: aggregate.firstSeen,
|
|
208
|
-
last_seen_at: aggregate.lastSeen,
|
|
232
|
+
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
233
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
209
234
|
event_count: aggregate.count,
|
|
210
235
|
},
|
|
211
236
|
update: {
|
|
212
|
-
first_seen_at: aggregate.firstSeen,
|
|
213
|
-
last_seen_at: aggregate.lastSeen,
|
|
237
|
+
first_seen_at: aggregate.firstSeen.toISOString(),
|
|
238
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
214
239
|
event_count: aggregate.count,
|
|
215
240
|
resolved_at: null,
|
|
216
241
|
},
|
|
@@ -246,7 +271,7 @@ async function deliverEscalation(
|
|
|
246
271
|
|
|
247
272
|
// 3. VERIFY delivery before recording it: the send must return a WhatsApp
|
|
248
273
|
// message id. No id => throw, the claim stays undelivered, next sweep retries.
|
|
249
|
-
const messageId =
|
|
274
|
+
const messageId = extractGowaMessageId(sendResult);
|
|
250
275
|
if (!messageId) {
|
|
251
276
|
throw new Error(
|
|
252
277
|
`platform-fault-escalation: WhatsApp send for '${kind}' returned no message id — not recording delivery`,
|
|
@@ -254,7 +279,7 @@ async function deliverEscalation(
|
|
|
254
279
|
}
|
|
255
280
|
|
|
256
281
|
// 4. Durable record: the work_items task the PA reads back / the operator answers on.
|
|
257
|
-
const workItem = await
|
|
282
|
+
const workItem = await orpc().work_items.create({
|
|
258
283
|
data: {
|
|
259
284
|
kind: "task",
|
|
260
285
|
parent_id: config.parent_project_id,
|
|
@@ -276,14 +301,14 @@ async function deliverEscalation(
|
|
|
276
301
|
});
|
|
277
302
|
|
|
278
303
|
// 5. Readback-correct the episode: delivered + linked in one update.
|
|
279
|
-
await
|
|
304
|
+
await orpc().platform_fault_escalations.update({
|
|
280
305
|
where: { kind },
|
|
281
306
|
data: {
|
|
282
|
-
escalated_at: now,
|
|
307
|
+
escalated_at: now.toISOString(),
|
|
283
308
|
work_item_id: workItem.id,
|
|
284
309
|
delivered_message_id: messageId,
|
|
285
|
-
last_updated_at: now,
|
|
286
|
-
updated_at: now,
|
|
310
|
+
last_updated_at: now.toISOString(),
|
|
311
|
+
updated_at: now.toISOString(),
|
|
287
312
|
},
|
|
288
313
|
});
|
|
289
314
|
|
|
@@ -300,19 +325,18 @@ async function deliverEscalation(
|
|
|
300
325
|
}
|
|
301
326
|
|
|
302
327
|
async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpisode): Promise<void> {
|
|
303
|
-
const prisma = getPrisma();
|
|
304
328
|
const now = new Date();
|
|
305
|
-
await
|
|
329
|
+
await orpc().platform_fault_escalations.update({
|
|
306
330
|
where: { kind: episode.kind },
|
|
307
331
|
data: {
|
|
308
|
-
last_seen_at: aggregate.lastSeen,
|
|
332
|
+
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
309
333
|
event_count: aggregate.count,
|
|
310
|
-
last_updated_at: now,
|
|
311
|
-
updated_at: now,
|
|
334
|
+
last_updated_at: now.toISOString(),
|
|
335
|
+
updated_at: now.toISOString(),
|
|
312
336
|
},
|
|
313
337
|
});
|
|
314
338
|
if (episode.workItemId) {
|
|
315
|
-
await
|
|
339
|
+
await orpc().comments.create({
|
|
316
340
|
data: {
|
|
317
341
|
work_item_id: episode.workItemId,
|
|
318
342
|
content: `Still recurring: ${aggregate.count} events, last at ${aggregate.lastSeen.toISOString()}. No new escalation (already escalated and unresolved).`,
|
|
@@ -328,14 +352,13 @@ async function updateEpisode(aggregate: FaultAggregate, episode: EscalationEpiso
|
|
|
328
352
|
}
|
|
329
353
|
|
|
330
354
|
async function resolveEpisode(episode: EscalationEpisode, quietMinutes: number): Promise<void> {
|
|
331
|
-
const prisma = getPrisma();
|
|
332
355
|
const now = new Date();
|
|
333
|
-
await
|
|
356
|
+
await orpc().platform_fault_escalations.update({
|
|
334
357
|
where: { kind: episode.kind },
|
|
335
|
-
data: { resolved_at: now, updated_at: now },
|
|
358
|
+
data: { resolved_at: now.toISOString(), updated_at: now.toISOString() },
|
|
336
359
|
});
|
|
337
360
|
if (episode.workItemId) {
|
|
338
|
-
await
|
|
361
|
+
await orpc().comments.create({
|
|
339
362
|
data: {
|
|
340
363
|
work_item_id: episode.workItemId,
|
|
341
364
|
content: `Fault stopped — no '${episode.kind}' events for ${Math.round(quietMinutes)} min. Episode closed; the fault will re-escalate if it recurs.`,
|
|
@@ -359,9 +382,18 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
|
|
|
359
382
|
// Only OPEN episodes matter: a resolved episode and no episode at all have
|
|
360
383
|
// the same meaning to decideSweep (a recurring sustained fault opens a NEW
|
|
361
384
|
// episode and escalates again).
|
|
362
|
-
const openEpisodes = await
|
|
385
|
+
const openEpisodes = (await orpc().platform_fault_escalations.findMany({
|
|
363
386
|
where: { resolved_at: null },
|
|
364
|
-
})
|
|
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
|
+
}>;
|
|
365
397
|
const episodes: EscalationEpisode[] = openEpisodes.map((row) => ({
|
|
366
398
|
kind: row.kind,
|
|
367
399
|
firstSeenAt: new Date(row.first_seen_at),
|