@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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.361",
9
+ "version": "0.23.363",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -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 prisma = getPrisma();
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 prisma.platform_fault_escalations.upsert({
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 = extractMessageId(sendResult);
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 prisma.work_items.create({
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 prisma.platform_fault_escalations.update({
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 prisma.platform_fault_escalations.update({
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 prisma.comments.create({
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 prisma.platform_fault_escalations.update({
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 prisma.comments.create({
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 getPrisma().platform_fault_escalations.findMany({
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),