@classytic/arc-notifications 0.6.3 → 0.9.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,6 +1,6 @@
1
1
  import { BaseChannel, ChannelConfig, DeliveryLog, DeliveryLogEntry, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
2
2
  import { JobDefinition, JobDispatcher } from "@classytic/arc/integrations/jobs";
3
- import { ArcModule } from "@classytic/arc/factory";
3
+ import { ArcModule, EventHandlerDefinition } from "@classytic/arc/factory";
4
4
  import { PermissionCheck } from "@classytic/arc/permissions";
5
5
  import { FastifyInstance, FastifyRequest } from "fastify";
6
6
  import { DomainEvent } from "@classytic/primitives/events";
@@ -339,6 +339,18 @@ interface NotificationTriggerOverrideDoc {
339
339
  * answer is a host-supplied predicate, never an expression DSL.
340
340
  */
341
341
  sourceChannels?: string[] | null;
342
+ /**
343
+ * Literal addresses to notify IN ADDITION to the role recipients.
344
+ *
345
+ * `recipients` holds ROLES, which only reaches people who hold an account.
346
+ * The person who wants order mail — an owner, an accountant, a shared
347
+ * ops@ inbox — often has no dashboard login at all, so a role list cannot
348
+ * express them.
349
+ *
350
+ * ADDITIVE, never a replacement: adding an address must not silence the staff
351
+ * feed, which is what substituting for `recipients` would do.
352
+ */
353
+ recipientEmails?: string[] | null;
342
354
  /** Copy overrides. Unset fields fall back to the code template. */
343
355
  title?: string | null;
344
356
  message?: string | null;
@@ -395,6 +407,8 @@ interface EffectiveTriggerSettings {
395
407
  channels: NotificationChannel[];
396
408
  /** `null` = unrestricted. See {@link NotificationTriggerOverrideDoc.sourceChannels}. */
397
409
  sourceChannels: string[] | null;
410
+ /** Extra literal addresses, ADDED to the role recipients. `[]` when none. */
411
+ recipientEmails: string[];
398
412
  title: string;
399
413
  message: string;
400
414
  priority?: "low" | "normal" | "high" | undefined;
@@ -405,6 +419,21 @@ interface CodeTriggerDefaults {
405
419
  title: string;
406
420
  message: string;
407
421
  priority?: "low" | "normal" | "high" | undefined;
422
+ /**
423
+ * The trigger's OWN origin restriction — `null`/absent means unrestricted.
424
+ *
425
+ * Every other field here is a code default an operator's row REPLACES. This
426
+ * one was the exception: the merge below hardcoded `?? null`, so a deployment
427
+ * could not state "a new-order alert is for web orders, not for the till" and
428
+ * every deployment shipped unscoped until someone hand-configured the UI.
429
+ *
430
+ * That matters most where it is least visible. A branch runs several
431
+ * registers, and unscoped means every counter sale fans out to every staff
432
+ * role on every one of them — resolved, written to the feed, and attempted on
433
+ * each connector, all before the response. The cashier who rang the sale is
434
+ * already looking at it.
435
+ */
436
+ sourceChannels?: readonly string[] | undefined;
408
437
  }
409
438
  declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
410
439
  /**
@@ -700,6 +729,18 @@ declare module "fastify" {
700
729
  }
701
730
  }
702
731
  declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
732
+ /**
733
+ * Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
734
+ * the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
735
+ * `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
736
+ */
737
+ declare function notifyTriggerHandlers(options: {
738
+ /** Handler-name prefix; arc rejects duplicate handler names across the module graph. */
739
+ readonly name: string;
740
+ readonly service: () => NotificationService;
741
+ readonly triggers: readonly NotifyTrigger[];
742
+ readonly recipientResolver?: RecipientResolver;
743
+ }): EventHandlerDefinition[];
703
744
  /** One event → N sends. Exported for direct unit testing. */
704
745
  declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
705
746
  recipientResolver?: RecipientResolver;
@@ -719,6 +760,11 @@ interface DeliveryLogDoc {
719
760
  readonly status: EntryStatus;
720
761
  readonly duration: number;
721
762
  readonly metadata?: Record<string, unknown>;
763
+ /**
764
+ * The organization the delivery belongs to — top-level so a store can index it and a
765
+ * reader can scope on it. `null` = company-wide (no organization named).
766
+ */
767
+ readonly organizationId?: string | null;
722
768
  /**
723
769
  * The business document this delivery concerned. A STORE MUST INDEX IT —
724
770
  * this field exists precisely so "every message about invoice X" is a keyed
@@ -872,6 +918,24 @@ interface NotificationTrigger {
872
918
  * boot.
873
919
  */
874
920
  channels?: readonly NotificationChannel[];
921
+ /**
922
+ * Restrict this trigger to events ORIGINATING from these sources — the
923
+ * deployment's own sales channels (`web` / `pos` / `marketplace`), NOT the
924
+ * delivery channels above. Two adjacent fields, two vocabularies.
925
+ *
926
+ * Absent = unrestricted, which is the historical behaviour and stays the
927
+ * default. An operator's stored setting REPLACES this, so declaring one
928
+ * narrows the out-of-the-box noise without taking the choice away.
929
+ *
930
+ * State it for any staff alert whose event can originate at a till: a branch
931
+ * runs several registers, and unscoped means every counter sale is resolved,
932
+ * written to the feed and attempted on every connector — before the response —
933
+ * to tell staff about a sale the cashier is already looking at.
934
+ *
935
+ * Never `[]`. Empty means "match nothing", which silences the trigger while
936
+ * looking like a filter; `enabled: false` is how you say that.
937
+ */
938
+ sourceChannels?: readonly string[];
875
939
  /**
876
940
  * Pull dispatch variables out of the event payload.
877
941
  *
@@ -1058,4 +1122,4 @@ interface TriggerCatalogEntry {
1058
1122
  */
1059
1123
  type TriggerCatalogPort = () => ReadonlyArray<TriggerCatalogEntry> | Promise<ReadonlyArray<TriggerCatalogEntry>>;
1060
1124
  //#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 };
1125
+ 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, notifyTriggerHandlers, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
package/dist/index.mjs CHANGED
@@ -1,7 +1,9 @@
1
+ import { createTtlMemo } from "@classytic/repo-core/cache";
1
2
  import { BaseChannel, NotificationService, createSimpleResolver } from "@classytic/notifications";
2
3
  import { defineResource } from "@classytic/arc";
3
4
  import { NotFoundError } from "@classytic/arc/utils";
4
5
  import { defineJob } from "@classytic/arc/integrations/jobs";
6
+ import { scopedValue } from "@classytic/arc/context";
5
7
  import { defineModule } from "@classytic/arc/factory";
6
8
  //#region src/backfill.ts
7
9
  function createInAppBackfill(options) {
@@ -114,23 +116,26 @@ const IN_APP_CHANNEL = "in-app";
114
116
  * TTL window.
115
117
  */
116
118
  function createCachedTriggerOverrideStore(load, options = {}) {
117
- const ttlMs = options.ttlMs ?? 3e4;
118
- const cache = /* @__PURE__ */ new Map();
119
+ /**
120
+ * `createTtlMemo`, never a hand-rolled map: the map wrote its entry AFTER
121
+ * the await, so the N channel expansions a parallel host dispatches together
122
+ * all missed. Single flight is what makes the TTL mean anything here.
123
+ */
124
+ const memo = createTtlMemo(load, {
125
+ ttlMs: options.ttlMs ?? 3e4,
126
+ keyOf: (organizationId) => organizationId ?? "__company__"
127
+ });
119
128
  return {
120
- async forOrganization(organizationId) {
121
- const key = organizationId ?? "__company__";
122
- const hit = cache.get(key);
123
- if (hit && Date.now() - hit.at < ttlMs) return hit.rows;
124
- const rows = await load(organizationId);
125
- cache.set(key, {
126
- at: Date.now(),
127
- rows
128
- });
129
- return rows;
129
+ forOrganization(organizationId) {
130
+ return memo.get(organizationId);
130
131
  },
131
- /** Call after a write so an admin sees their change immediately. */
132
+ /**
133
+ * Call after a write so an admin sees their change immediately. The
134
+ * in-process clear completes before this returns; the promise it wraps
135
+ * only exists to satisfy the adapter contract.
136
+ */
132
137
  invalidate() {
133
- cache.clear();
138
+ memo.invalidate();
134
139
  }
135
140
  };
136
141
  }
@@ -159,11 +164,36 @@ function resolveEffectiveSettings(triggerType, organizationId, rows, defaults) {
159
164
  const title = pick("title") ?? defaults.title;
160
165
  const message = pick("message") ?? defaults.message;
161
166
  const priority = pick("priority") ?? defaults.priority;
167
+ /**
168
+ * Operator row, then the trigger's own default, then unrestricted — the same
169
+ * ladder every field above uses. It used to stop at `?? null`, discarding a
170
+ * code default that therefore could not be written at all.
171
+ *
172
+ * `?? null`, never `?? []` — the two must stay distinguishable all the way
173
+ * through. `null` means unrestricted; `[]` would mean "match nothing", and
174
+ * collapsing one into the other is exactly the widening this field prevents.
175
+ * A `null` stored by an operator is a deliberate "every source", and it wins
176
+ * over the code default precisely because they set it.
177
+ */
178
+ /**
179
+ * PRESENCE, not `??` — the one field where a stored `null` is a decision.
180
+ *
181
+ * `pick` collapses `null` into "absent", which is right for every other
182
+ * setting and wrong here: `null` means "every source", and an operator who
183
+ * opens a filter must not have it re-narrowed by the code default on the next
184
+ * read. That is Rule 1 — a default is the fallback for an UNSET value, never
185
+ * an override of an explicit one.
186
+ *
187
+ * Scoped row wins over app-wide, same precedence as `pick`.
188
+ */
189
+ const rowWithField = [scoped, appWide].find((r) => r && "sourceChannels" in r);
190
+ const sourceChannels = rowWithField ? rowWithField.sourceChannels ?? null : defaults.sourceChannels ? [...defaults.sourceChannels] : null;
162
191
  return {
163
192
  enabled,
164
193
  recipients,
194
+ recipientEmails: pick("recipientEmails") ?? [],
165
195
  channels,
166
- sourceChannels: pick("sourceChannels") ?? null,
196
+ sourceChannels,
167
197
  title,
168
198
  message,
169
199
  ...priority ? { priority } : {}
@@ -277,23 +307,16 @@ function createNotificationChannelCatalog(connectorChannels = []) {
277
307
  */
278
308
  function createDbFirstResolver(options) {
279
309
  const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
280
- const cache = /* @__PURE__ */ new Map();
281
- const lookup = async (key) => {
282
- if (cacheTtlMs > 0) {
283
- const hit = cache.get(key);
284
- if (hit && Date.now() - hit.at < cacheTtlMs) return hit.doc;
285
- }
286
- const doc = await store.getByKey(key);
287
- if (cacheTtlMs > 0) cache.set(key, {
288
- doc,
289
- at: Date.now()
290
- });
291
- return doc;
292
- };
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 });
293
316
  const resolver = (async (templateId, data) => {
294
317
  let doc = null;
295
318
  try {
296
- doc = await lookup(templateId);
319
+ doc = await lookup.get(templateId);
297
320
  } catch (err) {
298
321
  onError?.(err, templateId);
299
322
  if (!fallback) throw err;
@@ -308,8 +331,7 @@ function createDbFirstResolver(options) {
308
331
  return fallback(templateId, data);
309
332
  });
310
333
  resolver.invalidate = (key) => {
311
- if (key === void 0) cache.clear();
312
- else cache.delete(key);
334
+ lookup.invalidate(key);
313
335
  };
314
336
  return resolver;
315
337
  }
@@ -841,26 +863,72 @@ function notificationsModule(config) {
841
863
  return service;
842
864
  },
843
865
  eventHandlers: (fastify) => {
844
- const triggers = notifyOn.filter((t) => t.enabled !== false);
845
- if (triggers.length === 0) return [];
846
- fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
847
- return triggers.map((trigger, index) => ({
848
- name: `${name}:${trigger.event}#${index}`,
849
- event: trigger.event,
850
- boundary: true,
851
- handler: async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} })
852
- }));
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;
853
874
  }
854
875
  });
855
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
+ }
856
922
  /** One event → N sends. Exported for direct unit testing. */
857
923
  async function dispatchTrigger(service, trigger, event, opts) {
858
924
  let resolved;
925
+ let organizationId;
859
926
  if (trigger.recipient) resolved = await trigger.recipient(event);
860
927
  else if (trigger.audience) {
861
928
  const audience = await trigger.audience(event);
862
929
  if (audience === null || audience === void 0) return;
863
- resolved = opts?.recipientResolver ? await opts.recipientResolver(audience, event) : void 0;
930
+ organizationId = audienceOrganization(audience);
931
+ resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
864
932
  }
865
933
  if (!resolved) return;
866
934
  const recipients = Array.isArray(resolved) ? resolved : [resolved];
@@ -886,6 +954,7 @@ async function dispatchTrigger(service, trigger, event, opts) {
886
954
  metadata: {
887
955
  source: "arc-notifications",
888
956
  eventId: event.meta.id,
957
+ ...organizationId !== void 0 ? { organizationId } : {},
889
958
  /**
890
959
  * The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
891
960
  *
@@ -925,9 +994,17 @@ function toEntry(doc) {
925
994
  status: doc.status,
926
995
  duration: doc.duration ?? 0,
927
996
  ...doc.metadata ? { metadata: doc.metadata } : {},
928
- ...doc.subject ? { subject: { ...doc.subject } } : {}
997
+ ...doc.subject ? { subject: { ...doc.subject } } : {},
998
+ ...doc.organizationId ? { organizationId: doc.organizationId } : {}
929
999
  };
930
1000
  }
1001
+ /** The payload's organization: its own metadata first, then the recipient's resolver stamp. */
1002
+ function organizationOf(payload) {
1003
+ const fromPayload = payload.metadata?.organizationId;
1004
+ if (typeof fromPayload === "string" && fromPayload !== "") return fromPayload;
1005
+ const fromRecipient = payload.recipient.metadata?.organizationId;
1006
+ return typeof fromRecipient === "string" && fromRecipient !== "" ? fromRecipient : null;
1007
+ }
931
1008
  function createRepositoryDeliveryLog(repository, options = {}) {
932
1009
  const { defaultLimit = 100 } = options;
933
1010
  return {
@@ -942,7 +1019,8 @@ function createRepositoryDeliveryLog(repository, options = {}) {
942
1019
  status: deriveStatus(dispatch),
943
1020
  duration: dispatch.duration,
944
1021
  ...payload.metadata ? { metadata: payload.metadata } : {},
945
- ...payload.subject ? { subject: payload.subject } : {}
1022
+ ...payload.subject ? { subject: payload.subject } : {},
1023
+ organizationId: organizationOf(payload)
946
1024
  };
947
1025
  await repository.create(doc);
948
1026
  },
@@ -1154,6 +1232,33 @@ function templatesResource(options) {
1154
1232
  //#endregion
1155
1233
  //#region src/triggerAdapter.ts
1156
1234
  /**
1235
+ * Trigger registry → `notifyOn` config.
1236
+ *
1237
+ * The host writes a flat, readable registry of `NotificationTrigger`s (what
1238
+ * fires, who gets it, what it says). The module wants `NotifyTrigger`s
1239
+ * (channels, audience resolver, data resolver, in-app block). This is the
1240
+ * conversion, and it is identical in every host — only the registry DATA differs.
1241
+ *
1242
+ * Moved here from `@spinekit/core/notifications` (2026-08-14) so it returns the
1243
+ * REAL `NotifyTrigger` instead of a hand-maintained structural mirror.
1244
+ *
1245
+ * ## What is BAKED IN
1246
+ * - customer-audience triggers get NO in-app channel. Buyers are not dashboard
1247
+ * users; a feed row for them is a row nobody can ever read.
1248
+ * - a trigger's `channels` list appends those channels unconditionally.
1249
+ * Hub-backed channels no-op when no provider is connected, so naming a
1250
+ * channel is always safe — the provider's presence IS the gate. Channel
1251
+ * names are OPEN: this package never enumerates the product's connectors.
1252
+ * - `extract` returning `null` yields a `null` audience, which the module
1253
+ * treats as "skip". This is how a trigger filters a broad event.
1254
+ * - a customer trigger whose `extract` returns no `recipient` also skips,
1255
+ * rather than dispatching to nobody.
1256
+ *
1257
+ * ## What stays HOST
1258
+ * - the registry itself (business copy, roles, which events matter)
1259
+ * - any entity kinds beyond the defaults, via `entityIdVars`
1260
+ */
1261
+ /**
1157
1262
  * `entityType` → the `extract` variable holding that entity's id.
1158
1263
  *
1159
1264
  * The in-app row wants an `entityId` for deep-linking and badge grouping, but
@@ -1349,7 +1454,29 @@ function toGatedNotifyTriggers(t, options, overrides) {
1349
1454
  channels: declaredChannels(t),
1350
1455
  title: t.template.title,
1351
1456
  message: t.template.message,
1352
- ...t.priority ? { priority: t.priority } : {}
1457
+ ...t.priority ? { priority: t.priority } : {},
1458
+ ...t.sourceChannels ? { sourceChannels: t.sourceChannels } : {}
1459
+ };
1460
+ /**
1461
+ * The effective settings resolve ONCE per event per trigger, shared by every
1462
+ * channel expansion below, memoised on the work scope arc enters per
1463
+ * dispatch. The slot is per TRIGGER (a fresh symbol per call), so two
1464
+ * triggers on one event never share a resolution even when they share a
1465
+ * type. Outside a scope there is nothing to share — one read per expansion,
1466
+ * slower, never wrong.
1467
+ */
1468
+ const slot = Symbol("arc-notifications.effectiveSettings");
1469
+ const effectiveFor = (organizationId) => {
1470
+ const load = async () => resolveEffectiveSettings(type, organizationId, await overrides.store.forOrganization(organizationId), defaults);
1471
+ const memo = scopedValue(slot, () => /* @__PURE__ */ new Map());
1472
+ if (!memo) return load();
1473
+ const key = organizationId ?? "__company__";
1474
+ const pending = memo.get(key);
1475
+ if (pending) return pending;
1476
+ const created = load();
1477
+ memo.set(key, created);
1478
+ created.catch(() => memo.delete(key));
1479
+ return created;
1353
1480
  };
1354
1481
  return candidateChannels(t, options.catalog).map((channel) => ({
1355
1482
  ...base,
@@ -1357,8 +1484,7 @@ function toGatedNotifyTriggers(t, options, overrides) {
1357
1484
  audience: async (e) => {
1358
1485
  const ex = t.extract(e.payload ?? {});
1359
1486
  if (!ex) return null;
1360
- const rows = await overrides.store.forOrganization(ex.organizationId ?? null);
1361
- const eff = resolveEffectiveSettings(type, ex.organizationId ?? null, rows, defaults);
1487
+ const eff = await effectiveFor(ex.organizationId ?? null);
1362
1488
  /**
1363
1489
  * Report a skip so it is COUNTABLE, then skip.
1364
1490
  *
@@ -1404,10 +1530,20 @@ function toGatedNotifyTriggers(t, options, overrides) {
1404
1530
  organizationId: ex.organizationId
1405
1531
  };
1406
1532
  }
1407
- if (eff.recipients.length === 0) return skip("no_recipients");
1533
+ /**
1534
+ * An operator who cleared the list means nobody — skip rather than
1535
+ * dispatching to an empty audience the resolver would widen.
1536
+ *
1537
+ * Literal addresses count as an audience. Someone who wants order mail at
1538
+ * `ops@` and nowhere else clears the roles and types the address; treating
1539
+ * that as "no recipients" would silence exactly the configuration they
1540
+ * just built, and report it as their own doing.
1541
+ */
1542
+ if (eff.recipients.length === 0 && eff.recipientEmails.length === 0) return skip("no_recipients");
1408
1543
  return {
1409
1544
  roles: eff.recipients,
1410
1545
  organizationId: ex.organizationId,
1546
+ ...eff.recipientEmails.length > 0 ? { recipientEmails: eff.recipientEmails } : {},
1411
1547
  ...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
1412
1548
  };
1413
1549
  }
@@ -1455,4 +1591,4 @@ function getRecipientMatrix(triggers) {
1455
1591
  return matrix;
1456
1592
  }
1457
1593
  //#endregion
1458
- 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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-notifications",
3
- "version": "0.6.3",
3
+ "version": "0.9.0",
4
4
  "description": "Notifications module for @classytic/arc — domain events → multi-channel notifications via @classytic/notifications, with DB-draftable templates, repo-backed delivery log, and a templates resource",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,10 +29,10 @@
29
29
  "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
30
30
  },
31
31
  "peerDependencies": {
32
- "@classytic/arc": ">=2.38.0",
33
- "@classytic/notifications": ">=2.5.0",
34
- "@classytic/primitives": ">=0.26.4",
35
- "@classytic/repo-core": ">=0.26.0"
32
+ "@classytic/arc": ">=2.43.0",
33
+ "@classytic/notifications": ">=2.6.0",
34
+ "@classytic/primitives": ">=0.31.0",
35
+ "@classytic/repo-core": ">=0.31.0"
36
36
  },
37
37
  "keywords": [
38
38
  "arc",