@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 +66 -2
- package/dist/index.mjs +184 -48
- package/package.json +5 -5
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"@classytic/notifications": ">=2.
|
|
34
|
-
"@classytic/primitives": ">=0.
|
|
35
|
-
"@classytic/repo-core": ">=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",
|