@cosmicdrift/kumiko-samples 0.342.0 → 0.343.0

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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/constants.ts +5 -1
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
  7. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  8. package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
  12. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
  13. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  14. package/packages/bundled-features/src/channel-email/feature.ts +1 -0
  15. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
  16. package/packages/bundled-features/src/channel-push/feature.ts +1 -0
  17. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  18. package/packages/bundled-features/src/channel-teams/changes.json +8 -0
  19. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  20. package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
  21. package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
  22. package/packages/bundled-features/src/delivery/changes.json +32 -0
  23. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
  24. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
  25. package/packages/bundled-features/src/delivery/constants.ts +4 -0
  26. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
  27. package/packages/bundled-features/src/delivery/events.ts +15 -1
  28. package/packages/bundled-features/src/delivery/feature.ts +32 -4
  29. package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
  30. package/packages/bundled-features/src/delivery/i18n.ts +55 -0
  31. package/packages/bundled-features/src/delivery/index.ts +2 -0
  32. package/packages/bundled-features/src/delivery/jobs.ts +43 -20
  33. package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
  34. package/packages/bundled-features/src/delivery/public-names.ts +4 -0
  35. package/packages/bundled-features/src/delivery/tables.ts +3 -0
  36. package/packages/bundled-features/src/delivery/testing.ts +5 -9
  37. package/packages/bundled-features/src/delivery/types.ts +6 -2
  38. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
  39. package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
  40. package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
  41. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
  42. package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
  43. package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
  44. package/packages/bundled-features/src/jobs/changes.json +6 -0
  45. package/packages/bundled-features/src/secrets/changes.json +6 -0
  46. package/packages/bundled-features/src/secrets/constants.ts +10 -0
  47. package/packages/bundled-features/src/secrets/feature.ts +4 -0
  48. package/packages/bundled-features/src/secrets/i18n.ts +5 -3
  49. package/packages/bundled-features/src/secrets/index.ts +8 -1
  50. package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
  51. package/packages/bundled-features/src/shared/index.ts +6 -0
  52. package/packages/bundled-features/src/shared/redact.ts +27 -0
  53. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
  54. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
  55. package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
  56. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  57. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
  58. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
  59. package/packages/bundled-features/src/user/index.ts +1 -0
  60. package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
  61. /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
@@ -1,22 +1,27 @@
1
+ import type { ChatSendFailureCode } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import { BlockedHostError, type EgressPolicy, egress } from "@cosmicdrift/kumiko-framework/http";
2
3
  import * as z from "zod";
3
4
 
4
5
  // Closed vocabulary: these codes (and nothing else) land in delivery_attempts.error.
5
6
  // Never err.message and never a provider response body — a fetch error can carry
6
7
  // the request URL, and the Telegram URL embeds the bot token.
7
- export type ChatSendFailureCode =
8
- | `http_${number}`
9
- | "timeout"
10
- | "network_error"
11
- | "redirect_blocked"
12
- | "host_not_allowed"
13
- | "missing_credentials"
14
- | "invalid_address";
8
+ export type { ChatSendFailureCode };
15
9
 
10
+ // confirmed=false: the provider accepted the request but does not say whether the
11
+ // message arrived (e.g. a 202 from a workflow endpoint).
16
12
  export type ChatSendResult =
17
- | { readonly ok: true }
13
+ | { readonly ok: true; readonly confirmed: boolean }
18
14
  | { readonly ok: false; readonly code: ChatSendFailureCode };
19
15
 
16
+ // What a classifier may look at on a 2xx answer. The body is never stored or logged.
17
+ export type ChatWebhookResponse = {
18
+ readonly status: number;
19
+ // At most 64 bytes, UTF-8 decoded.
20
+ readBodyPrefix(): Promise<string>;
21
+ };
22
+
23
+ const MAX_BODY_PREFIX_BYTES = 64;
24
+
20
25
  export const DEFAULT_CHAT_TIMEOUT_MS = 10_000;
21
26
 
22
27
  // Tenant-chosen connection name (the route address for webhook channels). Also
