@classytic/arc-notifications 0.2.0 → 0.4.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 +410 -6
- package/dist/index.mjs +348 -33
- package/package.json +7 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Classytic LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -197,3 +197,7 @@ direct queue dependency.
|
|
|
197
197
|
|
|
198
198
|
Generalized from be-prod's production `shared/notifications` (db-first resolver) +
|
|
199
199
|
`resources/notifications` (email-template model, admin resource, event-trigger registry).
|
|
200
|
+
|
|
201
|
+
## Trademark
|
|
202
|
+
|
|
203
|
+
MIT-licensed code. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see [TRADEMARK.md](TRADEMARK.md).
|
package/dist/index.d.mts
CHANGED
|
@@ -5,7 +5,6 @@ import { PermissionCheck } from "@classytic/arc/permissions";
|
|
|
5
5
|
import { FastifyInstance, FastifyRequest } from "fastify";
|
|
6
6
|
import { DomainEvent } from "@classytic/primitives/events";
|
|
7
7
|
import { RepositoryLike } from "@classytic/repo-core/adapter";
|
|
8
|
-
|
|
9
8
|
//#region src/inApp.d.ts
|
|
10
9
|
/**
|
|
11
10
|
* In-app notification content — the shared seam between a `notifyOn` trigger's
|
|
@@ -131,6 +130,23 @@ interface InAppFeedRepository<TRow = unknown> {
|
|
|
131
130
|
* `DELETE /:id`. Hard vs soft delete is the host's call.
|
|
132
131
|
*/
|
|
133
132
|
delete?(organizationId: string, userId: string, id: string): Promise<boolean>;
|
|
133
|
+
/**
|
|
134
|
+
* OPTIONAL — delete every ALREADY-READ row for this user; returns the count.
|
|
135
|
+
* When implemented, the feed resource registers `DELETE /read`.
|
|
136
|
+
*
|
|
137
|
+
* Read-only by design, and that is the whole safety property: an unread row
|
|
138
|
+
* is one the user has not seen, so a "clear" that removed it would destroy
|
|
139
|
+
* the notification instead of dismissing it — silently, since nothing can
|
|
140
|
+
* report a message nobody read. Odoo's garbage collector draws the same line
|
|
141
|
+
* (it vacuums SENT notifications and keeps failures), and per-row `delete`
|
|
142
|
+
* remains available for deliberately discarding something unread.
|
|
143
|
+
*
|
|
144
|
+
* This exists because `markAllRead` is not a clear: it greys the list and
|
|
145
|
+
* leaves every row in place, so an operator who wants an empty inbox has no
|
|
146
|
+
* verb at all — the state they are asking for is unreachable one row at a
|
|
147
|
+
* time.
|
|
148
|
+
*/
|
|
149
|
+
deleteRead?(organizationId: string, userId: string): Promise<number>;
|
|
134
150
|
}
|
|
135
151
|
/**
|
|
136
152
|
* Cache seam for the unread badge count — the hottest query in the system
|
|
@@ -278,9 +294,9 @@ interface InAppChannelConfig extends ChannelConfig {
|
|
|
278
294
|
}
|
|
279
295
|
declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
|
|
280
296
|
private readonly repository;
|
|
281
|
-
private readonly port
|
|
297
|
+
private readonly port;
|
|
282
298
|
private readonly eventName;
|
|
283
|
-
private readonly unreadCache
|
|
299
|
+
private readonly unreadCache;
|
|
284
300
|
constructor(config: InAppChannelConfig);
|
|
285
301
|
/** Skip (not fail) when this payload isn't an in-app notification. */
|
|
286
302
|
canSend(payload: NotificationPayload): SendResult | null;
|
|
@@ -364,6 +380,24 @@ interface NotifyTrigger<TPayload = unknown> {
|
|
|
364
380
|
readonly priority?: InAppPriority;
|
|
365
381
|
/** Template variables. Default: the event payload (when object-shaped). */
|
|
366
382
|
readonly data?: (event: DomainEvent<TPayload>) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
383
|
+
/**
|
|
384
|
+
* The BUSINESS DOCUMENT this trigger's notifications are about, extracted
|
|
385
|
+
* from the event — `{ sourceModel, sourceId }`.
|
|
386
|
+
*
|
|
387
|
+
* Belongs HERE, beside `recipient` and `data`, for the same reason they do:
|
|
388
|
+
* the notification control centre splits ownership so that CODE owns the
|
|
389
|
+
* event and its extraction while the DATABASE owns channels and recipients.
|
|
390
|
+
* Which document an event concerns is a fact about the event's shape, so it
|
|
391
|
+
* is code — an operator cannot configure it and must never have to.
|
|
392
|
+
*
|
|
393
|
+
* Omit for events that concern no single document (a segment broadcast). A
|
|
394
|
+
* fabricated subject is worse than none: it makes a document's send history
|
|
395
|
+
* show messages that were never about it.
|
|
396
|
+
*/
|
|
397
|
+
readonly subject?: (event: DomainEvent<TPayload>) => {
|
|
398
|
+
sourceModel: string;
|
|
399
|
+
sourceId: string;
|
|
400
|
+
} | undefined;
|
|
367
401
|
/**
|
|
368
402
|
* Dedup key for at-least-once transports. Default:
|
|
369
403
|
* `meta.idempotencyKey ?? "<type>:<meta.id>"`, suffixed per recipient on
|
|
@@ -373,12 +407,24 @@ interface NotifyTrigger<TPayload = unknown> {
|
|
|
373
407
|
/** Compose-time kill switch (env-gated triggers). Default true. */
|
|
374
408
|
readonly enabled?: boolean;
|
|
375
409
|
}
|
|
376
|
-
interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "queue"> {
|
|
410
|
+
interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "queue" | "channels"> {
|
|
411
|
+
/**
|
|
412
|
+
* The channels to dispatch through — a value, or a FUNCTION resolved at bootstrap.
|
|
413
|
+
*
|
|
414
|
+
* The function form exists for the same reason `queue`'s does: a channel can be expensive to
|
|
415
|
+
* build. A host wiring a provider-backed channel has to construct a hub, a credential vault and
|
|
416
|
+
* a repository to do it, and passing the built array means all of that runs while the module
|
|
417
|
+
* GRAPH is being composed — before arc has validated it, for a module the deployment may still
|
|
418
|
+
* reject. In be-prod that registered a mongoose model and derived an AES key at composition.
|
|
419
|
+
*
|
|
420
|
+
* Resolved inside `bootstrap`, where the service is constructed anyway, so nothing else moves.
|
|
421
|
+
*/
|
|
422
|
+
readonly channels?: NotificationServiceConfig["channels"] | ((fastify: FastifyInstance) => NotificationServiceConfig["channels"] | Promise<NotificationServiceConfig["channels"]>);
|
|
377
423
|
/** Module name (registry + export-map key). Default "notifications". */
|
|
378
424
|
readonly name?: string;
|
|
379
425
|
/** Bring-your-own service — skips construction; service config fields ignored. */
|
|
380
426
|
readonly service?: NotificationService;
|
|
381
|
-
/** Declarative event → notification triggers,
|
|
427
|
+
/** Declarative event → notification triggers, contributed as `eventHandlers`. */
|
|
382
428
|
readonly notifyOn?: readonly NotifyTrigger[];
|
|
383
429
|
/**
|
|
384
430
|
* Resolves a trigger's `audience` selector to recipients (the shared
|
|
@@ -423,6 +469,15 @@ interface DeliveryLogDoc {
|
|
|
423
469
|
readonly status: EntryStatus;
|
|
424
470
|
readonly duration: number;
|
|
425
471
|
readonly metadata?: Record<string, unknown>;
|
|
472
|
+
/**
|
|
473
|
+
* The business document this delivery concerned. A STORE MUST INDEX IT —
|
|
474
|
+
* this field exists precisely so "every message about invoice X" is a keyed
|
|
475
|
+
* read rather than a scan of the unindexed `metadata` blob.
|
|
476
|
+
*/
|
|
477
|
+
readonly subject?: {
|
|
478
|
+
readonly sourceModel: string;
|
|
479
|
+
readonly sourceId: string;
|
|
480
|
+
};
|
|
426
481
|
}
|
|
427
482
|
interface RepositoryDeliveryLogOptions {
|
|
428
483
|
/** Cap for `query()` when the caller gives no limit. Default 100. */
|
|
@@ -475,4 +530,353 @@ interface TemplatesResourceOptions {
|
|
|
475
530
|
}
|
|
476
531
|
declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
|
|
477
532
|
//#endregion
|
|
478
|
-
|
|
533
|
+
//#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
|
+
/** Who a trigger targets. */
|
|
553
|
+
type NotificationAudienceType = "staff" | "customer";
|
|
554
|
+
/** Direct contact for a customer-facing notification. */
|
|
555
|
+
interface NotificationRecipientContact {
|
|
556
|
+
email?: string;
|
|
557
|
+
phone?: string;
|
|
558
|
+
name?: string;
|
|
559
|
+
}
|
|
560
|
+
/** What `extract` returns: the scope, the template variables, and the target. */
|
|
561
|
+
interface NotificationExtractResult {
|
|
562
|
+
organizationId: string;
|
|
563
|
+
variables: Record<string, string>;
|
|
564
|
+
triggeredBy?: string;
|
|
565
|
+
/** Direct contact for `audienceType: 'customer'`. */
|
|
566
|
+
recipient?: NotificationRecipientContact;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* One notification, declared.
|
|
570
|
+
*
|
|
571
|
+
* Adding a notification is adding one of these to the host's registry — nothing
|
|
572
|
+
* else to touch.
|
|
573
|
+
*/
|
|
574
|
+
interface NotificationTrigger {
|
|
575
|
+
/** Arc event name to subscribe to (e.g. `'order:created'`). */
|
|
576
|
+
event: string;
|
|
577
|
+
/**
|
|
578
|
+
* Notification kind — the feed type AND the email template id. Defaults to
|
|
579
|
+
* the event name.
|
|
580
|
+
*
|
|
581
|
+
* Two triggers MAY share an event (an informational alert plus an escalation);
|
|
582
|
+
* distinct `type` values are what keep them independently filterable.
|
|
583
|
+
*/
|
|
584
|
+
type?: string;
|
|
585
|
+
/** In-app template. `{variable}` placeholders resolve against `extract`'s variables. */
|
|
586
|
+
template: {
|
|
587
|
+
title: string;
|
|
588
|
+
message: string;
|
|
589
|
+
/** Frontend route, with `{variable}` placeholders. */
|
|
590
|
+
link?: string;
|
|
591
|
+
/** Entity kind, for badge/icon grouping and entity-id derivation. */
|
|
592
|
+
entityType?: string;
|
|
593
|
+
};
|
|
594
|
+
/**
|
|
595
|
+
* Roles that receive this notification. `'*'` = all branch members.
|
|
596
|
+
* Ignored when `audienceType` is `'customer'` — that target comes from
|
|
597
|
+
* `extract`'s `recipient`.
|
|
598
|
+
*/
|
|
599
|
+
recipients: string[];
|
|
600
|
+
/**
|
|
601
|
+
* `'staff'` (default) resolves branch members by `recipients` roles.
|
|
602
|
+
* `'customer'` targets the direct contact `extract` returns, with NO staff
|
|
603
|
+
* lookup and NO in-app feed row (buyers are not dashboard users).
|
|
604
|
+
*/
|
|
605
|
+
audienceType?: NotificationAudienceType;
|
|
606
|
+
priority?: "low" | "normal" | "high";
|
|
607
|
+
/** Fan out to the connected email provider. */
|
|
608
|
+
sendEmail?: boolean;
|
|
609
|
+
/** Fan out to the connected WhatsApp provider. */
|
|
610
|
+
sendWhatsapp?: boolean;
|
|
611
|
+
/** Fan out to the connected SMS provider. */
|
|
612
|
+
sendSms?: boolean;
|
|
613
|
+
/**
|
|
614
|
+
* Pull dispatch variables out of the event payload.
|
|
615
|
+
*
|
|
616
|
+
* Returning `null` SKIPS the notification — this is the filter seam. A
|
|
617
|
+
* trigger that subscribes to a broad event (`order:payment.state_updated`)
|
|
618
|
+
* and only wants one case (`chargeStatus === 'failed'`) expresses that here,
|
|
619
|
+
* rather than notifying on every state change.
|
|
620
|
+
*/
|
|
621
|
+
extract: (payload: Record<string, any>) => NotificationExtractResult | null;
|
|
622
|
+
}
|
|
623
|
+
//#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
|
+
//#region src/triggerAdapter.d.ts
|
|
751
|
+
/**
|
|
752
|
+
* `entityType` → the `extract` variable holding that entity's id.
|
|
753
|
+
*
|
|
754
|
+
* The in-app row wants an `entityId` for deep-linking and badge grouping, but
|
|
755
|
+
* the variable it lives under is named per entity. This is the default
|
|
756
|
+
* commerce mapping; a host with its own entity kinds passes `entityIdVars`.
|
|
757
|
+
*/
|
|
758
|
+
declare const DEFAULT_ENTITY_ID_VARS: Record<string, string>;
|
|
759
|
+
interface TriggerAdapterOptions {
|
|
760
|
+
/**
|
|
761
|
+
* Extra `entityType` → id-variable mappings, merged over the defaults.
|
|
762
|
+
*
|
|
763
|
+
* An unmapped `entityType` simply yields no `entityId` — the notification
|
|
764
|
+
* still delivers, it just has nothing to deep-link to.
|
|
765
|
+
*/
|
|
766
|
+
entityIdVars?: Record<string, string>;
|
|
767
|
+
/**
|
|
768
|
+
* ADMIN OVERRIDES — when supplied, each trigger expands into one module
|
|
769
|
+
* trigger PER CHANNEL, and every one is gated on the stored settings for the
|
|
770
|
+
* event's organization.
|
|
771
|
+
*
|
|
772
|
+
* ## Why expansion rather than one trigger with dynamic channels
|
|
773
|
+
*
|
|
774
|
+
* `NotifyTrigger.channels` is a STATIC `readonly string[]` — arc resolves it
|
|
775
|
+
* at registration, not per dispatch. So "this scope wants order alerts on
|
|
776
|
+
* WhatsApp but not email" cannot be expressed by mutating one trigger's
|
|
777
|
+
* channel list at send time. It CAN be expressed as separate triggers, each
|
|
778
|
+
* restricted to one channel, each skipping when that channel is off for the
|
|
779
|
+
* scope — `audience` IS a per-event async function, which is the seam that
|
|
780
|
+
* makes this possible without changing arc.
|
|
781
|
+
*
|
|
782
|
+
* The cost is N subscriptions per trigger instead of one, and `extract` runs
|
|
783
|
+
* per channel. `extract` is pure and cheap by contract (the adapter already
|
|
784
|
+
* calls it twice per dispatch), so this trades a little fan-out for a control
|
|
785
|
+
* an operator can actually use.
|
|
786
|
+
*
|
|
787
|
+
* OMITTED = unchanged behaviour: one trigger, static channels from the
|
|
788
|
+
* `sendEmail`/`sendWhatsapp`/`sendSms` flags. Every existing host keeps its
|
|
789
|
+
* exact current dispatch until it opts in.
|
|
790
|
+
*/
|
|
791
|
+
overrides?: TriggerOverrideResolution;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* How the adapter reaches stored settings for the organization on an event.
|
|
795
|
+
*
|
|
796
|
+
* Just the store. The ORGANIZATION comes from the trigger's own `extract`
|
|
797
|
+
* (`ex.organizationId`) rather than a second resolver here — `extract` is
|
|
798
|
+
* already the one place that knows how a given event carries its scope, and a
|
|
799
|
+
* parallel answer would be a second place to get it wrong.
|
|
800
|
+
*/
|
|
801
|
+
interface TriggerOverrideResolution {
|
|
802
|
+
store: TriggerOverrideStore;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Adapt one authoring trigger to one module trigger.
|
|
806
|
+
*
|
|
807
|
+
* Note `extract` is invoked separately by `audience` and by `data`. The module
|
|
808
|
+
* calls both per dispatch, so an `extract` with side effects would run them
|
|
809
|
+
* twice — keep extractors pure (every one in practice is a payload reshape).
|
|
810
|
+
*/
|
|
811
|
+
declare function toNotifyTrigger(t: NotificationTrigger, options?: TriggerAdapterOptions): NotifyTrigger;
|
|
812
|
+
/**
|
|
813
|
+
* Adapt a whole registry — what a host assigns to the module's `notifyOn`.
|
|
814
|
+
*
|
|
815
|
+
* With `options.overrides`, each trigger becomes one entry per channel, gated on
|
|
816
|
+
* the admin's stored settings. Without it, the mapping is 1:1 and unchanged.
|
|
817
|
+
*/
|
|
818
|
+
declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], options?: TriggerAdapterOptions): NotifyTrigger[];
|
|
819
|
+
/**
|
|
820
|
+
* First trigger registered for an event.
|
|
821
|
+
*
|
|
822
|
+
* CAVEAT: an event may carry more than one trigger (an informational alert and
|
|
823
|
+
* an escalation to a different role set both listen to `stock:low`). This
|
|
824
|
+
* returns only the first. For anything that must see all of them — a docs page,
|
|
825
|
+
* an admin matrix, a dispatch count — use `getTriggersByEvent`.
|
|
826
|
+
*/
|
|
827
|
+
declare function getTriggerByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger | undefined;
|
|
828
|
+
/** Every trigger registered for an event, in registration order. */
|
|
829
|
+
declare function getTriggersByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger[];
|
|
830
|
+
/** Distinct event names the registry subscribes to (for docs / admin UI). */
|
|
831
|
+
declare function getRegisteredEvents(triggers: readonly NotificationTrigger[]): string[];
|
|
832
|
+
/**
|
|
833
|
+
* Notification kind → recipient roles (for the admin UI).
|
|
834
|
+
*
|
|
835
|
+
* Keyed by `type ?? event`, so two triggers sharing an event stay distinct
|
|
836
|
+
* rows — which is exactly why they carry different `type`s.
|
|
837
|
+
*/
|
|
838
|
+
declare function getRecipientMatrix(triggers: readonly NotificationTrigger[]): Record<string, string[]>;
|
|
839
|
+
//#endregion
|
|
840
|
+
//#region src/triggerCatalog.d.ts
|
|
841
|
+
/**
|
|
842
|
+
* One trigger as an operator sees it, with the CODE defaults an override replaces.
|
|
843
|
+
*
|
|
844
|
+
* Deliberately not the internal trigger shape: `extract` is a function and cannot
|
|
845
|
+
* cross the wire, and a UI has no use for it. What a settings screen needs is the
|
|
846
|
+
* identity, a human label, and the defaults so it can show "customised" against
|
|
847
|
+
* "default" without guessing.
|
|
848
|
+
*/
|
|
849
|
+
interface TriggerCatalogEntry {
|
|
850
|
+
/**
|
|
851
|
+
* The override key — a trigger's `type`, falling back to its `event`.
|
|
852
|
+
*
|
|
853
|
+
* This is the same identifier `toNotifyTrigger` uses as the template id, so a row
|
|
854
|
+
* created from this screen addresses the trigger the dispatch path will look up. A
|
|
855
|
+
* settings UI keyed on anything else would write rows nothing reads.
|
|
856
|
+
*/
|
|
857
|
+
triggerType: string;
|
|
858
|
+
/** The domain event it listens to, shown so an operator can trace cause to effect. */
|
|
859
|
+
event: string;
|
|
860
|
+
/** Human label. Falls back to `triggerType` when the host supplies none. */
|
|
861
|
+
label: string;
|
|
862
|
+
/** Optional grouping for the UI ("Orders", "Inventory"). */
|
|
863
|
+
group?: string;
|
|
864
|
+
/** Code-default recipient roles, replaced by an override's `recipients`. */
|
|
865
|
+
defaultRecipients: string[];
|
|
866
|
+
/** Code-default channels, replaced by an override's `channels`. */
|
|
867
|
+
defaultChannels: NotificationChannel[];
|
|
868
|
+
/** Code-default copy, overridable per scope. */
|
|
869
|
+
defaultTitle?: string;
|
|
870
|
+
defaultMessage?: string;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Host-supplied catalog reader.
|
|
874
|
+
*
|
|
875
|
+
* Optional on the resource: a deployment that does not expose its catalog simply has
|
|
876
|
+
* no `/catalog` route, and the settings screen says so rather than showing an empty
|
|
877
|
+
* matrix. Never defaulted to `() => []` — that would turn "not exposed" into "nothing
|
|
878
|
+
* exists", which is the silent-empty failure this file exists to avoid.
|
|
879
|
+
*/
|
|
880
|
+
type TriggerCatalogPort = () => ReadonlyArray<TriggerCatalogEntry> | Promise<ReadonlyArray<TriggerCatalogEntry>>;
|
|
881
|
+
//#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, NOTIFICATION_CHANNELS, type NotificationAudienceType, type NotificationChannel, type NotificationExtractResult, type NotificationRecipientContact, 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, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
|
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) {
|
|
@@ -251,7 +250,7 @@ var InAppChannel = class extends BaseChannel {
|
|
|
251
250
|
function createUnreadCountCache(options = {}) {
|
|
252
251
|
const { ttlMs = 3e4, maxEntries = 1e4 } = options;
|
|
253
252
|
const entries = /* @__PURE__ */ new Map();
|
|
254
|
-
const keyOf = (organizationId, userId) => `${organizationId}
|
|
253
|
+
const keyOf = (organizationId, userId) => `${organizationId}\u0000${userId}`;
|
|
255
254
|
return {
|
|
256
255
|
get(organizationId, userId) {
|
|
257
256
|
const key = keyOf(organizationId, userId);
|
|
@@ -303,7 +302,6 @@ function inAppFeedResource(options) {
|
|
|
303
302
|
method: "GET",
|
|
304
303
|
path: "/",
|
|
305
304
|
permissions,
|
|
306
|
-
raw: true,
|
|
307
305
|
summary: "List my notifications (newest first, paginated)",
|
|
308
306
|
schema: { querystring: {
|
|
309
307
|
type: "object",
|
|
@@ -325,7 +323,7 @@ function inAppFeedResource(options) {
|
|
|
325
323
|
},
|
|
326
324
|
additionalProperties: true
|
|
327
325
|
} },
|
|
328
|
-
|
|
326
|
+
rawHandler: async (req, reply) => {
|
|
329
327
|
const { userId, organizationId } = resolveScope(req);
|
|
330
328
|
const q = req.query ?? {};
|
|
331
329
|
const result = await repository.listForUser(organizationId, userId, {
|
|
@@ -341,9 +339,8 @@ function inAppFeedResource(options) {
|
|
|
341
339
|
method: "GET",
|
|
342
340
|
path: "/unread-count",
|
|
343
341
|
permissions,
|
|
344
|
-
raw: true,
|
|
345
342
|
summary: "Unread notification count (badge)",
|
|
346
|
-
|
|
343
|
+
rawHandler: async (req, reply) => {
|
|
347
344
|
const { userId, organizationId } = resolveScope(req);
|
|
348
345
|
if (unreadCache) {
|
|
349
346
|
const cached = await unreadCache.get(organizationId, userId);
|
|
@@ -360,9 +357,8 @@ function inAppFeedResource(options) {
|
|
|
360
357
|
method: "PATCH",
|
|
361
358
|
path: "/:id/read",
|
|
362
359
|
permissions,
|
|
363
|
-
raw: true,
|
|
364
360
|
summary: "Mark a single notification read",
|
|
365
|
-
|
|
361
|
+
rawHandler: async (req, reply) => {
|
|
366
362
|
const scope = resolveScope(req);
|
|
367
363
|
const id = req.params.id;
|
|
368
364
|
const updated = await repository.markRead(scope.organizationId, scope.userId, id);
|
|
@@ -375,9 +371,8 @@ function inAppFeedResource(options) {
|
|
|
375
371
|
method: "PATCH",
|
|
376
372
|
path: "/read-all",
|
|
377
373
|
permissions,
|
|
378
|
-
raw: true,
|
|
379
374
|
summary: "Mark all my notifications read",
|
|
380
|
-
|
|
375
|
+
rawHandler: async (req, reply) => {
|
|
381
376
|
const scope = resolveScope(req);
|
|
382
377
|
const modifiedCount = await repository.markAllRead(scope.organizationId, scope.userId);
|
|
383
378
|
await afterReadStateChange(scope, {
|
|
@@ -387,13 +382,27 @@ function inAppFeedResource(options) {
|
|
|
387
382
|
return reply.send({ modifiedCount });
|
|
388
383
|
}
|
|
389
384
|
},
|
|
385
|
+
...typeof repository.deleteRead === "function" ? [{
|
|
386
|
+
method: "DELETE",
|
|
387
|
+
path: "/read",
|
|
388
|
+
permissions,
|
|
389
|
+
summary: "Delete all my already-read notifications",
|
|
390
|
+
rawHandler: async (_req, reply) => {
|
|
391
|
+
const scope = resolveScope(_req);
|
|
392
|
+
const deletedCount = await repository.deleteRead(scope.organizationId, scope.userId);
|
|
393
|
+
await afterReadStateChange(scope, {
|
|
394
|
+
deletedCount,
|
|
395
|
+
clearedRead: true
|
|
396
|
+
});
|
|
397
|
+
return reply.send({ deletedCount });
|
|
398
|
+
}
|
|
399
|
+
}] : [],
|
|
390
400
|
...typeof repository.delete === "function" ? [{
|
|
391
401
|
method: "DELETE",
|
|
392
402
|
path: "/:id",
|
|
393
403
|
permissions,
|
|
394
|
-
raw: true,
|
|
395
404
|
summary: "Delete (dismiss) a notification",
|
|
396
|
-
|
|
405
|
+
rawHandler: async (req, reply) => {
|
|
397
406
|
const scope = resolveScope(req);
|
|
398
407
|
const id = req.params.id;
|
|
399
408
|
if (!await repository.delete(scope.organizationId, scope.userId, id)) throw new NotFoundError("Notification");
|
|
@@ -487,7 +496,7 @@ function notificationsJob(options = {}) {
|
|
|
487
496
|
ref.processor = processor;
|
|
488
497
|
},
|
|
489
498
|
async getJob(id) {
|
|
490
|
-
const status = await dispatcher.getStatus(id);
|
|
499
|
+
const status = await dispatcher.getStatus(name, id);
|
|
491
500
|
if (!status) return null;
|
|
492
501
|
const created = new Date(status.timestamp ?? Date.now());
|
|
493
502
|
return {
|
|
@@ -550,41 +559,45 @@ function notificationsJob(options = {}) {
|
|
|
550
559
|
* const svc = getModuleExports<NotificationService>(app, "notifications");
|
|
551
560
|
* ```
|
|
552
561
|
*
|
|
553
|
-
* Triggers
|
|
554
|
-
*
|
|
562
|
+
* Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
|
|
563
|
+
* arc owns subscription ordering AND teardown: it unsubscribes every handler at
|
|
564
|
+
* shutdown BEFORE any module `onClose` runs, while the service and its queue
|
|
565
|
+
* are still alive. A hand-rolled `afterResources` subscribe cannot promise that
|
|
566
|
+
* — its own `onClose` races the modules it depends on.
|
|
567
|
+
*
|
|
568
|
+
* Each handler declares `boundary: true`, so arc contains failures — logged and
|
|
569
|
+
* swallowed (fire-and-forget, matching arc's event gotcha), because a template
|
|
570
|
+
* or channel failure must not block the transport ack and DLQ the event. And
|
|
555
571
|
* `idempotencyKey` defaults from event identity because Redis Streams are
|
|
556
572
|
* at-least-once: redelivery must not double-send.
|
|
557
573
|
*/
|
|
558
574
|
function notificationsModule(config) {
|
|
559
|
-
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, ...serviceConfig } = config;
|
|
575
|
+
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
|
|
560
576
|
let service;
|
|
561
|
-
const unsubscribes = [];
|
|
562
577
|
return defineModule({
|
|
563
578
|
name,
|
|
564
|
-
bootstrap(fastify) {
|
|
579
|
+
async bootstrap(fastify) {
|
|
565
580
|
const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
|
|
581
|
+
const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
|
|
566
582
|
service = provided ?? new NotificationService({
|
|
567
583
|
...serviceConfig,
|
|
584
|
+
...resolvedChannels ? { channels: resolvedChannels } : {},
|
|
568
585
|
...resolvedQueue ? { queue: resolvedQueue } : {}
|
|
569
586
|
});
|
|
570
587
|
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
588
|
if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
|
|
572
589
|
return service;
|
|
573
590
|
},
|
|
574
|
-
|
|
591
|
+
eventHandlers: (fastify) => {
|
|
575
592
|
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 }), { name: `arc-notifications:${trigger.event}` });
|
|
581
|
-
unsubscribes.push(unsubscribe);
|
|
582
|
-
}
|
|
593
|
+
if (triggers.length === 0) return [];
|
|
583
594
|
fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
595
|
+
return triggers.map((trigger, index) => ({
|
|
596
|
+
name: `${name}:${trigger.event}#${index}`,
|
|
597
|
+
event: trigger.event,
|
|
598
|
+
boundary: true,
|
|
599
|
+
handler: async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} })
|
|
600
|
+
}));
|
|
588
601
|
}
|
|
589
602
|
});
|
|
590
603
|
}
|
|
@@ -609,6 +622,7 @@ async function dispatchTrigger(service, trigger, event, opts) {
|
|
|
609
622
|
const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
|
|
610
623
|
for (const [index, recipient] of recipients.entries()) {
|
|
611
624
|
const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
|
|
625
|
+
const subject = trigger.subject?.(event);
|
|
612
626
|
await service.send({
|
|
613
627
|
event: event.type,
|
|
614
628
|
template: trigger.template ?? event.type,
|
|
@@ -616,6 +630,7 @@ async function dispatchTrigger(service, trigger, event, opts) {
|
|
|
616
630
|
data: sendData,
|
|
617
631
|
...trigger.channels ? { channels: [...trigger.channels] } : {},
|
|
618
632
|
...idempotencyKey !== void 0 ? { idempotencyKey } : {},
|
|
633
|
+
...subject ? { subject } : {},
|
|
619
634
|
metadata: {
|
|
620
635
|
source: "arc-notifications",
|
|
621
636
|
eventId: event.meta.id
|
|
@@ -642,7 +657,8 @@ function toEntry(doc) {
|
|
|
642
657
|
results: doc.results ?? [],
|
|
643
658
|
status: doc.status,
|
|
644
659
|
duration: doc.duration ?? 0,
|
|
645
|
-
...doc.metadata ? { metadata: doc.metadata } : {}
|
|
660
|
+
...doc.metadata ? { metadata: doc.metadata } : {},
|
|
661
|
+
...doc.subject ? { subject: { ...doc.subject } } : {}
|
|
646
662
|
};
|
|
647
663
|
}
|
|
648
664
|
function createRepositoryDeliveryLog(repository, options = {}) {
|
|
@@ -658,7 +674,8 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
658
674
|
results: dispatch.results,
|
|
659
675
|
status: deriveStatus(dispatch),
|
|
660
676
|
duration: dispatch.duration,
|
|
661
|
-
...payload.metadata ? { metadata: payload.metadata } : {}
|
|
677
|
+
...payload.metadata ? { metadata: payload.metadata } : {},
|
|
678
|
+
...payload.subject ? { subject: payload.subject } : {}
|
|
662
679
|
};
|
|
663
680
|
await repository.create(doc);
|
|
664
681
|
},
|
|
@@ -668,6 +685,21 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
668
685
|
if (filter.recipientEmail) equality.recipientEmail = filter.recipientEmail;
|
|
669
686
|
if (filter.event) equality.event = filter.event;
|
|
670
687
|
if (filter.status) equality.status = filter.status;
|
|
688
|
+
/**
|
|
689
|
+
* Pushed into the EQUALITY filter, not the in-memory pass below.
|
|
690
|
+
*
|
|
691
|
+
* The post-fetch filters (`after`/`before`/`channel`) narrow a page that
|
|
692
|
+
* was already limited — acceptable for a range or an array membership.
|
|
693
|
+
* Subject is different: it is the primary way a document screen reads its
|
|
694
|
+
* own history, so filtering it in memory would fetch `limit` rows of
|
|
695
|
+
* OTHER documents' deliveries and return however few happened to match —
|
|
696
|
+
* an empty send history for an invoice that was definitely emailed.
|
|
697
|
+
* Dotted paths so the store's compound index is usable.
|
|
698
|
+
*/
|
|
699
|
+
if (filter.subject) {
|
|
700
|
+
equality["subject.sourceModel"] = filter.subject.sourceModel;
|
|
701
|
+
equality["subject.sourceId"] = filter.subject.sourceId;
|
|
702
|
+
}
|
|
671
703
|
const limit = filter.limit ?? defaultLimit;
|
|
672
704
|
const result = await repository.getAll({
|
|
673
705
|
filter: equality,
|
|
@@ -853,4 +885,287 @@ function templatesResource(options) {
|
|
|
853
885
|
});
|
|
854
886
|
}
|
|
855
887
|
//#endregion
|
|
856
|
-
|
|
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
|
+
//#region src/triggerAdapter.ts
|
|
1000
|
+
/**
|
|
1001
|
+
* `entityType` → the `extract` variable holding that entity's id.
|
|
1002
|
+
*
|
|
1003
|
+
* The in-app row wants an `entityId` for deep-linking and badge grouping, but
|
|
1004
|
+
* the variable it lives under is named per entity. This is the default
|
|
1005
|
+
* commerce mapping; a host with its own entity kinds passes `entityIdVars`.
|
|
1006
|
+
*/
|
|
1007
|
+
const DEFAULT_ENTITY_ID_VARS = {
|
|
1008
|
+
order: "orderId",
|
|
1009
|
+
transfer: "transferId",
|
|
1010
|
+
product: "productId"
|
|
1011
|
+
};
|
|
1012
|
+
/**
|
|
1013
|
+
* Adapt one authoring trigger to one module trigger.
|
|
1014
|
+
*
|
|
1015
|
+
* Note `extract` is invoked separately by `audience` and by `data`. The module
|
|
1016
|
+
* calls both per dispatch, so an `extract` with side effects would run them
|
|
1017
|
+
* twice — keep extractors pure (every one in practice is a payload reshape).
|
|
1018
|
+
*/
|
|
1019
|
+
function toNotifyTrigger(t, options = {}) {
|
|
1020
|
+
const type = t.type ?? t.event;
|
|
1021
|
+
const entityIdVars = {
|
|
1022
|
+
...DEFAULT_ENTITY_ID_VARS,
|
|
1023
|
+
...options.entityIdVars
|
|
1024
|
+
};
|
|
1025
|
+
const entityVar = t.template.entityType ? entityIdVars[t.template.entityType] : void 0;
|
|
1026
|
+
const channels = t.audienceType === "customer" ? [] : ["in-app"];
|
|
1027
|
+
if (t.sendEmail) channels.push("email");
|
|
1028
|
+
if (t.sendWhatsapp) channels.push("whatsapp");
|
|
1029
|
+
if (t.sendSms) channels.push("sms");
|
|
1030
|
+
return {
|
|
1031
|
+
event: t.event,
|
|
1032
|
+
template: type,
|
|
1033
|
+
...t.priority ? { priority: t.priority } : {},
|
|
1034
|
+
channels,
|
|
1035
|
+
audience: (e) => {
|
|
1036
|
+
const ex = t.extract(e.payload ?? {});
|
|
1037
|
+
if (!ex) return null;
|
|
1038
|
+
if (t.audienceType === "customer") {
|
|
1039
|
+
if (!ex.recipient) return null;
|
|
1040
|
+
return {
|
|
1041
|
+
recipient: ex.recipient,
|
|
1042
|
+
organizationId: ex.organizationId
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
roles: t.recipients,
|
|
1047
|
+
organizationId: ex.organizationId,
|
|
1048
|
+
...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
|
|
1049
|
+
};
|
|
1050
|
+
},
|
|
1051
|
+
data: (e) => t.extract(e.payload ?? {})?.variables ?? {},
|
|
1052
|
+
inApp: {
|
|
1053
|
+
title: t.template.title,
|
|
1054
|
+
message: t.template.message,
|
|
1055
|
+
...t.template.link ? { link: t.template.link } : {},
|
|
1056
|
+
...t.template.entityType ? { entityType: t.template.entityType } : {},
|
|
1057
|
+
...entityVar ? { entityId: `{${entityVar}}` } : {}
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** The channel set a trigger declares in CODE — its default before any override. */
|
|
1062
|
+
function declaredChannels(t) {
|
|
1063
|
+
const channels = t.audienceType === "customer" ? [] : ["in-app"];
|
|
1064
|
+
if (t.sendEmail) channels.push("email");
|
|
1065
|
+
if (t.sendWhatsapp) channels.push("whatsapp");
|
|
1066
|
+
if (t.sendSms) channels.push("sms");
|
|
1067
|
+
return channels;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Every channel an override could ever turn ON for this trigger.
|
|
1071
|
+
*
|
|
1072
|
+
* The union of the declared set and the configurable ones, because a scope may
|
|
1073
|
+
* enable a channel the code never flagged — that is the point of the control
|
|
1074
|
+
* centre. A customer-audience trigger stays out of `in-app`: buyers are not
|
|
1075
|
+
* dashboard users, so a feed row for them is one nobody can read, and no
|
|
1076
|
+
* override should be able to create it.
|
|
1077
|
+
*/
|
|
1078
|
+
function candidateChannels(t) {
|
|
1079
|
+
const configurable = [
|
|
1080
|
+
"email",
|
|
1081
|
+
"whatsapp",
|
|
1082
|
+
"sms"
|
|
1083
|
+
];
|
|
1084
|
+
return t.audienceType === "customer" ? configurable : ["in-app", ...configurable];
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* One module trigger per CHANNEL, each gated on the stored settings.
|
|
1088
|
+
*
|
|
1089
|
+
* The gate lives in `audience` because that is the only per-event async seam
|
|
1090
|
+
* arc gives a trigger, and returning `null` there is already the module's
|
|
1091
|
+
* documented "skip" — the same mechanism `extract` uses to filter a broad event.
|
|
1092
|
+
*/
|
|
1093
|
+
function toGatedNotifyTriggers(t, options, overrides) {
|
|
1094
|
+
const type = t.type ?? t.event;
|
|
1095
|
+
const base = toNotifyTrigger(t, options);
|
|
1096
|
+
const defaults = {
|
|
1097
|
+
recipients: t.recipients,
|
|
1098
|
+
channels: declaredChannels(t),
|
|
1099
|
+
title: t.template.title,
|
|
1100
|
+
message: t.template.message,
|
|
1101
|
+
...t.priority ? { priority: t.priority } : {}
|
|
1102
|
+
};
|
|
1103
|
+
return candidateChannels(t).map((channel) => ({
|
|
1104
|
+
...base,
|
|
1105
|
+
channels: [channel],
|
|
1106
|
+
audience: async (e) => {
|
|
1107
|
+
const ex = t.extract(e.payload ?? {});
|
|
1108
|
+
if (!ex) return null;
|
|
1109
|
+
const rows = await overrides.store.forOrganization(ex.organizationId ?? null);
|
|
1110
|
+
const eff = resolveEffectiveSettings(type, ex.organizationId ?? null, rows, defaults);
|
|
1111
|
+
if (!eff.enabled) return null;
|
|
1112
|
+
if (!eff.channels.includes(channel)) return null;
|
|
1113
|
+
if (t.audienceType === "customer") {
|
|
1114
|
+
if (!ex.recipient) return null;
|
|
1115
|
+
return {
|
|
1116
|
+
recipient: ex.recipient,
|
|
1117
|
+
organizationId: ex.organizationId
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
if (eff.recipients.length === 0) return null;
|
|
1121
|
+
return {
|
|
1122
|
+
roles: eff.recipients,
|
|
1123
|
+
organizationId: ex.organizationId,
|
|
1124
|
+
...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
}));
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Adapt a whole registry — what a host assigns to the module's `notifyOn`.
|
|
1131
|
+
*
|
|
1132
|
+
* With `options.overrides`, each trigger becomes one entry per channel, gated on
|
|
1133
|
+
* the admin's stored settings. Without it, the mapping is 1:1 and unchanged.
|
|
1134
|
+
*/
|
|
1135
|
+
function toNotifyTriggers(triggers, options = {}) {
|
|
1136
|
+
const { overrides } = options;
|
|
1137
|
+
if (!overrides) return triggers.map((t) => toNotifyTrigger(t, options));
|
|
1138
|
+
return triggers.flatMap((t) => toGatedNotifyTriggers(t, options, overrides));
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* First trigger registered for an event.
|
|
1142
|
+
*
|
|
1143
|
+
* CAVEAT: an event may carry more than one trigger (an informational alert and
|
|
1144
|
+
* an escalation to a different role set both listen to `stock:low`). This
|
|
1145
|
+
* returns only the first. For anything that must see all of them — a docs page,
|
|
1146
|
+
* an admin matrix, a dispatch count — use `getTriggersByEvent`.
|
|
1147
|
+
*/
|
|
1148
|
+
function getTriggerByEvent(triggers, event) {
|
|
1149
|
+
return triggers.find((t) => t.event === event);
|
|
1150
|
+
}
|
|
1151
|
+
/** Every trigger registered for an event, in registration order. */
|
|
1152
|
+
function getTriggersByEvent(triggers, event) {
|
|
1153
|
+
return triggers.filter((t) => t.event === event);
|
|
1154
|
+
}
|
|
1155
|
+
/** Distinct event names the registry subscribes to (for docs / admin UI). */
|
|
1156
|
+
function getRegisteredEvents(triggers) {
|
|
1157
|
+
return [...new Set(triggers.map((t) => t.event))];
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Notification kind → recipient roles (for the admin UI).
|
|
1161
|
+
*
|
|
1162
|
+
* Keyed by `type ?? event`, so two triggers sharing an event stay distinct
|
|
1163
|
+
* rows — which is exactly why they carry different `type`s.
|
|
1164
|
+
*/
|
|
1165
|
+
function getRecipientMatrix(triggers) {
|
|
1166
|
+
const matrix = {};
|
|
1167
|
+
for (const t of triggers) matrix[t.type ?? t.event] = t.recipients;
|
|
1168
|
+
return matrix;
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1171
|
+
export { DEFAULT_ENTITY_ID_VARS, INAPP_DATA_KEY, InAppChannel, NOTIFICATION_CHANNELS, SseChannel, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, 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.4.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": {
|
|
@@ -24,14 +24,15 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "tsdown",
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run --root ../.. packages/arc-notifications/tests",
|
|
27
28
|
"lint": "biome check src/",
|
|
28
29
|
"prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
|
-
"@classytic/arc": ">=2.
|
|
32
|
-
"@classytic/notifications": ">=2.
|
|
33
|
-
"@classytic/primitives": ">=0.
|
|
34
|
-
"@classytic/repo-core": ">=0.
|
|
32
|
+
"@classytic/arc": ">=2.33.0",
|
|
33
|
+
"@classytic/notifications": ">=2.4.0",
|
|
34
|
+
"@classytic/primitives": ">=0.23.0",
|
|
35
|
+
"@classytic/repo-core": ">=0.24.0"
|
|
35
36
|
},
|
|
36
37
|
"keywords": [
|
|
37
38
|
"arc",
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
"author": "Classytic",
|
|
46
47
|
"repository": {
|
|
47
48
|
"type": "git",
|
|
48
|
-
"url": "https://github.com/classytic/arc-ecosystem.git",
|
|
49
|
+
"url": "git+https://github.com/classytic/arc-ecosystem.git",
|
|
49
50
|
"directory": "packages/arc-notifications"
|
|
50
51
|
}
|
|
51
52
|
}
|