@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.
Files changed (32) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/auth-mailer.ts +28 -12
  3. package/src/auth-email-password/index.ts +3 -0
  4. package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
  5. package/src/channel-email/email-channel.ts +32 -20
  6. package/src/channel-email/feature.ts +2 -0
  7. package/src/channel-email/index.ts +6 -1
  8. package/src/channel-email/smtp-transport.ts +37 -0
  9. package/src/channel-in-app/feature.ts +1 -0
  10. package/src/channel-in-app/in-app-channel.ts +1 -0
  11. package/src/channel-push/feature.ts +1 -0
  12. package/src/channel-push/push-channel.ts +1 -0
  13. package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
  14. package/src/delivery/attempt-log.ts +54 -0
  15. package/src/delivery/channel-context.ts +17 -0
  16. package/src/delivery/constants.ts +35 -1
  17. package/src/delivery/delivery-service.ts +118 -47
  18. package/src/delivery/events.ts +7 -1
  19. package/src/delivery/feature.ts +38 -14
  20. package/src/delivery/index.ts +3 -0
  21. package/src/delivery/jobs.ts +153 -0
  22. package/src/delivery/tables.ts +5 -1
  23. package/src/delivery/types.ts +25 -2
  24. package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
  25. package/src/presets/dsgvo-self-service.ts +30 -0
  26. package/src/presets/index.ts +1 -0
  27. package/src/secrets/feature.ts +3 -1
  28. package/src/secrets/index.ts +1 -0
  29. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
  30. package/src/text-content/seeding.ts +35 -1
  31. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
  32. 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 { append } from "@cosmicdrift/kumiko-framework/event-store";
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 { DELIVERY_ATTEMPT_EVENT } from "./constants";
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 { name: usage.entityName, resolve: opts.resolve, send: opts.send };
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
- function buildChannelContext(tenantId: TenantId): ChannelContext {
156
- return { db: createTenantDb(db, tenantId), registry, sseBroker, tenantId };
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
- async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
160
- // Post-ES: each delivery attempt is a standalone event on its own
161
- // aggregate stream (fresh UUID per attempt). The `delivery-log` inline
162
- // projection materialises the same row shape into deliveryAttemptsTable.
163
- // Low-level append() does NOT auto-fire inline projections (only the
164
- // dispatcher / executor / ctx.appendEvent paths do), so we invoke
165
- // runProjectionsForEvent manually to keep the write synchronous with
166
- // the projection update — same TX, read-your-own-write semantics.
167
- const attemptId = generateId();
168
- const { tenantId, ...rest } = entry;
169
- // Schema-parse to match ctx.appendEvent's guarantee: a payload drift
170
- // between service + feature-registration fails loudly here instead of
171
- // landing on the events-table and crashing a consumer later.
172
- const payload = deliveryAttemptSchema.parse(rest);
173
- const stored = await append(db, {
174
- aggregateId: attemptId,
175
- aggregateType: "deliveryAttempt",
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
- expectedVersion: 0,
178
- type: DELIVERY_ATTEMPT_EVENT,
179
- payload,
180
- metadata: { userId: "system" },
181
- });
182
- await runProjectionsForEvent(stored, registry, db);
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
- const result = await channel.send(address, message, channelCtx);
329
- await logDelivery({
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
- recipientAddress: result.address ?? address,
335
- status: result.status,
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
- const result = await channel.send(address, message, channelCtx);
385
- await logDelivery({
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
- recipientAddress: result.address ?? address,
391
- status: result.status,
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
  }
@@ -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([DeliveryStatus.sent, DeliveryStatus.failed, DeliveryStatus.skipped]),
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>;
@@ -1,11 +1,12 @@
1
- import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
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 replaying the same event twice conflicts on
58
- // the PK rather than silently duplicating the log row.
59
- await insertOne(tx, deliveryAttemptsTable, {
60
- id: event.aggregateId,
61
- tenantId: event.tenantId,
62
- notificationType: p.notificationType,
63
- channel: p.channel,
64
- recipientId: p.recipientId,
65
- recipientAddress: p.recipientAddress,
66
- status: p.status,
67
- error: p.error,
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
  };
@@ -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
+ };
@@ -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
  });