@@ -41,6 +46,8 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
41
46
  readonly body: unknown;
42
47
  // Test seam; production always uses the policy-bound egress().
43
48
  readonly send?: (url: string, init: RequestInit) => Promise<Response>;
49
+ // Called for 2xx answers only. Default: every 2xx is a confirmed send.
50
+ readonly classifyResponse?: (response: ChatWebhookResponse) => Promise<ChatSendResult>;
44
51
  };
45
52
 
46
53
  // https targets are provider endpoints on public hosts: egress "external" adds
@@ -129,15 +136,54 @@ export async function postChatWebhook(request: ChatWebhookRequest): Promise<Chat
129
136
  return { ok: false, code: classifyFailure(err) };
130
137
  }
131
138
 
132
- // Free the connection; the body is never read or stored.
133
- await response.body?.cancel().catch(() => undefined);
134
-
135
139
  const { status } = response;
136
- if (status >= 300 && status < 400) return { ok: false, code: "redirect_blocked" };
137
- if (status >= 200 && status < 300) return { ok: true };
140
+ if (status >= 300 && status < 400) {
141
+ await response.body?.cancel().catch(() => undefined);
142
+ return { ok: false, code: "redirect_blocked" };
143
+ }
144
+ if (status >= 200 && status < 300) return classifySuccess(request, response);
145
+ await response.body?.cancel().catch(() => undefined);
138
146
  return { ok: false, code: `http_${status}` };
139
147
  }
140
148
 
149
+ // The body is cancelled afterwards in every case, even when the classifier throws,
150
+ // so the connection is freed and a large body is never read to the end.
151
+ async function classifySuccess(
152
+ request: ChatWebhookRequest,
153
+ response: Response,
154
+ ): Promise<ChatSendResult> {
155
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
156
+ try {
157
+ if (!request.classifyResponse) return { ok: true, confirmed: true };
158
+ return await request.classifyResponse({
159
+ status: response.status,
160
+ async readBodyPrefix() {
161
+ reader ??= response.body?.getReader();
162
+ if (!reader) return "";
163
+ const chunks: Uint8Array[] = [];
164
+ let total = 0;
165
+ while (total < MAX_BODY_PREFIX_BYTES) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ chunks.push(value);
169
+ total += value.length;
170
+ }
171
+ const joined = new Uint8Array(total);
172
+ let offset = 0;
173
+ for (const chunk of chunks) {
174
+ joined.set(chunk, offset);
175
+ offset += chunk.length;
176
+ }
177
+ return new TextDecoder().decode(joined.subarray(0, MAX_BODY_PREFIX_BYTES));
178
+ },
179
+ });
180
+ } catch (err) {
181
+ return { ok: false, code: classifyFailure(err) };
182
+ } finally {
183
+ await (reader ? reader.cancel() : response.body?.cancel())?.catch(() => undefined);
184
+ }
185
+ }
186
+
141
187
  export function truncateChars(text: string, maxChars: number): string {
142
188
  const chars = Array.from(text);
143
189
  return chars.length <= maxChars ? text : chars.slice(0, maxChars).join("");
@@ -8,10 +8,14 @@ export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
8
8
 
9
9
  export {
10
10
  DELIVERY_ATTEMPT_EVENT,
11
+ DELIVERY_CHANNEL_CELL_COMPONENT,
12
+ DELIVERY_ERROR_CELL_COMPONENT,
11
13
  DELIVERY_FEATURE,
12
14
  DELIVERY_LOG_SCREEN_ID,
13
15
  DELIVERY_RESUBSCRIBE_PATH,
14
16
  DELIVERY_STATUS_CELL_COMPONENT,
17
+ DELIVERY_TIME_CELL_COMPONENT,
18
+ DELIVERY_TYPE_CELL_COMPONENT,
15
19
  DELIVERY_UNSUBSCRIBE_PATH,
16
20
  DeliveryErrors,
17
21
  DeliveryHandlers,
@@ -2,6 +2,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type {
5
+ DeliveryErrorCode,
5
6
  EscapeHatchAuditSink,
6
7
  NotifyDelivery,
7
8
  NotifyJobDispatcher,
@@ -10,12 +11,13 @@ import type {
10
11
  TenantId,
11
12
  } from "@cosmicdrift/kumiko-framework/engine";
12
13
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
13
- import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
14
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
14
15
  import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
15
16
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
16
17
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
17
18
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
18
19
  import type { Redis } from "ioredis";
20
+ import { redactErrorText } from "../shared/index.js";
19
21
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
20
22
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
21
23
  import { buildChannelContext } from "./channel-context.js";
@@ -25,12 +27,18 @@ import { selectNotificationPreferences } from "./db/queries/preferences.js";
25
27
  import {
26
28
  type ChannelContext,
27
29
  type ChannelMessage,
30
+ type ChannelResult,
28
31
  type DeliveryChannel,
29
32
  type DeliveryLogEntry,
30
33
  type DeliveryService,
31
34
  isDeliveryChannelPlugin,
35
+ type RenderedMessage,
32
36
  } from "./types.js";
33
37
 
38
+ export function redactedMessageOf(err: unknown): string {
39
+ return redactErrorText(err instanceof Error ? err.message : String(err));
40
+ }
41
+
34
42
  export type RateLimitConfig = {
35
43
  readonly redis: Redis;
36
44
  readonly maxPerHour: number; // per channel per tenant
@@ -99,6 +107,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
99
107
  log,
100
108
  } = options;
101
109
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
110
+ const logError = createFallbackLogger("delivery", log);
102
111
 
103
112
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
104
113
  // Lua. Runs single-threaded in Redis, so two parallel clients can't both
@@ -232,6 +241,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
232
241
  recipientId: entry.recipientId,
233
242
  status: entry.status,
234
243
  error: entry.error,
244
+ ...(entry.confirmed === false && { confirmed: entry.confirmed }),
235
245
  deliveryAttemptId,
236
246
  });
237
247
  }
