@classytic/arc-notifications 0.9.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
  *
@@ -234,8 +251,7 @@ function evaluateSourceChannel(sourceChannels, sourceChannel) {
234
251
  reason: "source_channel_mismatch"
235
252
  };
236
253
  }
237
- //#endregion
238
- //#region src/channelCatalog.ts
254
+ // src/channelCatalog.ts
239
255
  /**
240
256
  * The NOTIFICATION CHANNEL CATALOG — which channels this deployment can deliver on.
241
257
  *
@@ -294,49 +310,9 @@ function createNotificationChannelCatalog(connectorChannels = []) {
294
310
  has: (channel) => lookup.has(channel)
295
311
  };
296
312
  }
297
- //#endregion
298
- //#region src/dbFirstResolver.ts
299
- /**
300
- * DB-first template resolver — generalized from be-prod's proven
301
- * `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
302
- * runtime: an active DB row for a template key wins; anything else falls back
303
- * to the code-defined resolver, so a bad draft can never take sends down.
304
- *
305
- * Storage-agnostic: `TemplateStore` is a one-method structural port
306
- * (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
307
- */
308
- function createDbFirstResolver(options) {
309
- const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
310
- /**
311
- * Single-flight memo: one event's channel expansions resolve the same key in
312
- * the same tick, and a `null` row (no draft) is an answer worth keeping. A
313
- * store failure rejects every waiter and caches nothing.
314
- */
315
- const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
316
- const resolver = (async (templateId, data) => {
317
- let doc = null;
318
- try {
319
- doc = await lookup.get(templateId);
320
- } catch (err) {
321
- onError?.(err, templateId);
322
- if (!fallback) throw err;
323
- doc = null;
324
- }
325
- if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
326
- ...doc.subject !== void 0 ? { subject: doc.subject } : {},
327
- ...doc.html !== void 0 ? { html: doc.html } : {},
328
- ...doc.text !== void 0 ? { text: doc.text } : {}
329
- } }, { escape: escapeHtml })(templateId, data);
330
- if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
331
- return fallback(templateId, data);
332
- });
333
- resolver.invalidate = (key) => {
334
- lookup.invalidate(key);
335
- };
336
- return resolver;
337
- }
338
- //#endregion
339
- //#region src/inApp.ts
313
+ // src/clock.ts
314
+ const systemNotificationClock = { now: () => /* @__PURE__ */ new Date() };
315
+ // src/inApp.ts
340
316
  /**
341
317
  * In-app notification content — the shared seam between a `notifyOn` trigger's
342
318
  * declarative `inApp` template and the channels that deliver it (the DB feed
@@ -406,8 +382,468 @@ function readInAppContent(data) {
406
382
  if (typeof c.title !== "string" || typeof c.message !== "string") return null;
407
383
  return c;
408
384
  }
409
- //#endregion
410
- //#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
411
847
  var InAppChannel = class extends BaseChannel {
412
848
  repository;
413
849
  port;
@@ -501,8 +937,7 @@ var InAppChannel = class extends BaseChannel {
501
937
  return typeof org === "string" && org ? org : void 0;
502
938
  }
503
939
  };
504
- //#endregion
505
- //#region src/inAppFeed.ts
940
+ // src/inAppFeed.ts
506
941
  /**
507
942
  * `inAppFeedResource` — the user-facing notification inbox as a normal arc
508
943
  * resource. Generalized from be-prod's `notification.resource`: a per-user,
@@ -523,6 +958,7 @@ var InAppChannel = class extends BaseChannel {
523
958
  */
