@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.d.mts CHANGED
@@ -1,11 +1,11 @@
1
- import { BaseChannel, ChannelConfig, DeliveryLog, DeliveryLogEntry, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
1
+ import { ArcModule, EventHandlerDefinition } from "@classytic/arc/factory";
2
+ import { BaseChannel, ChannelConfig, DeliveryLog, DeliveryLogEntry, DispatchResult, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
2
3
  import { JobDefinition, JobDispatcher } from "@classytic/arc/integrations/jobs";
3
- import { ArcModule } from "@classytic/arc/factory";
4
4
  import { PermissionCheck } from "@classytic/arc/permissions";
5
+ import { OffsetPaginationResult } from "@classytic/repo-core/pagination";
5
6
  import { FastifyInstance, FastifyRequest } from "fastify";
6
7
  import { DomainEvent } from "@classytic/primitives/events";
7
8
  import { RepositoryLike } from "@classytic/repo-core/adapter";
8
- //#region src/inApp.d.ts
9
9
  /**
10
10
  * In-app notification content — the shared seam between a `notifyOn` trigger's
11
11
  * declarative `inApp` template and the channels that deliver it (the DB feed
@@ -23,7 +23,7 @@ import { RepositoryLike } from "@classytic/repo-core/adapter";
23
23
  * in-app convention; the email template layer keeps its own `${var}` engine.
24
24
  */
25
25
  /** Reserved `payload.data` key carrying the rendered in-app content. */
26
- declare const INAPP_DATA_KEY = "__inApp";
26
+ export declare const INAPP_DATA_KEY = "__inApp";
27
27
  /**
28
28
  * Host realtime transport — push an event to one user within a tenant.
29
29
  * The transport itself (connection registry, heartbeats, the `/stream`
@@ -59,11 +59,21 @@ interface InAppContent {
59
59
  readonly priority: InAppPriority;
60
60
  }
61
61
  /** Render a trigger's in-app template against event data + priority. */
62
- declare function renderInApp(template: InAppTemplate, vars: Record<string, unknown>, priority?: InAppPriority): InAppContent;
62
+ export declare function renderInApp(template: InAppTemplate, vars: Record<string, unknown>, priority?: InAppPriority): InAppContent;
63
63
  /** Read the rendered in-app content a trigger parked on the send payload's data. */
64
- declare function readInAppContent(data: Record<string, unknown> | undefined): InAppContent | null;
65
- //#endregion
66
- //#region src/inAppFeed.d.ts
64
+ export declare function readInAppContent(data: Record<string, unknown> | undefined): InAppContent | null;
65
+ /**
66
+ * The notification clock — one seam so every "now" the in-app feed, the reconnect backfill
67
+ * and the delivery log stamp goes through a single injectable source.
68
+ *
69
+ * Structural on purpose: a host's own `Clock` (`{ now(): Date }`) satisfies it with no
70
+ * adapter, and a test passes a frozen or stepped one to make unread-cache expiry, ordering
71
+ * and `timestamp` stamps deterministic. The default is real time.
72
+ */
73
+ interface NotificationClock {
74
+ now(): Date;
75
+ }
76
+ export declare const systemNotificationClock: NotificationClock;
67
77
  /** A feed row the {@link InAppChannel} writes and this resource reads. */
68
78
  interface InAppFeedRow {
69
79
  readonly organizationId: string;
@@ -124,6 +134,18 @@ interface InAppFeedRepository<TRow = unknown> {
124
134
  * the debounce window) from double-toasting the same rows.
125
135
  */
126
136
  claimUndelivered?(organizationId: string, userId: string, limit: number): Promise<TRow[]>;
137
+ /**
138
+ * OPTIONAL — rows created AFTER `since`, oldest first, WITHOUT claiming them. Powers the
139
+ * cursor flavour of reconnect backfill (`onConnect(user, org, { since })`): a client that
140
+ * remembers the time of its last event (SSE `Last-Event-ID`) replays exactly the gap,
141
+ * including rows an earlier connection already received but dropped before rendering.
142
+ */
143
+ listSince?(organizationId: string, userId: string, since: Date, limit: number): Promise<TRow[]>;
144
+ /**
145
+ * OPTIONAL — stamp one row delivered (a live push reached a client), so the next
146
+ * reconnect does not re-send it. `false` when not found / not owned.
147
+ */
148
+ markDelivered?(organizationId: string, userId: string, id: string): Promise<boolean>;
127
149
  /**
128
150
  * OPTIONAL — delete one row (user dismissed it for good); `false` when not
129
151
  * found / not owned. When implemented, the feed resource registers
@@ -168,13 +190,15 @@ interface CreateUnreadCountCacheOptions {
168
190
  readonly ttlMs?: number;
169
191
  /** Entry cap — oldest entries evicted past it (bounds memory). Default 10_000. */
170
192
  readonly maxEntries?: number;
193
+ /** Source of "now" for entry freshness. Default real time. */
194
+ readonly clock?: NotificationClock;
171
195
  }
172
196
  /**
173
197
  * In-process TTL cache for unread counts. Per-process: invalidations don't
174
198
  * cross workers, so a multi-process host either accepts up-to-TTL staleness on
175
199
  * the other workers or brings a Redis-backed {@link UnreadCountCache}.
176
200
  */
177
- declare function createUnreadCountCache(options?: CreateUnreadCountCacheOptions): UnreadCountCache;
201
+ export declare function createUnreadCountCache(options?: CreateUnreadCountCacheOptions): UnreadCountCache;
178
202
  /** The current user + tenant, resolved off a request by the host. */
179
203
  interface InAppFeedScope {
180
204
  readonly userId: string;
@@ -206,10 +230,31 @@ interface InAppFeedResourceOptions {
206
230
  * instance with `InAppChannel` so new-row writes invalidate too.
207
231
  */
208
232
  readonly unreadCache?: UnreadCountCache;
233
+ /**
234
+ * Shape of `GET <prefix>/`. `"native"` (default) answers the port's `{ data, pagination }`
235
+ * page untouched; `"offset"` answers repo-core's canonical `OffsetPaginationResult` (see
236
+ * {@link feedListResult}) for a host whose every other list is that shape.
237
+ */
238
+ readonly listEnvelope?: "native" | "offset";
239
+ /**
240
+ * Also mount `markRead` (`POST /:id/action`) and `markAllRead` (`POST /action`) as arc
241
+ * action-router verbs ({@link FEED_ACTION_VERBS}, camelCase), beside the `PATCH` routes.
242
+ * Same handlers, same cache invalidation and cross-tab read-sync. Default false.
243
+ */
244
+ readonly actions?: boolean;
209
245
  }
210
- declare function inAppFeedResource(options: InAppFeedResourceOptions): import("@classytic/arc").ResourceDefinition<import("@classytic/arc").AnyRecord>;
211
- //#endregion
212
- //#region src/backfill.d.ts
246
+ /** The action-router verbs {@link InAppFeedResourceOptions.actions} mounts. */
247
+ export declare const FEED_ACTION_VERBS: {
248
+ readonly markRead: "markRead";
249
+ readonly markAllRead: "markAllRead";
250
+ };
251
+ /**
252
+ * A feed page in repo-core's canonical offset envelope. Pure and exported so a host that
253
+ * builds its own list route (or asserts the envelope in a test) converts the same way the
254
+ * resource does. `hasNext`/`hasPrev` follow from the page arithmetic the port already did.
255
+ */
256
+ export declare function feedListResult<TRow>(page: InAppFeedPage<TRow>): OffsetPaginationResult<TRow>;
257
+ export declare function inAppFeedResource(options: InAppFeedResourceOptions): import("@classytic/arc").ResourceDefinition<import("@classytic/arc").AnyRecord>;
213
258
  interface InAppBackfillOptions {
214
259
  /** Feed repository — MUST implement the optional `claimUndelivered`. */
215
260
  readonly repository: InAppFeedRepository;
@@ -224,15 +269,18 @@ interface InAppBackfillOptions {
224
269
  /** Claim/push failure sink (backfill is best-effort). Default: swallow. */
225
270
  readonly onError?: (error: unknown) => void;
226
271
  }
272
+ /** Per-connection hints for {@link InAppBackfill.onConnect}. */
273
+ interface InAppBackfillConnect {
274
+ /** Replay rows created after this instant (a `Last-Event-ID` / `since` cursor). */
275
+ readonly since?: Date | string | number | undefined;
276
+ }
227
277
  interface InAppBackfill {
228
278
  /** Call from the host's SSE/WS connection handler. Sync, never throws. */
229
- onConnect(userId: string, organizationId: string): void;
279
+ onConnect(userId: string, organizationId: string, connect?: InAppBackfillConnect): void;
230
280
  /** Cancel pending debounces (graceful shutdown / tests). */
231
281
  close(): void;
232
282
  }
233
- declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
234
- //#endregion
235
- //#region src/triggerOverride.d.ts
283
+ export declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
236
284
  /**
237
285
  * Notification trigger OVERRIDES — the admin-editable half of the registry.
238
286
  *
@@ -299,7 +347,7 @@ declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfi
299
347
  * which a host fills from its integrations registry. A channel nobody registered
300
348
  * is simply not offered, instead of being unrepresentable.
301
349
  */
302
- declare const IN_APP_CHANNEL = "in-app";
350
+ export declare const IN_APP_CHANNEL = "in-app";
303
351
  /**
304
352
  * Open by design — see above. Kept as a named alias rather than inlining
305
353
  * `string` so the intent reads at every call site and a future narrowing has one
@@ -339,6 +387,18 @@ interface NotificationTriggerOverrideDoc {
339
387
  * answer is a host-supplied predicate, never an expression DSL.
340
388
  */
341
389
  sourceChannels?: string[] | null;
390
+ /**
391
+ * Literal addresses to notify IN ADDITION to the role recipients.
392
+ *
393
+ * `recipients` holds ROLES, which only reaches people who hold an account.
394
+ * The person who wants order mail — an owner, an accountant, a shared
395
+ * ops@ inbox — often has no dashboard login at all, so a role list cannot
396
+ * express them.
397
+ *
398
+ * ADDITIVE, never a replacement: adding an address must not silence the staff
399
+ * feed, which is what substituting for `recipients` would do.
400
+ */
401
+ recipientEmails?: string[] | null;
342
402
  /** Copy overrides. Unset fields fall back to the code template. */
343
403
  title?: string | null;
344
404
  message?: string | null;
@@ -378,7 +438,7 @@ interface CreateCachedTriggerOverrideStoreOptions {
378
438
  * any mutation so an operator's change applies within one save rather than one
379
439
  * TTL window.
380
440
  */
381
- declare function createCachedTriggerOverrideStore(load: TriggerOverrideLoader, options?: CreateCachedTriggerOverrideStoreOptions): TriggerOverrideStore & {
441
+ export declare function createCachedTriggerOverrideStore(load: TriggerOverrideLoader, options?: CreateCachedTriggerOverrideStoreOptions): TriggerOverrideStore & {
382
442
  invalidate: () => void;
383
443
  };
384
444
  /**
@@ -395,6 +455,8 @@ interface EffectiveTriggerSettings {
395
455
  channels: NotificationChannel[];
396
456
  /** `null` = unrestricted. See {@link NotificationTriggerOverrideDoc.sourceChannels}. */
397
457
  sourceChannels: string[] | null;
458
+ /** Extra literal addresses, ADDED to the role recipients. `[]` when none. */
459
+ recipientEmails: string[];
398
460
  title: string;
399
461
  message: string;
400
462
  priority?: "low" | "normal" | "high" | undefined;
@@ -405,8 +467,23 @@ interface CodeTriggerDefaults {
405
467
  title: string;
406
468
  message: string;
407
469
  priority?: "low" | "normal" | "high" | undefined;
470
+ /**
471
+ * The trigger's OWN origin restriction — `null`/absent means unrestricted.
472
+ *
473
+ * Every other field here is a code default an operator's row REPLACES. This
474
+ * one was the exception: the merge below hardcoded `?? null`, so a deployment
475
+ * could not state "a new-order alert is for web orders, not for the till" and
476
+ * every deployment shipped unscoped until someone hand-configured the UI.
477
+ *
478
+ * That matters most where it is least visible. A branch runs several
479
+ * registers, and unscoped means every counter sale fans out to every staff
480
+ * role on every one of them — resolved, written to the feed, and attempted on
481
+ * each connector, all before the response. The cashier who rang the sale is
482
+ * already looking at it.
483
+ */
484
+ sourceChannels?: readonly string[] | undefined;
408
485
  }
409
- declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
486
+ export declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
410
487
  /**
411
488
  * Why a notification was not delivered.
412
489
  *
@@ -447,9 +524,7 @@ type NotificationDecision = {
447
524
  * is ever consulted, so an event with no `sourceChannel` under no restriction
448
525
  * still delivers.
449
526
  */
450
- declare function evaluateSourceChannel(sourceChannels: string[] | null | undefined, sourceChannel: string | undefined): NotificationDecision;
451
- //#endregion
452
- //#region src/channelCatalog.d.ts
527
+ export declare function evaluateSourceChannel(sourceChannels: string[] | null | undefined, sourceChannel: string | undefined): NotificationDecision;
453
528
  /**
454
529
  * Which channels a deployment can deliver on.
455
530
  *
@@ -480,9 +555,206 @@ interface NotificationChannelCatalog {
480
555
  * than stored, so a misconfigured provider cannot introduce an unusable ""
481
556
  * channel that validation would then accept.
482
557
  */
483
- declare function createNotificationChannelCatalog(connectorChannels?: readonly string[]): NotificationChannelCatalog;
484
- //#endregion
485
- //#region src/dbFirstResolver.d.ts
558
+ export declare function createNotificationChannelCatalog(connectorChannels?: readonly string[]): NotificationChannelCatalog;
559
+ /** `YYYY-MM-DD`. */
560
+ type CivilDateString = string;
561
+ /** True for a real calendar date in `YYYY-MM-DD` form. */
562
+ export declare function isCivilDateString(value: unknown): value is CivilDateString;
563
+ /** `date` moved by `days` (negative = earlier). */
564
+ export declare function addCivilDays(date: CivilDateString, days: number): CivilDateString;
565
+ /** Whole days from `a` to `b` (`b - a`). */
566
+ export declare function civilDaysBetween(a: CivilDateString, b: CivilDateString): number;
567
+ /** When a rule fires, relative to the item's date. */
568
+ interface DateRuleSchedule {
569
+ /**
570
+ * Days from the date to the FIRST occurrence: negative = before (`-30` = 30 days
571
+ * before), `0` = on the day, positive = after (`1` = the day after: "overdue").
572
+ */
573
+ readonly offsetDays: number;
574
+ /** Fire again every N days after the first occurrence while the item stays open. Omit = once. */
575
+ readonly repeatEveryDays?: number;
576
+ /** Bounds on repetition. Without it a repeating rule runs until the item is no longer open. */
577
+ readonly until?: {
578
+ /** Total occurrences, the first included. */
579
+ readonly occurrences?: number;
580
+ /** No occurrence after `date + offsetDays` (relative to the item's date, like `offsetDays`). */
581
+ readonly offsetDays?: number;
582
+ };
583
+ }
584
+ /**
585
+ * One admin-defined date rule. `TAudience` is the host's audience selector — this
586
+ * package never interprets it; it is handed to the host's `resolveAudience`.
587
+ */
588
+ interface DateRule<TAudience = unknown> {
589
+ /** Stable id: part of every ledger key, so it must not change for the life of the rule. */
590
+ readonly id: string;
591
+ /** Default true. */
592
+ readonly enabled?: boolean;
593
+ /** A registered {@link DateRuleSource} key. */
594
+ readonly source: string;
595
+ /** One of that source's `dateFields`. */
596
+ readonly dateField: string;
597
+ readonly schedule: DateRuleSchedule;
598
+ readonly audience: TAudience;
599
+ /** Restrict to these channel names. Omit = every channel that applies. */
600
+ readonly channels?: readonly string[];
601
+ /**
602
+ * Template id / notification kind (the feed row's type). Default
603
+ * `<source>.<dateField>`. With a template resolver configured on the service it
604
+ * also names the email template.
605
+ */
606
+ readonly template?: string;
607
+ /** In-app copy with `{var}` placeholders resolved against the item's `data`. */
608
+ readonly inApp?: InAppTemplate;
609
+ readonly priority?: InAppPriority;
610
+ /** Scope passed to the source; `null`/absent = every organization the source serves. */
611
+ readonly organizationId?: string | null;
612
+ }
613
+ /** One record a source reports, with the date the rule's `dateField` names. */
614
+ interface DateRuleItem {
615
+ /** Stable record id (part of the ledger key). */
616
+ readonly id: string;
617
+ /** The `dateField` value, `YYYY-MM-DD`. */
618
+ readonly date: CivilDateString;
619
+ /** Template variables (and anything the host's audience resolver needs). */
620
+ readonly data: Record<string, unknown>;
621
+ /** The business document, for the delivery log's per-document history. */
622
+ readonly subject?: {
623
+ readonly sourceModel: string;
624
+ readonly sourceId: string;
625
+ };
626
+ readonly organizationId?: string;
627
+ }
628
+ interface DateRuleSourceQuery {
629
+ readonly dateField: string;
630
+ /** Inclusive lower bound; absent = unbounded (an open-ended repeating rule). */
631
+ readonly from?: CivilDateString;
632
+ /** Inclusive upper bound. */
633
+ readonly to: CivilDateString;
634
+ readonly organizationId?: string | null;
635
+ }
636
+ /**
637
+ * A host record type that carries dates. Registered by the host (one per record
638
+ * type); the admin screen lists them through {@link DateRuleEngine.catalog}.
639
+ */
640
+ interface DateRuleSource {
641
+ readonly key: string;
642
+ readonly label?: string;
643
+ readonly dateFields: ReadonlyArray<{
644
+ readonly key: string;
645
+ readonly label?: string;
646
+ }>;
647
+ /** Template variables items carry, as a hint for rule authors (`{employeeName}`). */
648
+ readonly variables?: readonly string[];
649
+ /**
650
+ * OPEN items whose `dateField` lies in `[from, to]`. "Open" is the source's call:
651
+ * an item that no longer needs reminding must not be returned.
652
+ */
653
+ find(query: DateRuleSourceQuery): Promise<readonly DateRuleItem[]> | AsyncIterable<DateRuleItem>;
654
+ }
655
+ type DateRuleAudienceResolver<TAudience = unknown> = (audience: TAudience, context: {
656
+ readonly rule: DateRule<TAudience>;
657
+ readonly item: DateRuleItem;
658
+ readonly occurrence: DateRuleOccurrence;
659
+ }) => readonly Recipient[] | null | undefined | Promise<readonly Recipient[] | null | undefined>;
660
+ /**
661
+ * "Has this occurrence fired?" — claimed BEFORE dispatch, released on failure so the
662
+ * next sweep retries. Back it with a unique index in production; the memory ledger
663
+ * is for tests and single-process dev.
664
+ */
665
+ interface DateRuleLedger {
666
+ /** `true` when this call claimed the key (first time), `false` when already claimed. */
667
+ claim(key: string, info: DateRuleLedgerEntry): Promise<boolean>;
668
+ /** Undo a claim whose dispatch failed. */
669
+ release(key: string): Promise<void>;
670
+ }
671
+ interface DateRuleLedgerEntry {
672
+ readonly ruleId: string;
673
+ readonly itemId: string;
674
+ readonly occurrenceOn: CivilDateString;
675
+ readonly index: number;
676
+ readonly sweptOn: CivilDateString;
677
+ }
678
+ export declare function createMemoryDateRuleLedger(): DateRuleLedger & {
679
+ keys(): string[];
680
+ };
681
+ interface DateRuleOccurrence {
682
+ /** 0-based occurrence index. */
683
+ readonly index: number;
684
+ /** The civil date this occurrence is scheduled for. */
685
+ readonly on: CivilDateString;
686
+ }
687
+ /** Problems with a schedule, as messages; empty = valid. */
688
+ export declare function validateDateRuleSchedule(schedule: DateRuleSchedule): string[];
689
+ /**
690
+ * The occurrence of `schedule` for an item dated `date` that a sweep on `today`
691
+ * should send, or `null`. The latest occurrence on or before `today` is due when it
692
+ * is at most `graceDays` old — so a missed sweep catches up, but a rule created
693
+ * today does not replay years of history.
694
+ */
695
+ export declare function dueOccurrence(schedule: DateRuleSchedule, date: CivilDateString, today: CivilDateString, graceDays?: number): DateRuleOccurrence | null;
696
+ /** The `[from, to]` range of item dates a sweep on `today` must ask a source for. */
697
+ export declare function dateRuleWindow(schedule: DateRuleSchedule, today: CivilDateString, graceDays?: number): {
698
+ from?: CivilDateString;
699
+ to: CivilDateString;
700
+ };
701
+ interface DateRuleEngineOptions<TAudience = unknown> {
702
+ readonly sources: Iterable<DateRuleSource>;
703
+ /** The rules to evaluate (admin-created rows); disabled rows are skipped. */
704
+ readonly rules: () => readonly DateRule<TAudience>[] | Promise<readonly DateRule<TAudience>[]>;
705
+ readonly resolveAudience: DateRuleAudienceResolver<TAudience>;
706
+ /** The notification service (read per sweep, so a module export can back it). */
707
+ readonly service: () => NotificationService;
708
+ /** Default: an in-memory ledger (single process, lost on restart). */
709
+ readonly ledger?: DateRuleLedger;
710
+ /** How many days late an occurrence may still be sent (missed sweeps). Default 0. */
711
+ readonly graceDays?: number;
712
+ /** Extra send data per item (e.g. email `subject`/`text` rendered from the rule's copy). */
713
+ readonly data?: (rule: DateRule<TAudience>, item: DateRuleItem) => Record<string, unknown>;
714
+ /** A rule or item failed; the sweep continues. */
715
+ readonly onError?: (err: unknown, context: {
716
+ readonly ruleId: string;
717
+ readonly itemId?: string;
718
+ }) => void;
719
+ }
720
+ interface DateRuleSweepResult {
721
+ readonly date: CivilDateString;
722
+ /** Enabled rules evaluated. */
723
+ readonly rules: number;
724
+ /** Items whose occurrence was due. */
725
+ readonly due: number;
726
+ /** Occurrences dispatched by this sweep. */
727
+ readonly fired: number;
728
+ /** Occurrences already claimed (an earlier sweep, another replica). */
729
+ readonly duplicates: number;
730
+ /** Occurrences with nobody to tell. */
731
+ readonly noRecipients: number;
732
+ readonly failed: number;
733
+ }
734
+ interface DateRuleCatalogEntry {
735
+ readonly key: string;
736
+ readonly label: string;
737
+ readonly dateFields: ReadonlyArray<{
738
+ readonly key: string;
739
+ readonly label: string;
740
+ }>;
741
+ readonly variables: readonly string[];
742
+ }
743
+ interface DateRuleEngine<TAudience = unknown> {
744
+ /** Evaluate every enabled rule for the civil date `today`. */
745
+ sweep(today: CivilDateString): Promise<DateRuleSweepResult>;
746
+ /** Items a rule would notify on `today` (no claims, no sends): the admin "preview". */
747
+ preview(rule: DateRule<TAudience>, today: CivilDateString): Promise<Array<DateRuleItem & {
748
+ occurrence: DateRuleOccurrence;
749
+ }>>;
750
+ /** Registered sources for the rule editor. */
751
+ catalog(): DateRuleCatalogEntry[];
752
+ /** Problems with a rule against the registered sources, as messages; empty = valid. */
753
+ validate(rule: Pick<DateRule<TAudience>, "source" | "dateField" | "schedule">): string[];
754
+ }
755
+ /** Ledger key of one occurrence. */
756
+ export declare const dateRuleOccurrenceKey: (ruleId: string, itemId: string, on: CivilDateString) => string;
757
+ export declare function createDateRuleEngine<TAudience = unknown>(options: DateRuleEngineOptions<TAudience>): DateRuleEngine<TAudience>;
486
758
  /** One draftable template row — the shape be-prod's EmailTemplate model proved. */
487
759
  interface StoredTemplate {
488
760
  readonly key: string;
@@ -518,9 +790,7 @@ interface DbFirstTemplateResolver {
518
790
  /** Drop one key (after a draft is saved) or the whole cache. */
519
791
  invalidate(key?: string): void;
520
792
  }
521
- declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
522
- //#endregion
523
- //#region src/inAppChannel.d.ts
793
+ export declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
524
794
  interface InAppChannelConfig extends ChannelConfig {
525
795
  /** Feed store the channel writes rows to (host-owned persistence). */
526
796
  readonly repository: InAppFeedRepository;
@@ -542,7 +812,7 @@ interface InAppChannelConfig extends ChannelConfig {
542
812
  */
543
813
  readonly unreadCache?: UnreadCountCache;
544
814
  }
545
- declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
815
+ export declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
546
816
  private readonly repository;
547
817
  private readonly port;
548
818
  private readonly eventName;
@@ -553,8 +823,6 @@ declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
553
823
  send(payload: NotificationPayload): Promise<SendResult>;
554
824
  private orgOf;
555
825
  }
556
- //#endregion
557
- //#region src/jobsQueue.d.ts
558
826
  interface NotificationsJobOptions {
559
827
  /** Job/queue name registered with `jobsPlugin`. Default "notifications". */
560
828
  readonly name?: string;
@@ -590,9 +858,7 @@ interface NotificationsJob {
590
858
  * `enqueue`/`getJob`/`size` through `fastify.jobs`, keying `enqueue` on
591
859
  * `idempotencyKey` so crash-recovery / at-least-once redelivery dedupes.
592
860
  */
593
- declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
594
- //#endregion
595
- //#region src/module.d.ts
861
+ export declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
596
862
  /**
597
863
  * Resolve a trigger's host-typed `audience` selector to concrete recipients —
598
864
  * the shared seam so branch-role → members (or team → users, tenant → admins)
@@ -699,13 +965,23 @@ declare module "fastify" {
699
965
  notifications?: NotificationService;
700
966
  }
701
967
  }
702
- declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
968
+ export declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
969
+ /**
970
+ * Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
971
+ * the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
972
+ * `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
973
+ */
974
+ export declare function notifyTriggerHandlers(options: {
975
+ /** Handler-name prefix; arc rejects duplicate handler names across the module graph. */
976
+ readonly name: string;
977
+ readonly service: () => NotificationService;
978
+ readonly triggers: readonly NotifyTrigger[];
979
+ readonly recipientResolver?: RecipientResolver;
980
+ }): EventHandlerDefinition[];
703
981
  /** One event → N sends. Exported for direct unit testing. */
704
- declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
982
+ export declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
705
983
  recipientResolver?: RecipientResolver;
706
984
  }): Promise<void>;
707
- //#endregion
708
- //#region src/repositoryDeliveryLog.d.ts
709
985
  type EntryStatus = DeliveryLogEntry["status"];
710
986
  /** The persisted document shape (also what the resource/UI reads). */
711
987
  interface DeliveryLogDoc {
@@ -719,6 +995,11 @@ interface DeliveryLogDoc {
719
995
  readonly status: EntryStatus;
720
996
  readonly duration: number;
721
997
  readonly metadata?: Record<string, unknown>;
998
+ /**
999
+ * The organization the delivery belongs to — top-level so a store can index it and a
1000
+ * reader can scope on it. `null` = company-wide (no organization named).
1001
+ */
1002
+ readonly organizationId?: string | null;
722
1003
  /**
723
1004
  * The business document this delivery concerned. A STORE MUST INDEX IT —
724
1005
  * this field exists precisely so "every message about invoice X" is a keyed
@@ -728,21 +1009,54 @@ interface DeliveryLogDoc {
728
1009
  readonly sourceModel: string;
729
1010
  readonly sourceId: string;
730
1011
  };
1012
+ /**
1013
+ * The authored rule (an admin-defined trigger) that produced this delivery, when there
1014
+ * is one. A top-level column — not a `metadata` key — so a store can index it and
1015
+ * {@link RepositoryDeliveryLog.listByRule} is a keyed read.
1016
+ */
1017
+ readonly ruleId?: string;
1018
+ /** Host-typed columns stored verbatim beside the package's own. Never read by the package. */
1019
+ readonly extraFields?: Record<string, unknown>;
731
1020
  }
732
- interface RepositoryDeliveryLogOptions {
1021
+ interface RepositoryDeliveryLogOptions<TExtra extends Record<string, unknown> = Record<string, unknown>> {
733
1022
  /** Cap for `query()` when the caller gives no limit. Default 100. */
734
1023
  readonly defaultLimit?: number;
1024
+ /** Source of the entry `timestamp`. Default real time. */
1025
+ readonly clock?: NotificationClock;
1026
+ /**
1027
+ * Which authored rule a send belongs to. Default: `payload.metadata.ruleId` when it is a
1028
+ * non-empty string. A host whose rule id rides elsewhere (an event name such as
1029
+ * `notify.rule.<id>`) supplies its own resolver.
1030
+ */
1031
+ readonly ruleId?: (payload: NotificationPayload) => string | undefined;
1032
+ /** Host-typed columns to persist on the entry (`DeliveryLogDoc.extraFields`). */
1033
+ readonly extraFields?: (payload: NotificationPayload, dispatch: DispatchResult) => TExtra | undefined;
1034
+ }
1035
+ /** A delivery entry with the rule and host columns the plain `DeliveryLogEntry` has no slot for. */
1036
+ type RuleDeliveryLogEntry<TExtra extends Record<string, unknown> = Record<string, unknown>> = DeliveryLogEntry & {
1037
+ readonly ruleId?: string;
1038
+ readonly extraFields?: TExtra;
1039
+ };
1040
+ interface DeliveryLogByRuleQuery {
1041
+ /** Cap on rows returned. Falls back to the log's `defaultLimit`. */
1042
+ readonly limit?: number;
1043
+ readonly status?: EntryStatus;
1044
+ readonly after?: Date;
1045
+ readonly before?: Date;
1046
+ }
1047
+ /** The kernel `DeliveryLog` plus the per-rule read. Assignable wherever a `DeliveryLog` is. */
1048
+ interface RepositoryDeliveryLog<TExtra extends Record<string, unknown> = Record<string, unknown>> extends DeliveryLog {
1049
+ /** Newest-first deliveries of ONE authored rule. */
1050
+ listByRule(ruleId: string, query?: DeliveryLogByRuleQuery): Promise<RuleDeliveryLogEntry<TExtra>[]>;
735
1051
  }
736
- declare function createRepositoryDeliveryLog(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions): DeliveryLog;
737
- //#endregion
738
- //#region src/sseChannel.d.ts
1052
+ export declare function createRepositoryDeliveryLog<TExtra extends Record<string, unknown> = Record<string, unknown>>(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions<TExtra>): RepositoryDeliveryLog<TExtra>;
739
1053
  interface SseChannelConfig extends ChannelConfig {
740
1054
  /** Host realtime transport (be-prod's SSE manager, a WS hub, ...). */
741
1055
  readonly port: RealtimePushPort;
742
1056
  /** SSE event name pushed to the client. Default "notification". */
743
1057
  readonly eventName?: string;
744
1058
  }
745
- declare class SseChannel extends BaseChannel<SseChannelConfig> {
1059
+ export declare class SseChannel extends BaseChannel<SseChannelConfig> {
746
1060
  private readonly port;
747
1061
  private readonly eventName;
748
1062
  constructor(config: SseChannelConfig);
@@ -750,8 +1064,6 @@ declare class SseChannel extends BaseChannel<SseChannelConfig> {
750
1064
  send(payload: NotificationPayload): Promise<SendResult>;
751
1065
  private orgOf;
752
1066
  }
753
- //#endregion
754
- //#region src/templatesResource.d.ts
755
1067
  /** Code-defined template map (same shape `createSimpleResolver` accepts). */
756
1068
  type CodeTemplates = Record<string, {
757
1069
  readonly subject?: string;
@@ -778,9 +1090,7 @@ interface TemplatesResourceOptions {
778
1090
  /** Tenant field. Default `false` — templates are company-wide (be-prod convention). */
779
1091
  readonly tenantField?: string | false;
780
1092
  }
781
- declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
782
- //#endregion
783
- //#region src/triggerAuthoring.d.ts
1093
+ export declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
784
1094
  /** Who a trigger targets. */
785
1095
  type NotificationAudienceType = "staff" | "customer";
786
1096
  /** Direct contact for a customer-facing notification. */
@@ -872,6 +1182,24 @@ interface NotificationTrigger {
872
1182
  * boot.
873
1183
  */
874
1184
  channels?: readonly NotificationChannel[];
1185
+ /**
1186
+ * Restrict this trigger to events ORIGINATING from these sources — the
1187
+ * deployment's own sales channels (`web` / `pos` / `marketplace`), NOT the
1188
+ * delivery channels above. Two adjacent fields, two vocabularies.
1189
+ *
1190
+ * Absent = unrestricted, which is the historical behaviour and stays the
1191
+ * default. An operator's stored setting REPLACES this, so declaring one
1192
+ * narrows the out-of-the-box noise without taking the choice away.
1193
+ *
1194
+ * State it for any staff alert whose event can originate at a till: a branch
1195
+ * runs several registers, and unscoped means every counter sale is resolved,
1196
+ * written to the feed and attempted on every connector — before the response —
1197
+ * to tell staff about a sale the cashier is already looking at.
1198
+ *
1199
+ * Never `[]`. Empty means "match nothing", which silences the trigger while
1200
+ * looking like a filter; `enabled: false` is how you say that.
1201
+ */
1202
+ sourceChannels?: readonly string[];
875
1203
  /**
876
1204
  * Pull dispatch variables out of the event payload.
877
1205
  *
@@ -882,8 +1210,6 @@ interface NotificationTrigger {
882
1210
  */
883
1211
  extract: (payload: Record<string, any>) => NotificationExtractResult | null;
884
1212
  }
885
- //#endregion
886
- //#region src/triggerAdapter.d.ts
887
1213
  /**
888
1214
  * `entityType` → the `extract` variable holding that entity's id.
889
1215
  *
@@ -891,7 +1217,7 @@ interface NotificationTrigger {
891
1217
  * the variable it lives under is named per entity. This is the default
892
1218
  * commerce mapping; a host with its own entity kinds passes `entityIdVars`.
893
1219
  */
894
- declare const DEFAULT_ENTITY_ID_VARS: Record<string, string>;
1220
+ export declare const DEFAULT_ENTITY_ID_VARS: Record<string, string>;
895
1221
  interface TriggerAdapterOptions {
896
1222
  /**
897
1223
  * Extra `entityType` → id-variable mappings, merged over the defaults.
@@ -987,14 +1313,14 @@ interface TriggerOverrideResolution {
987
1313
  * calls both per dispatch, so an `extract` with side effects would run them
988
1314
  * twice — keep extractors pure (every one in practice is a payload reshape).
989
1315
  */
990
- declare function toNotifyTrigger(t: NotificationTrigger, options?: TriggerAdapterOptions): NotifyTrigger;
1316
+ export declare function toNotifyTrigger(t: NotificationTrigger, options?: TriggerAdapterOptions): NotifyTrigger;
991
1317
  /**
992
1318
  * Adapt a whole registry — what a host assigns to the module's `notifyOn`.
993
1319
  *
994
1320
  * With `options.overrides`, each trigger becomes one entry per channel, gated on
995
1321
  * the admin's stored settings. Without it, the mapping is 1:1 and unchanged.
996
1322
  */
997
- declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], options?: TriggerAdapterOptions): NotifyTrigger[];
1323
+ export declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], options?: TriggerAdapterOptions): NotifyTrigger[];
998
1324
  /**
999
1325
  * First trigger registered for an event.
1000
1326
  *
@@ -1003,20 +1329,18 @@ declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], opti
1003
1329
  * returns only the first. For anything that must see all of them — a docs page,
1004
1330
  * an admin matrix, a dispatch count — use `getTriggersByEvent`.
1005
1331
  */
1006
- declare function getTriggerByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger | undefined;
1332
+ export declare function getTriggerByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger | undefined;
1007
1333
  /** Every trigger registered for an event, in registration order. */
1008
- declare function getTriggersByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger[];
1334
+ export declare function getTriggersByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger[];
1009
1335
  /** Distinct event names the registry subscribes to (for docs / admin UI). */
1010
- declare function getRegisteredEvents(triggers: readonly NotificationTrigger[]): string[];
1336
+ export declare function getRegisteredEvents(triggers: readonly NotificationTrigger[]): string[];
1011
1337
  /**
1012
1338
  * Notification kind → recipient roles (for the admin UI).
1013
1339
  *
1014
1340
  * Keyed by `type ?? event`, so two triggers sharing an event stay distinct
1015
1341
  * rows — which is exactly why they carry different `type`s.
1016
1342
  */
1017
- declare function getRecipientMatrix(triggers: readonly NotificationTrigger[]): Record<string, string[]>;
1018
- //#endregion
1019
- //#region src/triggerCatalog.d.ts
1343
+ export declare function getRecipientMatrix(triggers: readonly NotificationTrigger[]): Record<string, string[]>;
1020
1344
  /**
1021
1345
  * One trigger as an operator sees it, with the CODE defaults an override replaces.
1022
1346
  *
@@ -1057,5 +1381,4 @@ interface TriggerCatalogEntry {
1057
1381
  * exists", which is the silent-empty failure this file exists to avoid.
1058
1382
  */
1059
1383
  type TriggerCatalogPort = () => ReadonlyArray<TriggerCatalogEntry> | Promise<ReadonlyArray<TriggerCatalogEntry>>;
1060
- //#endregion
1061
- export { type CodeTemplates, type CodeTriggerDefaults, type CreateCachedTriggerOverrideStoreOptions, type CreateUnreadCountCacheOptions, DEFAULT_ENTITY_ID_VARS, type DbFirstResolverOptions, type DbFirstTemplateResolver, type DeliveryLogDoc, type EffectiveTriggerSettings, INAPP_DATA_KEY, IN_APP_CHANNEL, type InAppBackfill, type InAppBackfillOptions, InAppChannel, type InAppChannelConfig, type InAppContent, type InAppFeedListOptions, type InAppFeedPage, type InAppFeedRepository, type InAppFeedResourceOptions, type InAppFeedRow, type InAppFeedScope, type InAppPriority, type InAppTemplate, type NotificationAudienceType, type NotificationChannel, type NotificationChannelCatalog, type NotificationDecision, type NotificationExtractResult, type NotificationRecipientContact, type NotificationSkipReason, type NotificationTrigger, type NotificationTriggerOverrideDoc, type NotificationsJob, type NotificationsJobOptions, type NotificationsModuleConfig, type NotifyTrigger, type RealtimePushPort, type RecipientResolver, type RepositoryDeliveryLogOptions, SseChannel, type SseChannelConfig, type StoredTemplate, type TemplateStore, type TemplatesResourceOptions, type TriggerAdapterOptions, type TriggerCatalogEntry, type TriggerCatalogPort, type TriggerOverrideLoader, type TriggerOverrideResolution, type TriggerOverrideStore, type UnreadCountCache, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, evaluateSourceChannel, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
1384
+ export type { CivilDateString, CodeTemplates, CodeTriggerDefaults, CreateCachedTriggerOverrideStoreOptions, CreateUnreadCountCacheOptions, DateRule, DateRuleAudienceResolver, DateRuleCatalogEntry, DateRuleEngine, DateRuleEngineOptions, DateRuleItem, DateRuleLedger, DateRuleLedgerEntry, DateRuleOccurrence, DateRuleSchedule, DateRuleSource, DateRuleSourceQuery, DateRuleSweepResult, DbFirstResolverOptions, DbFirstTemplateResolver, DeliveryLogByRuleQuery, DeliveryLogDoc, EffectiveTriggerSettings, InAppBackfill, InAppBackfillConnect, InAppBackfillOptions, InAppChannelConfig, InAppContent, InAppFeedListOptions, InAppFeedPage, InAppFeedRepository, InAppFeedResourceOptions, InAppFeedRow, InAppFeedScope, InAppPriority, InAppTemplate, NotificationAudienceType, NotificationChannel, NotificationChannelCatalog, NotificationClock, NotificationDecision, NotificationExtractResult, NotificationRecipientContact, NotificationSkipReason, NotificationTrigger, NotificationTriggerOverrideDoc, NotificationsJob, NotificationsJobOptions, NotificationsModuleConfig, NotifyTrigger, RealtimePushPort, RecipientResolver, RepositoryDeliveryLog, RepositoryDeliveryLogOptions, RuleDeliveryLogEntry, SseChannelConfig, StoredTemplate, TemplateStore, TemplatesResourceOptions, TriggerAdapterOptions, TriggerCatalogEntry, TriggerCatalogPort, TriggerOverrideLoader, TriggerOverrideResolution, TriggerOverrideStore, UnreadCountCache };