@@ -244,6 +254,36 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
244
254
  recordDelivery(run, entry, attemptId);
245
255
  }
246
256
 
257
+ // The stored/returned error is only the code; the redacted message goes to the log.
258
+ async function logInlineFailure(
259
+ run: NotifyRun,
260
+ args: {
261
+ readonly channel: DeliveryChannel;
262
+ readonly address: string;
263
+ readonly tenantId: TenantId;
264
+ readonly recipientId: string | null;
265
+ readonly notificationType: string;
266
+ readonly priority: NotifyPriority;
267
+ },
268
+ code: DeliveryErrorCode,
269
+ err: unknown,
270
+ ): Promise<void> {
271
+ logError.error(`${args.channel.name} ${code}: ${redactedMessageOf(err)}`, {
272
+ notificationType: args.notificationType,
273
+ channel: args.channel.name,
274
+ });
275
+ await logDelivery(run, {
276
+ tenantId: args.tenantId,
277
+ notificationType: args.notificationType,
278
+ channel: args.channel.name,
279
+ recipientId: args.recipientId,
280
+ recipientAddress: args.address,
281
+ status: "failed",
282
+ error: code,
283
+ priority: args.priority,
284
+ });
285
+ }
286
+
247
287
  // Deliver one resolved (channel, address) pair. Inline channels (inApp) and
248
288
  // the no-job-runner fallback render + send synchronously and log the terminal
249
289
  // status. Queued channels (email/push) record a `queued` attempt up front and
@@ -305,8 +345,24 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
305
345
  recordDelivery(run, queuedEntry, deliveryAttemptId);
