@classytic/arc-notifications 0.8.0 → 0.10.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/dist/index.mjs CHANGED
@@ -1,21 +1,31 @@
1
1
  import { createTtlMemo } from "@classytic/repo-core/cache";
2
+ import { scopedValue } from "@classytic/arc/context";
3
+ import { defineModule } from "@classytic/arc/factory";
2
4
  import { BaseChannel, NotificationService, createSimpleResolver } from "@classytic/notifications";
3
5
  import { defineResource } from "@classytic/arc";
4
6
  import { NotFoundError } from "@classytic/arc/utils";
5
7
  import { defineJob } from "@classytic/arc/integrations/jobs";
6
- import { scopedValue } from "@classytic/arc/context";
7
- import { defineModule } from "@classytic/arc/factory";
8
- //#region src/backfill.ts
8
+ // src/backfill.ts
9
+ const rowId = (row) => {
10
+ const r = row;
11
+ const id = r?.id ?? r?._id;
12
+ return id === void 0 || id === null ? void 0 : String(id);
13
+ };
9
14
  function createInAppBackfill(options) {
10
15
  const { repository, port, eventName = "notification:backfill", limit = 100, onError } = options;
11
16
  const debounceMs = options.debounceMs ?? 2e3;
12
17
  if (typeof repository.claimUndelivered !== "function") throw new Error("createInAppBackfill requires a repository implementing claimUndelivered() (atomic fetch-undelivered-and-mark-delivered). Add it to your feed repository, or skip backfill and let clients refetch GET /notifications/ on connect.");
13
18
  const claimUndelivered = repository.claimUndelivered.bind(repository);
14
19
  const pending = /* @__PURE__ */ new Map();
20
+ const sinces = /* @__PURE__ */ new Map();
21
+ const listSince = repository.listSince?.bind(repository);
15
22
  let closed = false;
16
- const run = async (userId, organizationId) => {
23
+ const run = async (userId, organizationId, since) => {
17
24
  try {
18
- const rows = await claimUndelivered(organizationId, userId, limit);
25
+ const claimed = await claimUndelivered(organizationId, userId, limit);
26
+ const replayed = since && listSince ? await listSince(organizationId, userId, since, limit) : [];
27
+ const seen = new Set(replayed.map(rowId));
28
+ const rows = [...replayed, ...claimed.filter((row) => !seen.has(rowId(row)))];
19
29
  if (!rows.length || closed) return;
20
30
  await port.push(userId, organizationId, eventName, { notifications: rows });
21
31
  } catch (error) {
@@ -23,24 +33,31 @@ function createInAppBackfill(options) {
23
33
  }
24
34
  };
25
35
  return {
26
- onConnect(userId, organizationId) {
36
+ onConnect(userId, organizationId, connect) {
27
37
  if (closed) return;
28
38
  const key = `${organizationId} ${userId}`;
39
+ const since = connect?.since === void 0 ? void 0 : new Date(connect.since);
40
+ if (since && !Number.isNaN(since.getTime())) {
41
+ const earlier = sinces.get(key);
42
+ if (!earlier || since < earlier) sinces.set(key, since);
43
+ }
29
44
  if (pending.has(key)) return;
30
45
  pending.set(key, setTimeout(() => {
31
46
  pending.delete(key);
32
- run(userId, organizationId);
47
+ const replayFrom = sinces.get(key);
48
+ sinces.delete(key);
49
+ run(userId, organizationId, replayFrom);
33
50
  }, debounceMs));
34
51
  },
35
52
  close() {
36
53
  closed = true;
37
54
  for (const timer of pending.values()) clearTimeout(timer);
38
55
  pending.clear();
56
+ sinces.clear();
39
57
  }
40
58
  };
41
59
  }
42
- //#endregion
43
- //#region src/triggerOverride.ts
60
+ // src/triggerOverride.ts
44
61
  /**
45
62
  * Notification trigger OVERRIDES — the admin-editable half of the registry.
46
63
  *
@@ -164,11 +181,36 @@ function resolveEffectiveSettings(triggerType, organizationId, rows, defaults) {
164
181
  const title = pick("title") ?? defaults.title;
165
182
  const message = pick("message") ?? defaults.message;
166
183
  const priority = pick("priority") ?? defaults.priority;
184
+ /**
185
+ * Operator row, then the trigger's own default, then unrestricted — the same
186
+ * ladder every field above uses. It used to stop at `?? null`, discarding a
187
+ * code default that therefore could not be written at all.
188
+ *
189
+ * `?? null`, never `?? []` — the two must stay distinguishable all the way
190
+ * through. `null` means unrestricted; `[]` would mean "match nothing", and
191
+ * collapsing one into the other is exactly the widening this field prevents.
192
+ * A `null` stored by an operator is a deliberate "every source", and it wins
193
+ * over the code default precisely because they set it.
194
+ */
195
+ /**
196
+ * PRESENCE, not `??` — the one field where a stored `null` is a decision.
197
+ *
198
+ * `pick` collapses `null` into "absent", which is right for every other
199
+ * setting and wrong here: `null` means "every source", and an operator who
200
+ * opens a filter must not have it re-narrowed by the code default on the next
201
+ * read. That is Rule 1 — a default is the fallback for an UNSET value, never
202
+ * an override of an explicit one.
203
+ *
204
+ * Scoped row wins over app-wide, same precedence as `pick`.
205
+ */
206
+ const rowWithField = [scoped, appWide].find((r) => r && "sourceChannels" in r);
207
+ const sourceChannels = rowWithField ? rowWithField.sourceChannels ?? null : defaults.sourceChannels ? [...defaults.sourceChannels] : null;
167
208
  return {
168
209
  enabled,
169
210
  recipients,
211
+ recipientEmails: pick("recipientEmails") ?? [],
170
212
  channels,
171
- sourceChannels: pick("sourceChannels") ?? null,
213
+ sourceChannels,
172
214
  title,
173
215
  message,
174
216
  ...priority ? { priority } : {}
@@ -209,8 +251,7 @@ function evaluateSourceChannel(sourceChannels, sourceChannel) {
209
251
  reason: "source_channel_mismatch"
210
252
  };
211
253
  }
212
- //#endregion
213
- //#region src/channelCatalog.ts
254
+ // src/channelCatalog.ts
214
255
  /**
215
256
  * The NOTIFICATION CHANNEL CATALOG — which channels this deployment can deliver on.
216
257
  *
@@ -269,49 +310,9 @@ function createNotificationChannelCatalog(connectorChannels = []) {
269
310
  has: (channel) => lookup.has(channel)
270
311
  };
271
312
  }
272
- //#endregion
273
- //#region src/dbFirstResolver.ts
274
- /**
275
- * DB-first template resolver — generalized from be-prod's proven
276
- * `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
277
- * runtime: an active DB row for a template key wins; anything else falls back
278
- * to the code-defined resolver, so a bad draft can never take sends down.
279
- *
280
- * Storage-agnostic: `TemplateStore` is a one-method structural port
281
- * (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
282
- */
283
- function createDbFirstResolver(options) {
284
- const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
285
- /**
286
- * Single-flight memo: one event's channel expansions resolve the same key in
287
- * the same tick, and a `null` row (no draft) is an answer worth keeping. A
288
- * store failure rejects every waiter and caches nothing.
289
- */
290
- const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
291
- const resolver = (async (templateId, data) => {
292
- let doc = null;
293
- try {
294
- doc = await lookup.get(templateId);
295
- } catch (err) {
296
- onError?.(err, templateId);
297
- if (!fallback) throw err;
298
- doc = null;
299
- }
300
- if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
301
- ...doc.subject !== void 0 ? { subject: doc.subject } : {},
302
- ...doc.html !== void 0 ? { html: doc.html } : {},
303
- ...doc.text !== void 0 ? { text: doc.text } : {}
304
- } }, { escape: escapeHtml })(templateId, data);
305
- if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
306
- return fallback(templateId, data);
307
- });
308
- resolver.invalidate = (key) => {
309
- lookup.invalidate(key);
310
- };
311
- return resolver;
312
- }
313
- //#endregion
314
- //#region src/inApp.ts
313
+ // src/clock.ts
314
+ const systemNotificationClock = { now: () => /* @__PURE__ */ new Date() };
315
+ // src/inApp.ts
315
316
  /**
316
317
  * In-app notification content — the shared seam between a `notifyOn` trigger's
317
318
  * declarative `inApp` template and the channels that deliver it (the DB feed
@@ -381,8 +382,468 @@ function readInAppContent(data) {
381
382
  if (typeof c.title !== "string" || typeof c.message !== "string") return null;
382
383
  return c;
383
384
  }
384
- //#endregion
385
- //#region src/inAppChannel.ts
385
+ // src/module.ts
386
+ /**
387
+ * `notificationsModule` — the blessed seam from arc domain events to
388
+ * user-facing notifications.
389
+ *
390
+ * Every arc app used to re-derive the same wiring: build a
391
+ * NotificationService, subscribe `app.events`, map event payloads to
392
+ * recipients, thread idempotency for at-least-once transports. This module is
393
+ * that wiring, once:
394
+ *
395
+ * ```ts
396
+ * const app = await createApp({
397
+ * modules: [
398
+ * notificationsModule({
399
+ * channels: [new EmailChannel({ from, transport })],
400
+ * templates: createDbFirstResolver({ store, fallback }),
401
+ * notifyOn: [{
402
+ * event: "approval.requested",
403
+ * template: "approval-requested",
404
+ * recipient: (e) => directory.contact(e.payload.approverId),
405
+ * data: (e) => ({ title: e.payload.title }),
406
+ * }],
407
+ * }),
408
+ * ],
409
+ * });
410
+ * app.notifications?.send({ ... }); // decorated service
411
+ * const svc = getModuleExports<NotificationService>(app, "notifications");
412
+ * ```
413
+ *
414
+ * Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
415
+ * arc owns subscription ordering AND teardown: it unsubscribes every handler at
416
+ * shutdown BEFORE any module `onClose` runs, while the service and its queue
417
+ * are still alive. A hand-rolled `afterResources` subscribe cannot promise that
418
+ * — its own `onClose` races the modules it depends on.
419
+ *
420
+ * Each handler declares `boundary: true`, so arc contains failures — logged and
421
+ * swallowed (fire-and-forget, matching arc's event gotcha), because a template
422
+ * or channel failure must not block the transport ack and DLQ the event. And
423
+ * `idempotencyKey` defaults from event identity because Redis Streams are
424
+ * at-least-once: redelivery must not double-send.
425
+ */
426
+ function notificationsModule(config) {
427
+ const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
428
+ let service;
429
+ return defineModule({
430
+ name,
431
+ async bootstrap(fastify) {
432
+ const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
433
+ const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
434
+ service = provided ?? new NotificationService({
435
+ ...serviceConfig,
436
+ ...resolvedChannels ? { channels: resolvedChannels } : {},
437
+ ...resolvedQueue ? { queue: resolvedQueue } : {}
438
+ });
439
+ if (!resolvedQueue && !provided && notifyOn.some((t) => t.enabled !== false)) fastify.log.warn(`[arc-notifications] module "${name}" wires notifyOn trigger(s) with NO durable queue — sends run on the in-memory queue and are lost on restart / not shared across instances. Wire \`queue: (f) => notificationsJob(...).toQueueAdapter(f.jobs)\` (with arc's jobsPlugin) for durability.`);
440
+ if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
441
+ return service;
442
+ },
443
+ eventHandlers: (fastify) => {
444
+ const handlers = notifyTriggerHandlers({
445
+ name,
446
+ service: () => service,
447
+ triggers: notifyOn,
448
+ ...recipientResolver ? { recipientResolver } : {}
449
+ });
450
+ if (handlers.length > 0) fastify.log.debug(`[arc-notifications] ${handlers.length} trigger(s) subscribed`);
451
+ return handlers;
452
+ }
453
+ });
454
+ }
455
+ /**
456
+ * Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
457
+ * the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
458
+ * `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
459
+ */
460
+ function notifyTriggerHandlers(options) {
461
+ const { name, service, recipientResolver } = options;
462
+ return options.triggers.filter((t) => t.enabled !== false).map((trigger, index) => ({
463
+ name: `${name}:${trigger.event}#${index}`,
464
+ event: trigger.event,
465
+ boundary: true,
466
+ handler: async (event) => dispatchTrigger(service(), trigger, event, recipientResolver ? { recipientResolver } : {})
467
+ }));
468
+ }
469
+ const audienceSlot = Symbol("arc-notifications.resolvedAudience");
470
+ /** Content key for an audience — key order must not fragment the memo. */
471
+ function audienceKey(audience) {
472
+ return JSON.stringify(audience, (_k, v) => v !== null && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v);
473
+ }
474
+ /**
475
+ * Resolve an audience ONCE per dispatch, shared by every expansion that asks.
476
+ *
477
+ * The resolver is a database read in every real host (members by role, then
478
+ * users for contact); N channel expansions of one trigger hand it the same
479
+ * audience. Keyed on CONTENT, so two triggers asking the same question share
480
+ * too. A rejected resolution is dropped so the next expansion retries.
481
+ * Outside a work scope nothing is shared.
482
+ */
483
+ function resolveAudience(resolver, audience, event) {
484
+ const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
485
+ if (!memo) return Promise.resolve(resolver(audience, event));
486
+ const key = audienceKey(audience);
487
+ const pending = memo.get(key);
488
+ if (pending) return pending;
489
+ const created = Promise.resolve(resolver(audience, event));
490
+ memo.set(key, created);
491
+ created.catch(() => memo.delete(key));
492
+ return created;
493
+ }
494
+ /** The `organizationId` an audience names (`{ roles, organizationId, … }`), when it names one. */
495
+ function audienceOrganization(audience) {
496
+ if (audience === null || typeof audience !== "object") return void 0;
497
+ const org = audience.organizationId;
498
+ return typeof org === "string" && org !== "" ? org : void 0;
499
+ }
500
+ /** One event → N sends. Exported for direct unit testing. */
501
+ async function dispatchTrigger(service, trigger, event, opts) {
502
+ let resolved;
503
+ let organizationId;
504
+ if (trigger.recipient) resolved = await trigger.recipient(event);
505
+ else if (trigger.audience) {
506
+ const audience = await trigger.audience(event);
507
+ if (audience === null || audience === void 0) return;
508
+ organizationId = audienceOrganization(audience);
509
+ resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
510
+ }
511
+ if (!resolved) return;
512
+ const recipients = Array.isArray(resolved) ? resolved : [resolved];
513
+ if (recipients.length === 0) return;
514
+ const payload = event.payload;
515
+ const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
516
+ const sendData = trigger.inApp ? {
517
+ ...data,
518
+ [INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
519
+ } : data;
520
+ const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
521
+ for (const [index, recipient] of recipients.entries()) {
522
+ const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
523
+ const subject = trigger.subject?.(event);
524
+ await service.send({
525
+ event: event.type,
526
+ template: trigger.template ?? event.type,
527
+ recipient,
528
+ data: sendData,
529
+ ...trigger.channels ? { channels: [...trigger.channels] } : {},
530
+ ...idempotencyKey !== void 0 ? { idempotencyKey } : {},
531
+ ...subject ? { subject } : {},
532
+ metadata: {
533
+ source: "arc-notifications",
534
+ eventId: event.meta.id,
535
+ ...organizationId !== void 0 ? { organizationId } : {},
536
+ /**
537
+ * The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
538
+ *
539
+ * Stated here because only the dispatcher knows it. A channel that has to
540
+ * infer "is this the same notification?" from the payload gets it wrong in
541
+ * both directions, and both were shipped: keying on the event TYPE
542
+ * suppressed every order after the first, and keying on `meta.id`
543
+ * (absent when a host re-publishes an enriched event) deduped nothing and
544
+ * sent the ops group two copies of every alert.
545
+ *
546
+ * Unique per occurrence, IDENTICAL across that occurrence's recipient
547
+ * fan-out — which is exactly the distinction a destination-collapsing
548
+ * channel needs and cannot derive for itself.
549
+ */
550
+ ...baseKey !== void 0 ? { notificationId: baseKey } : {}
551
+ }
552
+ });
553
+ }
554
+ }
555
+ // src/dateRules.ts
556
+ const CIVIL = /^(\d{4})-(\d{2})-(\d{2})$/;
557
+ const DAY_MS = 864e5;
558
+ /** True for a real calendar date in `YYYY-MM-DD` form. */
559
+ function isCivilDateString(value) {
560
+ if (typeof value !== "string") return false;
561
+ const m = CIVIL.exec(value);
562
+ if (!m) return false;
563
+ return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).toISOString().slice(0, 10) === value;
564
+ }
565
+ function toDayNumber(value) {
566
+ if (!isCivilDateString(value)) throw new TypeError(`not a civil date (YYYY-MM-DD): ${value}`);
567
+ return Date.parse(`${value}T00:00:00Z`) / DAY_MS;
568
+ }
569
+ function fromDayNumber(day) {
570
+ return (/* @__PURE__ */ new Date(day * DAY_MS)).toISOString().slice(0, 10);
571
+ }
572
+ /** `date` moved by `days` (negative = earlier). */
573
+ function addCivilDays(date, days) {
574
+ return fromDayNumber(toDayNumber(date) + days);
575
+ }
576
+ /** Whole days from `a` to `b` (`b - a`). */
577
+ function civilDaysBetween(a, b) {
578
+ return toDayNumber(b) - toDayNumber(a);
579
+ }
580
+ function createMemoryDateRuleLedger() {
581
+ const claimed = /* @__PURE__ */ new Set();
582
+ return {
583
+ async claim(key) {
584
+ if (claimed.has(key)) return false;
585
+ claimed.add(key);
586
+ return true;
587
+ },
588
+ async release(key) {
589
+ claimed.delete(key);
590
+ },
591
+ keys: () => [...claimed]
592
+ };
593
+ }
594
+ /** Problems with a schedule, as messages; empty = valid. */
595
+ function validateDateRuleSchedule(schedule) {
596
+ const problems = [];
597
+ if (!Number.isInteger(schedule.offsetDays)) problems.push("offsetDays must be a whole number of days");
598
+ if (schedule.repeatEveryDays !== void 0 && (!Number.isInteger(schedule.repeatEveryDays) || schedule.repeatEveryDays < 1)) problems.push("repeatEveryDays must be a whole number of days, at least 1");
599
+ const until = schedule.until;
600
+ if (until?.occurrences !== void 0 && (!Number.isInteger(until.occurrences) || until.occurrences < 1)) problems.push("until.occurrences must be at least 1");
601
+ if (until?.offsetDays !== void 0) {
602
+ if (!Number.isInteger(until.offsetDays)) problems.push("until.offsetDays must be a whole number of days");
603
+ else if (until.offsetDays < schedule.offsetDays) problems.push("until.offsetDays cannot be before offsetDays");
604
+ }
605
+ if (until && schedule.repeatEveryDays === void 0) problems.push("until applies only to a repeating rule");
606
+ return problems;
607
+ }
608
+ /**
609
+ * The occurrence of `schedule` for an item dated `date` that a sweep on `today`
610
+ * should send, or `null`. The latest occurrence on or before `today` is due when it
611
+ * is at most `graceDays` old — so a missed sweep catches up, but a rule created
612
+ * today does not replay years of history.
613
+ */
614
+ function dueOccurrence(schedule, date, today, graceDays = 0) {
615
+ const first = addCivilDays(date, schedule.offsetDays);
616
+ const elapsed = civilDaysBetween(first, today);
617
+ if (elapsed < 0) return null;
618
+ const every = schedule.repeatEveryDays;
619
+ let index = every ? Math.floor(elapsed / every) : 0;
620
+ if (every) {
621
+ const max = schedule.until?.occurrences;
622
+ if (max !== void 0 && index > max - 1) index = max - 1;
623
+ const lastOffset = schedule.until?.offsetDays;
624
+ if (lastOffset !== void 0) {
625
+ const cap = Math.floor((lastOffset - schedule.offsetDays) / every);
626
+ if (index > cap) index = cap;
627
+ }
628
+ }
629
+ const on = addCivilDays(first, every ? index * every : 0);
630
+ return civilDaysBetween(on, today) <= graceDays ? {
631
+ index,
632
+ on
633
+ } : null;
634
+ }
635
+ /** The `[from, to]` range of item dates a sweep on `today` must ask a source for. */
636
+ function dateRuleWindow(schedule, today, graceDays = 0) {
637
+ const to = addCivilDays(today, -schedule.offsetDays);
638
+ if (!schedule.repeatEveryDays) return {
639
+ from: addCivilDays(to, -graceDays),
640
+ to
641
+ };
642
+ const every = schedule.repeatEveryDays;
643
+ const spans = [];
644
+ if (schedule.until?.occurrences !== void 0) spans.push((schedule.until.occurrences - 1) * every);
645
+ if (schedule.until?.offsetDays !== void 0) spans.push(schedule.until.offsetDays - schedule.offsetDays);
646
+ if (spans.length === 0) return { to };
647
+ return {
648
+ from: addCivilDays(to, -(Math.min(...spans) + graceDays)),
649
+ to
650
+ };
651
+ }
652
+ /** Ledger key of one occurrence. */
653
+ const dateRuleOccurrenceKey = (ruleId, itemId, on) => `daterule:${ruleId}:${itemId}:${on}`;
654
+ async function collect(found) {
655
+ if (Symbol.asyncIterator in found) {
656
+ const out = [];
657
+ for await (const item of found) out.push(item);
658
+ return out;
659
+ }
660
+ return [...await found];
661
+ }
662
+ function createDateRuleEngine(options) {
663
+ const sources = /* @__PURE__ */ new Map();
664
+ for (const s of options.sources) {
665
+ if (sources.has(s.key)) throw new Error(`[arc-notifications] duplicate date-rule source "${s.key}"`);
666
+ sources.set(s.key, s);
667
+ }
668
+ const ledger = options.ledger ?? createMemoryDateRuleLedger();
669
+ const graceDays = options.graceDays ?? 0;
670
+ const validate = (rule) => {
671
+ const problems = validateDateRuleSchedule(rule.schedule);
672
+ const source = sources.get(rule.source);
673
+ if (!source) problems.unshift(`unknown source "${rule.source}"`);
674
+ else if (!source.dateFields.some((f) => f.key === rule.dateField)) problems.unshift(`source "${rule.source}" has no date field "${rule.dateField}"`);
675
+ return problems;
676
+ };
677
+ /** Items due for `rule` on `today`, each with its occurrence. */
678
+ const dueItems = async (rule, today) => {
679
+ const source = sources.get(rule.source);
680
+ if (!source) throw new Error(`unknown date-rule source "${rule.source}"`);
681
+ const window = dateRuleWindow(rule.schedule, today, graceDays);
682
+ const items = await collect(source.find({
683
+ dateField: rule.dateField,
684
+ ...window.from !== void 0 ? { from: window.from } : {},
685
+ to: window.to,
686
+ ...rule.organizationId !== void 0 ? { organizationId: rule.organizationId } : {}
687
+ }));
688
+ const out = [];
689
+ for (const item of items) {
690
+ if (!isCivilDateString(item.date)) continue;
691
+ const occurrence = dueOccurrence(rule.schedule, item.date, today, graceDays);
692
+ if (occurrence) out.push({
693
+ ...item,
694
+ occurrence
695
+ });
696
+ }
697
+ return out;
698
+ };
699
+ const sweep = async (today) => {
700
+ if (!isCivilDateString(today)) throw new TypeError(`sweep date must be YYYY-MM-DD: ${today}`);
701
+ const rules = (await options.rules()).filter((r) => r.enabled !== false);
702
+ let due = 0;
703
+ let fired = 0;
704
+ let duplicates = 0;
705
+ let noRecipients = 0;
706
+ let failed = 0;
707
+ for (const rule of rules) {
708
+ let items;
709
+ try {
710
+ const problems = validate(rule);
711
+ if (problems.length > 0) throw new Error(`invalid date rule: ${problems.join("; ")}`);
712
+ items = await dueItems(rule, today);
713
+ } catch (err) {
714
+ failed++;
715
+ options.onError?.(err, { ruleId: rule.id });
716
+ continue;
717
+ }
718
+ for (const item of items) {
719
+ due++;
720
+ const { occurrence } = item;
721
+ const key = dateRuleOccurrenceKey(rule.id, item.id, occurrence.on);
722
+ if (!await ledger.claim(key, {
723
+ ruleId: rule.id,
724
+ itemId: item.id,
725
+ occurrenceOn: occurrence.on,
726
+ index: occurrence.index,
727
+ sweptOn: today
728
+ })) {
729
+ duplicates++;
730
+ continue;
731
+ }
732
+ try {
733
+ const recipients = await options.resolveAudience(rule.audience, {
734
+ rule,
735
+ item,
736
+ occurrence
737
+ });
738
+ if (!recipients || recipients.length === 0) {
739
+ noRecipients++;
740
+ continue;
741
+ }
742
+ const type = rule.template ?? `${rule.source}.${rule.dateField}`;
743
+ const data = {
744
+ ...item.data,
745
+ dueOn: item.date,
746
+ occurrenceOn: occurrence.on,
747
+ occurrence: occurrence.index + 1,
748
+ daysFromDate: civilDaysBetween(item.date, today),
749
+ ...options.data?.(rule, item) ?? {}
750
+ };
751
+ const event = {
752
+ type,
753
+ payload: data,
754
+ meta: {
755
+ id: key,
756
+ timestamp: /* @__PURE__ */ new Date(),
757
+ idempotencyKey: key
758
+ }
759
+ };
760
+ await dispatchTrigger(options.service(), {
761
+ event: type,
762
+ template: type,
763
+ recipient: () => recipients,
764
+ data: () => data,
765
+ ...rule.channels ? { channels: rule.channels } : {},
766
+ ...rule.inApp ? { inApp: rule.inApp } : {},
767
+ ...rule.priority ? { priority: rule.priority } : {},
768
+ ...item.subject ? { subject: () => item.subject } : {}
769
+ }, event);
770
+ fired++;
771
+ } catch (err) {
772
+ failed++;
773
+ await ledger.release(key).catch(() => void 0);
774
+ options.onError?.(err, {
775
+ ruleId: rule.id,
776
+ itemId: item.id
777
+ });
778
+ }
779
+ }
780
+ }
781
+ return {
782
+ date: today,
783
+ rules: rules.length,
784
+ due,
785
+ fired,
786
+ duplicates,
787
+ noRecipients,
788
+ failed
789
+ };
790
+ };
791
+ return {
792
+ sweep,
793
+ preview: (rule, today) => dueItems(rule, today),
794
+ catalog: () => [...sources.values()].map((s) => ({
795
+ key: s.key,
796
+ label: s.label ?? s.key,
797
+ dateFields: s.dateFields.map((f) => ({
798
+ key: f.key,
799
+ label: f.label ?? f.key
800
+ })),
801
+ variables: [...s.variables ?? []]
802
+ })),
803
+ validate
804
+ };
805
+ }
806
+ // src/dbFirstResolver.ts
807
+ /**
808
+ * DB-first template resolver — generalized from be-prod's proven
809
+ * `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
810
+ * runtime: an active DB row for a template key wins; anything else falls back
811
+ * to the code-defined resolver, so a bad draft can never take sends down.
812
+ *
813
+ * Storage-agnostic: `TemplateStore` is a one-method structural port
814
+ * (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
815
+ */
816
+ function createDbFirstResolver(options) {
817
+ const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
818
+ /**
819
+ * Single-flight memo: one event's channel expansions resolve the same key in
820
+ * the same tick, and a `null` row (no draft) is an answer worth keeping. A
821
+ * store failure rejects every waiter and caches nothing.
822
+ */
823
+ const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
824
+ const resolver = (async (templateId, data) => {
825
+ let doc = null;
826
+ try {
827
+ doc = await lookup.get(templateId);
828
+ } catch (err) {
829
+ onError?.(err, templateId);
830
+ if (!fallback) throw err;
831
+ doc = null;
832
+ }
833
+ if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
834
+ ...doc.subject !== void 0 ? { subject: doc.subject } : {},
835
+ ...doc.html !== void 0 ? { html: doc.html } : {},
836
+ ...doc.text !== void 0 ? { text: doc.text } : {}
837
+ } }, { escape: escapeHtml })(templateId, data);
838
+ if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
839
+ return fallback(templateId, data);
840
+ });
841
+ resolver.invalidate = (key) => {
842
+ lookup.invalidate(key);
843
+ };
844
+ return resolver;
845
+ }
846
+ // src/inAppChannel.ts
386
847
  var InAppChannel = class extends BaseChannel {
387
848
  repository;
388
849
  port;
@@ -476,8 +937,7 @@ var InAppChannel = class extends BaseChannel {
476
937
  return typeof org === "string" && org ? org : void 0;
477
938
  }
478
939
  };