524
959
  function createUnreadCountCache(options = {}) {
525
960
  const { ttlMs = 3e4, maxEntries = 1e4 } = options;
961
+ const clock = options.clock ?? systemNotificationClock;
526
962
  const entries = /* @__PURE__ */ new Map();
527
963
  const keyOf = (organizationId, userId) => `${organizationId}\u0000${userId}`;
528
964
  return {
@@ -530,7 +966,7 @@ function createUnreadCountCache(options = {}) {
530
966
  const key = keyOf(organizationId, userId);
531
967
  const entry = entries.get(key);
532
968
  if (!entry) return void 0;
533
- if (entry.expiresAt <= Date.now()) {
969
+ if (entry.expiresAt <= clock.now().getTime()) {
534
970
  entries.delete(key);
535
971
  return;
536
972
  }
@@ -543,7 +979,7 @@ function createUnreadCountCache(options = {}) {
543
979
  }
544
980
  entries.set(keyOf(organizationId, userId), {
545
981
  count,
546
- expiresAt: Date.now() + ttlMs
982
+ expiresAt: clock.now().getTime() + ttlMs
547
983
  });
548
984
  },
549
985
  invalidate(organizationId, userId) {
@@ -551,12 +987,36 @@ function createUnreadCountCache(options = {}) {
551
987
  }
552
988
  };
553
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
+ }
554
1013
  function toInt(value, fallback) {
555
1014
  const n = Number(value);
556
1015
  return Number.isInteger(n) && n > 0 ? n : fallback;
557
1016
  }
558
1017
  function inAppFeedResource(options) {
559
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";
560
1020
  /** Fire-and-forget: read-sync + cache invalidation must never fail a request. */
561
1021
  const afterReadStateChange = async (scope, data) => {
562
1022
  try {
@@ -606,7 +1066,7 @@ function inAppFeedResource(options) {
606
1066
  unreadOnly: q.unreadOnly === "true",
607
1067
  ...typeof q.type === "string" && q.type ? { type: q.type } : {}
608
1068
  });
609
- return reply.send(result);
1069
+ return reply.send(envelope === "offset" ? feedListResult(result) : result);
610
1070
  }
611
1071
  },
612
1072
  {
@@ -687,11 +1147,41 @@ function inAppFeedResource(options) {
687
1147
  return reply.send({ deleted: true });
688
1148
  }
689
1149
  }] : []
690
- ]
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
+ } } : {}
691
1182
  });
692
1183
  }