306
346
  } else {
307
347
  // Inline (inApp) or no-job-runner fallback: render + send synchronously.
308
- const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
309
- const result = await channel.send(address, message, channelCtx, rendered);
348
+ let rendered: RenderedMessage | undefined;
349
+ if (channel.render) {
350
+ try {
351
+ rendered = await channel.render(message, channelCtx);
352
+ } catch (err) {
353
+ await logInlineFailure(run, args, "render_failed", err);
354
+ // skip: logInlineFailure recorded and logged the failure
355
+ return;
356
+ }
357
+ }
358
+ let result: ChannelResult;
359
+ try {
360
+ result = await channel.send(address, message, channelCtx, rendered);
361
+ } catch (err) {
362
+ await logInlineFailure(run, args, "send_failed", err);
363
+ // skip: logInlineFailure recorded and logged the failure
364
+ return;
365
+ }
310
366
  await logDelivery(run, {
311
367
  tenantId,
312
368
  notificationType,
@@ -316,6 +372,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
316
372
  status: result.status,
317
373
  error: result.error ?? null,
318
374
  priority,
375
+ ...(result.confirmed === false && { confirmed: result.confirmed }),
319
376
  });
320
377
  }
321
378
  }
@@ -517,6 +574,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
517
574
  priority,
518
575
  });
519
576
  } catch (err) {
577
+ logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
578
+ notificationType,
579
+ channel: channel.name,
580
+ });
520
581
  await logDelivery(run, {
521
582
  tenantId,
522
583
  notificationType,
@@ -524,7 +585,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
524
585
  recipientId: userId,
525
586
  recipientAddress: null,
526
587
  status: "failed",
527
- error: err instanceof Error ? err.message : String(err),
588
+ error: "channel_error",
528
589
  priority,
529
590
  });
530
591
  }
@@ -599,6 +660,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
599
660
  priority,
600
661
  });
601
662
  } catch (err) {
663
+ logError.error(`${channel.name} channel_error: ${redactedMessageOf(err)}`, {
664
+ notificationType,
665
+ channel: channel.name,
666
+ });
602
667
  await logDelivery(run, {
603
668
  tenantId,
604
669
  notificationType,
@@ -606,7 +671,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
606
671
  recipientId,
607
672
  recipientAddress: address,
608
673
  status: "failed",
609
- error: err instanceof Error ? err.message : String(err),
674
+ error: "channel_error",
610
675
  priority,
611
676
  });
612
677
  }
@@ -3,6 +3,11 @@
3
3
  // delivery-service.ts (validates payloads before the low-level append()
4
4
  // — out-of-dispatcher writes otherwise skip schema enforcement).
5
5
 
6
+ import {
7
+ DELIVERY_FAILURE_CODES,
8
+ DELIVERY_SKIP_REASONS,
9
+ HTTP_ERROR_CODE_PATTERN,
10
+ } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import * as z from "zod";
7
12
  import { DeliveryStatus } from "./constants.js";
8
13
 
@@ -17,8 +22,17 @@ export const deliveryAttemptSchema = z.object({
17
22
  DeliveryStatus.failed,
18
23
  DeliveryStatus.skipped,
19
24
  ]),
20
- error: z.string().nullable(),
25
+ // Closed code set; z.custom would not serialize to JSON-Schema. Events written
26
+ // before the code vocabulary may hold free text, they are not re-validated on replay.
27
+ error: z
28
+ .union([
29
+ z.enum([...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS]),
30
+ z.string().regex(HTTP_ERROR_CODE_PATTERN),
31
+ ])
32
+ .nullable(),
21
33
  priority: z.enum(["critical", "normal", "low"]),
34
+ // Provider accepted the request but does not confirm delivery (e.g. Teams 202).
35
+ confirmed: z.literal(false).optional(),
22
36
  });
23
37
 
24
38
  export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
@@ -8,9 +8,13 @@ import {
8
8
  import type * as z from "zod";
9
9
  import {
10
10
  DELIVERY_ATTEMPT_EVENT,
11
+ DELIVERY_CHANNEL_CELL_COMPONENT,
11
12
  DELIVERY_CHANNEL_EXTENSION,
13
+ DELIVERY_ERROR_CELL_COMPONENT,
12
14
  DELIVERY_LOG_SCREEN_ID,
13
15
  DELIVERY_STATUS_CELL_COMPONENT,
16
+ DELIVERY_TIME_CELL_COMPONENT,
17
+ DELIVERY_TYPE_CELL_COMPONENT,
14
18
  DeliveryJobNames,
15
19
  DeliveryQueries,
16
20
  } from "./constants.js";
@@ -118,8 +122,14 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
118
122
  status: p.status,
119
123
  error: p.error,
120
124
  priority: p.priority,
125
+ confirmed: p.confirmed ?? null,
126
+ },
127
+ {
128
+ status: p.status,
129
+ error: p.error,
130
+ recipientAddress: p.recipientAddress,
131
+ confirmed: p.confirmed ?? null,
121
132
  },