479
- //#endregion
480
- //#region src/inAppFeed.ts
940
+ // src/inAppFeed.ts
481
941
  /**
482
942
  * `inAppFeedResource` — the user-facing notification inbox as a normal arc
483
943
  * resource. Generalized from be-prod's `notification.resource`: a per-user,
@@ -498,6 +958,7 @@ var InAppChannel = class extends BaseChannel {
498
958
  */
499
959
  function createUnreadCountCache(options = {}) {
500
960
  const { ttlMs = 3e4, maxEntries = 1e4 } = options;
961
+ const clock = options.clock ?? systemNotificationClock;
501
962
  const entries = /* @__PURE__ */ new Map();
502
963
  const keyOf = (organizationId, userId) => `${organizationId}\u0000${userId}`;
503
964
  return {
@@ -505,7 +966,7 @@ function createUnreadCountCache(options = {}) {
505
966
  const key = keyOf(organizationId, userId);
506
967
  const entry = entries.get(key);
507
968
  if (!entry) return void 0;
508
- if (entry.expiresAt <= Date.now()) {
969
+ if (entry.expiresAt <= clock.now().getTime()) {
509
970
  entries.delete(key);
510
971
  return;
511
972
  }
@@ -518,7 +979,7 @@ function createUnreadCountCache(options = {}) {
518
979
  }
519
980
  entries.set(keyOf(organizationId, userId), {
520
981
  count,
521
- expiresAt: Date.now() + ttlMs
982
+ expiresAt: clock.now().getTime() + ttlMs
522
983
  });
523
984
  },
524
985
  invalidate(organizationId, userId) {
@@ -526,12 +987,36 @@ function createUnreadCountCache(options = {}) {
526
987
  }
527
988
  };
528
989
  }
990
+ /** The action-router verbs {@link InAppFeedResourceOptions.actions} mounts. */
991
+ const FEED_ACTION_VERBS = {
992
+ markRead: "markRead",
993
+ markAllRead: "markAllRead"
994
+ };
995
+ /**
996
+ * A feed page in repo-core's canonical offset envelope. Pure and exported so a host that
997
+ * builds its own list route (or asserts the envelope in a test) converts the same way the
998
+ * resource does. `hasNext`/`hasPrev` follow from the page arithmetic the port already did.
999
+ */
1000
+ function feedListResult(page) {
1001
+ const { page: current, limit, total, totalPages } = page.pagination;
1002
+ return {
1003
+ method: "offset",
1004
+ data: page.data,
1005
+ page: current,
1006
+ limit,
1007
+ total,
1008
+ pages: totalPages,
1009
+ hasNext: current < totalPages,
1010
+ hasPrev: current > 1
1011
+ };
1012
+ }
529
1013
  function toInt(value, fallback) {
530
1014
  const n = Number(value);
531
1015
  return Number.isInteger(n) && n > 0 ? n : fallback;
532
1016
  }
533
1017
  function inAppFeedResource(options) {
534
1018
  const { repository, resolveScope, permissions, name = "in-app-notification", prefix = "/notifications", maxLimit = 50, port, readSyncEventName = "notification:read", unreadCache } = options;
1019
+ const envelope = options.listEnvelope ?? "native";
535
1020
  /** Fire-and-forget: read-sync + cache invalidation must never fail a request. */
536
1021
  const afterReadStateChange = async (scope, data) => {
537
1022
  try {
@@ -581,7 +1066,7 @@ function inAppFeedResource(options) {
581
1066
  unreadOnly: q.unreadOnly === "true",
582
1067
  ...typeof q.type === "string" && q.type ? { type: q.type } : {}
583
1068
  });
584
- return reply.send(result);
1069
+ return reply.send(envelope === "offset" ? feedListResult(result) : result);
585
1070
  }
586
1071
  },
587
1072
  {
@@ -662,11 +1147,41 @@ function inAppFeedResource(options) {
662
1147
  return reply.send({ deleted: true });
663
1148
  }
664
1149
  }] : []
665
- ]
1150
+ ],
1151
+ ...options.actions ? { actions: {
1152
+ [FEED_ACTION_VERBS.markRead]: {
1153
+ permissions,
1154
+ description: "Mark one of my notifications read",
1155
+ target: {
1156
+ scope: "handler",
1157
+ reason: "feed rows are looked up by (organization, user, id) in the repository"
1158
+ },
1159
+ handler: async (id, _data, req) => {
1160
+ const scope = resolveScope(req);
1161
+ const updated = await repository.markRead(scope.organizationId, scope.userId, id);
1162
+ if (!updated) throw new NotFoundError("Notification");
1163
+ await afterReadStateChange(scope, { id });
1164
+ return updated;
1165
+ }
1166
+ },
1167
+ [FEED_ACTION_VERBS.markAllRead]: {
1168
+ permissions,
1169
+ id: false,
1170
+ description: "Mark all my notifications read",
1171
+ handler: async (_id, _data, req) => {
1172
+ const scope = resolveScope(req);
1173
+ const modifiedCount = await repository.markAllRead(scope.organizationId, scope.userId);
1174
+ await afterReadStateChange(scope, {
1175
+ all: true,
1176
+ modifiedCount
1177
+ });
1178
+ return { modifiedCount };
1179
+ }
1180
+ }
1181
+ } } : {}
666
1182
  });
