@classytic/arc-notifications 0.3.0 → 0.5.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/LICENSE +21 -0
- package/README.md +4 -0
- package/dist/index.d.mts +587 -3
- package/dist/index.mjs +607 -39
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { BaseChannel, NotificationService, createSimpleResolver } from "@classyt
|
|
|
2
2
|
import { defineResource } from "@classytic/arc";
|
|
3
3
|
import { NotFoundError } from "@classytic/arc/utils";
|
|
4
4
|
import { defineJob } from "@classytic/arc/integrations/jobs";
|
|
5
|
-
import { subscribeWithBoundary } from "@classytic/arc/events";
|
|
6
5
|
import { defineModule } from "@classytic/arc/factory";
|
|
7
6
|
//#region src/backfill.ts
|
|
8
7
|
function createInAppBackfill(options) {
|
|
@@ -39,6 +38,233 @@ function createInAppBackfill(options) {
|
|
|
39
38
|
};
|
|
40
39
|
}
|
|
41
40
|
//#endregion
|
|
41
|
+
//#region src/triggerOverride.ts
|
|
42
|
+
/**
|
|
43
|
+
* Notification trigger OVERRIDES — the admin-editable half of the registry.
|
|
44
|
+
*
|
|
45
|
+
* ## The split, and why it is not "make triggers DB rows"
|
|
46
|
+
*
|
|
47
|
+
* A `NotificationTrigger` carries two very different kinds of thing:
|
|
48
|
+
*
|
|
49
|
+
* | stays CODE | becomes DATA |
|
|
50
|
+
* |---|---|
|
|
51
|
+
* | `event` — what to subscribe to | `enabled` — does this fire here at all |
|
|
52
|
+
* | `extract` — a typed function: filters the event and pulls variables | `recipients` — which roles get told |
|
|
53
|
+
* | | `channels` — in-app / email / whatsapp / sms |
|
|
54
|
+
* | | `template` — the copy an operator wants to reword |
|
|
55
|
+
*
|
|
56
|
+
* `extract` is the reason the whole registry cannot become rows. It decides
|
|
57
|
+
* whether a broad event is even relevant (`chargeStatus === 'failed'`) and maps
|
|
58
|
+
* a payload to variables — expressing that as data means shipping an evaluator,
|
|
59
|
+
* which is a code-execution surface in the notification path. Odoo draws the
|
|
60
|
+
* same line: `base.automation` rows hold the trigger and the filter, while the
|
|
61
|
+
* action BODY is Python in `ir.actions.server`.
|
|
62
|
+
*
|
|
63
|
+
* So the code registry is the CATALOG (what this deployment is capable of
|
|
64
|
+
* announcing) and these rows are the CONFIGURATION (what a given scope has
|
|
65
|
+
* actually turned on, to whom, over which channel).
|
|
66
|
+
*
|
|
67
|
+
* ## Scoped per ORGANIZATION, with an app-wide fallback
|
|
68
|
+
*
|
|
69
|
+
* `organizationId: null` is the app-wide default; a row with an organization id
|
|
70
|
+
* overrides it for that scope only. Absence of a row means "use the code
|
|
71
|
+
* default" — a fresh deployment behaves exactly as its registry declares, and
|
|
72
|
+
* turning the feature on requires an operator to actually decide something.
|
|
73
|
+
*
|
|
74
|
+
* ## Kit-less by design
|
|
75
|
+
*
|
|
76
|
+
* This package carries NO database dependency (see `repositoryDeliveryLog` /
|
|
77
|
+
* `templatesResource` — the same posture). This file owns the ROW SHAPE, the
|
|
78
|
+
* store PORT, the TTL-cached store wrapper over a load port, and the effective-
|
|
79
|
+
* settings RESOLUTION. The persistence binding (a mongoose model and its
|
|
80
|
+
* indexes, in the product layer) supplies `load`.
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* A channel is a NAME, not a member of a closed set.
|
|
84
|
+
*
|
|
85
|
+
* ## Why this is open, and was wrong closed
|
|
86
|
+
*
|
|
87
|
+
* This was `["in-app", "email", "whatsapp", "sms"] as const`, so the framework
|
|
88
|
+
* enumerated every channel any product could ever deliver on. Adding Telegram —
|
|
89
|
+
* a provider the `@classytic/social` kernel already implements and the
|
|
90
|
+
* integrations hub can already register at runtime — required editing this
|
|
91
|
+
* framework package, its authoring shape, its adapter, then rebuilding and
|
|
92
|
+
* re-copying it into every host. An integration that is DATA everywhere else
|
|
93
|
+
* (registered provider → catalog entry → credential form → connection row) hit
|
|
94
|
+
* one hardcoded array and became a code change.
|
|
95
|
+
*
|
|
96
|
+
* That is a policy literal in the framework layer: `arc-notifications` is the
|
|
97
|
+
* generic MECHANISM (dispatch an event to channels) and has no business knowing
|
|
98
|
+
* which channels a product sells. The product layer owns the catalog.
|
|
99
|
+
*
|
|
100
|
+
* `in-app` is the ONE exception and stays named, because the framework itself
|
|
101
|
+
* implements it (`InAppChannel` writes the dashboard feed) — it is a mechanism,
|
|
102
|
+
* not a connector.
|
|
103
|
+
*
|
|
104
|
+
* The available set now arrives as DATA: `TriggerAdapterOptions.availableChannels`,
|
|
105
|
+
* which a host fills from its integrations registry. A channel nobody registered
|
|
106
|
+
* is simply not offered, instead of being unrepresentable.
|
|
107
|
+
*/
|
|
108
|
+
const IN_APP_CHANNEL = "in-app";
|
|
109
|
+
/**
|
|
110
|
+
* TTL-cached {@link TriggerOverrideStore} over a load port.
|
|
111
|
+
*
|
|
112
|
+
* `invalidate()` exists for the write path: the admin resource calls it after
|
|
113
|
+
* any mutation so an operator's change applies within one save rather than one
|
|
114
|
+
* TTL window.
|
|
115
|
+
*/
|
|
116
|
+
function createCachedTriggerOverrideStore(load, options = {}) {
|
|
117
|
+
const ttlMs = options.ttlMs ?? 3e4;
|
|
118
|
+
const cache = /* @__PURE__ */ new Map();
|
|
119
|
+
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;
|
|
130
|
+
},
|
|
131
|
+
/** Call after a write so an admin sees their change immediately. */
|
|
132
|
+
invalidate() {
|
|
133
|
+
cache.clear();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function resolveEffectiveSettings(triggerType, organizationId, rows, defaults) {
|
|
138
|
+
const mine = rows.filter((r) => r.triggerType === triggerType);
|
|
139
|
+
const scoped = organizationId ? mine.find((r) => r.organizationId === organizationId) : void 0;
|
|
140
|
+
const appWide = mine.find((r) => r.organizationId == null);
|
|
141
|
+
/**
|
|
142
|
+
* `??` throughout, and the field it actually protects is `enabled`.
|
|
143
|
+
*
|
|
144
|
+
* An earlier version of this comment claimed `||` would swallow an EMPTY
|
|
145
|
+
* recipients array. It would not — `[]` is truthy in JS — and a falsification
|
|
146
|
+
* proved it: swapping the operator on `recipients` changed nothing. The real
|
|
147
|
+
* hazard is `enabled: false`, where `false || true` silently re-enables a
|
|
148
|
+
* trigger an operator deliberately switched off. That one IS falsifiable, and
|
|
149
|
+
* the trigger-override test fails when the operator is swapped.
|
|
150
|
+
*
|
|
151
|
+
* Recorded rather than quietly corrected: a comment asserting a guarantee the
|
|
152
|
+
* code does not have is the same defect as a guard that cannot fail, and the
|
|
153
|
+
* next reader would have "protected" the wrong line.
|
|
154
|
+
*/
|
|
155
|
+
const pick = (key) => scoped?.[key] ?? appWide?.[key] ?? void 0;
|
|
156
|
+
const enabled = pick("enabled") ?? true;
|
|
157
|
+
const recipients = pick("recipients") ?? defaults.recipients;
|
|
158
|
+
const channels = pick("channels") ?? defaults.channels;
|
|
159
|
+
const title = pick("title") ?? defaults.title;
|
|
160
|
+
const message = pick("message") ?? defaults.message;
|
|
161
|
+
const priority = pick("priority") ?? defaults.priority;
|
|
162
|
+
return {
|
|
163
|
+
enabled,
|
|
164
|
+
recipients,
|
|
165
|
+
channels,
|
|
166
|
+
sourceChannels: pick("sourceChannels") ?? null,
|
|
167
|
+
title,
|
|
168
|
+
message,
|
|
169
|
+
...priority ? { priority } : {}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Does this event's origin satisfy the trigger's source-channel restriction?
|
|
174
|
+
*
|
|
175
|
+
* Three outcomes, all named — the whole point of this function.
|
|
176
|
+
*
|
|
177
|
+
* ## Why `unknown` SKIPS rather than delivers
|
|
178
|
+
*
|
|
179
|
+
* An earlier design delivered when the event carried no `sourceChannel`, citing
|
|
180
|
+
* "absence of an answer is not a negative answer". That misreads the rule: it
|
|
181
|
+
* forbids treating unknown as a NEGATIVE, it does not license treating it as a
|
|
182
|
+
* POSITIVE. Against a restrictive filter, delivering an event of unknown origin
|
|
183
|
+
* silently violates an operator who explicitly selected `['web']` — a general
|
|
184
|
+
* default overriding a specific instruction, which is the defect class this
|
|
185
|
+
* codebase exists to design against.
|
|
186
|
+
*
|
|
187
|
+
* The cost of each mistake is not symmetric either. Deliver-on-unknown leaks
|
|
188
|
+
* alerts an operator switched off and cannot see; skip-on-unknown withholds an
|
|
189
|
+
* alert AND emits `source_channel_unknown`, which is visible. Prefer the failure
|
|
190
|
+
* that reports itself.
|
|
191
|
+
*
|
|
192
|
+
* Unrestricted triggers are unaffected: `null` short-circuits before the origin
|
|
193
|
+
* is ever consulted, so an event with no `sourceChannel` under no restriction
|
|
194
|
+
* still delivers.
|
|
195
|
+
*/
|
|
196
|
+
function evaluateSourceChannel(sourceChannels, sourceChannel) {
|
|
197
|
+
if (sourceChannels == null) return { decision: "deliver" };
|
|
198
|
+
if (!sourceChannel) return {
|
|
199
|
+
decision: "skip",
|
|
200
|
+
reason: "source_channel_unknown"
|
|
201
|
+
};
|
|
202
|
+
return sourceChannels.includes(sourceChannel) ? { decision: "deliver" } : {
|
|
203
|
+
decision: "skip",
|
|
204
|
+
reason: "source_channel_mismatch"
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/channelCatalog.ts
|
|
209
|
+
/**
|
|
210
|
+
* The NOTIFICATION CHANNEL CATALOG — which channels this deployment can deliver on.
|
|
211
|
+
*
|
|
212
|
+
* ## Why this exists as one concept
|
|
213
|
+
*
|
|
214
|
+
* Three places need the same answer and each used to hardcode its own list:
|
|
215
|
+
*
|
|
216
|
+
* | place | old form | what it broke |
|
|
217
|
+
* |---|---|---|
|
|
218
|
+
* | trigger adapter | `["email","whatsapp","sms"]` | a registered provider was never offered in trigger settings |
|
|
219
|
+
* | override API schema | `enum: [...NOTIFICATION_CHANNELS]` | saving an unlisted channel returned 400 |
|
|
220
|
+
* | override mongoose schema | `enum: [...]` | and the write was rejected too |
|
|
221
|
+
*
|
|
222
|
+
* So a connector could exist in the integrations hub, appear in its catalog,
|
|
223
|
+
* accept credentials — and still be impossible to enable on a notification,
|
|
224
|
+
* because three separate literals disagreed with the registry. Adding one meant
|
|
225
|
+
* editing a framework package, a product schema and a database enum, then
|
|
226
|
+
* rebuilding and re-copying both packages into every host.
|
|
227
|
+
*
|
|
228
|
+
* A catalog is DATA. The deployment that registers providers is the only thing
|
|
229
|
+
* that knows which exist, so it builds this once and hands it to everything that
|
|
230
|
+
* asks. Adding a channel becomes: register the provider. Nothing else.
|
|
231
|
+
*
|
|
232
|
+
* ## What this is NOT
|
|
233
|
+
*
|
|
234
|
+
* Not a registry of HOW to deliver — that is the channel implementation, which
|
|
235
|
+
* lives in the product layer (`@spinekit/integrations` builds hub-backed
|
|
236
|
+
* channels from connected providers). This answers only "is `telegram` a thing
|
|
237
|
+
* here, and what is the full set?", which is what validation and UI need.
|
|
238
|
+
*
|
|
239
|
+
* ## Why validation still exists
|
|
240
|
+
*
|
|
241
|
+
* Dropping the enums without replacing them would let a typo (`whatapp`) save
|
|
242
|
+
* cleanly and then silently never deliver — the exact failure mode the closed
|
|
243
|
+
* union was clumsily preventing. So the check moved to the BOUNDARY, where the
|
|
244
|
+
* live catalog is known, rather than being deleted.
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* Build a catalog from the connector channel names a deployment has registered.
|
|
248
|
+
*
|
|
249
|
+
* `in-app` is added automatically and cannot be omitted — the framework
|
|
250
|
+
* implements that channel itself (`InAppChannel` writes the dashboard feed), so
|
|
251
|
+
* it is always deliverable and is not a connector a host could fail to register.
|
|
252
|
+
*
|
|
253
|
+
* @param connectorChannels service names from the host's integrations registry
|
|
254
|
+
* (`'email'`, `'whatsapp'`, `'telegram'`, …). Blank entries are dropped rather
|
|
255
|
+
* than stored, so a misconfigured provider cannot introduce an unusable ""
|
|
256
|
+
* channel that validation would then accept.
|
|
257
|
+
*/
|
|
258
|
+
function createNotificationChannelCatalog(connectorChannels = []) {
|
|
259
|
+
const names = [IN_APP_CHANNEL, ...connectorChannels.map((c) => c.trim()).filter((c) => c.length > 0)];
|
|
260
|
+
const unique = [...new Set(names)];
|
|
261
|
+
const lookup = new Set(unique);
|
|
262
|
+
return {
|
|
263
|
+
list: () => unique,
|
|
264
|
+
has: (channel) => lookup.has(channel)
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
//#endregion
|
|
42
268
|
//#region src/dbFirstResolver.ts
|
|
43
269
|
/**
|
|
44
270
|
* DB-first template resolver — generalized from be-prod's proven
|
|
@@ -108,7 +334,7 @@ function createDbFirstResolver(options) {
|
|
|
108
334
|
/** Reserved `payload.data` key carrying the rendered in-app content. */
|
|
109
335
|
const INAPP_DATA_KEY = "__inApp";
|
|
110
336
|
/** `{var}` interpolation — unknown keys are left as-is (visible, not silently blanked). */
|
|
111
|
-
function interpolate(template, vars) {
|
|
337
|
+
function interpolate$1(template, vars) {
|
|
112
338
|
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
113
339
|
const v = vars[key];
|
|
114
340
|
return v === void 0 || v === null ? `{${key}}` : String(v);
|
|
@@ -117,11 +343,11 @@ function interpolate(template, vars) {
|
|
|
117
343
|
/** Render a trigger's in-app template against event data + priority. */
|
|
118
344
|
function renderInApp(template, vars, priority = "normal") {
|
|
119
345
|
return {
|
|
120
|
-
title: interpolate(template.title, vars),
|
|
121
|
-
message: interpolate(template.message, vars),
|
|
122
|
-
...template.link !== void 0 ? { link: interpolate(template.link, vars) } : {},
|
|
123
|
-
...template.entityType !== void 0 ? { entityType: interpolate(template.entityType, vars) } : {},
|
|
124
|
-
...template.entityId !== void 0 ? { entityId: interpolate(template.entityId, vars) } : {},
|
|
346
|
+
title: interpolate$1(template.title, vars),
|
|
347
|
+
message: interpolate$1(template.message, vars),
|
|
348
|
+
...template.link !== void 0 ? { link: interpolate$1(template.link, vars) } : {},
|
|
349
|
+
...template.entityType !== void 0 ? { entityType: interpolate$1(template.entityType, vars) } : {},
|
|
350
|
+
...template.entityId !== void 0 ? { entityId: interpolate$1(template.entityId, vars) } : {},
|
|
125
351
|
priority
|
|
126
352
|
};
|
|
127
353
|
}
|
|
@@ -303,7 +529,6 @@ function inAppFeedResource(options) {
|
|
|
303
529
|
method: "GET",
|
|
304
530
|
path: "/",
|
|
305
531
|
permissions,
|
|
306
|
-
raw: true,
|
|
307
532
|
summary: "List my notifications (newest first, paginated)",
|
|
308
533
|
schema: { querystring: {
|
|
309
534
|
type: "object",
|
|
@@ -325,7 +550,7 @@ function inAppFeedResource(options) {
|
|
|
325
550
|
},
|
|
326
551
|
additionalProperties: true
|
|
327
552
|
} },
|
|
328
|
-
|
|
553
|
+
rawHandler: async (req, reply) => {
|
|
329
554
|
const { userId, organizationId } = resolveScope(req);
|
|
330
555
|
const q = req.query ?? {};
|
|
331
556
|
const result = await repository.listForUser(organizationId, userId, {
|
|
@@ -341,9 +566,8 @@ function inAppFeedResource(options) {
|
|
|
341
566
|
method: "GET",
|
|
342
567
|
path: "/unread-count",
|
|
343
568
|
permissions,
|
|
344
|
-
raw: true,
|
|
345
569
|
summary: "Unread notification count (badge)",
|
|
346
|
-
|
|
570
|
+
rawHandler: async (req, reply) => {
|
|
347
571
|
const { userId, organizationId } = resolveScope(req);
|
|
348
572
|
if (unreadCache) {
|
|
349
573
|
const cached = await unreadCache.get(organizationId, userId);
|
|
@@ -360,9 +584,8 @@ function inAppFeedResource(options) {
|
|
|
360
584
|
method: "PATCH",
|
|
361
585
|
path: "/:id/read",
|
|
362
586
|
permissions,
|
|
363
|
-
raw: true,
|
|
364
587
|
summary: "Mark a single notification read",
|
|
365
|
-
|
|
588
|
+
rawHandler: async (req, reply) => {
|
|
366
589
|
const scope = resolveScope(req);
|
|
367
590
|
const id = req.params.id;
|
|
368
591
|
const updated = await repository.markRead(scope.organizationId, scope.userId, id);
|
|
@@ -375,9 +598,8 @@ function inAppFeedResource(options) {
|
|
|
375
598
|
method: "PATCH",
|
|
376
599
|
path: "/read-all",
|
|
377
600
|
permissions,
|
|
378
|
-
raw: true,
|
|
379
601
|
summary: "Mark all my notifications read",
|
|
380
|
-
|
|
602
|
+
rawHandler: async (req, reply) => {
|
|
381
603
|
const scope = resolveScope(req);
|
|
382
604
|
const modifiedCount = await repository.markAllRead(scope.organizationId, scope.userId);
|
|
383
605
|
await afterReadStateChange(scope, {
|
|
@@ -387,13 +609,27 @@ function inAppFeedResource(options) {
|
|
|
387
609
|
return reply.send({ modifiedCount });
|
|
388
610
|
}
|
|
389
611
|
},
|
|
612
|
+
...typeof repository.deleteRead === "function" ? [{
|
|
613
|
+
method: "DELETE",
|
|
614
|
+
path: "/read",
|
|
615
|
+
permissions,
|
|
616
|
+
summary: "Delete all my already-read notifications",
|
|
617
|
+
rawHandler: async (_req, reply) => {
|
|
618
|
+
const scope = resolveScope(_req);
|
|
619
|
+
const deletedCount = await repository.deleteRead(scope.organizationId, scope.userId);
|
|
620
|
+
await afterReadStateChange(scope, {
|
|
621
|
+
deletedCount,
|
|
622
|
+
clearedRead: true
|
|
623
|
+
});
|
|
624
|
+
return reply.send({ deletedCount });
|
|
625
|
+
}
|
|
626
|
+
}] : [],
|
|
390
627
|
...typeof repository.delete === "function" ? [{
|
|
391
628
|
method: "DELETE",
|
|
392
629
|
path: "/:id",
|
|
393
630
|
permissions,
|
|
394
|
-
raw: true,
|
|
395
631
|
summary: "Delete (dismiss) a notification",
|
|
396
|
-
|
|
632
|
+
rawHandler: async (req, reply) => {
|
|
397
633
|
const scope = resolveScope(req);
|
|
398
634
|
const id = req.params.id;
|
|
399
635
|
if (!await repository.delete(scope.organizationId, scope.userId, id)) throw new NotFoundError("Notification");
|
|
@@ -487,7 +723,7 @@ function notificationsJob(options = {}) {
|
|
|
487
723
|
ref.processor = processor;
|
|
488
724
|
},
|
|
489
725
|
async getJob(id) {
|
|
490
|
-
const status = await dispatcher.getStatus(id);
|
|
726
|
+
const status = await dispatcher.getStatus(name, id);
|
|
491
727
|
if (!status) return null;
|
|
492
728
|
const created = new Date(status.timestamp ?? Date.now());
|
|
493
729
|
return {
|
|
@@ -550,41 +786,45 @@ function notificationsJob(options = {}) {
|
|
|
550
786
|
* const svc = getModuleExports<NotificationService>(app, "notifications");
|
|
551
787
|
* ```
|
|
552
788
|
*
|
|
553
|
-
* Triggers
|
|
554
|
-
*
|
|
789
|
+
* Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
|
|
790
|
+
* arc owns subscription ordering AND teardown: it unsubscribes every handler at
|
|
791
|
+
* shutdown BEFORE any module `onClose` runs, while the service and its queue
|
|
792
|
+
* are still alive. A hand-rolled `afterResources` subscribe cannot promise that
|
|
793
|
+
* — its own `onClose` races the modules it depends on.
|
|
794
|
+
*
|
|
795
|
+
* Each handler declares `boundary: true`, so arc contains failures — logged and
|
|
796
|
+
* swallowed (fire-and-forget, matching arc's event gotcha), because a template
|
|
797
|
+
* or channel failure must not block the transport ack and DLQ the event. And
|
|
555
798
|
* `idempotencyKey` defaults from event identity because Redis Streams are
|
|
556
799
|
* at-least-once: redelivery must not double-send.
|
|
557
800
|
*/
|
|
558
801
|
function notificationsModule(config) {
|
|
559
|
-
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, ...serviceConfig } = config;
|
|
802
|
+
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
|
|
560
803
|
let service;
|
|
561
|
-
const unsubscribes = [];
|
|
562
804
|
return defineModule({
|
|
563
805
|
name,
|
|
564
|
-
bootstrap(fastify) {
|
|
806
|
+
async bootstrap(fastify) {
|
|
565
807
|
const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
|
|
808
|
+
const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
|
|
566
809
|
service = provided ?? new NotificationService({
|
|
567
810
|
...serviceConfig,
|
|
811
|
+
...resolvedChannels ? { channels: resolvedChannels } : {},
|
|
568
812
|
...resolvedQueue ? { queue: resolvedQueue } : {}
|
|
569
813
|
});
|
|
570
814
|
if (!resolvedQueue && !provided && notifyOn.some((t) => t.enabled !== false)) fastify.log.warn(`[arc-notifications] module "${name}" wires notifyOn trigger(s) with NO durable queue — sends run on the in-memory queue and are lost on restart / not shared across instances. Wire \`queue: (f) => notificationsJob(...).toQueueAdapter(f.jobs)\` (with arc's jobsPlugin) for durability.`);
|
|
571
815
|
if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
|
|
572
816
|
return service;
|
|
573
817
|
},
|
|
574
|
-
|
|
818
|
+
eventHandlers: (fastify) => {
|
|
575
819
|
const triggers = notifyOn.filter((t) => t.enabled !== false);
|
|
576
|
-
if (triggers.length === 0) return;
|
|
577
|
-
const bus = fastify;
|
|
578
|
-
if (!bus.events) throw new Error(`[arc-notifications] module "${name}" declares ${triggers.length} notifyOn trigger(s) but \`fastify.events\` is not decorated — register arc's eventPlugin (or drop notifyOn).`);
|
|
579
|
-
for (const trigger of triggers) {
|
|
580
|
-
const unsubscribe = await subscribeWithBoundary(bus, trigger.event, async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} }), { name: `arc-notifications:${trigger.event}` });
|
|
581
|
-
unsubscribes.push(unsubscribe);
|
|
582
|
-
}
|
|
820
|
+
if (triggers.length === 0) return [];
|
|
583
821
|
fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
822
|
+
return triggers.map((trigger, index) => ({
|
|
823
|
+
name: `${name}:${trigger.event}#${index}`,
|
|
824
|
+
event: trigger.event,
|
|
825
|
+
boundary: true,
|
|
826
|
+
handler: async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} })
|
|
827
|
+
}));
|
|
588
828
|
}
|
|
589
829
|
});
|
|
590
830
|
}
|
|
@@ -609,6 +849,7 @@ async function dispatchTrigger(service, trigger, event, opts) {
|
|
|
609
849
|
const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
|
|
610
850
|
for (const [index, recipient] of recipients.entries()) {
|
|
611
851
|
const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
|
|
852
|
+
const subject = trigger.subject?.(event);
|
|
612
853
|
await service.send({
|
|
613
854
|
event: event.type,
|
|
614
855
|
template: trigger.template ?? event.type,
|
|
@@ -616,9 +857,25 @@ async function dispatchTrigger(service, trigger, event, opts) {
|
|
|
616
857
|
data: sendData,
|
|
617
858
|
...trigger.channels ? { channels: [...trigger.channels] } : {},
|
|
618
859
|
...idempotencyKey !== void 0 ? { idempotencyKey } : {},
|
|
860
|
+
...subject ? { subject } : {},
|
|
619
861
|
metadata: {
|
|
620
862
|
source: "arc-notifications",
|
|
621
|
-
eventId: event.meta.id
|
|
863
|
+
eventId: event.meta.id,
|
|
864
|
+
/**
|
|
865
|
+
* The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
|
|
866
|
+
*
|
|
867
|
+
* Stated here because only the dispatcher knows it. A channel that has to
|
|
868
|
+
* infer "is this the same notification?" from the payload gets it wrong in
|
|
869
|
+
* both directions, and both were shipped: keying on the event TYPE
|
|
870
|
+
* suppressed every order after the first, and keying on `meta.id`
|
|
871
|
+
* (absent when a host re-publishes an enriched event) deduped nothing and
|
|
872
|
+
* sent the ops group two copies of every alert.
|
|
873
|
+
*
|
|
874
|
+
* Unique per occurrence, IDENTICAL across that occurrence's recipient
|
|
875
|
+
* fan-out — which is exactly the distinction a destination-collapsing
|
|
876
|
+
* channel needs and cannot derive for itself.
|
|
877
|
+
*/
|
|
878
|
+
...baseKey !== void 0 ? { notificationId: baseKey } : {}
|
|
622
879
|
}
|
|
623
880
|
});
|
|
624
881
|
}
|
|
@@ -642,7 +899,8 @@ function toEntry(doc) {
|
|
|
642
899
|
results: doc.results ?? [],
|
|
643
900
|
status: doc.status,
|
|
644
901
|
duration: doc.duration ?? 0,
|
|
645
|
-
...doc.metadata ? { metadata: doc.metadata } : {}
|
|
902
|
+
...doc.metadata ? { metadata: doc.metadata } : {},
|
|
903
|
+
...doc.subject ? { subject: { ...doc.subject } } : {}
|
|
646
904
|
};
|
|
647
905
|
}
|
|
648
906
|
function createRepositoryDeliveryLog(repository, options = {}) {
|
|
@@ -658,7 +916,8 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
658
916
|
results: dispatch.results,
|
|
659
917
|
status: deriveStatus(dispatch),
|
|
660
918
|
duration: dispatch.duration,
|
|
661
|
-
...payload.metadata ? { metadata: payload.metadata } : {}
|
|
919
|
+
...payload.metadata ? { metadata: payload.metadata } : {},
|
|
920
|
+
...payload.subject ? { subject: payload.subject } : {}
|
|
662
921
|
};
|
|
663
922
|
await repository.create(doc);
|
|
664
923
|
},
|
|
@@ -668,6 +927,21 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
668
927
|
if (filter.recipientEmail) equality.recipientEmail = filter.recipientEmail;
|
|
669
928
|
if (filter.event) equality.event = filter.event;
|
|
670
929
|
if (filter.status) equality.status = filter.status;
|
|
930
|
+
/**
|
|
931
|
+
* Pushed into the EQUALITY filter, not the in-memory pass below.
|
|
932
|
+
*
|
|
933
|
+
* The post-fetch filters (`after`/`before`/`channel`) narrow a page that
|
|
934
|
+
* was already limited — acceptable for a range or an array membership.
|
|
935
|
+
* Subject is different: it is the primary way a document screen reads its
|
|
936
|
+
* own history, so filtering it in memory would fetch `limit` rows of
|
|
937
|
+
* OTHER documents' deliveries and return however few happened to match —
|
|
938
|
+
* an empty send history for an invoice that was definitely emailed.
|
|
939
|
+
* Dotted paths so the store's compound index is usable.
|
|
940
|
+
*/
|
|
941
|
+
if (filter.subject) {
|
|
942
|
+
equality["subject.sourceModel"] = filter.subject.sourceModel;
|
|
943
|
+
equality["subject.sourceId"] = filter.subject.sourceId;
|
|
944
|
+
}
|
|
671
945
|
const limit = filter.limit ?? defaultLimit;
|
|
672
946
|
const result = await repository.getAll({
|
|
673
947
|
filter: equality,
|
|
@@ -853,4 +1127,298 @@ function templatesResource(options) {
|
|
|
853
1127
|
});
|
|
854
1128
|
}
|
|
855
1129
|
//#endregion
|
|
856
|
-
|
|
1130
|
+
//#region src/triggerAdapter.ts
|
|
1131
|
+
/**
|
|
1132
|
+
* `entityType` → the `extract` variable holding that entity's id.
|
|
1133
|
+
*
|
|
1134
|
+
* The in-app row wants an `entityId` for deep-linking and badge grouping, but
|
|
1135
|
+
* the variable it lives under is named per entity. This is the default
|
|
1136
|
+
* commerce mapping; a host with its own entity kinds passes `entityIdVars`.
|
|
1137
|
+
*/
|
|
1138
|
+
const DEFAULT_ENTITY_ID_VARS = {
|
|
1139
|
+
order: "orderId",
|
|
1140
|
+
transfer: "transferId",
|
|
1141
|
+
product: "productId"
|
|
1142
|
+
};
|
|
1143
|
+
/**
|
|
1144
|
+
* Adapt one authoring trigger to one module trigger.
|
|
1145
|
+
*
|
|
1146
|
+
* Note `extract` is invoked separately by `audience` and by `data`. The module
|
|
1147
|
+
* calls both per dispatch, so an `extract` with side effects would run them
|
|
1148
|
+
* twice — keep extractors pure (every one in practice is a payload reshape).
|
|
1149
|
+
*/
|
|
1150
|
+
function toNotifyTrigger(t, options = {}) {
|
|
1151
|
+
const type = t.type ?? t.event;
|
|
1152
|
+
const entityIdVars = {
|
|
1153
|
+
...DEFAULT_ENTITY_ID_VARS,
|
|
1154
|
+
...options.entityIdVars
|
|
1155
|
+
};
|
|
1156
|
+
const entityVar = t.template.entityType ? entityIdVars[t.template.entityType] : void 0;
|
|
1157
|
+
const channels = declaredChannels(t);
|
|
1158
|
+
return {
|
|
1159
|
+
event: t.event,
|
|
1160
|
+
template: type,
|
|
1161
|
+
...t.priority ? { priority: t.priority } : {},
|
|
1162
|
+
channels,
|
|
1163
|
+
/**
|
|
1164
|
+
* Dedup on the SUBJECT, not on the delivery.
|
|
1165
|
+
*
|
|
1166
|
+
* The module's default is `meta.idempotencyKey ?? "<type>:<meta.id>"`, and
|
|
1167
|
+
* `meta.id` is a fresh uuid per publish — so a redelivered or re-published
|
|
1168
|
+
* event yields a DIFFERENT key and dedup can never engage. Delivery here is
|
|
1169
|
+
* at-least-once by contract (be-prod's own engine docblock: "direct
|
|
1170
|
+
* post-commit publish + relay … every consumer must be idempotent"), and one
|
|
1171
|
+
* order genuinely produced two `customer:order.placed` publishes, so every
|
|
1172
|
+
* channel fired twice. Two identical emails read as a mail-client quirk; two
|
|
1173
|
+
* Telegram messages are what finally made it visible.
|
|
1174
|
+
*
|
|
1175
|
+
* Keyed on `<type>:<entityId>` because the notification is ABOUT that entity
|
|
1176
|
+
* — "order X was placed" is one fact however many times its event is
|
|
1177
|
+
* delivered. `entityVar` is the same variable the in-app row deep-links with,
|
|
1178
|
+
* so this needs no per-trigger configuration and applies to every trigger
|
|
1179
|
+
* that names an `entityType`.
|
|
1180
|
+
*
|
|
1181
|
+
* Returns `undefined` when there is no entity id, which falls back to the
|
|
1182
|
+
* module default. A broadcast with no subject has nothing stable to dedupe on,
|
|
1183
|
+
* and inventing a key there would collapse legitimately distinct sends.
|
|
1184
|
+
*/
|
|
1185
|
+
...entityVar ? { idempotencyKey: (e) => {
|
|
1186
|
+
const id = t.extract(e.payload ?? {})?.variables?.[entityVar];
|
|
1187
|
+
return id === void 0 || id === null || id === "" ? void 0 : `${type}:${String(id)}`;
|
|
1188
|
+
} } : {},
|
|
1189
|
+
audience: (e) => {
|
|
1190
|
+
const ex = t.extract(e.payload ?? {});
|
|
1191
|
+
if (!ex) return null;
|
|
1192
|
+
if (t.audienceType === "customer") {
|
|
1193
|
+
if (!ex.recipient) return null;
|
|
1194
|
+
return {
|
|
1195
|
+
recipient: ex.recipient,
|
|
1196
|
+
organizationId: ex.organizationId
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
roles: t.recipients,
|
|
1201
|
+
organizationId: ex.organizationId,
|
|
1202
|
+
...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
|
|
1203
|
+
};
|
|
1204
|
+
},
|
|
1205
|
+
data: (e) => {
|
|
1206
|
+
const vars = t.extract(e.payload ?? {})?.variables ?? {};
|
|
1207
|
+
return {
|
|
1208
|
+
...vars,
|
|
1209
|
+
...copyFor(t, vars)
|
|
1210
|
+
};
|
|
1211
|
+
},
|
|
1212
|
+
inApp: {
|
|
1213
|
+
title: t.template.title,
|
|
1214
|
+
message: t.template.message,
|
|
1215
|
+
...t.template.link ? { link: t.template.link } : {},
|
|
1216
|
+
...t.template.entityType ? { entityType: t.template.entityType } : {},
|
|
1217
|
+
...entityVar ? { entityId: `{${entityVar}}` } : {}
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* `{placeholder}` interpolation over a trigger's own copy.
|
|
1223
|
+
*
|
|
1224
|
+
* The same substitution `InAppChannel` performs on `inApp.title`/`message` —
|
|
1225
|
+
* lifted here so EVERY channel can have the text, not just the one this package
|
|
1226
|
+
* implements. Unknown placeholders are left verbatim rather than blanked: a
|
|
1227
|
+
* literal `{orderNumber}` in a message is a visible bug someone fixes, while an
|
|
1228
|
+
* empty gap reads as intentional copy.
|
|
1229
|
+
*/
|
|
1230
|
+
function interpolate(template, vars) {
|
|
1231
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) => {
|
|
1232
|
+
const v = vars[key];
|
|
1233
|
+
return v === void 0 || v === null ? whole : String(v);
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* The notification's rendered copy, under RESERVED keys.
|
|
1238
|
+
*
|
|
1239
|
+
* Connector channels (WhatsApp, Telegram, SMS, …) receive only `payload.data`,
|
|
1240
|
+
* and until now that held nothing but the trigger's extracted variables — the
|
|
1241
|
+
* copy lived in `inApp`, which only the in-app channel reads. Email was the sole
|
|
1242
|
+
* exception because a host supplies a separate template resolver for it. So every
|
|
1243
|
+
* other channel was structurally incapable of rendering a message, and skipped
|
|
1244
|
+
* with "empty body" no matter how well it was configured.
|
|
1245
|
+
*
|
|
1246
|
+
* Prefixed (`notification*`) so a trigger free to name its own variables cannot
|
|
1247
|
+
* collide with them — a trigger with a `title` variable would otherwise silently
|
|
1248
|
+
* overwrite the copy, or be overwritten by it.
|
|
1249
|
+
*
|
|
1250
|
+
* These are the CODE defaults. Per-scope copy overrides are resolved in the
|
|
1251
|
+
* gated variant's `audience`, which is async, while `data` is sync — so an
|
|
1252
|
+
* operator's custom wording reaches the in-app row and email today, and wiring it
|
|
1253
|
+
* through to connectors needs the kernel to allow an async `data`.
|
|
1254
|
+
*/
|
|
1255
|
+
function copyFor(t, vars) {
|
|
1256
|
+
const out = {};
|
|
1257
|
+
if (t.template.title) out.notificationTitle = interpolate(t.template.title, vars);
|
|
1258
|
+
if (t.template.message) out.notificationMessage = interpolate(t.template.message, vars);
|
|
1259
|
+
return out;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* The channel set a trigger declares in CODE — its default before any override.
|
|
1263
|
+
*
|
|
1264
|
+
* `in-app` is prepended for a staff audience because the FRAMEWORK implements
|
|
1265
|
+
* that channel; every other name comes from the trigger's own `channels` list,
|
|
1266
|
+
* so a product can deliver on anything its integrations hub has registered
|
|
1267
|
+
* without this package knowing the name.
|
|
1268
|
+
*/
|
|
1269
|
+
function declaredChannels(t) {
|
|
1270
|
+
return [...t.audienceType === "customer" ? [] : [IN_APP_CHANNEL], ...t.channels ?? []];
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Every channel an override could ever turn ON for this trigger.
|
|
1274
|
+
*
|
|
1275
|
+
* The union of the declared set and the configurable ones, because a scope may
|
|
1276
|
+
* enable a channel the code never flagged — that is the point of the control
|
|
1277
|
+
* centre. A customer-audience trigger stays out of `in-app`: buyers are not
|
|
1278
|
+
* dashboard users, so a feed row for them is one nobody can read, and no
|
|
1279
|
+
* override should be able to create it.
|
|
1280
|
+
*/
|
|
1281
|
+
function candidateChannels(t, catalog) {
|
|
1282
|
+
/**
|
|
1283
|
+
* The CONFIGURABLE set comes from the CATALOG, never a literal here.
|
|
1284
|
+
*
|
|
1285
|
+
* It was `["email", "whatsapp", "sms"]` — the framework listing the product's
|
|
1286
|
+
* connectors, which is why registering a provider in the hub could never reach
|
|
1287
|
+
* an operator's trigger settings. The catalog is built by the deployment that
|
|
1288
|
+
* knows what it registered, so a connector that exists is offered and one that
|
|
1289
|
+
* does not is absent, with no edit in this package.
|
|
1290
|
+
*
|
|
1291
|
+
* Falling back to the trigger's own declaration keeps a caller that supplies
|
|
1292
|
+
* no catalog working: the degradation is "no NEW channels offered", never
|
|
1293
|
+
* "no channels at all".
|
|
1294
|
+
*/
|
|
1295
|
+
const configurable = (catalog?.list() ?? t.channels ?? []).filter((c) => c !== IN_APP_CHANNEL);
|
|
1296
|
+
const unique = [...new Set(configurable)];
|
|
1297
|
+
/**
|
|
1298
|
+
* A customer audience never gets `in-app`: buyers are not dashboard users, so
|
|
1299
|
+
* a feed row for them is one nobody can read, and no override may create it.
|
|
1300
|
+
*/
|
|
1301
|
+
return t.audienceType === "customer" ? unique : [IN_APP_CHANNEL, ...unique];
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* One module trigger per CHANNEL, each gated on the stored settings.
|
|
1305
|
+
*
|
|
1306
|
+
* The gate lives in `audience` because that is the only per-event async seam
|
|
1307
|
+
* arc gives a trigger, and returning `null` there is already the module's
|
|
1308
|
+
* documented "skip" — the same mechanism `extract` uses to filter a broad event.
|
|
1309
|
+
*/
|
|
1310
|
+
function toGatedNotifyTriggers(t, options, overrides) {
|
|
1311
|
+
const type = t.type ?? t.event;
|
|
1312
|
+
const base = toNotifyTrigger(t, options);
|
|
1313
|
+
const defaults = {
|
|
1314
|
+
recipients: t.recipients,
|
|
1315
|
+
channels: declaredChannels(t),
|
|
1316
|
+
title: t.template.title,
|
|
1317
|
+
message: t.template.message,
|
|
1318
|
+
...t.priority ? { priority: t.priority } : {}
|
|
1319
|
+
};
|
|
1320
|
+
return candidateChannels(t, options.catalog).map((channel) => ({
|
|
1321
|
+
...base,
|
|
1322
|
+
channels: [channel],
|
|
1323
|
+
audience: async (e) => {
|
|
1324
|
+
const ex = t.extract(e.payload ?? {});
|
|
1325
|
+
if (!ex) return null;
|
|
1326
|
+
const rows = await overrides.store.forOrganization(ex.organizationId ?? null);
|
|
1327
|
+
const eff = resolveEffectiveSettings(type, ex.organizationId ?? null, rows, defaults);
|
|
1328
|
+
/**
|
|
1329
|
+
* Report a skip so it is COUNTABLE, then skip.
|
|
1330
|
+
*
|
|
1331
|
+
* A bare `return null` erases why, and "suppressed because the operator
|
|
1332
|
+
* scoped this to web" then looks identical to "the trigger never fired"
|
|
1333
|
+
* and to "recipient resolution came back empty". That indistinguishability
|
|
1334
|
+
* is how a staff-notification regression stayed invisible once already.
|
|
1335
|
+
*
|
|
1336
|
+
* Counters only — this package never PERSISTS a decision. A row per
|
|
1337
|
+
* skipped (event x trigger x channel) would out-write real deliveries, and
|
|
1338
|
+
* storage is product persistence, not framework mechanism.
|
|
1339
|
+
*
|
|
1340
|
+
* Fired once per CHANNEL, because that is the granularity this loop runs
|
|
1341
|
+
* at: a two-channel trigger suppressed for one event reports twice. That
|
|
1342
|
+
* is right for delivery accounting and over-counts for "how many events
|
|
1343
|
+
* were suppressed", so the channel is included and the consumer aggregates.
|
|
1344
|
+
*/
|
|
1345
|
+
const skip = (reason) => {
|
|
1346
|
+
options.onDecision?.({
|
|
1347
|
+
triggerType: type,
|
|
1348
|
+
channel,
|
|
1349
|
+
reason,
|
|
1350
|
+
organizationId: ex.organizationId ?? null
|
|
1351
|
+
});
|
|
1352
|
+
return null;
|
|
1353
|
+
};
|
|
1354
|
+
if (!eff.enabled) return skip("disabled");
|
|
1355
|
+
/**
|
|
1356
|
+
* The source-channel gate — e.g. "staff alerts for web orders only".
|
|
1357
|
+
*
|
|
1358
|
+
* Placed AFTER `enabled` and BEFORE the channel-membership test on
|
|
1359
|
+
* purpose: `enabled: false` is the operator's broadest statement and
|
|
1360
|
+
* should win, while channel membership is fan-out bookkeeping rather than
|
|
1361
|
+
* a decision about this event.
|
|
1362
|
+
*/
|
|
1363
|
+
const originDecision = evaluateSourceChannel(eff.sourceChannels, ex.sourceChannel);
|
|
1364
|
+
if (originDecision.decision === "skip") return skip(originDecision.reason);
|
|
1365
|
+
if (!eff.channels.includes(channel)) return null;
|
|
1366
|
+
if (t.audienceType === "customer") {
|
|
1367
|
+
if (!ex.recipient) return null;
|
|
1368
|
+
return {
|
|
1369
|
+
recipient: ex.recipient,
|
|
1370
|
+
organizationId: ex.organizationId
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
if (eff.recipients.length === 0) return skip("no_recipients");
|
|
1374
|
+
return {
|
|
1375
|
+
roles: eff.recipients,
|
|
1376
|
+
organizationId: ex.organizationId,
|
|
1377
|
+
...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
}));
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Adapt a whole registry — what a host assigns to the module's `notifyOn`.
|
|
1384
|
+
*
|
|
1385
|
+
* With `options.overrides`, each trigger becomes one entry per channel, gated on
|
|
1386
|
+
* the admin's stored settings. Without it, the mapping is 1:1 and unchanged.
|
|
1387
|
+
*/
|
|
1388
|
+
function toNotifyTriggers(triggers, options = {}) {
|
|
1389
|
+
const { overrides } = options;
|
|
1390
|
+
if (!overrides) return triggers.map((t) => toNotifyTrigger(t, options));
|
|
1391
|
+
return triggers.flatMap((t) => toGatedNotifyTriggers(t, options, overrides));
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* First trigger registered for an event.
|
|
1395
|
+
*
|
|
1396
|
+
* CAVEAT: an event may carry more than one trigger (an informational alert and
|
|
1397
|
+
* an escalation to a different role set both listen to `stock:low`). This
|
|
1398
|
+
* returns only the first. For anything that must see all of them — a docs page,
|
|
1399
|
+
* an admin matrix, a dispatch count — use `getTriggersByEvent`.
|
|
1400
|
+
*/
|
|
1401
|
+
function getTriggerByEvent(triggers, event) {
|
|
1402
|
+
return triggers.find((t) => t.event === event);
|
|
1403
|
+
}
|
|
1404
|
+
/** Every trigger registered for an event, in registration order. */
|
|
1405
|
+
function getTriggersByEvent(triggers, event) {
|
|
1406
|
+
return triggers.filter((t) => t.event === event);
|
|
1407
|
+
}
|
|
1408
|
+
/** Distinct event names the registry subscribes to (for docs / admin UI). */
|
|
1409
|
+
function getRegisteredEvents(triggers) {
|
|
1410
|
+
return [...new Set(triggers.map((t) => t.event))];
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Notification kind → recipient roles (for the admin UI).
|
|
1414
|
+
*
|
|
1415
|
+
* Keyed by `type ?? event`, so two triggers sharing an event stay distinct
|
|
1416
|
+
* rows — which is exactly why they carry different `type`s.
|
|
1417
|
+
*/
|
|
1418
|
+
function getRecipientMatrix(triggers) {
|
|
1419
|
+
const matrix = {};
|
|
1420
|
+
for (const t of triggers) matrix[t.type ?? t.event] = t.recipients;
|
|
1421
|
+
return matrix;
|
|
1422
|
+
}
|
|
1423
|
+
//#endregion
|
|
1424
|
+
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 };
|