693
- //#endregion
694
- //#region src/jobsQueue.ts
1184
+ // src/jobsQueue.ts
695
1185
  /**
696
1186
  * `fastify.jobs` bridge — reuse arc's job subsystem instead of standing up a
697
1187
  * second one.
@@ -803,179 +1293,7 @@ function notificationsJob(options = {}) {
803
1293
  toQueueAdapter
804
1294
  };
805
1295
  }
806
- //#endregion
807
- //#region src/module.ts
808
- /**
809
- * `notificationsModule` — the blessed seam from arc domain events to
810
- * user-facing notifications.
811
- *
812
- * Every arc app used to re-derive the same wiring: build a
813
- * NotificationService, subscribe `app.events`, map event payloads to
814
- * recipients, thread idempotency for at-least-once transports. This module is
815
- * that wiring, once:
816
- *
817
- * ```ts
818
- * const app = await createApp({
819
- * modules: [
820
- * notificationsModule({
821
- * channels: [new EmailChannel({ from, transport })],
822
- * templates: createDbFirstResolver({ store, fallback }),
823
- * notifyOn: [{
824
- * event: "approval.requested",
825
- * template: "approval-requested",
826
- * recipient: (e) => directory.contact(e.payload.approverId),
827
- * data: (e) => ({ title: e.payload.title }),
828
- * }],
829
- * }),
830
- * ],
831
- * });
832
- * app.notifications?.send({ ... }); // decorated service
833
- * const svc = getModuleExports<NotificationService>(app, "notifications");
834
- * ```
835
- *
836
- * Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
837
- * arc owns subscription ordering AND teardown: it unsubscribes every handler at
838
- * shutdown BEFORE any module `onClose` runs, while the service and its queue
839
- * are still alive. A hand-rolled `afterResources` subscribe cannot promise that
840
- * — its own `onClose` races the modules it depends on.
841
- *
842
- * Each handler declares `boundary: true`, so arc contains failures — logged and
843
- * swallowed (fire-and-forget, matching arc's event gotcha), because a template
844
- * or channel failure must not block the transport ack and DLQ the event. And
845
- * `idempotencyKey` defaults from event identity because Redis Streams are
846
- * at-least-once: redelivery must not double-send.
847
- */
848
- function notificationsModule(config) {
849
- const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
850
- let service;
851
- return defineModule({
852
- name,
853
- async bootstrap(fastify) {
854
- const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
855
- const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
856
- service = provided ?? new NotificationService({
857
- ...serviceConfig,
858
- ...resolvedChannels ? { channels: resolvedChannels } : {},
859
- ...resolvedQueue ? { queue: resolvedQueue } : {}
860
- });
861
- 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.`);
862
- if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
863
- return service;
864
- },
865
- eventHandlers: (fastify) => {
866
- const handlers = notifyTriggerHandlers({
867
- name,
868
- service: () => service,
869
- triggers: notifyOn,
870
- ...recipientResolver ? { recipientResolver } : {}
871
- });
872
- if (handlers.length > 0) fastify.log.debug(`[arc-notifications] ${handlers.length} trigger(s) subscribed`);
873
- return handlers;
874
- }
875
- });
876
- }
877
- /**
878
- * Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
879
- * the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
880
- * `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
881
- */
882
- function notifyTriggerHandlers(options) {
883
- const { name, service, recipientResolver } = options;
884
- return options.triggers.filter((t) => t.enabled !== false).map((trigger, index) => ({
885
- name: `${name}:${trigger.event}#${index}`,
886
- event: trigger.event,
887
- boundary: true,
888
- handler: async (event) => dispatchTrigger(service(), trigger, event, recipientResolver ? { recipientResolver } : {})
889
- }));
890
- }
891
- const audienceSlot = Symbol("arc-notifications.resolvedAudience");
892
- /** Content key for an audience — key order must not fragment the memo. */
893
- function audienceKey(audience) {
894
- 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);
895
- }
896
- /**
897
- * Resolve an audience ONCE per dispatch, shared by every expansion that asks.
898
- *
899
- * The resolver is a database read in every real host (members by role, then
900
- * users for contact); N channel expansions of one trigger hand it the same
901
- * audience. Keyed on CONTENT, so two triggers asking the same question share
902
- * too. A rejected resolution is dropped so the next expansion retries.
903
- * Outside a work scope nothing is shared.
904
- */
905
- function resolveAudience(resolver, audience, event) {
906
- const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
907
- if (!memo) return Promise.resolve(resolver(audience, event));
908
- const key = audienceKey(audience);
909
- const pending = memo.get(key);
910
- if (pending) return pending;
911
- const created = Promise.resolve(resolver(audience, event));
912
- memo.set(key, created);
913
- created.catch(() => memo.delete(key));
914
- return created;
915
- }
916
- /** The `organizationId` an audience names (`{ roles, organizationId, … }`), when it names one. */
917
- function audienceOrganization(audience) {
918
- if (audience === null || typeof audience !== "object") return void 0;
919
- const org = audience.organizationId;
920
- return typeof org === "string" && org !== "" ? org : void 0;
921
- }
922
- /** One event → N sends. Exported for direct unit testing. */
923
- async function dispatchTrigger(service, trigger, event, opts) {
924
- let resolved;
925
- let organizationId;
926
- if (trigger.recipient) resolved = await trigger.recipient(event);
927
- else if (trigger.audience) {
928
- const audience = await trigger.audience(event);
929
- if (audience === null || audience === void 0) return;
930
- organizationId = audienceOrganization(audience);
931
- resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
932
- }
933
- if (!resolved) return;
934
- const recipients = Array.isArray(resolved) ? resolved : [resolved];
935
- if (recipients.length === 0) return;
936
- const payload = event.payload;
937
- const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
938
- const sendData = trigger.inApp ? {
939
- ...data,
940
- [INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
941
- } : data;
942
- const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
943
- for (const [index, recipient] of recipients.entries()) {
944
- const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
945
- const subject = trigger.subject?.(event);
946
- await service.send({
947
- event: event.type,
948
- template: trigger.template ?? event.type,
949
- recipient,
950
- data: sendData,
951
- ...trigger.channels ? { channels: [...trigger.channels] } : {},
952
- ...idempotencyKey !== void 0 ? { idempotencyKey } : {},
953
- ...subject ? { subject } : {},
954
- metadata: {
955
- source: "arc-notifications",
956
- eventId: event.meta.id,
957
- ...organizationId !== void 0 ? { organizationId } : {},
958
- /**
959
- * The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
960
- *
961
- * Stated here because only the dispatcher knows it. A channel that has to
962
- * infer "is this the same notification?" from the payload gets it wrong in
963
- * both directions, and both were shipped: keying on the event TYPE
964
- * suppressed every order after the first, and keying on `meta.id`
965
- * (absent when a host re-publishes an enriched event) deduped nothing and
966
- * sent the ops group two copies of every alert.
967
- *
968
- * Unique per occurrence, IDENTICAL across that occurrence's recipient
969
- * fan-out — which is exactly the distinction a destination-collapsing
970
- * channel needs and cannot derive for itself.
971
- */
972
- ...baseKey !== void 0 ? { notificationId: baseKey } : {}
973
- }
974
- });
975
- }
976
- }
977
- //#endregion
978
- //#region src/repositoryDeliveryLog.ts
1296
+ // src/repositoryDeliveryLog.ts
979
1297
  function deriveStatus(dispatch) {
980
1298
  if (dispatch.sent > 0 && dispatch.failed === 0) return "delivered";
981
1299
  if (dispatch.sent > 0 && dispatch.failed > 0) return "partial";
@@ -1007,10 +1325,17 @@ function organizationOf(payload) {
1007
1325
  }
1008
1326
  function createRepositoryDeliveryLog(repository, options = {}) {
1009
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
+ });
1010
1333
  return {
1011
1334
  async record(payload, dispatch) {
1335
+ const ruleId = resolveRuleId(payload);
1336
+ const extraFields = options.extraFields?.(payload, dispatch);
1012
1337
  const doc = {
1013
- timestamp: /* @__PURE__ */ new Date(),
1338
+ timestamp: clock.now(),
1014
1339
  event: payload.event,
1015
1340
  ...payload.recipient.id ? { recipientId: payload.recipient.id } : {},
1016
1341
  ...payload.recipient.email ? { recipientEmail: payload.recipient.email } : {},
@@ -1020,7 +1345,9 @@ function createRepositoryDeliveryLog(repository, options = {}) {
1020
1345
  duration: dispatch.duration,
1021
1346
  ...payload.metadata ? { metadata: payload.metadata } : {},
1022
1347
  ...payload.subject ? { subject: payload.subject } : {},
1023
- organizationId: organizationOf(payload)
1348
+ organizationId: organizationOf(payload),
1349
+ ...ruleId ? { ruleId } : {},
1350
+ ...extraFields ? { extraFields } : {}
1024
1351
  };
1025
1352
  await repository.create(doc);
1026
1353
  },
@@ -1048,6 +1375,7 @@ function createRepositoryDeliveryLog(repository, options = {}) {
1048
1375
  const limit = filter.limit ?? defaultLimit;
1049
1376
  const result = await repository.getAll({
1050
1377
  filter: equality,
1378
+ filters: equality,
1051
1379
  limit
1052
1380
  }, void 0);
1053
1381
  let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => toEntry(r));
@@ -1057,14 +1385,36 @@ function createRepositoryDeliveryLog(repository, options = {}) {
1057
1385
  if (channel) entries = entries.filter((e) => e.channels.includes(channel));
1058
1386
  return entries.slice(0, limit);
1059
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
+ },
1060
1411
  async get(id) {
1061
1412
  const doc = await repository.getById(id);
1062
1413
  return doc ? toEntry(doc) : null;
1063
1414
  }
1064
1415
  };
1065
1416
  }
1066
- //#endregion
1067
- //#region src/sseChannel.ts
1417
+ // src/sseChannel.ts
1068
1418
  var SseChannel = class extends BaseChannel {
1069
1419
  port;
1070
1420
  eventName;
@@ -1124,8 +1474,7 @@ var SseChannel = class extends BaseChannel {
1124
1474
  return typeof org === "string" && org ? org : void 0;
1125
1475
  }
1126
1476
  };
1127
- //#endregion
1128
- //#region src/templatesResource.ts
1477
+ // src/templatesResource.ts
1129
1478
  /**
1130
1479
  * `templatesResource` — DB-draftable notification templates as a normal arc
1131
1480
  * resource. Generalized from be-prod's email-template resource: admins draft
@@ -1229,8 +1578,7 @@ function templatesResource(options) {
1229
1578
  }]
1230
1579
  });
1231
1580
  }
1232
- //#endregion
1233
- //#region src/triggerAdapter.ts
1581
+ // src/triggerAdapter.ts
1234
1582
  /**
1235
1583
  * Trigger registry → `notifyOn` config.
1236
1584
  *
@@ -1590,5 +1938,4 @@ function getRecipientMatrix(triggers) {
1590
1938
  for (const t of triggers) matrix[t.type ?? t.event] = t.recipients;
1591
1939
  return matrix;
1592
1940
  }
1593
- //#endregion
1594
- 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, notifyTriggerHandlers, 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 };