@classytic/arc-notifications 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -0
- package/dist/index.d.mts +587 -3
- package/dist/index.mjs +607 -39
- package/package.json +6 -6
package/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
|
@@ -130,6 +130,23 @@ interface InAppFeedRepository<TRow = unknown> {
|
|
|
130
130
|
* `DELETE /:id`. Hard vs soft delete is the host's call.
|
|
131
131
|
*/
|
|
132
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>;
|
|
133
150
|
}
|
|
134
151
|
/**
|
|
135
152
|
* Cache seam for the unread badge count — the hottest query in the system
|
|
@@ -215,6 +232,256 @@ interface InAppBackfill {
|
|
|
215
232
|
}
|
|
216
233
|
declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
|
|
217
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
|
|
218
485
|
//#region src/dbFirstResolver.d.ts
|
|
219
486
|
/** One draftable template row — the shape be-prod's EmailTemplate model proved. */
|
|
220
487
|
interface StoredTemplate {
|
|
@@ -363,6 +630,24 @@ interface NotifyTrigger<TPayload = unknown> {
|
|
|
363
630
|
readonly priority?: InAppPriority;
|
|
364
631
|
/** Template variables. Default: the event payload (when object-shaped). */
|
|
365
632
|
readonly data?: (event: DomainEvent<TPayload>) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
633
|
+
/**
|
|
634
|
+
* The BUSINESS DOCUMENT this trigger's notifications are about, extracted
|
|
635
|
+
* from the event — `{ sourceModel, sourceId }`.
|
|
636
|
+
*
|
|
637
|
+
* Belongs HERE, beside `recipient` and `data`, for the same reason they do:
|
|
638
|
+
* the notification control centre splits ownership so that CODE owns the
|
|
639
|
+
* event and its extraction while the DATABASE owns channels and recipients.
|
|
640
|
+
* Which document an event concerns is a fact about the event's shape, so it
|
|
641
|
+
* is code — an operator cannot configure it and must never have to.
|
|
642
|
+
*
|
|
643
|
+
* Omit for events that concern no single document (a segment broadcast). A
|
|
644
|
+
* fabricated subject is worse than none: it makes a document's send history
|
|
645
|
+
* show messages that were never about it.
|
|
646
|
+
*/
|
|
647
|
+
readonly subject?: (event: DomainEvent<TPayload>) => {
|
|
648
|
+
sourceModel: string;
|
|
649
|
+
sourceId: string;
|
|
650
|
+
} | undefined;
|
|
366
651
|
/**
|
|
367
652
|
* Dedup key for at-least-once transports. Default:
|
|
368
653
|
* `meta.idempotencyKey ?? "<type>:<meta.id>"`, suffixed per recipient on
|
|
@@ -372,12 +657,24 @@ interface NotifyTrigger<TPayload = unknown> {
|
|
|
372
657
|
/** Compose-time kill switch (env-gated triggers). Default true. */
|
|
373
658
|
readonly enabled?: boolean;
|
|
374
659
|
}
|
|
375
|
-
interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "queue"> {
|
|
660
|
+
interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "queue" | "channels"> {
|
|
661
|
+
/**
|
|
662
|
+
* The channels to dispatch through — a value, or a FUNCTION resolved at bootstrap.
|
|
663
|
+
*
|
|
664
|
+
* The function form exists for the same reason `queue`'s does: a channel can be expensive to
|
|
665
|
+
* build. A host wiring a provider-backed channel has to construct a hub, a credential vault and
|
|
666
|
+
* a repository to do it, and passing the built array means all of that runs while the module
|
|
667
|
+
* GRAPH is being composed — before arc has validated it, for a module the deployment may still
|
|
668
|
+
* reject. In be-prod that registered a mongoose model and derived an AES key at composition.
|
|
669
|
+
*
|
|
670
|
+
* Resolved inside `bootstrap`, where the service is constructed anyway, so nothing else moves.
|
|
671
|
+
*/
|
|
672
|
+
readonly channels?: NotificationServiceConfig["channels"] | ((fastify: FastifyInstance) => NotificationServiceConfig["channels"] | Promise<NotificationServiceConfig["channels"]>);
|
|
376
673
|
/** Module name (registry + export-map key). Default "notifications". */
|
|
377
674
|
readonly name?: string;
|
|
378
675
|
/** Bring-your-own service — skips construction; service config fields ignored. */
|
|
379
676
|
readonly service?: NotificationService;
|
|
380
|
-
/** Declarative event → notification triggers,
|
|
677
|
+
/** Declarative event → notification triggers, contributed as `eventHandlers`. */
|
|
381
678
|
readonly notifyOn?: readonly NotifyTrigger[];
|
|
382
679
|
/**
|
|
383
680
|
* Resolves a trigger's `audience` selector to recipients (the shared
|
|
@@ -422,6 +719,15 @@ interface DeliveryLogDoc {
|
|
|
422
719
|
readonly status: EntryStatus;
|
|
423
720
|
readonly duration: number;
|
|
424
721
|
readonly metadata?: Record<string, unknown>;
|
|
722
|
+
/**
|
|
723
|
+
* The business document this delivery concerned. A STORE MUST INDEX IT —
|
|
724
|
+
* this field exists precisely so "every message about invoice X" is a keyed
|
|
725
|
+
* read rather than a scan of the unindexed `metadata` blob.
|
|
726
|
+
*/
|
|
727
|
+
readonly subject?: {
|
|
728
|
+
readonly sourceModel: string;
|
|
729
|
+
readonly sourceId: string;
|
|
730
|
+
};
|
|
425
731
|
}
|
|
426
732
|
interface RepositoryDeliveryLogOptions {
|
|
427
733
|
/** Cap for `query()` when the caller gives no limit. Default 100. */
|
|
@@ -474,4 +780,282 @@ interface TemplatesResourceOptions {
|
|
|
474
780
|
}
|
|
475
781
|
declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
|
|
476
782
|
//#endregion
|
|
477
|
-
|
|
783
|
+
//#region src/triggerAuthoring.d.ts
|
|
784
|
+
/** Who a trigger targets. */
|
|
785
|
+
type NotificationAudienceType = "staff" | "customer";
|
|
786
|
+
/** Direct contact for a customer-facing notification. */
|
|
787
|
+
interface NotificationRecipientContact {
|
|
788
|
+
email?: string;
|
|
789
|
+
phone?: string;
|
|
790
|
+
name?: string;
|
|
791
|
+
}
|
|
792
|
+
/** What `extract` returns: the scope, the template variables, and the target. */
|
|
793
|
+
interface NotificationExtractResult {
|
|
794
|
+
organizationId: string;
|
|
795
|
+
variables: Record<string, string>;
|
|
796
|
+
triggeredBy?: string;
|
|
797
|
+
/** Direct contact for `audienceType: 'customer'`. */
|
|
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;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* One notification, declared.
|
|
817
|
+
*
|
|
818
|
+
* Adding a notification is adding one of these to the host's registry — nothing
|
|
819
|
+
* else to touch.
|
|
820
|
+
*/
|
|
821
|
+
interface NotificationTrigger {
|
|
822
|
+
/** Arc event name to subscribe to (e.g. `'order:created'`). */
|
|
823
|
+
event: string;
|
|
824
|
+
/**
|
|
825
|
+
* Notification kind — the feed type AND the email template id. Defaults to
|
|
826
|
+
* the event name.
|
|
827
|
+
*
|
|
828
|
+
* Two triggers MAY share an event (an informational alert plus an escalation);
|
|
829
|
+
* distinct `type` values are what keep them independently filterable.
|
|
830
|
+
*/
|
|
831
|
+
type?: string;
|
|
832
|
+
/** In-app template. `{variable}` placeholders resolve against `extract`'s variables. */
|
|
833
|
+
template: {
|
|
834
|
+
title: string;
|
|
835
|
+
message: string;
|
|
836
|
+
/** Frontend route, with `{variable}` placeholders. */
|
|
837
|
+
link?: string;
|
|
838
|
+
/** Entity kind, for badge/icon grouping and entity-id derivation. */
|
|
839
|
+
entityType?: string;
|
|
840
|
+
};
|
|
841
|
+
/**
|
|
842
|
+
* Roles that receive this notification. `'*'` = all branch members.
|
|
843
|
+
* Ignored when `audienceType` is `'customer'` — that target comes from
|
|
844
|
+
* `extract`'s `recipient`.
|
|
845
|
+
*/
|
|
846
|
+
recipients: string[];
|
|
847
|
+
/**
|
|
848
|
+
* `'staff'` (default) resolves branch members by `recipients` roles.
|
|
849
|
+
* `'customer'` targets the direct contact `extract` returns, with NO staff
|
|
850
|
+
* lookup and NO in-app feed row (buyers are not dashboard users).
|
|
851
|
+
*/
|
|
852
|
+
audienceType?: NotificationAudienceType;
|
|
853
|
+
priority?: "low" | "normal" | "high";
|
|
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[];
|
|
875
|
+
/**
|
|
876
|
+
* Pull dispatch variables out of the event payload.
|
|
877
|
+
*
|
|
878
|
+
* Returning `null` SKIPS the notification — this is the filter seam. A
|
|
879
|
+
* trigger that subscribes to a broad event (`order:payment.state_updated`)
|
|
880
|
+
* and only wants one case (`chargeStatus === 'failed'`) expresses that here,
|
|
881
|
+
* rather than notifying on every state change.
|
|
882
|
+
*/
|
|
883
|
+
extract: (payload: Record<string, any>) => NotificationExtractResult | null;
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/triggerAdapter.d.ts
|
|
887
|
+
/**
|
|
888
|
+
* `entityType` → the `extract` variable holding that entity's id.
|
|
889
|
+
*
|
|
890
|
+
* The in-app row wants an `entityId` for deep-linking and badge grouping, but
|
|
891
|
+
* the variable it lives under is named per entity. This is the default
|
|
892
|
+
* commerce mapping; a host with its own entity kinds passes `entityIdVars`.
|
|
893
|
+
*/
|
|
894
|
+
declare const DEFAULT_ENTITY_ID_VARS: Record<string, string>;
|
|
895
|
+
interface TriggerAdapterOptions {
|
|
896
|
+
/**
|
|
897
|
+
* Extra `entityType` → id-variable mappings, merged over the defaults.
|
|
898
|
+
*
|
|
899
|
+
* An unmapped `entityType` simply yields no `entityId` — the notification
|
|
900
|
+
* still delivers, it just has nothing to deep-link to.
|
|
901
|
+
*/
|
|
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;
|
|
934
|
+
/**
|
|
935
|
+
* ADMIN OVERRIDES — when supplied, each trigger expands into one module
|
|
936
|
+
* trigger PER CHANNEL, and every one is gated on the stored settings for the
|
|
937
|
+
* event's organization.
|
|
938
|
+
*
|
|
939
|
+
* ## Why expansion rather than one trigger with dynamic channels
|
|
940
|
+
*
|
|
941
|
+
* `NotifyTrigger.channels` is a STATIC `readonly string[]` — arc resolves it
|
|
942
|
+
* at registration, not per dispatch. So "this scope wants order alerts on
|
|
943
|
+
* WhatsApp but not email" cannot be expressed by mutating one trigger's
|
|
944
|
+
* channel list at send time. It CAN be expressed as separate triggers, each
|
|
945
|
+
* restricted to one channel, each skipping when that channel is off for the
|
|
946
|
+
* scope — `audience` IS a per-event async function, which is the seam that
|
|
947
|
+
* makes this possible without changing arc.
|
|
948
|
+
*
|
|
949
|
+
* The cost is N subscriptions per trigger instead of one, and `extract` runs
|
|
950
|
+
* per channel. `extract` is pure and cheap by contract (the adapter already
|
|
951
|
+
* calls it twice per dispatch), so this trades a little fan-out for a control
|
|
952
|
+
* an operator can actually use.
|
|
953
|
+
*
|
|
954
|
+
* OMITTED = unchanged behaviour: one trigger, static channels from the
|
|
955
|
+
* trigger's own `channels` list. Every existing host keeps its exact current
|
|
956
|
+
* dispatch until it opts in.
|
|
957
|
+
*/
|
|
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;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* How the adapter reaches stored settings for the organization on an event.
|
|
974
|
+
*
|
|
975
|
+
* Just the store. The ORGANIZATION comes from the trigger's own `extract`
|
|
976
|
+
* (`ex.organizationId`) rather than a second resolver here — `extract` is
|
|
977
|
+
* already the one place that knows how a given event carries its scope, and a
|
|
978
|
+
* parallel answer would be a second place to get it wrong.
|
|
979
|
+
*/
|
|
980
|
+
interface TriggerOverrideResolution {
|
|
981
|
+
store: TriggerOverrideStore;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Adapt one authoring trigger to one module trigger.
|
|
985
|
+
*
|
|
986
|
+
* Note `extract` is invoked separately by `audience` and by `data`. The module
|
|
987
|
+
* calls both per dispatch, so an `extract` with side effects would run them
|
|
988
|
+
* twice — keep extractors pure (every one in practice is a payload reshape).
|
|
989
|
+
*/
|
|
990
|
+
declare function toNotifyTrigger(t: NotificationTrigger, options?: TriggerAdapterOptions): NotifyTrigger;
|
|
991
|
+
/**
|
|
992
|
+
* Adapt a whole registry — what a host assigns to the module's `notifyOn`.
|
|
993
|
+
*
|
|
994
|
+
* With `options.overrides`, each trigger becomes one entry per channel, gated on
|
|
995
|
+
* the admin's stored settings. Without it, the mapping is 1:1 and unchanged.
|
|
996
|
+
*/
|
|
997
|
+
declare function toNotifyTriggers(triggers: readonly NotificationTrigger[], options?: TriggerAdapterOptions): NotifyTrigger[];
|
|
998
|
+
/**
|
|
999
|
+
* First trigger registered for an event.
|
|
1000
|
+
*
|
|
1001
|
+
* CAVEAT: an event may carry more than one trigger (an informational alert and
|
|
1002
|
+
* an escalation to a different role set both listen to `stock:low`). This
|
|
1003
|
+
* returns only the first. For anything that must see all of them — a docs page,
|
|
1004
|
+
* an admin matrix, a dispatch count — use `getTriggersByEvent`.
|
|
1005
|
+
*/
|
|
1006
|
+
declare function getTriggerByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger | undefined;
|
|
1007
|
+
/** Every trigger registered for an event, in registration order. */
|
|
1008
|
+
declare function getTriggersByEvent(triggers: readonly NotificationTrigger[], event: string): NotificationTrigger[];
|
|
1009
|
+
/** Distinct event names the registry subscribes to (for docs / admin UI). */
|
|
1010
|
+
declare function getRegisteredEvents(triggers: readonly NotificationTrigger[]): string[];
|
|
1011
|
+
/**
|
|
1012
|
+
* Notification kind → recipient roles (for the admin UI).
|
|
1013
|
+
*
|
|
1014
|
+
* Keyed by `type ?? event`, so two triggers sharing an event stay distinct
|
|
1015
|
+
* rows — which is exactly why they carry different `type`s.
|
|
1016
|
+
*/
|
|
1017
|
+
declare function getRecipientMatrix(triggers: readonly NotificationTrigger[]): Record<string, string[]>;
|
|
1018
|
+
//#endregion
|
|
1019
|
+
//#region src/triggerCatalog.d.ts
|
|
1020
|
+
/**
|
|
1021
|
+
* One trigger as an operator sees it, with the CODE defaults an override replaces.
|
|
1022
|
+
*
|
|
1023
|
+
* Deliberately not the internal trigger shape: `extract` is a function and cannot
|
|
1024
|
+
* cross the wire, and a UI has no use for it. What a settings screen needs is the
|
|
1025
|
+
* identity, a human label, and the defaults so it can show "customised" against
|
|
1026
|
+
* "default" without guessing.
|
|
1027
|
+
*/
|
|
1028
|
+
interface TriggerCatalogEntry {
|
|
1029
|
+
/**
|
|
1030
|
+
* The override key — a trigger's `type`, falling back to its `event`.
|
|
1031
|
+
*
|
|
1032
|
+
* This is the same identifier `toNotifyTrigger` uses as the template id, so a row
|
|
1033
|
+
* created from this screen addresses the trigger the dispatch path will look up. A
|
|
1034
|
+
* settings UI keyed on anything else would write rows nothing reads.
|
|
1035
|
+
*/
|
|
1036
|
+
triggerType: string;
|
|
1037
|
+
/** The domain event it listens to, shown so an operator can trace cause to effect. */
|
|
1038
|
+
event: string;
|
|
1039
|
+
/** Human label. Falls back to `triggerType` when the host supplies none. */
|
|
1040
|
+
label: string;
|
|
1041
|
+
/** Optional grouping for the UI ("Orders", "Inventory"). */
|
|
1042
|
+
group?: string;
|
|
1043
|
+
/** Code-default recipient roles, replaced by an override's `recipients`. */
|
|
1044
|
+
defaultRecipients: string[];
|
|
1045
|
+
/** Code-default channels, replaced by an override's `channels`. */
|
|
1046
|
+
defaultChannels: NotificationChannel[];
|
|
1047
|
+
/** Code-default copy, overridable per scope. */
|
|
1048
|
+
defaultTitle?: string;
|
|
1049
|
+
defaultMessage?: string;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Host-supplied catalog reader.
|
|
1053
|
+
*
|
|
1054
|
+
* Optional on the resource: a deployment that does not expose its catalog simply has
|
|
1055
|
+
* no `/catalog` route, and the settings screen says so rather than showing an empty
|
|
1056
|
+
* matrix. Never defaulted to `() => []` — that would turn "not exposed" into "nothing
|
|
1057
|
+
* exists", which is the silent-empty failure this file exists to avoid.
|
|
1058
|
+
*/
|
|
1059
|
+
type TriggerCatalogPort = () => ReadonlyArray<TriggerCatalogEntry> | Promise<ReadonlyArray<TriggerCatalogEntry>>;
|
|
1060
|
+
//#endregion
|
|
1061
|
+
export { type CodeTemplates, type CodeTriggerDefaults, type CreateCachedTriggerOverrideStoreOptions, type CreateUnreadCountCacheOptions, DEFAULT_ENTITY_ID_VARS, type DbFirstResolverOptions, type DbFirstTemplateResolver, type DeliveryLogDoc, type EffectiveTriggerSettings, INAPP_DATA_KEY, IN_APP_CHANNEL, type InAppBackfill, type InAppBackfillOptions, InAppChannel, type InAppChannelConfig, type InAppContent, type InAppFeedListOptions, type InAppFeedPage, type InAppFeedRepository, type InAppFeedResourceOptions, type InAppFeedRow, type InAppFeedScope, type InAppPriority, type InAppTemplate, type NotificationAudienceType, type NotificationChannel, type NotificationChannelCatalog, type NotificationDecision, type NotificationExtractResult, type NotificationRecipientContact, type NotificationSkipReason, type NotificationTrigger, type NotificationTriggerOverrideDoc, type NotificationsJob, type NotificationsJobOptions, type NotificationsModuleConfig, type NotifyTrigger, type RealtimePushPort, type RecipientResolver, type RepositoryDeliveryLogOptions, SseChannel, type SseChannelConfig, type StoredTemplate, type TemplateStore, type TemplatesResourceOptions, type TriggerAdapterOptions, type TriggerCatalogEntry, type TriggerCatalogPort, type TriggerOverrideLoader, type TriggerOverrideResolution, type TriggerOverrideStore, type UnreadCountCache, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, evaluateSourceChannel, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
|