122
- { status: p.status, error: p.error, recipientAddress: p.recipientAddress },
123
133
  );
124
134
  },
125
135
  },
@@ -168,20 +178,38 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
168
178
  type: "projectionList",
169
179
  query: DeliveryQueries.log,
170
180
  columns: [
181
+ {
182
+ field: "createdAt",
183
+ label: i18nKey("delivery.log.col.createdAt"),
184
+ renderer: { react: { __component: DELIVERY_TIME_CELL_COMPONENT } },
185
+ },
171
186
  {
172
187
  field: "tenantId",
173
188
  label: i18nKey("delivery.log.col.tenantId"),
174
189
  refEntity: "tenant:tenant",
175
190
  refLabelField: "name",
176
191
  },
177
- { field: "type", label: i18nKey("delivery.log.col.type") },
178
- { field: "channel", label: i18nKey("delivery.log.col.channel") },
179
- { field: "recipient", label: i18nKey("delivery.log.col.recipient") },
192
+ {
193
+ field: "type",
194
+ label: i18nKey("delivery.log.col.type"),
195
+ renderer: { react: { __component: DELIVERY_TYPE_CELL_COMPONENT } },
196
+ },
197
+ {
198
+ field: "channel",
199
+ label: i18nKey("delivery.log.col.channel"),
200
+ renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
201
+ },
202
+ { field: "recipientLabel", label: i18nKey("delivery.log.col.recipient") },
180
203
  {
181
204
  field: "status",
182
205
  label: i18nKey("delivery.log.col.status"),
183
206
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
184
207
  },
208
+ {
209
+ field: "error",
210
+ label: i18nKey("delivery.log.col.error"),
211
+ renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
212
+ },
185
213
  ],
186
214
  // Cursor-based paging: deliveryAttemptsTable's id is an event-stream
187
215
  // aggregate id (not a sequential PK), so the "pages" default (offset +
@@ -1,15 +1,25 @@
1
1
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { decodeKeysetCursor, encodeKeysetCursor } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ decodeKeysetCursor,
4
+ encodeKeysetCursor,
5
+ type TenantDb,
6
+ } from "@cosmicdrift/kumiko-framework/db";
3
7
  import {
4
8
  access,
9
+ type DeliveryErrorCode,
5
10
  definePagedQueryHandler,
11
+ isDeliveryErrorCode,
12
+ isUuid,
6
13
  MAX_LIST_LIMIT,
7
14
  type NotifyPriority,
15
+ type Registry,
16
+ SYSTEM_USER_ID,
8
17
  } from "@cosmicdrift/kumiko-framework/engine";
9
18
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
19
  import { Temporal } from "temporal-polyfill";
11
20
  import * as z from "zod";
12
21
  import { decryptStoredPii } from "../../shared/index.js";
22
+ import { resolveUserDisplayNames, USER_FEATURE } from "../../user/index.js";
13
23
  import type { DeliveryStatusValue } from "../constants.js";
14
24
  import { deliveryAttemptsTable } from "../tables.js";
15
25
 
@@ -21,17 +31,25 @@ type DeliveryLogRow = {
21
31
  recipientId: string | null;
22
32
  recipientAddress: string | null;
23
33
  status: DeliveryStatusValue;
34
+ // Legacy rows may hold free text; the handler masks those on the way out.
24
35
  error: string | null;
25
36
  priority: NotifyPriority;
37
+ confirmed: boolean | null;
26
38
  createdAt: Temporal.Instant;
27
39
  };
