@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,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.362",
9
+ "version": "0.23.364",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -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 STORED-form URL
10
- * (`/api/files/<key>`) the client persists into avatar_url/logo_url — the
11
- * display path rewrites it to the public `/api/assets/<encoded-key>` proxy
12
- * via `toAssetPath` (@teamscala/formatters/asset-url). Self-authenticates
13
- * (getSession) so it mounts BEFORE the auth gate.
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/files/${key}`;
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 prisma = getPrisma();
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 prisma.platform_fault_escalations.upsert({
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 prisma.work_items.create({
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 prisma.platform_fault_escalations.update({
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 prisma.platform_fault_escalations.update({
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 prisma.comments.create({
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 prisma.platform_fault_escalations.update({
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 prisma.comments.create({
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 getPrisma().platform_fault_escalations.findMany({
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 = toAssetPath(partnerOrg.logo_url);
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 = toAssetPath(clientOrg.logo_url);
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/files/c503629a/logos/cropped.png",
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/files/c503629a/logos/cropped.png",
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). Mirrors the asset
138
- * proxy's key extraction: `toAssetPath` normalises every stored form (bare
139
- * key, `/api/files/`, tigris URL) to `/api/assets/<key>`, and the proxy
140
- * decodes that path segment to the S3 key. Skew-tolerant: any miss → null.
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
- const assetPath = toAssetPath(stored);
155
- if (!assetPath.startsWith(ASSETS_PREFIX)) return null;
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);