667
1183
  }
668
- //#endregion
669
- //#region src/jobsQueue.ts
1184
+ // src/jobsQueue.ts
670
1185
  /**
671
1186
  * `fastify.jobs` bridge — reuse arc's job subsystem instead of standing up a
672
1187
  * second one.
@@ -778,157 +1293,7 @@ function notificationsJob(options = {}) {
778
1293
  toQueueAdapter
779
1294
  };
780
1295
  }
781
- //#endregion
782
- //#region src/module.ts
783
- /**
784
- * `notificationsModule` — the blessed seam from arc domain events to
785
- * user-facing notifications.
786
- *
787
- * Every arc app used to re-derive the same wiring: build a
788
- * NotificationService, subscribe `app.events`, map event payloads to
789
- * recipients, thread idempotency for at-least-once transports. This module is
790
- * that wiring, once:
791
- *
792
- * ```ts
793
- * const app = await createApp({
794
- * modules: [
795
- * notificationsModule({
796
- * channels: [new EmailChannel({ from, transport })],
797
- * templates: createDbFirstResolver({ store, fallback }),
798
- * notifyOn: [{
799
- * event: "approval.requested",
800
- * template: "approval-requested",
801
- * recipient: (e) => directory.contact(e.payload.approverId),
802
- * data: (e) => ({ title: e.payload.title }),
803
- * }],
804
- * }),
805
- * ],
806
- * });
807
- * app.notifications?.send({ ... }); // decorated service
808
- * const svc = getModuleExports<NotificationService>(app, "notifications");
809
- * ```
810
- *
811
- * Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
812
- * arc owns subscription ordering AND teardown: it unsubscribes every handler at
813
- * shutdown BEFORE any module `onClose` runs, while the service and its queue
814
- * are still alive. A hand-rolled `afterResources` subscribe cannot promise that
815
- * — its own `onClose` races the modules it depends on.
816
- *
817
- * Each handler declares `boundary: true`, so arc contains failures — logged and
818
- * swallowed (fire-and-forget, matching arc's event gotcha), because a template
819
- * or channel failure must not block the transport ack and DLQ the event. And
820
- * `idempotencyKey` defaults from event identity because Redis Streams are
821
- * at-least-once: redelivery must not double-send.
822
- */
823
- function notificationsModule(config) {
824
- const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
825
- let service;
826
- return defineModule({
827
- name,
828
- async bootstrap(fastify) {
829
- const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
830
- const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
831
- service = provided ?? new NotificationService({
832
- ...serviceConfig,
833
- ...resolvedChannels ? { channels: resolvedChannels } : {},
834
- ...resolvedQueue ? { queue: resolvedQueue } : {}
835
- });
836
- if (!resolvedQueue && !provided && notifyOn.some((t) => t.enabled !== false)) fastify.log.warn(`[arc-notifications] module "${name}" wires notifyOn trigger(s) with NO durable queue — sends run on the in-memory queue and are lost on restart / not shared across instances. Wire \`queue: (f) => notificationsJob(...).toQueueAdapter(f.jobs)\` (with arc's jobsPlugin) for durability.`);
837
- if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
838
- return service;
839
- },
840
- eventHandlers: (fastify) => {
841
- const triggers = notifyOn.filter((t) => t.enabled !== false);
842
- if (triggers.length === 0) return [];
843
- fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
844
- return triggers.map((trigger, index) => ({
845
- name: `${name}:${trigger.event}#${index}`,
846
- event: trigger.event,
847
- boundary: true,
848
- handler: async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} })
849
- }));
850
- }
851
- });
852
- }
853
- const audienceSlot = Symbol("arc-notifications.resolvedAudience");
854
- /** Content key for an audience — key order must not fragment the memo. */
855
- function audienceKey(audience) {
856
- return JSON.stringify(audience, (_k, v) => v !== null && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v);
857
- }
858
- /**
859
- * Resolve an audience ONCE per dispatch, shared by every expansion that asks.
860
- *
861
- * The resolver is a database read in every real host (members by role, then
862
- * users for contact); N channel expansions of one trigger hand it the same
863
- * audience. Keyed on CONTENT, so two triggers asking the same question share
864
- * too. A rejected resolution is dropped so the next expansion retries.
865
- * Outside a work scope nothing is shared.
866
- */
867
- function resolveAudience(resolver, audience, event) {
868
- const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
869
- if (!memo) return Promise.resolve(resolver(audience, event));
870
- const key = audienceKey(audience);
871
- const pending = memo.get(key);
872
- if (pending) return pending;
873
- const created = Promise.resolve(resolver(audience, event));
874
- memo.set(key, created);
875
- created.catch(() => memo.delete(key));
876
- return created;
877
- }
878
- /** One event → N sends. Exported for direct unit testing. */
879
- async function dispatchTrigger(service, trigger, event, opts) {
880
- let resolved;
881
- if (trigger.recipient) resolved = await trigger.recipient(event);
882
- else if (trigger.audience) {
883
- const audience = await trigger.audience(event);
884
- if (audience === null || audience === void 0) return;
885
- resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
886
- }
887
- if (!resolved) return;
888
- const recipients = Array.isArray(resolved) ? resolved : [resolved];
889
- if (recipients.length === 0) return;
890
- const payload = event.payload;
891
- const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
892
- const sendData = trigger.inApp ? {
893
- ...data,
894
- [INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
895
- } : data;
896
- const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
897
- for (const [index, recipient] of recipients.entries()) {
898
- const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
899
- const subject = trigger.subject?.(event);
900
- await service.send({
901
- event: event.type,
902
- template: trigger.template ?? event.type,
903
- recipient,
904
- data: sendData,
905
- ...trigger.channels ? { channels: [...trigger.channels] } : {},
906
- ...idempotencyKey !== void 0 ? { idempotencyKey } : {},
907
- ...subject ? { subject } : {},
908
- metadata: {
909
- source: "arc-notifications",
910
- eventId: event.meta.id,
911
- /**
912
- * The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
913
- *
914
- * Stated here because only the dispatcher knows it. A channel that has to
915
- * infer "is this the same notification?" from the payload gets it wrong in
916
- * both directions, and both were shipped: keying on the event TYPE
917
- * suppressed every order after the first, and keying on `meta.id`
918
- * (absent when a host re-publishes an enriched event) deduped nothing and
919
- * sent the ops group two copies of every alert.
920
- *
921
- * Unique per occurrence, IDENTICAL across that occurrence's recipient
922
- * fan-out — which is exactly the distinction a destination-collapsing
923
- * channel needs and cannot derive for itself.
924
- */
925
- ...baseKey !== void 0 ? { notificationId: baseKey } : {}
926
- }
927
- });
928
- }
929
- }
930
- //#endregion
931
- //#region src/repositoryDeliveryLog.ts
1296
+ // src/repositoryDeliveryLog.ts
932
1297
  function deriveStatus(dispatch) {
933
1298
  if (dispatch.sent > 0 && dispatch.failed === 0) return "delivered";
934
1299
  if (dispatch.sent > 0 && dispatch.failed > 0) return "partial";
@@ -947,15 +1312,30 @@ function toEntry(doc) {
947
1312
  status: doc.status,
948
1313
  duration: doc.duration ?? 0,
949
1314
  ...doc.metadata ? { metadata: doc.metadata } : {},
950
- ...doc.subject ? { subject: { ...doc.subject } } : {}
1315
+ ...doc.subject ? { subject: { ...doc.subject } } : {},
1316
+ ...doc.organizationId ? { organizationId: doc.organizationId } : {}
951
1317
  };
952
1318
  }
1319
+ /** The payload's organization: its own metadata first, then the recipient's resolver stamp. */
1320
+ function organizationOf(payload) {
1321
+ const fromPayload = payload.metadata?.organizationId;
1322
+ if (typeof fromPayload === "string" && fromPayload !== "") return fromPayload;
1323
+ const fromRecipient = payload.recipient.metadata?.organizationId;
1324
+ return typeof fromRecipient === "string" && fromRecipient !== "" ? fromRecipient : null;
1325
+ }
953
1326
  function createRepositoryDeliveryLog(repository, options = {}) {
954
1327
  const { defaultLimit = 100 } = options;
1328
+ const clock = options.clock ?? systemNotificationClock;
1329
+ const resolveRuleId = options.ruleId ?? ((payload) => {
1330
+ const fromMetadata = payload.metadata?.ruleId;
1331
+ return typeof fromMetadata === "string" && fromMetadata !== "" ? fromMetadata : void 0;
1332
+ });
955
1333
  return {
956
1334
  async record(payload, dispatch) {
1335
+ const ruleId = resolveRuleId(payload);
1336
+ const extraFields = options.extraFields?.(payload, dispatch);
957
1337
  const doc = {
958
- timestamp: /* @__PURE__ */ new Date(),
1338
+ timestamp: clock.now(),
959
1339
  event: payload.event,
960
1340
  ...payload.recipient.id ? { recipientId: payload.recipient.id } : {},
961
1341
  ...payload.recipient.email ? { recipientEmail: payload.recipient.email } : {},
@@ -964,7 +1344,10 @@ function createRepositoryDeliveryLog(repository, options = {}) {
964
1344
  status: deriveStatus(dispatch),
965
1345
  duration: dispatch.duration,
966
1346
  ...payload.metadata ? { metadata: payload.metadata } : {},
967
- ...payload.subject ? { subject: payload.subject } : {}
1347
+ ...payload.subject ? { subject: payload.subject } : {},
1348
+ organizationId: organizationOf(payload),
1349
+ ...ruleId ? { ruleId } : {},
1350
+ ...extraFields ? { extraFields } : {}
968
1351
  };
969
1352
  await repository.create(doc);
970
1353
  },
@@ -992,6 +1375,7 @@ function createRepositoryDeliveryLog(repository, options = {}) {
992
1375
  const limit = filter.limit ?? defaultLimit;
993
1376
  const result = await repository.getAll({
994
1377
  filter: equality,
1378
+ filters: equality,
995
1379
  limit
996
1380
  }, void 0);
997
1381
  let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => toEntry(r));
@@ -1001,14 +1385,36 @@ function createRepositoryDeliveryLog(repository, options = {}) {
1001
1385
  if (channel) entries = entries.filter((e) => e.channels.includes(channel));
1002
1386
  return entries.slice(0, limit);
1003
1387
  },
1388
+ async listByRule(ruleId, filter = {}) {
1389
+ const equality = { ruleId };
1390
+ if (filter.status) equality.status = filter.status;
1391
+ const limit = filter.limit ?? defaultLimit;
1392
+ const result = await repository.getAll({
1393
+ filter: equality,
1394
+ filters: equality,
1395
+ sort: { timestamp: -1 },
1396
+ limit
1397
+ }, void 0);
1398
+ let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => {
1399
+ const doc = r;
1400
+ return {
1401
+ ...toEntry(doc),
1402
+ ...doc.ruleId ? { ruleId: doc.ruleId } : {},
1403
+ ...doc.extraFields ? { extraFields: doc.extraFields } : {}
1404
+ };
1405
+ });
1406
+ if (filter.after) entries = entries.filter((e) => e.timestamp >= filter.after);
1407
+ if (filter.before) entries = entries.filter((e) => e.timestamp <= filter.before);
1408
+ entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
1409
+ return entries.slice(0, limit);
1410
+ },
1004
1411
  async get(id) {
1005
1412
  const doc = await repository.getById(id);
1006
1413
  return doc ? toEntry(doc) : null;
1007
1414
  }
1008
1415
  };
1009
1416
  }
1010
- //#endregion
1011
- //#region src/sseChannel.ts
1417
+ // src/sseChannel.ts
1012
1418
  var SseChannel = class extends BaseChannel {
1013
1419
  port;
1014
1420
  eventName;
@@ -1068,8 +1474,7 @@ var SseChannel = class extends BaseChannel {
1068
1474
  return typeof org === "string" && org ? org : void 0;
1069
1475
  }
1070
1476
  };
1071
- //#endregion
1072
- //#region src/templatesResource.ts
1477
+ // src/templatesResource.ts
1073
1478
  /**
1074
1479
  * `templatesResource` — DB-draftable notification templates as a normal arc
1075
1480
  * resource. Generalized from be-prod's email-template resource: admins draft
@@ -1173,8 +1578,7 @@ function templatesResource(options) {
1173
1578
  }]
1174
1579
  });
1175
1580
  }
1176
- //#endregion
1177
- //#region src/triggerAdapter.ts
1581
+ // src/triggerAdapter.ts
1178
1582
  /**
1179
1583
  * Trigger registry → `notifyOn` config.
1180
1584
  *
@@ -1398,7 +1802,8 @@ function toGatedNotifyTriggers(t, options, overrides) {
1398
1802
  channels: declaredChannels(t),
1399
1803
  title: t.template.title,
1400
1804
  message: t.template.message,
1401
- ...t.priority ? { priority: t.priority } : {}
1805
+ ...t.priority ? { priority: t.priority } : {},
1806
+ ...t.sourceChannels ? { sourceChannels: t.sourceChannels } : {}
1402
1807
  };
1403
1808
  /**
1404
1809
  * The effective settings resolve ONCE per event per trigger, shared by every
@@ -1473,10 +1878,20 @@ function toGatedNotifyTriggers(t, options, overrides) {
1473
1878
  organizationId: ex.organizationId
1474
1879
  };
1475
1880
  }
1476
- if (eff.recipients.length === 0) return skip("no_recipients");
1881
+ /**
1882
+ * An operator who cleared the list means nobody — skip rather than
1883
+ * dispatching to an empty audience the resolver would widen.
1884
+ *
1885
+ * Literal addresses count as an audience. Someone who wants order mail at
1886
+ * `ops@` and nowhere else clears the roles and types the address; treating
1887
+ * that as "no recipients" would silence exactly the configuration they
1888
+ * just built, and report it as their own doing.
1889
+ */
1890
+ if (eff.recipients.length === 0 && eff.recipientEmails.length === 0) return skip("no_recipients");
1477
1891
  return {
1478
1892
  roles: eff.recipients,
1479
1893
  organizationId: ex.organizationId,
1894
+ ...eff.recipientEmails.length > 0 ? { recipientEmails: eff.recipientEmails } : {},
1480
1895
  ...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
1481
1896
  };
1482
1897
  }
@@ -1523,5 +1938,4 @@ function getRecipientMatrix(triggers) {
1523
1938
  for (const t of triggers) matrix[t.type ?? t.event] = t.recipients;
1524
1939
  return matrix;
1525
1940
  }
1526
- //#endregion
1527
- export { DEFAULT_ENTITY_ID_VARS, INAPP_DATA_KEY, IN_APP_CHANNEL, InAppChannel, SseChannel, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, evaluateSourceChannel, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
1941
+ export { DEFAULT_ENTITY_ID_VARS, FEED_ACTION_VERBS, INAPP_DATA_KEY, IN_APP_CHANNEL, InAppChannel, SseChannel, addCivilDays, civilDaysBetween, createCachedTriggerOverrideStore, createDateRuleEngine, createDbFirstResolver, createInAppBackfill, createMemoryDateRuleLedger, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dateRuleOccurrenceKey, dateRuleWindow, dispatchTrigger, dueOccurrence, evaluateSourceChannel, feedListResult, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, isCivilDateString, notificationsJob, notificationsModule, notifyTriggerHandlers, readInAppContent, renderInApp, resolveEffectiveSettings, systemNotificationClock, templatesResource, toNotifyTrigger, toNotifyTriggers, validateDateRuleSchedule };