@cosmicdrift/kumiko-bundled-features 0.100.0 → 0.102.1
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 +7 -6
- package/src/auth-email-password/auth-mailer.ts +28 -12
- package/src/auth-email-password/index.ts +3 -0
- package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
- package/src/channel-email/email-channel.ts +32 -20
- package/src/channel-email/feature.ts +2 -0
- package/src/channel-email/index.ts +6 -1
- package/src/channel-email/smtp-transport.ts +37 -0
- package/src/channel-in-app/feature.ts +1 -0
- package/src/channel-in-app/in-app-channel.ts +1 -0
- package/src/channel-push/feature.ts +1 -0
- package/src/channel-push/push-channel.ts +1 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
- package/src/delivery/attempt-log.ts +54 -0
- package/src/delivery/channel-context.ts +17 -0
- package/src/delivery/constants.ts +35 -1
- package/src/delivery/delivery-service.ts +118 -47
- package/src/delivery/events.ts +7 -1
- package/src/delivery/feature.ts +38 -14
- package/src/delivery/index.ts +3 -0
- package/src/delivery/jobs.ts +153 -0
- package/src/delivery/tables.ts +5 -1
- package/src/delivery/types.ts +25 -2
- package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
- package/src/presets/dsgvo-self-service.ts +30 -0
- package/src/presets/index.ts +1 -0
- package/src/secrets/feature.ts +3 -1
- package/src/secrets/index.ts +1 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
- package/src/text-content/seeding.ts +35 -1
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { ChannelContext } from "./types";
|
|
5
|
+
|
|
6
|
+
// Build the per-tenant context a channel's resolve/render/send receives.
|
|
7
|
+
// Shared by the synchronous delivery-service path and the async job handlers
|
|
8
|
+
// (which pass sseBroker=undefined — only the inline inApp channel uses SSE,
|
|
9
|
+
// and it never runs through a job).
|
|
10
|
+
export function buildChannelContext(
|
|
11
|
+
db: DbConnection,
|
|
12
|
+
registry: Registry,
|
|
13
|
+
sseBroker: SseBroker | undefined,
|
|
14
|
+
tenantId: TenantId,
|
|
15
|
+
): ChannelContext {
|
|
16
|
+
return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
|
|
17
|
+
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
1
4
|
// Feature name
|
|
2
5
|
export const DELIVERY_FEATURE = "delivery" as const;
|
|
3
6
|
|
|
7
|
+
// notify() priority → BullMQ job priority. Lower number = processed first; all
|
|
8
|
+
// > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
|
|
9
|
+
// FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
|
|
10
|
+
export const deliveryPriorityRank: Record<NotifyPriority, number> = {
|
|
11
|
+
critical: 1,
|
|
12
|
+
normal: 2,
|
|
13
|
+
low: 3,
|
|
14
|
+
};
|
|
15
|
+
|
|
4
16
|
// Qualified write handler names (QN format: scope:type:name)
|
|
5
17
|
export const DeliveryHandlers = {
|
|
6
18
|
setPreference: "delivery:write:set-preference",
|
|
@@ -18,8 +30,12 @@ export const DeliveryErrors = {
|
|
|
18
30
|
channelFailed: "delivery_channel_failed",
|
|
19
31
|
} as const;
|
|
20
32
|
|
|
21
|
-
// Delivery status values
|
|
33
|
+
// Delivery status values. `queued` is the initial state of an async attempt
|
|
34
|
+
// (email/push) between dispatch and the worker running — it transitions to
|
|
35
|
+
// sent/failed once the delivery.send job finishes. Synchronous attempts
|
|
36
|
+
// (inApp, skips) never observe `queued`.
|
|
22
37
|
export const DeliveryStatus = {
|
|
38
|
+
queued: "queued",
|
|
23
39
|
sent: "sent",
|
|
24
40
|
failed: "failed",
|
|
25
41
|
skipped: "skipped",
|
|
@@ -31,3 +47,21 @@ export type DeliveryStatusValue = (typeof DeliveryStatus)[keyof typeof DeliveryS
|
|
|
31
47
|
// attempt (sent / failed / skipped). A multi-stream-projection materialises
|
|
32
48
|
// each event into a row in deliveryAttemptsTable for the log-query handler.
|
|
33
49
|
export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
|
|
50
|
+
|
|
51
|
+
// Background jobs that carry async (queued-mode) channels. render decouples the
|
|
52
|
+
// expensive template step from the send so each retries independently; render
|
|
53
|
+
// dispatches send on success. Channels without a render() (push) skip straight
|
|
54
|
+
// to send.
|
|
55
|
+
//
|
|
56
|
+
// Short names are what r.job() registers; the registry qualifies them to
|
|
57
|
+
// `scope:job:name`. Dispatch (notify + render→send chaining) must use the
|
|
58
|
+
// QUALIFIED name, so DeliveryJobs holds the qualified form via qn().
|
|
59
|
+
export const DeliveryJobNames = {
|
|
60
|
+
render: "render",
|
|
61
|
+
send: "send",
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
export const DeliveryJobs = {
|
|
65
|
+
render: qn(DELIVERY_FEATURE, QnTypes.job, DeliveryJobNames.render),
|
|
66
|
+
send: qn(DELIVERY_FEATURE, QnTypes.job, DeliveryJobNames.send),
|
|
67
|
+
} as const;
|
|
@@ -3,14 +3,14 @@ import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
|
3
3
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type { NotifyPriority, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import {
|
|
7
|
-
import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
6
|
+
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
8
7
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
9
8
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
9
|
import type { Redis } from "ioredis";
|
|
11
|
-
import {
|
|
10
|
+
import { appendAttemptEvent, logAttempt } from "./attempt-log";
|
|
11
|
+
import { buildChannelContext } from "./channel-context";
|
|
12
|
+
import { DeliveryJobs, deliveryPriorityRank } from "./constants";
|
|
12
13
|
import { selectNotificationPreferences } from "./db/queries/preferences";
|
|
13
|
-
import { deliveryAttemptSchema } from "./events";
|
|
14
14
|
import type {
|
|
15
15
|
ChannelContext,
|
|
16
16
|
ChannelMessage,
|
|
@@ -39,6 +39,10 @@ export type DeliveryServiceOptions = {
|
|
|
39
39
|
// Must be present whenever callers rely on idempotencyKey, otherwise notify()
|
|
40
40
|
// throws at the callsite (silent no-op would be a correctness bug).
|
|
41
41
|
readonly idempotencyRedis?: Redis;
|
|
42
|
+
// Job runner for async (queued-mode) channels: email/push render+send run in
|
|
43
|
+
// the delivery.render → delivery.send jobs. When absent, queued channels fall
|
|
44
|
+
// back to synchronous inline delivery (job-less setups, unit tests).
|
|
45
|
+
readonly jobRunner?: JobRunner;
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
// Build channel list from registry extension usages
|
|
@@ -47,10 +51,18 @@ export function collectChannels(registry: Registry): DeliveryChannel[] {
|
|
|
47
51
|
return usages.map((usage) => {
|
|
48
52
|
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
49
53
|
const opts = usage.options as {
|
|
54
|
+
mode: DeliveryChannel["mode"];
|
|
50
55
|
resolve: DeliveryChannel["resolve"];
|
|
56
|
+
render?: DeliveryChannel["render"];
|
|
51
57
|
send: DeliveryChannel["send"];
|
|
52
58
|
};
|
|
53
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
name: usage.entityName,
|
|
61
|
+
mode: opts.mode,
|
|
62
|
+
resolve: opts.resolve,
|
|
63
|
+
render: opts.render,
|
|
64
|
+
send: opts.send,
|
|
65
|
+
};
|
|
54
66
|
});
|
|
55
67
|
}
|
|
56
68
|
|
|
@@ -69,6 +81,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
69
81
|
rateLimit,
|
|
70
82
|
isChannelKilled,
|
|
71
83
|
idempotencyRedis,
|
|
84
|
+
jobRunner,
|
|
72
85
|
} = options;
|
|
73
86
|
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
74
87
|
|
|
@@ -152,34 +165,83 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
152
165
|
)) as readonly string[];
|
|
153
166
|
}
|
|
154
167
|
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
// Single-shot terminal log (inline channels, skips, idempotency dups). Async
|
|
169
|
+
// attempts instead append a queued event up front and a terminal event from
|
|
170
|
+
// the send job — see deliverViaChannel + jobs.ts.
|
|
171
|
+
async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
|
|
172
|
+
await logAttempt(db, registry, entry);
|
|
157
173
|
}
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
// Deliver one resolved (channel, address) pair. Inline channels (inApp) and
|
|
176
|
+
// the no-job-runner fallback render + send synchronously and log the terminal
|
|
177
|
+
// status. Queued channels (email/push) record a `queued` attempt up front and
|
|
178
|
+
// hand off to the delivery.render (or delivery.send) job, which appends the
|
|
179
|
+
// terminal event on the same attempt stream.
|
|
180
|
+
async function deliverViaChannel(args: {
|
|
181
|
+
channel: DeliveryChannel;
|
|
182
|
+
address: string;
|
|
183
|
+
message: ChannelMessage;
|
|
184
|
+
channelCtx: ChannelContext;
|
|
185
|
+
tenantId: TenantId;
|
|
186
|
+
recipientId: string | null;
|
|
187
|
+
notificationType: string;
|
|
188
|
+
priority: NotifyPriority;
|
|
189
|
+
}): Promise<void> {
|
|
190
|
+
const {
|
|
191
|
+
channel,
|
|
192
|
+
address,
|
|
193
|
+
message,
|
|
194
|
+
channelCtx,
|
|
176
195
|
tenantId,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
196
|
+
recipientId,
|
|
197
|
+
notificationType,
|
|
198
|
+
priority,
|
|
199
|
+
} = args;
|
|
200
|
+
|
|
201
|
+
if (channel.mode === "queued" && jobRunner) {
|
|
202
|
+
// Async hand-off: record the attempt as queued, then dispatch the
|
|
203
|
+
// render/send job — the terminal sent/failed event is appended by the
|
|
204
|
+
// job, not here.
|
|
205
|
+
const deliveryAttemptId = generateId();
|
|
206
|
+
await appendAttemptEvent(db, registry, deliveryAttemptId, {
|
|
207
|
+
tenantId,
|
|
208
|
+
notificationType,
|
|
209
|
+
channel: channel.name,
|
|
210
|
+
recipientId,
|
|
211
|
+
recipientAddress: address,
|
|
212
|
+
status: "queued",
|
|
213
|
+
error: null,
|
|
214
|
+
priority,
|
|
215
|
+
});
|
|
216
|
+
await jobRunner.dispatch(
|
|
217
|
+
channel.render ? DeliveryJobs.render : DeliveryJobs.send,
|
|
218
|
+
{
|
|
219
|
+
channelName: channel.name,
|
|
220
|
+
address,
|
|
221
|
+
tenantId,
|
|
222
|
+
recipientId,
|
|
223
|
+
notificationType,
|
|
224
|
+
deliveryAttemptId,
|
|
225
|
+
priority,
|
|
226
|
+
message,
|
|
227
|
+
},
|
|
228
|
+
{ priority: deliveryPriorityRank[priority] },
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
// Inline (inApp) or no-job-runner fallback: render + send synchronously.
|
|
232
|
+
const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
|
|
233
|
+
const result = await channel.send(address, message, channelCtx, rendered);
|
|
234
|
+
await logDelivery({
|
|
235
|
+
tenantId,
|
|
236
|
+
notificationType,
|
|
237
|
+
channel: channel.name,
|
|
238
|
+
recipientId,
|
|
239
|
+
recipientAddress: result.address ?? address,
|
|
240
|
+
status: result.status,
|
|
241
|
+
error: result.error ?? null,
|
|
242
|
+
priority,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
183
245
|
}
|
|
184
246
|
|
|
185
247
|
function buildMessage(
|
|
@@ -254,7 +316,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
254
316
|
tenantId: TenantId,
|
|
255
317
|
priority: NotifyPriority,
|
|
256
318
|
): Promise<void> {
|
|
257
|
-
const channelCtx = buildChannelContext(tenantId);
|
|
319
|
+
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
|
|
258
320
|
|
|
259
321
|
for (const channel of channels) {
|
|
260
322
|
const message = buildMessage(notificationType, data, channel.name);
|
|
@@ -271,6 +333,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
271
333
|
recipientAddress: null,
|
|
272
334
|
status: "skipped",
|
|
273
335
|
error: "channel_disabled",
|
|
336
|
+
priority,
|
|
274
337
|
});
|
|
275
338
|
continue;
|
|
276
339
|
}
|
|
@@ -288,6 +351,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
288
351
|
recipientAddress: null,
|
|
289
352
|
status: "skipped",
|
|
290
353
|
error: "preference_disabled",
|
|
354
|
+
priority,
|
|
291
355
|
});
|
|
292
356
|
continue;
|
|
293
357
|
}
|
|
@@ -305,6 +369,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
305
369
|
recipientAddress: null,
|
|
306
370
|
status: "skipped",
|
|
307
371
|
error: "rate_limited",
|
|
372
|
+
priority,
|
|
308
373
|
});
|
|
309
374
|
continue;
|
|
310
375
|
}
|
|
@@ -321,19 +386,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
321
386
|
recipientAddress: null,
|
|
322
387
|
status: "skipped",
|
|
323
388
|
error: "no_address",
|
|
389
|
+
priority,
|
|
324
390
|
});
|
|
325
391
|
continue;
|
|
326
392
|
}
|
|
327
393
|
|
|
328
|
-
|
|
329
|
-
|
|
394
|
+
await deliverViaChannel({
|
|
395
|
+
channel,
|
|
396
|
+
address,
|
|
397
|
+
message,
|
|
398
|
+
channelCtx,
|
|
330
399
|
tenantId,
|
|
331
|
-
notificationType,
|
|
332
|
-
channel: channel.name,
|
|
333
400
|
recipientId: userId,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
error: result.error ?? null,
|
|
401
|
+
notificationType,
|
|
402
|
+
priority,
|
|
337
403
|
});
|
|
338
404
|
} catch (err) {
|
|
339
405
|
await logDelivery({
|
|
@@ -344,6 +410,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
344
410
|
recipientAddress: null,
|
|
345
411
|
status: "failed",
|
|
346
412
|
error: err instanceof Error ? err.message : String(err),
|
|
413
|
+
priority,
|
|
347
414
|
});
|
|
348
415
|
}
|
|
349
416
|
}
|
|
@@ -354,8 +421,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
354
421
|
notificationType: string,
|
|
355
422
|
data: Readonly<Record<string, unknown>> | undefined,
|
|
356
423
|
tenantId: TenantId,
|
|
424
|
+
priority: NotifyPriority,
|
|
357
425
|
): Promise<void> {
|
|
358
|
-
const channelCtx = buildChannelContext(tenantId);
|
|
426
|
+
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
|
|
359
427
|
|
|
360
428
|
// Direct routing skips preferences (no user account) but NOT rate limit
|
|
361
429
|
// — direct sends can still be abused (webhook replays, test harnesses).
|
|
@@ -375,21 +443,22 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
375
443
|
recipientAddress: address,
|
|
376
444
|
status: "skipped",
|
|
377
445
|
error: "rate_limited",
|
|
446
|
+
priority,
|
|
378
447
|
});
|
|
379
448
|
continue;
|
|
380
449
|
}
|
|
381
450
|
}
|
|
382
451
|
|
|
383
452
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
453
|
+
await deliverViaChannel({
|
|
454
|
+
channel,
|
|
455
|
+
address,
|
|
456
|
+
message,
|
|
457
|
+
channelCtx,
|
|
386
458
|
tenantId,
|
|
387
|
-
notificationType,
|
|
388
|
-
channel: channel.name,
|
|
389
459
|
recipientId: null,
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
error: result.error ?? null,
|
|
460
|
+
notificationType,
|
|
461
|
+
priority,
|
|
393
462
|
});
|
|
394
463
|
} catch (err) {
|
|
395
464
|
await logDelivery({
|
|
@@ -400,6 +469,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
400
469
|
recipientAddress: address,
|
|
401
470
|
status: "failed",
|
|
402
471
|
error: err instanceof Error ? err.message : String(err),
|
|
472
|
+
priority,
|
|
403
473
|
});
|
|
404
474
|
}
|
|
405
475
|
}
|
|
@@ -421,6 +491,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
421
491
|
recipientAddress: null,
|
|
422
492
|
status: "skipped",
|
|
423
493
|
error: "duplicate_idempotency_key",
|
|
494
|
+
priority,
|
|
424
495
|
});
|
|
425
496
|
// skip: duplicate send deduped via idempotency key, logged above
|
|
426
497
|
return;
|
|
@@ -428,7 +499,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
428
499
|
}
|
|
429
500
|
|
|
430
501
|
if (route) {
|
|
431
|
-
await deliverDirect(route, notificationType, data, tenantId);
|
|
502
|
+
await deliverDirect(route, notificationType, data, tenantId, priority);
|
|
432
503
|
// skip: direct route delivered, no recipient resolution needed
|
|
433
504
|
return;
|
|
434
505
|
}
|
package/src/delivery/events.ts
CHANGED
|
@@ -11,8 +11,14 @@ export const deliveryAttemptSchema = z.object({
|
|
|
11
11
|
channel: z.string(),
|
|
12
12
|
recipientId: z.string().nullable(),
|
|
13
13
|
recipientAddress: z.string().nullable(),
|
|
14
|
-
status: z.enum([
|
|
14
|
+
status: z.enum([
|
|
15
|
+
DeliveryStatus.queued,
|
|
16
|
+
DeliveryStatus.sent,
|
|
17
|
+
DeliveryStatus.failed,
|
|
18
|
+
DeliveryStatus.skipped,
|
|
19
|
+
]),
|
|
15
20
|
error: z.string().nullable(),
|
|
21
|
+
priority: z.enum(["critical", "normal", "low"]),
|
|
16
22
|
});
|
|
17
23
|
|
|
18
24
|
export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
|
package/src/delivery/feature.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import type { z } from "zod";
|
|
4
|
-
import { DELIVERY_ATTEMPT_EVENT } from "./constants";
|
|
4
|
+
import { DELIVERY_ATTEMPT_EVENT, DeliveryJobNames } from "./constants";
|
|
5
5
|
import { deliveryAttemptSchema } from "./events";
|
|
6
6
|
import { logQuery } from "./handlers/log.query";
|
|
7
7
|
import { preferencesQuery } from "./handlers/preferences.query";
|
|
8
8
|
import { setPreferenceWrite } from "./handlers/set-preference.write";
|
|
9
|
+
import { deliveryRenderJob, deliverySendJob } from "./jobs";
|
|
9
10
|
import {
|
|
10
11
|
deliveryAttemptsTable,
|
|
11
12
|
deliveryAttemptsTableMeta,
|
|
@@ -54,18 +55,26 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
54
55
|
apply: {
|
|
55
56
|
[DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
|
|
56
57
|
const p = event.payload as z.infer<typeof deliveryAttemptSchema>; // @cast-boundary engine-payload
|
|
57
|
-
// PK = aggregateId
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
// PK = aggregateId. An async attempt accrues multiple events on one
|
|
59
|
+
// stream (queued → sent/failed): the first INSERTs, later events
|
|
60
|
+
// UPDATE the same row. Events arrive in version order, so the last
|
|
61
|
+
// status wins; replays stay idempotent (same row, same values).
|
|
62
|
+
await upsertByPk(
|
|
63
|
+
tx,
|
|
64
|
+
deliveryAttemptsTable,
|
|
65
|
+
{
|
|
66
|
+
id: event.aggregateId,
|
|
67
|
+
tenantId: event.tenantId,
|
|
68
|
+
notificationType: p.notificationType,
|
|
69
|
+
channel: p.channel,
|
|
70
|
+
recipientId: p.recipientId,
|
|
71
|
+
recipientAddress: p.recipientAddress,
|
|
72
|
+
status: p.status,
|
|
73
|
+
error: p.error,
|
|
74
|
+
priority: p.priority,
|
|
75
|
+
},
|
|
76
|
+
{ status: p.status, error: p.error, recipientAddress: p.recipientAddress },
|
|
77
|
+
);
|
|
69
78
|
},
|
|
70
79
|
},
|
|
71
80
|
});
|
|
@@ -80,6 +89,21 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
80
89
|
onRegister: () => {},
|
|
81
90
|
});
|
|
82
91
|
|
|
92
|
+
// Async delivery pipeline for queued-mode channels. render decouples the
|
|
93
|
+
// expensive template step (own worker, own retry) and dispatches send;
|
|
94
|
+
// channels without a render() go straight to send. Dispatched explicitly
|
|
95
|
+
// from the delivery-service, hence manual trigger.
|
|
96
|
+
r.job(
|
|
97
|
+
DeliveryJobNames.render,
|
|
98
|
+
{ trigger: { manual: true }, retries: 3, backoff: "exponential" },
|
|
99
|
+
deliveryRenderJob,
|
|
100
|
+
);
|
|
101
|
+
r.job(
|
|
102
|
+
DeliveryJobNames.send,
|
|
103
|
+
{ trigger: { manual: true }, retries: 3, backoff: "exponential" },
|
|
104
|
+
deliverySendJob,
|
|
105
|
+
);
|
|
106
|
+
|
|
83
107
|
const handlers = {
|
|
84
108
|
setPreference: r.writeHandler(setPreferenceWrite),
|
|
85
109
|
};
|
package/src/delivery/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
DELIVERY_FEATURE,
|
|
4
4
|
DeliveryErrors,
|
|
5
5
|
DeliveryHandlers,
|
|
6
|
+
DeliveryJobs,
|
|
6
7
|
DeliveryQueries,
|
|
7
8
|
DeliveryStatus,
|
|
8
9
|
} from "./constants";
|
|
@@ -21,9 +22,11 @@ export type {
|
|
|
21
22
|
ChannelMessage,
|
|
22
23
|
ChannelResult,
|
|
23
24
|
DeliveryChannel,
|
|
25
|
+
DeliveryChannelMode,
|
|
24
26
|
DeliveryLogEntry,
|
|
25
27
|
DeliveryService,
|
|
26
28
|
NotificationRenderer,
|
|
29
|
+
RenderedMessage,
|
|
27
30
|
RendererInput,
|
|
28
31
|
} from "./types";
|
|
29
32
|
export {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// delivery.render → delivery.send job handlers. Async (queued-mode) channels
|
|
2
|
+
// are delivered here instead of inline in notify(): render runs the expensive
|
|
3
|
+
// template step in its own worker and dispatches send on success, so a render
|
|
4
|
+
// crash never blocks the SMTP send and each step retries independently. The
|
|
5
|
+
// `queued` attempt event is written by the delivery-service at dispatch time;
|
|
6
|
+
// these handlers append the terminal sent/failed event on the same stream.
|
|
7
|
+
|
|
8
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import type {
|
|
10
|
+
AppContext,
|
|
11
|
+
JobHandlerFn,
|
|
12
|
+
Registry,
|
|
13
|
+
TenantId,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { appendAttemptEvent } from "./attempt-log";
|
|
18
|
+
import { buildChannelContext } from "./channel-context";
|
|
19
|
+
import { DeliveryJobs, deliveryPriorityRank } from "./constants";
|
|
20
|
+
import { collectChannels } from "./delivery-service";
|
|
21
|
+
import type { ChannelMessage, DeliveryChannel, DeliveryLogEntry, RenderedMessage } from "./types";
|
|
22
|
+
|
|
23
|
+
const channelMessageSchema = z.object({
|
|
24
|
+
notificationType: z.string(),
|
|
25
|
+
title: z.string(),
|
|
26
|
+
body: z.string().optional(),
|
|
27
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const renderJobPayloadSchema = z.object({
|
|
31
|
+
channelName: z.string(),
|
|
32
|
+
address: z.string(),
|
|
33
|
+
tenantId: z.string(),
|
|
34
|
+
recipientId: z.string().nullable(),
|
|
35
|
+
notificationType: z.string(),
|
|
36
|
+
deliveryAttemptId: z.string(),
|
|
37
|
+
priority: z.enum(["critical", "normal", "low"]),
|
|
38
|
+
message: channelMessageSchema,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
42
|
+
rendered: z.object({ html: z.string(), subject: z.string() }).optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
type RenderJobPayload = z.infer<typeof renderJobPayloadSchema>;
|
|
46
|
+
|
|
47
|
+
function requireDeps(ctx: AppContext): { db: DbConnection; registry: Registry } {
|
|
48
|
+
const registry = ctx.registry;
|
|
49
|
+
if (!registry) throw new Error("delivery job: missing registry in job context");
|
|
50
|
+
// Job context provides the raw system connection (not tenant-scoped); the
|
|
51
|
+
// append + projection layer scopes per event payload.
|
|
52
|
+
const db = ctx.db as DbConnection; // @cast-boundary engine-bridge — job ctx db is the system connection
|
|
53
|
+
if (!db) throw new Error("delivery job: missing db in job context");
|
|
54
|
+
return { db, registry };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveChannel(registry: Registry, name: string): DeliveryChannel {
|
|
58
|
+
const channel = collectChannels(registry).find((c) => c.name === name);
|
|
59
|
+
if (!channel) throw new Error(`delivery job: unknown channel "${name}"`);
|
|
60
|
+
return channel;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toMessage(p: RenderJobPayload): ChannelMessage {
|
|
64
|
+
return {
|
|
65
|
+
notificationType: p.message.notificationType,
|
|
66
|
+
title: p.message.title,
|
|
67
|
+
body: p.message.body,
|
|
68
|
+
data: p.message.data,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function entryFor(
|
|
73
|
+
p: RenderJobPayload,
|
|
74
|
+
status: DeliveryLogEntry["status"],
|
|
75
|
+
error: string | null,
|
|
76
|
+
address: string | null,
|
|
77
|
+
): DeliveryLogEntry {
|
|
78
|
+
return {
|
|
79
|
+
tenantId: p.tenantId as TenantId, // @cast-boundary engine-payload — job payload string is the stream tenant
|
|
80
|
+
notificationType: p.notificationType,
|
|
81
|
+
channel: p.channelName,
|
|
82
|
+
recipientId: p.recipientId,
|
|
83
|
+
recipientAddress: address,
|
|
84
|
+
status,
|
|
85
|
+
error,
|
|
86
|
+
priority: p.priority,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function messageOf(err: unknown): string {
|
|
91
|
+
return err instanceof Error ? err.message : String(err);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Render the message and hand off to delivery.send. On failure: record the
|
|
95
|
+
// terminal failed event and rethrow so BullMQ retries this step (re-render);
|
|
96
|
+
// the send step is never reached, so a stuck render can't half-send.
|
|
97
|
+
export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
98
|
+
const p = renderJobPayloadSchema.parse(payload);
|
|
99
|
+
const { db, registry } = requireDeps(ctx);
|
|
100
|
+
const channel = resolveChannel(registry, p.channelName);
|
|
101
|
+
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
102
|
+
const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
if (!channel.render) {
|
|
106
|
+
throw new Error(`delivery.render: channel "${p.channelName}" has no render step`);
|
|
107
|
+
}
|
|
108
|
+
const rendered: RenderedMessage = await channel.render(toMessage(p), channelCtx);
|
|
109
|
+
const jobRunner = ctx["jobRunner"] as JobRunner; // @cast-boundary dynamic-key — dispatch lives on the concrete runner
|
|
110
|
+
await jobRunner.dispatch(
|
|
111
|
+
DeliveryJobs.send,
|
|
112
|
+
{ ...p, rendered },
|
|
113
|
+
{ priority: deliveryPriorityRank[p.priority] },
|
|
114
|
+
);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
await appendAttemptEvent(
|
|
117
|
+
db,
|
|
118
|
+
registry,
|
|
119
|
+
p.deliveryAttemptId,
|
|
120
|
+
entryFor(p, "failed", `render: ${messageOf(err)}`, p.address),
|
|
121
|
+
);
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Deliver via the channel using the rendered payload (if any). On failure:
|
|
127
|
+
// record the terminal failed event and rethrow so BullMQ retries the send with
|
|
128
|
+
// the same already-rendered HTML — no re-render needed.
|
|
129
|
+
export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
|
|
130
|
+
const p = sendJobPayloadSchema.parse(payload);
|
|
131
|
+
const { db, registry } = requireDeps(ctx);
|
|
132
|
+
const channel = resolveChannel(registry, p.channelName);
|
|
133
|
+
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
134
|
+
const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const result = await channel.send(p.address, toMessage(p), channelCtx, p.rendered);
|
|
138
|
+
await appendAttemptEvent(
|
|
139
|
+
db,
|
|
140
|
+
registry,
|
|
141
|
+
p.deliveryAttemptId,
|
|
142
|
+
entryFor(p, result.status, result.error ?? null, result.address ?? p.address),
|
|
143
|
+
);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
await appendAttemptEvent(
|
|
146
|
+
db,
|
|
147
|
+
registry,
|
|
148
|
+
p.deliveryAttemptId,
|
|
149
|
+
entryFor(p, "failed", `send: ${messageOf(err)}`, p.address),
|
|
150
|
+
);
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
};
|
package/src/delivery/tables.ts
CHANGED
|
@@ -36,8 +36,11 @@ export const deliveryAttemptsTable = pgTable("read_delivery_attempts", {
|
|
|
36
36
|
// User-IDs as UUID-strings post-ES migration.
|
|
37
37
|
recipientId: text("recipient_id"),
|
|
38
38
|
recipientAddress: text("recipient_address"),
|
|
39
|
-
status: text("status").notNull().$type<"sent" | "failed" | "skipped">(),
|
|
39
|
+
status: text("status").notNull().$type<"queued" | "sent" | "failed" | "skipped">(),
|
|
40
40
|
error: text("error"),
|
|
41
|
+
// Default covers rows that predate the column; new rows always carry the
|
|
42
|
+
// notify() priority from the event payload.
|
|
43
|
+
priority: text("priority").notNull().default("normal").$type<"critical" | "normal" | "low">(),
|
|
41
44
|
createdAt: instant("created_at").default(sql`now()`).notNull(),
|
|
42
45
|
});
|
|
43
46
|
|
|
@@ -60,6 +63,7 @@ export const deliveryAttemptsTableMeta: EntityTableMeta = defineUnmanagedTable({
|
|
|
60
63
|
{ name: "recipient_address", pgType: "text", notNull: false },
|
|
61
64
|
{ name: "status", pgType: "text", notNull: true },
|
|
62
65
|
{ name: "error", pgType: "text", notNull: false },
|
|
66
|
+
{ name: "priority", pgType: "text", notNull: true, defaultSql: "'normal'" },
|
|
63
67
|
{ name: "created_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
|
|
64
68
|
],
|
|
65
69
|
});
|