28
40
 
41
+ function toClientError(stored: string | null): DeliveryErrorCode | null {
42
+ if (stored === null) return null;
43
+ return isDeliveryErrorCode(stored) ? stored : "channel_error";
44
+ }
45
+
29
46
  // Sort whitelist keyed by the DISPLAY field the client actually sends: the
30
47
  // declarative projectionList renderer marks every listed column uniformly
31
48
  // sortable and a header click sends `sort=<screen.columns[].field>`, not the
32
49
  // DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
33
- // "recipient" is deliberately absent — it's PII decrypted per-row in
34
- // application code, so there is no column to ORDER BY in SQL for it.
50
+ // "recipient" is deliberately absent — it's PII (name or decrypted address)
51
+ // resolved in application code, so there is no column to ORDER BY in SQL for it.
52
+ // A click on such a header sends an unlisted sort value and falls back to createdAt.
35
53
  const DELIVERY_LOG_SORT_COLUMNS = {
36
54
  createdAt: "createdAt",
37
55
  tenantId: "tenantId",
@@ -67,6 +85,48 @@ function decodeSortCursor(column: DeliveryLogSortColumn, cursor: string): Decode
67
85
  return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
68
86
  }
69
87
 
88
+ // One batched name lookup for the whole page. Only when the user feature is
89
+ // mounted: delivery does not require it, and a direct-address send has no user.
90
+ async function loadDisplayNames(
91
+ db: TenantDb,
92
+ registry: Registry,
93
+ rows: readonly DeliveryLogRow[],
94
+ ): Promise<ReadonlyMap<string, string>> {
95
+ const recipientIds = [
96
+ ...new Set(
97
+ rows
98
+ .map((row) => row.recipientId)
99
+ .filter((id): id is string => id !== null && id !== SYSTEM_USER_ID && isUuid(id)),
100
+ ),
101
+ ];
102
+ if (recipientIds.length === 0 || registry.getFeature(USER_FEATURE) === undefined)
103
+ return new Map();
104
+ return resolveUserDisplayNames(db, recipientIds);
105
+ }
106
+
107
+ // recipientLabel: display name, else the decrypted address, else the id.
108
+ async function toClientRow(row: DeliveryLogRow, displayNames: ReadonlyMap<string, string>) {
109
+ const address =
110
+ row.recipientAddress !== null
111
+ ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
112
+ : null;
113
+ const name = row.recipientId !== null ? displayNames.get(row.recipientId) : undefined;
114
+ return {
115
+ id: row.id,
116
+ tenantId: row.tenantId,
117
+ recipientId: row.recipientId,
118
+ type: row.notificationType,
119
+ channel: row.channel,
120
+ recipient: address,
121
+ recipientLabel: name ?? address ?? row.recipientId,
122
+ status: row.status,
123
+ error: toClientError(row.error),
124
+ priority: row.priority,
125
+ confirmed: row.confirmed,
126
+ createdAt: row.createdAt,
127
+ };
128
+ }
129
+
70
130
  export const logQuery = definePagedQueryHandler({
71
131
  name: "log",
72
132
  schema: z.object({
@@ -153,24 +213,9 @@ export const logQuery = definePagedQueryHandler({
153
213
  // here, not in the client: a projectionList screen has no entity to
154
214
  // derive a field-mapping from, so the row shape the query returns is the
155
215
  // shape the declarative columns read directly.
216
+ const displayNames = await loadDisplayNames(db, ctx.registry, rows);
156
217
  return {
157
- rows: await Promise.all(
158
- rows.map(async (row) => ({
159
- id: row.id,
160
- tenantId: row.tenantId,
161
- recipientId: row.recipientId,
162
- type: row.notificationType,
163
- channel: row.channel,
164
- recipient:
165
- row.recipientAddress !== null
166
- ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
167
- : row.recipientAddress,
168
- status: row.status,
169
- error: row.error,
170
- priority: row.priority,
171
- createdAt: row.createdAt,
172
- })),
173
- ),
218
+ rows: await Promise.all(rows.map((row) => toClientRow(row, displayNames))),
174
219
  nextCursor,
175
220
  };
176
221
  },
@@ -1,11 +1,66 @@
1
+ import {
2
+ DELIVERY_FAILURE_CODES,
3
+ DELIVERY_SKIP_REASONS,
4
+ type DeliveryFailureCode,
5
+ type DeliverySkipReason,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { DeliveryStatus, type DeliveryStatusValue } from "./public-names.js";
8
+
1
9
  type LocalizedString = { readonly en: string };
2
10
 
11
+ // Typed over the code vocabularies, so a new code without a label fails to compile.
12
+ const ERROR_LABELS = {
13
+ timeout: "Timed out",
14
+ network_error: "Network error",
15
+ redirect_blocked: "Redirect blocked",
16
+ host_not_allowed: "Host not allowed",
17
+ missing_credentials: "Missing credentials",
18
+ invalid_address: "Invalid address",
19
+ unexpected_response: "Unexpected provider response",
20
+ render_failed: "Rendering failed",
21
+ send_failed: "Sending failed",
22
+ channel_error: "Channel error",
23
+ channel_disabled: "Channel disabled",
24
+ preference_disabled: "Disabled in preferences",
25
+ rate_limited: "Rate limited",
26
+ no_address: "No address",
27
+ unsubscribed: "Unsubscribed",
28
+ duplicate_idempotency_key: "Duplicate request",
29
+ } as const satisfies Record<DeliveryFailureCode | DeliverySkipReason, string>;
30
+
31
+ const STATUS_LABELS = {
32
+ queued: "Queued",
33
+ sent: "Sent",
34
+ failed: "Failed",
35
+ skipped: "Skipped",
36
+ } as const satisfies Record<DeliveryStatusValue, string>;
37
+
38
+ function labelKeys(
39
+ prefix: string,
40
+ codes: readonly string[],
41
+ labels: Readonly<Record<string, string>>,
42
+ ): Record<string, LocalizedString> {
43
+ return Object.fromEntries(
44
+ codes.map((code) => [`${prefix}.${code}`, { en: labels[code] ?? code }]),
45
+ );
46
+ }
47
+
3
48
  export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
49
  "screen:delivery-log.title": { en: "Delivery log" },
5
50
  "delivery:nav.deliveryLog": { en: "Delivery" },
51
+ "delivery.log.col.createdAt": { en: "Time" },
6
52
  "delivery.log.col.tenantId": { en: "Tenant" },
7
53
  "delivery.log.col.type": { en: "Type" },
8
54
  "delivery.log.col.channel": { en: "Channel" },
9
55
  "delivery.log.col.recipient": { en: "Recipient" },
10
56
  "delivery.log.col.status": { en: "Status" },
57
+ "delivery.log.col.error": { en: "Error" },
58
+ ...labelKeys("delivery.status", Object.values(DeliveryStatus), STATUS_LABELS),
59
+ ...labelKeys(
60
+ "delivery.error",
61
+ [...DELIVERY_FAILURE_CODES, ...DELIVERY_SKIP_REASONS],
62
+ ERROR_LABELS,
63
+ ),
64
+ "delivery.status.sentUnconfirmed": { en: "Sent (unconfirmed)" },
65
+ "delivery.error.http": { en: "HTTP {status}" },
11
66
  };
@@ -10,6 +10,7 @@ export {
10
10
  type ChatSendFailureCode,
11
11
  type ChatSendResult,
12
12
  type ChatWebhookRequest,
13
+ type ChatWebhookResponse,
13
14
  type ChatWebhookTarget,
14
15
  chatConnectionNameSchema,
15
16
  chatWebhookUrlSchema,
@@ -38,6 +39,7 @@ export {
38
39
  type RateLimitConfig,
39
40
  } from "./delivery-service.js";
40
41
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
42
+ export { createDeliveryNotifyFactory } from "./notify-factory.js";
41
43
  export {
42
44
  deliveryAttemptsTable,
43
45
  notificationAddressOptOutEntity,