@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -1
- package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -0
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
- package/packages/bundled-features/src/channel-push/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/changes.json +8 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/changes.json +32 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
- package/packages/bundled-features/src/delivery/constants.ts +4 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
- package/packages/bundled-features/src/delivery/events.ts +15 -1
- package/packages/bundled-features/src/delivery/feature.ts +32 -4
- package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
- package/packages/bundled-features/src/delivery/i18n.ts +55 -0
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +43 -20
- package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
- package/packages/bundled-features/src/delivery/public-names.ts +4 -0
- package/packages/bundled-features/src/delivery/tables.ts +3 -0
- package/packages/bundled-features/src/delivery/testing.ts +5 -9
- package/packages/bundled-features/src/delivery/types.ts +6 -2
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
- package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
- package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
- package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
- package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/constants.ts +10 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -0
- package/packages/bundled-features/src/secrets/i18n.ts +5 -3
- package/packages/bundled-features/src/secrets/index.ts +8 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/redact.ts +27 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
- package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
- package/packages/bundled-features/src/user/index.ts +1 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
- /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)
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
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
|
|
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,
|