@classytic/arc-notifications 0.8.0 → 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
@@ -164,11 +164,36 @@ function resolveEffectiveSettings(triggerType, organizationId, rows, defaults) {
164
164
  const title = pick("title") ?? defaults.title;
165
165
  const message = pick("message") ?? defaults.message;
166
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;
167
191
  return {
168
192
  enabled,
169
193
  recipients,
194
+ recipientEmails: pick("recipientEmails") ?? [],
170
195
  channels,
171
- sourceChannels: pick("sourceChannels") ?? null,
196
+ sourceChannels,
172
197
  title,
173
198
  message,
174
199
  ...priority ? { priority } : {}
@@ -838,18 +863,31 @@ function notificationsModule(config) {
838
863
  return service;
839
864
  },
840
865
  eventHandlers: (fastify) => {
841
- const triggers = notifyOn.filter((t) => t.enabled !== false);
842
- if (triggers.length === 0) return [];
843
- fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
844
- return triggers.map((trigger, index) => ({
845
- name: `${name}:${trigger.event}#${index}`,
846
- event: trigger.event,
847
- boundary: true,
848
- handler: async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} })
849
- }));
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;
850
874
  }
851
875
  });
852
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
+ }
853
891
  const audienceSlot = Symbol("arc-notifications.resolvedAudience");
854
892
  /** Content key for an audience — key order must not fragment the memo. */
855
893
  function audienceKey(audience) {
@@ -875,13 +913,21 @@ function resolveAudience(resolver, audience, event) {
875
913
  created.catch(() => memo.delete(key));
876
914
  return created;
877
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
+ }
878
922
  /** One event → N sends. Exported for direct unit testing. */
879
923
  async function dispatchTrigger(service, trigger, event, opts) {
880
924
  let resolved;
925
+ let organizationId;
881
926
  if (trigger.recipient) resolved = await trigger.recipient(event);
882
927
  else if (trigger.audience) {
883
928
  const audience = await trigger.audience(event);
884
929
  if (audience === null || audience === void 0) return;
930
+ organizationId = audienceOrganization(audience);
885
931
  resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
886
932
  }
887
933
  if (!resolved) return;
@@ -908,6 +954,7 @@ async function dispatchTrigger(service, trigger, event, opts) {
908
954
  metadata: {
909
955
  source: "arc-notifications",
910
956
  eventId: event.meta.id,
957
+ ...organizationId !== void 0 ? { organizationId } : {},
911
958
  /**
912
959
  * The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
913
960
  *
@@ -947,9 +994,17 @@ function toEntry(doc) {
947
994
  status: doc.status,
948
995
  duration: doc.duration ?? 0,
949
996
  ...doc.metadata ? { metadata: doc.metadata } : {},
950
- ...doc.subject ? { subject: { ...doc.subject } } : {}
997
+ ...doc.subject ? { subject: { ...doc.subject } } : {},
998
+ ...doc.organizationId ? { organizationId: doc.organizationId } : {}
951
999
  };
952
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
+ }
953
1008
  function createRepositoryDeliveryLog(repository, options = {}) {
954
1009
  const { defaultLimit = 100 } = options;
955
1010
  return {
@@ -964,7 +1019,8 @@ function createRepositoryDeliveryLog(repository, options = {}) {
964
1019
  status: deriveStatus(dispatch),
965
1020
  duration: dispatch.duration,
966
1021
  ...payload.metadata ? { metadata: payload.metadata } : {},
967
- ...payload.subject ? { subject: payload.subject } : {}
1022
+ ...payload.subject ? { subject: payload.subject } : {},
1023
+ organizationId: organizationOf(payload)
968
1024
  };
969
1025
  await repository.create(doc);
970
1026
  },
@@ -1398,7 +1454,8 @@ function toGatedNotifyTriggers(t, options, overrides) {
1398
1454
  channels: declaredChannels(t),
1399
1455
  title: t.template.title,
1400
1456
  message: t.template.message,
1401
- ...t.priority ? { priority: t.priority } : {}
1457
+ ...t.priority ? { priority: t.priority } : {},
1458
+ ...t.sourceChannels ? { sourceChannels: t.sourceChannels } : {}
1402
1459
  };
1403
1460
  /**
1404
1461
  * The effective settings resolve ONCE per event per trigger, shared by every
@@ -1473,10 +1530,20 @@ function toGatedNotifyTriggers(t, options, overrides) {
1473
1530
  organizationId: ex.organizationId
1474
1531
  };
1475
1532
  }
1476
- 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");
1477
1543
  return {
1478
1544
  roles: eff.recipients,
1479
1545
  organizationId: ex.organizationId,
1546
+ ...eff.recipientEmails.length > 0 ? { recipientEmails: eff.recipientEmails } : {},
1480
1547
  ...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
1481
1548
  };
1482
1549
  }
@@ -1524,4 +1591,4 @@ function getRecipientMatrix(triggers) {
1524
1591
  return matrix;
1525
1592
  }
1526
1593
  //#endregion
1527
- export { DEFAULT_ENTITY_ID_VARS, INAPP_DATA_KEY, IN_APP_CHANNEL, InAppChannel, SseChannel, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, evaluateSourceChannel, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
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.8.0",
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.40.0",
33
- "@classytic/notifications": ">=2.5.0",
34
- "@classytic/primitives": ">=0.27.0",
35
- "@classytic/repo-core": ">=0.27.1"
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",