@classytic/arc-notifications 0.4.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/dist/index.d.mts +332 -153
- package/dist/index.mjs +393 -140
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -232,6 +232,256 @@ interface InAppBackfill {
|
|
|
232
232
|
}
|
|
233
233
|
declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
|
|
234
234
|
//#endregion
|
|
235
|
+
//#region src/triggerOverride.d.ts
|
|
236
|
+
/**
|
|
237
|
+
* Notification trigger OVERRIDES — the admin-editable half of the registry.
|
|
238
|
+
*
|
|
239
|
+
* ## The split, and why it is not "make triggers DB rows"
|
|
240
|
+
*
|
|
241
|
+
* A `NotificationTrigger` carries two very different kinds of thing:
|
|
242
|
+
*
|
|
243
|
+
* | stays CODE | becomes DATA |
|
|
244
|
+
* |---|---|
|
|
245
|
+
* | `event` — what to subscribe to | `enabled` — does this fire here at all |
|
|
246
|
+
* | `extract` — a typed function: filters the event and pulls variables | `recipients` — which roles get told |
|
|
247
|
+
* | | `channels` — in-app / email / whatsapp / sms |
|
|
248
|
+
* | | `template` — the copy an operator wants to reword |
|
|
249
|
+
*
|
|
250
|
+
* `extract` is the reason the whole registry cannot become rows. It decides
|
|
251
|
+
* whether a broad event is even relevant (`chargeStatus === 'failed'`) and maps
|
|
252
|
+
* a payload to variables — expressing that as data means shipping an evaluator,
|
|
253
|
+
* which is a code-execution surface in the notification path. Odoo draws the
|
|
254
|
+
* same line: `base.automation` rows hold the trigger and the filter, while the
|
|
255
|
+
* action BODY is Python in `ir.actions.server`.
|
|
256
|
+
*
|
|
257
|
+
* So the code registry is the CATALOG (what this deployment is capable of
|
|
258
|
+
* announcing) and these rows are the CONFIGURATION (what a given scope has
|
|
259
|
+
* actually turned on, to whom, over which channel).
|
|
260
|
+
*
|
|
261
|
+
* ## Scoped per ORGANIZATION, with an app-wide fallback
|
|
262
|
+
*
|
|
263
|
+
* `organizationId: null` is the app-wide default; a row with an organization id
|
|
264
|
+
* overrides it for that scope only. Absence of a row means "use the code
|
|
265
|
+
* default" — a fresh deployment behaves exactly as its registry declares, and
|
|
266
|
+
* turning the feature on requires an operator to actually decide something.
|
|
267
|
+
*
|
|
268
|
+
* ## Kit-less by design
|
|
269
|
+
*
|
|
270
|
+
* This package carries NO database dependency (see `repositoryDeliveryLog` /
|
|
271
|
+
* `templatesResource` — the same posture). This file owns the ROW SHAPE, the
|
|
272
|
+
* store PORT, the TTL-cached store wrapper over a load port, and the effective-
|
|
273
|
+
* settings RESOLUTION. The persistence binding (a mongoose model and its
|
|
274
|
+
* indexes, in the product layer) supplies `load`.
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* A channel is a NAME, not a member of a closed set.
|
|
278
|
+
*
|
|
279
|
+
* ## Why this is open, and was wrong closed
|
|
280
|
+
*
|
|
281
|
+
* This was `["in-app", "email", "whatsapp", "sms"] as const`, so the framework
|
|
282
|
+
* enumerated every channel any product could ever deliver on. Adding Telegram —
|
|
283
|
+
* a provider the `@classytic/social` kernel already implements and the
|
|
284
|
+
* integrations hub can already register at runtime — required editing this
|
|
285
|
+
* framework package, its authoring shape, its adapter, then rebuilding and
|
|
286
|
+
* re-copying it into every host. An integration that is DATA everywhere else
|
|
287
|
+
* (registered provider → catalog entry → credential form → connection row) hit
|
|
288
|
+
* one hardcoded array and became a code change.
|
|
289
|
+
*
|
|
290
|
+
* That is a policy literal in the framework layer: `arc-notifications` is the
|
|
291
|
+
* generic MECHANISM (dispatch an event to channels) and has no business knowing
|
|
292
|
+
* which channels a product sells. The product layer owns the catalog.
|
|
293
|
+
*
|
|
294
|
+
* `in-app` is the ONE exception and stays named, because the framework itself
|
|
295
|
+
* implements it (`InAppChannel` writes the dashboard feed) — it is a mechanism,
|
|
296
|
+
* not a connector.
|
|
297
|
+
*
|
|
298
|
+
* The available set now arrives as DATA: `TriggerAdapterOptions.availableChannels`,
|
|
299
|
+
* which a host fills from its integrations registry. A channel nobody registered
|
|
300
|
+
* is simply not offered, instead of being unrepresentable.
|
|
301
|
+
*/
|
|
302
|
+
declare const IN_APP_CHANNEL = "in-app";
|
|
303
|
+
/**
|
|
304
|
+
* Open by design — see above. Kept as a named alias rather than inlining
|
|
305
|
+
* `string` so the intent reads at every call site and a future narrowing has one
|
|
306
|
+
* place to happen.
|
|
307
|
+
*/
|
|
308
|
+
type NotificationChannel = string;
|
|
309
|
+
interface NotificationTriggerOverrideDoc {
|
|
310
|
+
/**
|
|
311
|
+
* The trigger's `type` (falling back to its `event`) — the same key
|
|
312
|
+
* `toNotifyTrigger` uses as the template id, so a row is addressable by the
|
|
313
|
+
* identifier an admin already sees in the catalog.
|
|
314
|
+
*/
|
|
315
|
+
triggerType: string;
|
|
316
|
+
/** `null` = app-wide default; an organization id overrides it for that scope. */
|
|
317
|
+
organizationId?: string | null;
|
|
318
|
+
/** `false` silences the trigger entirely for this scope. */
|
|
319
|
+
enabled?: boolean;
|
|
320
|
+
/** Replaces the code trigger's roles. Empty array = nobody (use `enabled` to silence). */
|
|
321
|
+
recipients?: string[] | null;
|
|
322
|
+
/** Replaces the code trigger's channel set. */
|
|
323
|
+
channels?: NotificationChannel[] | null;
|
|
324
|
+
/**
|
|
325
|
+
* Restrict this trigger to events ORIGINATING from these sources.
|
|
326
|
+
*
|
|
327
|
+
* Deliberately generic: this package must not learn a product's vocabulary.
|
|
328
|
+
* A commerce host means "web / pos / marketplace" and labels the field "Sales
|
|
329
|
+
* channel" in its UI; another product could mean "api / import / manual". The
|
|
330
|
+
* framework only compares `NotificationExtractResult.sourceChannel` against
|
|
331
|
+
* this list.
|
|
332
|
+
*
|
|
333
|
+
* `null` / absent = **every source** — today's behaviour, so an untouched
|
|
334
|
+
* deployment is unaffected. `[]` is NOT "every source"; it is rejected at the
|
|
335
|
+
* API boundary, because an empty allow-list that reads as "any" is a filter
|
|
336
|
+
* that silently widens. To silence a trigger, use `enabled: false`.
|
|
337
|
+
*
|
|
338
|
+
* NOT a condition language. If an allow-list ever proves insufficient the
|
|
339
|
+
* answer is a host-supplied predicate, never an expression DSL.
|
|
340
|
+
*/
|
|
341
|
+
sourceChannels?: string[] | null;
|
|
342
|
+
/** Copy overrides. Unset fields fall back to the code template. */
|
|
343
|
+
title?: string | null;
|
|
344
|
+
message?: string | null;
|
|
345
|
+
priority?: "low" | "normal" | "high" | null;
|
|
346
|
+
updatedBy?: string | null;
|
|
347
|
+
}
|
|
348
|
+
/** The reader the resolver needs. Structural, so a host can pass a cache or a fake. */
|
|
349
|
+
interface TriggerOverrideStore {
|
|
350
|
+
/**
|
|
351
|
+
* Every override that could apply to `organizationId` — its scope rows AND
|
|
352
|
+
* the app-wide (`null`) rows. Returning both in one read is deliberate:
|
|
353
|
+
* resolving a fallback with a second query per event would put a round trip on
|
|
354
|
+
* the notification path.
|
|
355
|
+
*/
|
|
356
|
+
forOrganization(organizationId: string | null): Promise<NotificationTriggerOverrideDoc[]>;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The persistence PORT the cached store wraps — "give me every row that could
|
|
360
|
+
* apply to this scope" (its own rows and the `null` app-wide rows, one read).
|
|
361
|
+
*/
|
|
362
|
+
type TriggerOverrideLoader = (organizationId: string | null) => Promise<NotificationTriggerOverrideDoc[]>;
|
|
363
|
+
interface CreateCachedTriggerOverrideStoreOptions {
|
|
364
|
+
/**
|
|
365
|
+
* Cache TTL in ms. Default 30s.
|
|
366
|
+
*
|
|
367
|
+
* These rows change when an admin edits a form — rarely — but they are read on
|
|
368
|
+
* EVERY notifiable event, so an uncached store puts a query in front of every
|
|
369
|
+
* order. 30s bounds how long a change takes to take effect without making the
|
|
370
|
+
* hot path pay for a lookup that almost never differs.
|
|
371
|
+
*/
|
|
372
|
+
ttlMs?: number;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* TTL-cached {@link TriggerOverrideStore} over a load port.
|
|
376
|
+
*
|
|
377
|
+
* `invalidate()` exists for the write path: the admin resource calls it after
|
|
378
|
+
* any mutation so an operator's change applies within one save rather than one
|
|
379
|
+
* TTL window.
|
|
380
|
+
*/
|
|
381
|
+
declare function createCachedTriggerOverrideStore(load: TriggerOverrideLoader, options?: CreateCachedTriggerOverrideStoreOptions): TriggerOverrideStore & {
|
|
382
|
+
invalidate: () => void;
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* The EFFECTIVE settings for one trigger in one scope.
|
|
386
|
+
*
|
|
387
|
+
* Precedence is scope → app-wide → code default, and each FIELD resolves
|
|
388
|
+
* independently: a scope that only overrode `channels` still inherits the
|
|
389
|
+
* app-wide `recipients`. Whole-row precedence would force an admin to restate
|
|
390
|
+
* every field to change one, and restating is how two settings silently drift.
|
|
391
|
+
*/
|
|
392
|
+
interface EffectiveTriggerSettings {
|
|
393
|
+
enabled: boolean;
|
|
394
|
+
recipients: string[];
|
|
395
|
+
channels: NotificationChannel[];
|
|
396
|
+
/** `null` = unrestricted. See {@link NotificationTriggerOverrideDoc.sourceChannels}. */
|
|
397
|
+
sourceChannels: string[] | null;
|
|
398
|
+
title: string;
|
|
399
|
+
message: string;
|
|
400
|
+
priority?: "low" | "normal" | "high" | undefined;
|
|
401
|
+
}
|
|
402
|
+
interface CodeTriggerDefaults {
|
|
403
|
+
recipients: string[];
|
|
404
|
+
channels: NotificationChannel[];
|
|
405
|
+
title: string;
|
|
406
|
+
message: string;
|
|
407
|
+
priority?: "low" | "normal" | "high" | undefined;
|
|
408
|
+
}
|
|
409
|
+
declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
|
|
410
|
+
/**
|
|
411
|
+
* Why a notification was not delivered.
|
|
412
|
+
*
|
|
413
|
+
* A CLOSED set, and it lives here rather than in each host: left to hosts every
|
|
414
|
+
* one invents its own vocabulary and none of it is comparable. Emitting these as
|
|
415
|
+
* counters is database-agnostic and stays inside this package's remit — PERSISTING
|
|
416
|
+
* them is product persistence and belongs to the spine, which is why nothing here
|
|
417
|
+
* writes a row.
|
|
418
|
+
*/
|
|
419
|
+
type NotificationSkipReason = "disabled" | "source_channel_mismatch" | "source_channel_unknown" | "no_recipients" | "trigger_condition_unmet";
|
|
420
|
+
type NotificationDecision = {
|
|
421
|
+
decision: "deliver";
|
|
422
|
+
} | {
|
|
423
|
+
decision: "skip";
|
|
424
|
+
reason: NotificationSkipReason;
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Does this event's origin satisfy the trigger's source-channel restriction?
|
|
428
|
+
*
|
|
429
|
+
* Three outcomes, all named — the whole point of this function.
|
|
430
|
+
*
|
|
431
|
+
* ## Why `unknown` SKIPS rather than delivers
|
|
432
|
+
*
|
|
433
|
+
* An earlier design delivered when the event carried no `sourceChannel`, citing
|
|
434
|
+
* "absence of an answer is not a negative answer". That misreads the rule: it
|
|
435
|
+
* forbids treating unknown as a NEGATIVE, it does not license treating it as a
|
|
436
|
+
* POSITIVE. Against a restrictive filter, delivering an event of unknown origin
|
|
437
|
+
* silently violates an operator who explicitly selected `['web']` — a general
|
|
438
|
+
* default overriding a specific instruction, which is the defect class this
|
|
439
|
+
* codebase exists to design against.
|
|
440
|
+
*
|
|
441
|
+
* The cost of each mistake is not symmetric either. Deliver-on-unknown leaks
|
|
442
|
+
* alerts an operator switched off and cannot see; skip-on-unknown withholds an
|
|
443
|
+
* alert AND emits `source_channel_unknown`, which is visible. Prefer the failure
|
|
444
|
+
* that reports itself.
|
|
445
|
+
*
|
|
446
|
+
* Unrestricted triggers are unaffected: `null` short-circuits before the origin
|
|
447
|
+
* is ever consulted, so an event with no `sourceChannel` under no restriction
|
|
448
|
+
* still delivers.
|
|
449
|
+
*/
|
|
450
|
+
declare function evaluateSourceChannel(sourceChannels: string[] | null | undefined, sourceChannel: string | undefined): NotificationDecision;
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/channelCatalog.d.ts
|
|
453
|
+
/**
|
|
454
|
+
* Which channels a deployment can deliver on.
|
|
455
|
+
*
|
|
456
|
+
* An interface rather than a bare array so a host may back it with something
|
|
457
|
+
* live (its integrations registry) without every consumer re-deriving the set,
|
|
458
|
+
* and so `has()` is one implementation instead of an `includes` at each site.
|
|
459
|
+
*/
|
|
460
|
+
interface NotificationChannelCatalog {
|
|
461
|
+
/**
|
|
462
|
+
* Every deliverable channel, `in-app` first.
|
|
463
|
+
*
|
|
464
|
+
* Ordered and de-duplicated, because this drives a UI: an operator seeing
|
|
465
|
+
* channels reorder between screens has no way to tell that from a bug.
|
|
466
|
+
*/
|
|
467
|
+
list(): readonly NotificationChannel[];
|
|
468
|
+
/** Is this a channel this deployment can actually deliver on? */
|
|
469
|
+
has(channel: string): boolean;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Build a catalog from the connector channel names a deployment has registered.
|
|
473
|
+
*
|
|
474
|
+
* `in-app` is added automatically and cannot be omitted — the framework
|
|
475
|
+
* implements that channel itself (`InAppChannel` writes the dashboard feed), so
|
|
476
|
+
* it is always deliverable and is not a connector a host could fail to register.
|
|
477
|
+
*
|
|
478
|
+
* @param connectorChannels service names from the host's integrations registry
|
|
479
|
+
* (`'email'`, `'whatsapp'`, `'telegram'`, …). Blank entries are dropped rather
|
|
480
|
+
* than stored, so a misconfigured provider cannot introduce an unusable ""
|
|
481
|
+
* channel that validation would then accept.
|
|
482
|
+
*/
|
|
483
|
+
declare function createNotificationChannelCatalog(connectorChannels?: readonly string[]): NotificationChannelCatalog;
|
|
484
|
+
//#endregion
|
|
235
485
|
//#region src/dbFirstResolver.d.ts
|
|
236
486
|
/** One draftable template row — the shape be-prod's EmailTemplate model proved. */
|
|
237
487
|
interface StoredTemplate {
|
|
@@ -531,24 +781,6 @@ interface TemplatesResourceOptions {
|
|
|
531
781
|
declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
|
|
532
782
|
//#endregion
|
|
533
783
|
//#region src/triggerAuthoring.d.ts
|
|
534
|
-
/**
|
|
535
|
-
* Notification trigger contract — the readable DSL a host writes its registry in.
|
|
536
|
-
*
|
|
537
|
-
* This module owns the MECHANISM (subscription, dispatch, channel fan-out) and its
|
|
538
|
-
* `NotifyTrigger` is the shape the dispatcher consumes. That shape is
|
|
539
|
-
* dispatch-oriented: audience resolvers, data extractors, channel arrays. Writing
|
|
540
|
-
* forty of them by hand is how a registry stops being readable.
|
|
541
|
-
*
|
|
542
|
-
* `NotificationTrigger` is the AUTHORING shape — one flat entry per notification,
|
|
543
|
-
* saying what fires, who gets it, and what it says. `toNotifyTriggers` in
|
|
544
|
-
* `./triggerAdapter.js` converts one to the other.
|
|
545
|
-
*
|
|
546
|
-
* Moved here from `@spinekit/core/notifications` (2026-08-14): the previous home
|
|
547
|
-
* declared a structural mirror of `NotifyTrigger` to avoid depending on this
|
|
548
|
-
* package — a re-declared provider type, and once narrower than its subject
|
|
549
|
-
* (sync-only `audience`), which silently foreclosed valid implementations. Living
|
|
550
|
-
* beside the owner, the mirror is unnecessary and the compiler checks the real seam.
|
|
551
|
-
*/
|
|
552
784
|
/** Who a trigger targets. */
|
|
553
785
|
type NotificationAudienceType = "staff" | "customer";
|
|
554
786
|
/** Direct contact for a customer-facing notification. */
|
|
@@ -564,6 +796,21 @@ interface NotificationExtractResult {
|
|
|
564
796
|
triggeredBy?: string;
|
|
565
797
|
/** Direct contact for `audienceType: 'customer'`. */
|
|
566
798
|
recipient?: NotificationRecipientContact;
|
|
799
|
+
/**
|
|
800
|
+
* Where the triggering document ORIGINATED — matched against an override's
|
|
801
|
+
* `sourceChannels` allow-list.
|
|
802
|
+
*
|
|
803
|
+
* Deliberately an opaque string: this package must not learn a product's
|
|
804
|
+
* vocabulary. A commerce host passes its order channel (`'web'`, `'pos'`);
|
|
805
|
+
* another product might pass `'api'` or `'import'`.
|
|
806
|
+
*
|
|
807
|
+
* Omitting it is safe for an UNRESTRICTED trigger (the common case — the
|
|
808
|
+
* allow-list is consulted only when one is set). But a trigger an operator
|
|
809
|
+
* HAS restricted will skip with `source_channel_unknown` rather than deliver,
|
|
810
|
+
* because widening an explicit restriction is worse than withholding one
|
|
811
|
+
* alert visibly. So: if a trigger could ever be scoped, populate this.
|
|
812
|
+
*/
|
|
813
|
+
sourceChannel?: string;
|
|
567
814
|
}
|
|
568
815
|
/**
|
|
569
816
|
* One notification, declared.
|
|
@@ -604,12 +851,27 @@ interface NotificationTrigger {
|
|
|
604
851
|
*/
|
|
605
852
|
audienceType?: NotificationAudienceType;
|
|
606
853
|
priority?: "low" | "normal" | "high";
|
|
607
|
-
/**
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
854
|
+
/**
|
|
855
|
+
* Connector channels this trigger fans out to, by NAME — `['email',
|
|
856
|
+
* 'whatsapp']`, `['telegram']`, whatever the deployment has registered.
|
|
857
|
+
*
|
|
858
|
+
* This replaced three booleans (`sendEmail` / `sendWhatsapp` / `sendSms`).
|
|
859
|
+
* One flag per channel meant the framework enumerated the product's connector
|
|
860
|
+
* catalog, so adding a channel the integrations hub could already register at
|
|
861
|
+
* runtime was a framework edit plus a rebuild in every host — see the note on
|
|
862
|
+
* {@link NotificationChannel}.
|
|
863
|
+
*
|
|
864
|
+
* `in-app` is NOT listed here. A staff-audience trigger always gets the
|
|
865
|
+
* dashboard feed (the framework implements that channel itself); a
|
|
866
|
+
* `customer` audience never does, because buyers are not dashboard users.
|
|
867
|
+
*
|
|
868
|
+
* A name here that the deployment has not connected simply never delivers —
|
|
869
|
+
* the hub-backed channel resolves nothing and reports `skipped` with a reason.
|
|
870
|
+
* That is deliberate: a trigger may declare an aspiration the operator has not
|
|
871
|
+
* wired yet, and it must be visible in the delivery log rather than refused at
|
|
872
|
+
* boot.
|
|
873
|
+
*/
|
|
874
|
+
channels?: readonly NotificationChannel[];
|
|
613
875
|
/**
|
|
614
876
|
* Pull dispatch variables out of the event payload.
|
|
615
877
|
*
|
|
@@ -621,132 +883,6 @@ interface NotificationTrigger {
|
|
|
621
883
|
extract: (payload: Record<string, any>) => NotificationExtractResult | null;
|
|
622
884
|
}
|
|
623
885
|
//#endregion
|
|
624
|
-
//#region src/triggerOverride.d.ts
|
|
625
|
-
/**
|
|
626
|
-
* Notification trigger OVERRIDES — the admin-editable half of the registry.
|
|
627
|
-
*
|
|
628
|
-
* ## The split, and why it is not "make triggers DB rows"
|
|
629
|
-
*
|
|
630
|
-
* A `NotificationTrigger` carries two very different kinds of thing:
|
|
631
|
-
*
|
|
632
|
-
* | stays CODE | becomes DATA |
|
|
633
|
-
* |---|---|
|
|
634
|
-
* | `event` — what to subscribe to | `enabled` — does this fire here at all |
|
|
635
|
-
* | `extract` — a typed function: filters the event and pulls variables | `recipients` — which roles get told |
|
|
636
|
-
* | | `channels` — in-app / email / whatsapp / sms |
|
|
637
|
-
* | | `template` — the copy an operator wants to reword |
|
|
638
|
-
*
|
|
639
|
-
* `extract` is the reason the whole registry cannot become rows. It decides
|
|
640
|
-
* whether a broad event is even relevant (`chargeStatus === 'failed'`) and maps
|
|
641
|
-
* a payload to variables — expressing that as data means shipping an evaluator,
|
|
642
|
-
* which is a code-execution surface in the notification path. Odoo draws the
|
|
643
|
-
* same line: `base.automation` rows hold the trigger and the filter, while the
|
|
644
|
-
* action BODY is Python in `ir.actions.server`.
|
|
645
|
-
*
|
|
646
|
-
* So the code registry is the CATALOG (what this deployment is capable of
|
|
647
|
-
* announcing) and these rows are the CONFIGURATION (what a given scope has
|
|
648
|
-
* actually turned on, to whom, over which channel).
|
|
649
|
-
*
|
|
650
|
-
* ## Scoped per ORGANIZATION, with an app-wide fallback
|
|
651
|
-
*
|
|
652
|
-
* `organizationId: null` is the app-wide default; a row with an organization id
|
|
653
|
-
* overrides it for that scope only. Absence of a row means "use the code
|
|
654
|
-
* default" — a fresh deployment behaves exactly as its registry declares, and
|
|
655
|
-
* turning the feature on requires an operator to actually decide something.
|
|
656
|
-
*
|
|
657
|
-
* ## Kit-less by design
|
|
658
|
-
*
|
|
659
|
-
* This package carries NO database dependency (see `repositoryDeliveryLog` /
|
|
660
|
-
* `templatesResource` — the same posture). This file owns the ROW SHAPE, the
|
|
661
|
-
* store PORT, the TTL-cached store wrapper over a load port, and the effective-
|
|
662
|
-
* settings RESOLUTION. The persistence binding (a mongoose model and its
|
|
663
|
-
* indexes, in the product layer) supplies `load`.
|
|
664
|
-
*/
|
|
665
|
-
/** The channels a trigger can be routed to. `in-app` is the dashboard feed. */
|
|
666
|
-
declare const NOTIFICATION_CHANNELS: readonly ["in-app", "email", "whatsapp", "sms"];
|
|
667
|
-
type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];
|
|
668
|
-
interface NotificationTriggerOverrideDoc {
|
|
669
|
-
/**
|
|
670
|
-
* The trigger's `type` (falling back to its `event`) — the same key
|
|
671
|
-
* `toNotifyTrigger` uses as the template id, so a row is addressable by the
|
|
672
|
-
* identifier an admin already sees in the catalog.
|
|
673
|
-
*/
|
|
674
|
-
triggerType: string;
|
|
675
|
-
/** `null` = app-wide default; an organization id overrides it for that scope. */
|
|
676
|
-
organizationId?: string | null;
|
|
677
|
-
/** `false` silences the trigger entirely for this scope. */
|
|
678
|
-
enabled?: boolean;
|
|
679
|
-
/** Replaces the code trigger's roles. Empty array = nobody (use `enabled` to silence). */
|
|
680
|
-
recipients?: string[] | null;
|
|
681
|
-
/** Replaces the code trigger's channel set. */
|
|
682
|
-
channels?: NotificationChannel[] | null;
|
|
683
|
-
/** Copy overrides. Unset fields fall back to the code template. */
|
|
684
|
-
title?: string | null;
|
|
685
|
-
message?: string | null;
|
|
686
|
-
priority?: "low" | "normal" | "high" | null;
|
|
687
|
-
updatedBy?: string | null;
|
|
688
|
-
}
|
|
689
|
-
/** The reader the resolver needs. Structural, so a host can pass a cache or a fake. */
|
|
690
|
-
interface TriggerOverrideStore {
|
|
691
|
-
/**
|
|
692
|
-
* Every override that could apply to `organizationId` — its scope rows AND
|
|
693
|
-
* the app-wide (`null`) rows. Returning both in one read is deliberate:
|
|
694
|
-
* resolving a fallback with a second query per event would put a round trip on
|
|
695
|
-
* the notification path.
|
|
696
|
-
*/
|
|
697
|
-
forOrganization(organizationId: string | null): Promise<NotificationTriggerOverrideDoc[]>;
|
|
698
|
-
}
|
|
699
|
-
/**
|
|
700
|
-
* The persistence PORT the cached store wraps — "give me every row that could
|
|
701
|
-
* apply to this scope" (its own rows and the `null` app-wide rows, one read).
|
|
702
|
-
*/
|
|
703
|
-
type TriggerOverrideLoader = (organizationId: string | null) => Promise<NotificationTriggerOverrideDoc[]>;
|
|
704
|
-
interface CreateCachedTriggerOverrideStoreOptions {
|
|
705
|
-
/**
|
|
706
|
-
* Cache TTL in ms. Default 30s.
|
|
707
|
-
*
|
|
708
|
-
* These rows change when an admin edits a form — rarely — but they are read on
|
|
709
|
-
* EVERY notifiable event, so an uncached store puts a query in front of every
|
|
710
|
-
* order. 30s bounds how long a change takes to take effect without making the
|
|
711
|
-
* hot path pay for a lookup that almost never differs.
|
|
712
|
-
*/
|
|
713
|
-
ttlMs?: number;
|
|
714
|
-
}
|
|
715
|
-
/**
|
|
716
|
-
* TTL-cached {@link TriggerOverrideStore} over a load port.
|
|
717
|
-
*
|
|
718
|
-
* `invalidate()` exists for the write path: the admin resource calls it after
|
|
719
|
-
* any mutation so an operator's change applies within one save rather than one
|
|
720
|
-
* TTL window.
|
|
721
|
-
*/
|
|
722
|
-
declare function createCachedTriggerOverrideStore(load: TriggerOverrideLoader, options?: CreateCachedTriggerOverrideStoreOptions): TriggerOverrideStore & {
|
|
723
|
-
invalidate: () => void;
|
|
724
|
-
};
|
|
725
|
-
/**
|
|
726
|
-
* The EFFECTIVE settings for one trigger in one scope.
|
|
727
|
-
*
|
|
728
|
-
* Precedence is scope → app-wide → code default, and each FIELD resolves
|
|
729
|
-
* independently: a scope that only overrode `channels` still inherits the
|
|
730
|
-
* app-wide `recipients`. Whole-row precedence would force an admin to restate
|
|
731
|
-
* every field to change one, and restating is how two settings silently drift.
|
|
732
|
-
*/
|
|
733
|
-
interface EffectiveTriggerSettings {
|
|
734
|
-
enabled: boolean;
|
|
735
|
-
recipients: string[];
|
|
736
|
-
channels: NotificationChannel[];
|
|
737
|
-
title: string;
|
|
738
|
-
message: string;
|
|
739
|
-
priority?: "low" | "normal" | "high" | undefined;
|
|
740
|
-
}
|
|
741
|
-
interface CodeTriggerDefaults {
|
|
742
|
-
recipients: string[];
|
|
743
|
-
channels: NotificationChannel[];
|
|
744
|
-
title: string;
|
|
745
|
-
message: string;
|
|
746
|
-
priority?: "low" | "normal" | "high" | undefined;
|
|
747
|
-
}
|
|
748
|
-
declare function resolveEffectiveSettings(triggerType: string, organizationId: string | null, rows: readonly NotificationTriggerOverrideDoc[], defaults: CodeTriggerDefaults): EffectiveTriggerSettings;
|
|
749
|
-
//#endregion
|
|
750
886
|
//#region src/triggerAdapter.d.ts
|
|
751
887
|
/**
|
|
752
888
|
* `entityType` → the `extract` variable holding that entity's id.
|
|
@@ -764,6 +900,37 @@ interface TriggerAdapterOptions {
|
|
|
764
900
|
* still delivers, it just has nothing to deep-link to.
|
|
765
901
|
*/
|
|
766
902
|
entityIdVars?: Record<string, string>;
|
|
903
|
+
/**
|
|
904
|
+
* Observe a notification that was NOT delivered, and why.
|
|
905
|
+
*
|
|
906
|
+
* Optional and side-effect-free by default: a host that wires nothing gets
|
|
907
|
+
* today's behaviour exactly. Wire it to a metrics counter — `triggerType` and
|
|
908
|
+
* `reason` are both closed sets, so cardinality stays bounded.
|
|
909
|
+
*
|
|
910
|
+
* ## Counters, not rows
|
|
911
|
+
*
|
|
912
|
+
* This intentionally hands over a value rather than writing anything. A row
|
|
913
|
+
* per skipped (event x trigger x channel) would out-write real deliveries —
|
|
914
|
+
* every POS order multiplied by every candidate trigger and channel — to
|
|
915
|
+
* answer a question no operator has asked. And persistence is product
|
|
916
|
+
* concern: this package is database-agnostic and must stay that way.
|
|
917
|
+
*
|
|
918
|
+
* ## Why it exists at all
|
|
919
|
+
*
|
|
920
|
+
* Without it a skip is a bare `null`, and "the operator scoped this to web"
|
|
921
|
+
* is indistinguishable from "the trigger never matched" and from "recipient
|
|
922
|
+
* resolution returned empty". A misconfigured scope then silently stops
|
|
923
|
+
* alerts with nothing to look at.
|
|
924
|
+
*
|
|
925
|
+
* Must not throw: it runs on the dispatch path. Failures here are the host's
|
|
926
|
+
* to contain.
|
|
927
|
+
*/
|
|
928
|
+
onDecision?: (decision: {
|
|
929
|
+
triggerType: string;
|
|
930
|
+
channel: NotificationChannel;
|
|
931
|
+
reason: NotificationSkipReason;
|
|
932
|
+
organizationId: string | null;
|
|
933
|
+
}) => void;
|
|
767
934
|
/**
|
|
768
935
|
* ADMIN OVERRIDES — when supplied, each trigger expands into one module
|
|
769
936
|
* trigger PER CHANNEL, and every one is gated on the stored settings for the
|
|
@@ -785,10 +952,22 @@ interface TriggerAdapterOptions {
|
|
|
785
952
|
* an operator can actually use.
|
|
786
953
|
*
|
|
787
954
|
* OMITTED = unchanged behaviour: one trigger, static channels from the
|
|
788
|
-
* `
|
|
789
|
-
*
|
|
955
|
+
* trigger's own `channels` list. Every existing host keeps its exact current
|
|
956
|
+
* dispatch until it opts in.
|
|
790
957
|
*/
|
|
791
958
|
overrides?: TriggerOverrideResolution;
|
|
959
|
+
/**
|
|
960
|
+
* Which channels this deployment can deliver on — see {@link NotificationChannelCatalog}.
|
|
961
|
+
*
|
|
962
|
+
* A host builds it once from its integrations registry and passes the SAME
|
|
963
|
+
* instance here and to the override resource, so trigger settings offer
|
|
964
|
+
* exactly what validation accepts. Registering a provider is then the only
|
|
965
|
+
* step to gain a channel.
|
|
966
|
+
*
|
|
967
|
+
* Omitted, each trigger falls back to the channels it declares in code — so
|
|
968
|
+
* the worst case is "no new channels offered", never "no channels".
|
|
969
|
+
*/
|
|
970
|
+
catalog?: NotificationChannelCatalog;
|
|
792
971
|
}
|
|
793
972
|
/**
|
|
794
973
|
* How the adapter reaches stored settings for the organization on an event.
|
|
@@ -879,4 +1058,4 @@ interface TriggerCatalogEntry {
|
|
|
879
1058
|
*/
|
|
880
1059
|
type TriggerCatalogPort = () => ReadonlyArray<TriggerCatalogEntry> | Promise<ReadonlyArray<TriggerCatalogEntry>>;
|
|
881
1060
|
//#endregion
|
|
882
|
-
export { type CodeTemplates, type CodeTriggerDefaults, type CreateCachedTriggerOverrideStoreOptions, type CreateUnreadCountCacheOptions, DEFAULT_ENTITY_ID_VARS, type DbFirstResolverOptions, type DbFirstTemplateResolver, type DeliveryLogDoc, type EffectiveTriggerSettings, INAPP_DATA_KEY, 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,
|
|
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 };
|
package/dist/index.mjs
CHANGED
|
@@ -38,6 +38,233 @@ function createInAppBackfill(options) {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
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
|
|
41
268
|
//#region src/dbFirstResolver.ts
|
|
42
269
|
/**
|
|
43
270
|
* DB-first template resolver — generalized from be-prod's proven
|
|
@@ -107,7 +334,7 @@ function createDbFirstResolver(options) {
|
|
|
107
334
|
/** Reserved `payload.data` key carrying the rendered in-app content. */
|
|
108
335
|
const INAPP_DATA_KEY = "__inApp";
|
|
109
336
|
/** `{var}` interpolation — unknown keys are left as-is (visible, not silently blanked). */
|
|
110
|
-
function interpolate(template, vars) {
|
|
337
|
+
function interpolate$1(template, vars) {
|
|
111
338
|
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
112
339
|
const v = vars[key];
|
|
113
340
|
return v === void 0 || v === null ? `{${key}}` : String(v);
|
|
@@ -116,11 +343,11 @@ function interpolate(template, vars) {
|
|
|
116
343
|
/** Render a trigger's in-app template against event data + priority. */
|
|
117
344
|
function renderInApp(template, vars, priority = "normal") {
|
|
118
345
|
return {
|
|
119
|
-
title: interpolate(template.title, vars),
|
|
120
|
-
message: interpolate(template.message, vars),
|
|
121
|
-
...template.link !== void 0 ? { link: interpolate(template.link, vars) } : {},
|
|
122
|
-
...template.entityType !== void 0 ? { entityType: interpolate(template.entityType, vars) } : {},
|
|
123
|
-
...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) } : {},
|
|
124
351
|
priority
|
|
125
352
|
};
|
|
126
353
|
}
|
|
@@ -633,7 +860,22 @@ async function dispatchTrigger(service, trigger, event, opts) {
|
|
|
633
860
|
...subject ? { subject } : {},
|
|
634
861
|
metadata: {
|
|
635
862
|
source: "arc-notifications",
|
|
636
|
-
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 } : {}
|
|
637
879
|
}
|
|
638
880
|
});
|
|
639
881
|
}
|
|
@@ -885,117 +1127,6 @@ function templatesResource(options) {
|
|
|
885
1127
|
});
|
|
886
1128
|
}
|
|
887
1129
|
//#endregion
|
|
888
|
-
//#region src/triggerOverride.ts
|
|
889
|
-
/**
|
|
890
|
-
* Notification trigger OVERRIDES — the admin-editable half of the registry.
|
|
891
|
-
*
|
|
892
|
-
* ## The split, and why it is not "make triggers DB rows"
|
|
893
|
-
*
|
|
894
|
-
* A `NotificationTrigger` carries two very different kinds of thing:
|
|
895
|
-
*
|
|
896
|
-
* | stays CODE | becomes DATA |
|
|
897
|
-
* |---|---|
|
|
898
|
-
* | `event` — what to subscribe to | `enabled` — does this fire here at all |
|
|
899
|
-
* | `extract` — a typed function: filters the event and pulls variables | `recipients` — which roles get told |
|
|
900
|
-
* | | `channels` — in-app / email / whatsapp / sms |
|
|
901
|
-
* | | `template` — the copy an operator wants to reword |
|
|
902
|
-
*
|
|
903
|
-
* `extract` is the reason the whole registry cannot become rows. It decides
|
|
904
|
-
* whether a broad event is even relevant (`chargeStatus === 'failed'`) and maps
|
|
905
|
-
* a payload to variables — expressing that as data means shipping an evaluator,
|
|
906
|
-
* which is a code-execution surface in the notification path. Odoo draws the
|
|
907
|
-
* same line: `base.automation` rows hold the trigger and the filter, while the
|
|
908
|
-
* action BODY is Python in `ir.actions.server`.
|
|
909
|
-
*
|
|
910
|
-
* So the code registry is the CATALOG (what this deployment is capable of
|
|
911
|
-
* announcing) and these rows are the CONFIGURATION (what a given scope has
|
|
912
|
-
* actually turned on, to whom, over which channel).
|
|
913
|
-
*
|
|
914
|
-
* ## Scoped per ORGANIZATION, with an app-wide fallback
|
|
915
|
-
*
|
|
916
|
-
* `organizationId: null` is the app-wide default; a row with an organization id
|
|
917
|
-
* overrides it for that scope only. Absence of a row means "use the code
|
|
918
|
-
* default" — a fresh deployment behaves exactly as its registry declares, and
|
|
919
|
-
* turning the feature on requires an operator to actually decide something.
|
|
920
|
-
*
|
|
921
|
-
* ## Kit-less by design
|
|
922
|
-
*
|
|
923
|
-
* This package carries NO database dependency (see `repositoryDeliveryLog` /
|
|
924
|
-
* `templatesResource` — the same posture). This file owns the ROW SHAPE, the
|
|
925
|
-
* store PORT, the TTL-cached store wrapper over a load port, and the effective-
|
|
926
|
-
* settings RESOLUTION. The persistence binding (a mongoose model and its
|
|
927
|
-
* indexes, in the product layer) supplies `load`.
|
|
928
|
-
*/
|
|
929
|
-
/** The channels a trigger can be routed to. `in-app` is the dashboard feed. */
|
|
930
|
-
const NOTIFICATION_CHANNELS = [
|
|
931
|
-
"in-app",
|
|
932
|
-
"email",
|
|
933
|
-
"whatsapp",
|
|
934
|
-
"sms"
|
|
935
|
-
];
|
|
936
|
-
/**
|
|
937
|
-
* TTL-cached {@link TriggerOverrideStore} over a load port.
|
|
938
|
-
*
|
|
939
|
-
* `invalidate()` exists for the write path: the admin resource calls it after
|
|
940
|
-
* any mutation so an operator's change applies within one save rather than one
|
|
941
|
-
* TTL window.
|
|
942
|
-
*/
|
|
943
|
-
function createCachedTriggerOverrideStore(load, options = {}) {
|
|
944
|
-
const ttlMs = options.ttlMs ?? 3e4;
|
|
945
|
-
const cache = /* @__PURE__ */ new Map();
|
|
946
|
-
return {
|
|
947
|
-
async forOrganization(organizationId) {
|
|
948
|
-
const key = organizationId ?? "__company__";
|
|
949
|
-
const hit = cache.get(key);
|
|
950
|
-
if (hit && Date.now() - hit.at < ttlMs) return hit.rows;
|
|
951
|
-
const rows = await load(organizationId);
|
|
952
|
-
cache.set(key, {
|
|
953
|
-
at: Date.now(),
|
|
954
|
-
rows
|
|
955
|
-
});
|
|
956
|
-
return rows;
|
|
957
|
-
},
|
|
958
|
-
/** Call after a write so an admin sees their change immediately. */
|
|
959
|
-
invalidate() {
|
|
960
|
-
cache.clear();
|
|
961
|
-
}
|
|
962
|
-
};
|
|
963
|
-
}
|
|
964
|
-
function resolveEffectiveSettings(triggerType, organizationId, rows, defaults) {
|
|
965
|
-
const mine = rows.filter((r) => r.triggerType === triggerType);
|
|
966
|
-
const scoped = organizationId ? mine.find((r) => r.organizationId === organizationId) : void 0;
|
|
967
|
-
const appWide = mine.find((r) => r.organizationId == null);
|
|
968
|
-
/**
|
|
969
|
-
* `??` throughout, and the field it actually protects is `enabled`.
|
|
970
|
-
*
|
|
971
|
-
* An earlier version of this comment claimed `||` would swallow an EMPTY
|
|
972
|
-
* recipients array. It would not — `[]` is truthy in JS — and a falsification
|
|
973
|
-
* proved it: swapping the operator on `recipients` changed nothing. The real
|
|
974
|
-
* hazard is `enabled: false`, where `false || true` silently re-enables a
|
|
975
|
-
* trigger an operator deliberately switched off. That one IS falsifiable, and
|
|
976
|
-
* the trigger-override test fails when the operator is swapped.
|
|
977
|
-
*
|
|
978
|
-
* Recorded rather than quietly corrected: a comment asserting a guarantee the
|
|
979
|
-
* code does not have is the same defect as a guard that cannot fail, and the
|
|
980
|
-
* next reader would have "protected" the wrong line.
|
|
981
|
-
*/
|
|
982
|
-
const pick = (key) => scoped?.[key] ?? appWide?.[key] ?? void 0;
|
|
983
|
-
const enabled = pick("enabled") ?? true;
|
|
984
|
-
const recipients = pick("recipients") ?? defaults.recipients;
|
|
985
|
-
const channels = pick("channels") ?? defaults.channels;
|
|
986
|
-
const title = pick("title") ?? defaults.title;
|
|
987
|
-
const message = pick("message") ?? defaults.message;
|
|
988
|
-
const priority = pick("priority") ?? defaults.priority;
|
|
989
|
-
return {
|
|
990
|
-
enabled,
|
|
991
|
-
recipients,
|
|
992
|
-
channels,
|
|
993
|
-
title,
|
|
994
|
-
message,
|
|
995
|
-
...priority ? { priority } : {}
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
|
-
//#endregion
|
|
999
1130
|
//#region src/triggerAdapter.ts
|
|
1000
1131
|
/**
|
|
1001
1132
|
* `entityType` → the `extract` variable holding that entity's id.
|
|
@@ -1023,15 +1154,38 @@ function toNotifyTrigger(t, options = {}) {
|
|
|
1023
1154
|
...options.entityIdVars
|
|
1024
1155
|
};
|
|
1025
1156
|
const entityVar = t.template.entityType ? entityIdVars[t.template.entityType] : void 0;
|
|
1026
|
-
const channels = t
|
|
1027
|
-
if (t.sendEmail) channels.push("email");
|
|
1028
|
-
if (t.sendWhatsapp) channels.push("whatsapp");
|
|
1029
|
-
if (t.sendSms) channels.push("sms");
|
|
1157
|
+
const channels = declaredChannels(t);
|
|
1030
1158
|
return {
|
|
1031
1159
|
event: t.event,
|
|
1032
1160
|
template: type,
|
|
1033
1161
|
...t.priority ? { priority: t.priority } : {},
|
|
1034
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
|
+
} } : {},
|
|
1035
1189
|
audience: (e) => {
|
|
1036
1190
|
const ex = t.extract(e.payload ?? {});
|
|
1037
1191
|
if (!ex) return null;
|
|
@@ -1048,7 +1202,13 @@ function toNotifyTrigger(t, options = {}) {
|
|
|
1048
1202
|
...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
|
|
1049
1203
|
};
|
|
1050
1204
|
},
|
|
1051
|
-
data: (e) =>
|
|
1205
|
+
data: (e) => {
|
|
1206
|
+
const vars = t.extract(e.payload ?? {})?.variables ?? {};
|
|
1207
|
+
return {
|
|
1208
|
+
...vars,
|
|
1209
|
+
...copyFor(t, vars)
|
|
1210
|
+
};
|
|
1211
|
+
},
|
|
1052
1212
|
inApp: {
|
|
1053
1213
|
title: t.template.title,
|
|
1054
1214
|
message: t.template.message,
|
|
@@ -1058,13 +1218,56 @@ function toNotifyTrigger(t, options = {}) {
|
|
|
1058
1218
|
}
|
|
1059
1219
|
};
|
|
1060
1220
|
}
|
|
1061
|
-
/**
|
|
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
|
+
*/
|
|
1062
1269
|
function declaredChannels(t) {
|
|
1063
|
-
|
|
1064
|
-
if (t.sendEmail) channels.push("email");
|
|
1065
|
-
if (t.sendWhatsapp) channels.push("whatsapp");
|
|
1066
|
-
if (t.sendSms) channels.push("sms");
|
|
1067
|
-
return channels;
|
|
1270
|
+
return [...t.audienceType === "customer" ? [] : [IN_APP_CHANNEL], ...t.channels ?? []];
|
|
1068
1271
|
}
|
|
1069
1272
|
/**
|
|
1070
1273
|
* Every channel an override could ever turn ON for this trigger.
|
|
@@ -1075,13 +1278,27 @@ function declaredChannels(t) {
|
|
|
1075
1278
|
* dashboard users, so a feed row for them is one nobody can read, and no
|
|
1076
1279
|
* override should be able to create it.
|
|
1077
1280
|
*/
|
|
1078
|
-
function candidateChannels(t) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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];
|
|
1085
1302
|
}
|
|
1086
1303
|
/**
|
|
1087
1304
|
* One module trigger per CHANNEL, each gated on the stored settings.
|
|
@@ -1100,7 +1317,7 @@ function toGatedNotifyTriggers(t, options, overrides) {
|
|
|
1100
1317
|
message: t.template.message,
|
|
1101
1318
|
...t.priority ? { priority: t.priority } : {}
|
|
1102
1319
|
};
|
|
1103
|
-
return candidateChannels(t).map((channel) => ({
|
|
1320
|
+
return candidateChannels(t, options.catalog).map((channel) => ({
|
|
1104
1321
|
...base,
|
|
1105
1322
|
channels: [channel],
|
|
1106
1323
|
audience: async (e) => {
|
|
@@ -1108,7 +1325,43 @@ function toGatedNotifyTriggers(t, options, overrides) {
|
|
|
1108
1325
|
if (!ex) return null;
|
|
1109
1326
|
const rows = await overrides.store.forOrganization(ex.organizationId ?? null);
|
|
1110
1327
|
const eff = resolveEffectiveSettings(type, ex.organizationId ?? null, rows, defaults);
|
|
1111
|
-
|
|
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);
|
|
1112
1365
|
if (!eff.channels.includes(channel)) return null;
|
|
1113
1366
|
if (t.audienceType === "customer") {
|
|
1114
1367
|
if (!ex.recipient) return null;
|
|
@@ -1117,7 +1370,7 @@ function toGatedNotifyTriggers(t, options, overrides) {
|
|
|
1117
1370
|
organizationId: ex.organizationId
|
|
1118
1371
|
};
|
|
1119
1372
|
}
|
|
1120
|
-
if (eff.recipients.length === 0) return
|
|
1373
|
+
if (eff.recipients.length === 0) return skip("no_recipients");
|
|
1121
1374
|
return {
|
|
1122
1375
|
roles: eff.recipients,
|
|
1123
1376
|
organizationId: ex.organizationId,
|
|
@@ -1168,4 +1421,4 @@ function getRecipientMatrix(triggers) {
|
|
|
1168
1421
|
return matrix;
|
|
1169
1422
|
}
|
|
1170
1423
|
//#endregion
|
|
1171
|
-
export { DEFAULT_ENTITY_ID_VARS, INAPP_DATA_KEY,
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-notifications",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|