@stone-js/notifications 0.8.17

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.
@@ -0,0 +1,345 @@
1
+ import { Promiseable } from '@stone-js/core';
2
+ /**
3
+ * Who a notification is for.
4
+ *
5
+ * Deliberately thin, and open. This module has no idea what a person is in your application, and it
6
+ * must not: the only thing a notification library cannot ship is your idea of a person. What it needs
7
+ * is where to reach them, and in which language.
8
+ */
9
+ export interface Recipient {
10
+ /** Whatever the application calls this person. Used to address the in-app channel. */
11
+ id?: string;
12
+ /** Where to write. */
13
+ email?: string;
14
+ /** Where to text. */
15
+ phone?: string;
16
+ /** Where to push. Registering and storing them is the application's business. */
17
+ deviceTokens?: string[];
18
+ /** The language **this person** reads, which is not the language of the request. */
19
+ locale?: string;
20
+ /** Anything a channel of your own needs. */
21
+ [key: string]: unknown;
22
+ }
23
+ /**
24
+ * A recipient, or the id of one.
25
+ *
26
+ * An id is resolved through `stone.notifications.recipients`, so an application that keeps people in
27
+ * a vault reads the address at send time rather than copying it into a message. What is not copied
28
+ * does not have to be erased.
29
+ */
30
+ export type RecipientInput = Recipient | string;
31
+ /** What a channel is handed: the key, the params, and the text rendered for this person. */
32
+ export interface RenderedNotification {
33
+ /** The template key, carried through so a channel can log what it sent without the body. */
34
+ template: string;
35
+ /** What the template was rendered with. */
36
+ params: Record<string, unknown>;
37
+ /** A one-line subject, for the channels that have one. */
38
+ subject: string;
39
+ /** The message itself. */
40
+ body: string;
41
+ /** The locale it was rendered in. */
42
+ locale: string;
43
+ }
44
+ /** How a delivery ended. */
45
+ export type DeliveryStatus = 'sent' | 'failed' | 'unreachable';
46
+ /**
47
+ * What a channel answers.
48
+ *
49
+ * It **answers**, and never throws, for everything it can foresee: a missing address, a provider
50
+ * refusing, a rate limit. A channel that throws is treated as retryable, because a throw is an
51
+ * adapter bug and burying a whole channel's traffic the day a provider client changes its errors
52
+ * would be worse than one retry too many.
53
+ */
54
+ export interface DeliveryOutcome {
55
+ /** Whether it went out. */
56
+ status: DeliveryStatus;
57
+ /**
58
+ * Whether trying again could work.
59
+ *
60
+ * The distinction a channel owes its caller: a provider being down is retryable, an address that
61
+ * does not exist never will be, and retrying it forever is how a queue fills up with work that
62
+ * cannot succeed.
63
+ */
64
+ retryable?: boolean;
65
+ /** Why, in words a person reading a log can act on. */
66
+ reason?: string;
67
+ /** The provider's own id for the message, when it gives one. */
68
+ id?: string;
69
+ /** Which channel answered. Filled in by the notifier. */
70
+ channel?: string;
71
+ }
72
+ /**
73
+ * A way to reach someone.
74
+ *
75
+ * The whole port, and it is small on purpose: a channel never decides **whether** to send. That is
76
+ * the application's call, and an application that models consent, preferences or quiet hours makes
77
+ * it before calling here. A channel that started deciding would be a second policy to keep in step
78
+ * with the first.
79
+ */
80
+ export interface NotificationChannel {
81
+ /** The name a notification refers to it by. */
82
+ readonly name: string;
83
+ /**
84
+ * Send one message to one person.
85
+ *
86
+ * @param message - The rendered notification.
87
+ * @param recipient - Who it is for.
88
+ * @returns How it ended.
89
+ */
90
+ send: (message: RenderedNotification, recipient: Recipient) => Promise<DeliveryOutcome>;
91
+ }
92
+ /** How a channel is built from what the application configured. */
93
+ export type NotificationChannelFactory = (config: ChannelConfig) => NotificationChannel;
94
+ /** The channels this package ships. Any other name is one an application registered. */
95
+ export type NotificationDriver = 'log' | 'in-app' | 'smtp' | (string & {});
96
+ /** What a configured channel declares. */
97
+ export interface ChannelConfig {
98
+ /** The name it is resolved under. */
99
+ name: string;
100
+ /** Which driver builds it. Defaults to `log`, and ignored when `factory` is given. */
101
+ driver?: NotificationDriver;
102
+ /**
103
+ * Build the channel yourself, instead of naming a driver this package ships.
104
+ *
105
+ * This is how an application reaches a provider nobody here has heard of, and how `sms` and `push`
106
+ * are done: they are named in the types and not implemented, because a channel that picked a vendor
107
+ * would be wrong for everyone who chose a different one.
108
+ *
109
+ * It is declared here, with the other channels, rather than registered on the manager from a
110
+ * provider: the container is rebuilt for every event, so anything registered imperatively during
111
+ * one event is gone for the next.
112
+ */
113
+ factory?: NotificationChannelFactory;
114
+ /**
115
+ * A class the container builds into a channel.
116
+ *
117
+ * What `@NotificationChannel('sms')` declares. Built through the container, so the channel's
118
+ * constructor is auto-wired like any other service and can ask for whatever it needs.
119
+ */
120
+ module?: unknown;
121
+ /** Anything the driver needs. */
122
+ [key: string]: unknown;
123
+ }
124
+ /** Options for the SMTP channel. */
125
+ export interface SmtpChannelConfig extends ChannelConfig {
126
+ /** A `nodemailer` transport, or the options to build one. */
127
+ transport?: unknown;
128
+ /** Where mail appears to come from. Required, because no default could be right. */
129
+ from?: string;
130
+ }
131
+ /** Options for the in-app channel. */
132
+ export interface InAppChannelConfig extends ChannelConfig {
133
+ /** The channel to broadcast on. Defaults to `user.{id}.notifications`. */
134
+ channelFor?: (recipient: Recipient) => string;
135
+ /** The event name clients listen for. Defaults to `notification`. */
136
+ event?: string;
137
+ }
138
+ /** A template, as the application declares it when it does not use a catalogue. */
139
+ export type TemplateInput = string | {
140
+ subject?: string;
141
+ body: string;
142
+ } | ((params: Record<string, unknown>, locale: string) => {
143
+ subject?: string;
144
+ body: string;
145
+ });
146
+ /** One channel's worth of content: a body, and a subject for the channels that have one. */
147
+ export interface NoticeContent {
148
+ /** A one-line subject. Ignored by a channel that has none. */
149
+ subject?: string;
150
+ /** The message itself. */
151
+ body: string;
152
+ }
153
+ /**
154
+ * What a notice says, per channel.
155
+ *
156
+ * A body keyed by channel name, because a text message is not an email: one has a subject and room
157
+ * to explain, the other has 160 characters. A single content, or a bare string, applies to every
158
+ * channel the notice uses.
159
+ */
160
+ export type NoticeContentInput = Record<string, NoticeContent | string> | NoticeContent | string;
161
+ /** What a notice is told about the person it is writing to. */
162
+ export interface NoticeContext {
163
+ /** The language **this person** reads. */
164
+ locale: string;
165
+ /** Who it is for, so the content can use their name. */
166
+ recipient: Recipient;
167
+ }
168
+ /**
169
+ * A notice: a class that knows what to say, and to whom.
170
+ *
171
+ * The decorator declares what a notice **is**; the class holds what it **says**. That separation is
172
+ * the point: metadata belongs on the declaration, content belongs in code, where it can read the
173
+ * event, translate, format a date, or ask a service for a link.
174
+ *
175
+ * ```ts
176
+ * @Notice({ name: 'guardianship.consent_needed', on: 'identity.guardian.invited.v1' })
177
+ * export class ConsentNeeded {
178
+ * constructor ({ i18n }) { this.i18n = i18n }
179
+ *
180
+ * recipients (event) { return event.guardianId }
181
+ *
182
+ * notify (event, { locale }) {
183
+ * return {
184
+ * smtp: { subject: this.i18n.t('consent.subject', { lng: locale }), body: … },
185
+ * 'in-app': { body: … }
186
+ * }
187
+ * }
188
+ * }
189
+ * ```
190
+ */
191
+ export interface NoticeInstance<EventType = any> {
192
+ /**
193
+ * What this notice says, for this person.
194
+ *
195
+ * Called once per recipient, never once per channel, so a name in the body is rendered once and a
196
+ * channel-specific body is chosen from what it returns.
197
+ */
198
+ notify: (event: EventType, context: NoticeContext) => Promiseable<NoticeContentInput>;
199
+ /**
200
+ * Who learns about it.
201
+ *
202
+ * **Required when the notice reacts to an event**, because nobody else can say: the event carries
203
+ * the account, and only the notice knows which field that is. Unused when a caller names the
204
+ * recipient itself.
205
+ */
206
+ recipients?: (event: EventType) => Promiseable<RecipientInput | RecipientInput[]>;
207
+ /**
208
+ * A key that makes this occurrence unique.
209
+ *
210
+ * The answer to the most common production failure of any notification system: the same message
211
+ * twice, because a queue is at-least-once, because a retry half succeeded, or because two events
212
+ * describe one fact. Return a key and the second attempt is dropped.
213
+ */
214
+ dedupe?: (event: EventType) => Promiseable<string | undefined>;
215
+ }
216
+ /**
217
+ * What a notice declares about itself.
218
+ *
219
+ * Metadata only. Content lives in the class, which is why there is no `content` here: a decorator
220
+ * that carried message bodies would put text in the one place it cannot be translated, formatted or
221
+ * computed.
222
+ */
223
+ export interface NoticeDeclaration {
224
+ /** The name a caller refers to it by, and the key its deduplication is filed under. */
225
+ name: string;
226
+ /**
227
+ * The domain event it reacts to.
228
+ *
229
+ * With it, **nobody calls the notifier**: a module emits what happened, and the notice that named
230
+ * that event says who learns about it. The module that emitted imports nothing and is never
231
+ * reopened when a channel is added.
232
+ *
233
+ * Needs the event bus listener to be enabled, since that is what delivers a domain event.
234
+ */
235
+ on?: string;
236
+ /** The channels it uses. Defaults to `stone.notifications.default`. */
237
+ channels?: string[];
238
+ /** The class. Built through the container, so it can ask for i18n, a repository, anything. */
239
+ module?: unknown;
240
+ /** Whether `module` is a class. */
241
+ isClass?: boolean;
242
+ }
243
+ /** What one call to the notifier reports back. */
244
+ export interface NotificationReceipt {
245
+ /** True when delivery was handed to a queue rather than performed here. */
246
+ queued: boolean;
247
+ /** What each channel answered, empty when the work was queued. */
248
+ deliveries: DeliveryOutcome[];
249
+ /** True when this occurrence had already been sent, and was dropped rather than sent again. */
250
+ duplicate?: boolean;
251
+ }
252
+ /** What a notification says about itself, beyond the template and its params. */
253
+ export interface NotifyOptions {
254
+ /** Which channels to use. Defaults to `stone.notifications.default`. */
255
+ channels?: string[];
256
+ /** Force the language, when it is not the recipient's own. Rarely right. */
257
+ locale?: string;
258
+ /** Send here and now instead of queueing, whatever the configuration says. */
259
+ inline?: boolean;
260
+ /** Wait this many seconds before delivering. Needs a queue; ignored without one, out loud. */
261
+ delay?: number;
262
+ /**
263
+ * A key that makes this occurrence unique, so it is not sent twice.
264
+ *
265
+ * Stated here for a direct call; a notice states its own through `dedupe(event)`.
266
+ */
267
+ dedupe?: string;
268
+ }
269
+ /**
270
+ * How notifications are configured (`stone.notifications.*`).
271
+ *
272
+ * **What this module does and does not decide.** It decides who learns what, through which channel,
273
+ * and in which language. It never decides *whether* to send: consent, preferences, quiet hours and
274
+ * audiences are the application's, because the rules that matter there are about its own people.
275
+ * A framework imposing them would be wrong for the first application that has different ones.
276
+ */
277
+ export interface NotificationsConfig {
278
+ /** The channels this application configures. */
279
+ channels?: ChannelConfig[];
280
+ /**
281
+ * The notices this application declares, when it declares them in configuration rather than with
282
+ * `@Notice`. Both are read, and both say the same thing.
283
+ */
284
+ notices?: NoticeDeclaration[];
285
+ /**
286
+ * How a repeated occurrence is recognised.
287
+ *
288
+ * Keys are held in `@stone-js/cache`, so the store is the one the application already chose, and
289
+ * this module stores nothing of its own. Without the cache module, deduplication does not happen
290
+ * and says so once: silently sending twice is the failure it exists to prevent.
291
+ */
292
+ dedupe?: {
293
+ /** How long a key is remembered, in seconds. Defaults to a day. */
294
+ ttl?: number;
295
+ /** Which cache store holds them. Defaults to the application's default store. */
296
+ store?: string;
297
+ };
298
+ /**
299
+ * Whether each delivery is announced on the event bus.
300
+ *
301
+ * `notification.delivered` and `notification.failed`, carrying the notice, the channel and the
302
+ * outcome. On by default when a bus is enabled, and it is how an application keeps the delivery
303
+ * ledger it wants: this module records nothing, because a ledger belongs to whoever answers
304
+ * "why did they never receive it".
305
+ */
306
+ announce?: boolean;
307
+ /**
308
+ * The channels a notification uses when it names none.
309
+ *
310
+ * Defaults to `['log']`, which delivers nothing and says so on first use. That is deliberate: a
311
+ * default that quietly sent real mail would send it from the first test run.
312
+ */
313
+ default?: string[];
314
+ /**
315
+ * How to turn an id into a person.
316
+ *
317
+ * The one thing this module cannot ship. Point it at whatever already knows, and the address is
318
+ * read at send time rather than copied into a message.
319
+ *
320
+ * ```ts
321
+ * recipients: async (id) => await accounts.contactFor(id)
322
+ * ```
323
+ */
324
+ recipients?: (id: string) => Promiseable<Recipient | undefined>;
325
+ /**
326
+ * Templates, for an application that does not carry a translation catalogue.
327
+ *
328
+ * When `@stone-js/i18n` is enabled, keys are looked up there instead, in the **recipient's**
329
+ * locale. These are the fallback, and the override: a key found here is used as it stands.
330
+ */
331
+ templates?: Record<string, TemplateInput>;
332
+ /**
333
+ * Whether delivery is handed to a queue or performed in the request.
334
+ *
335
+ * Defaults to `queue` when `@stone-js/queue` is enabled, and to `inline` otherwise, saying so
336
+ * once. Queueing is what this module is shaped around: deciding and recording is fast, reaching a
337
+ * mail provider is not, and a request that waits for one is a request that times out on a
338
+ * function-as-a-service platform.
339
+ */
340
+ dispatch?: 'queue' | 'inline';
341
+ /** The queue to dispatch on. Defaults to the application's default queue. */
342
+ queue?: string;
343
+ /** How many times a queued delivery is retried. Defaults to what the queue does. */
344
+ attempts?: number;
345
+ }
@@ -0,0 +1,50 @@
1
+ import { ClassType } from '@stone-js/core';
2
+ /** What `@Notice` declares. Metadata only: the content lives in the class. */
3
+ export interface NoticeOptions {
4
+ /** The name a caller refers to it by. */
5
+ name: string;
6
+ /** The domain event it reacts to, so nobody has to call the notifier. */
7
+ on?: string;
8
+ /** The channels it uses. Defaults to `stone.notifications.default`. */
9
+ channels?: string[];
10
+ }
11
+ /**
12
+ * Declare a class as a notice: something a person receives.
13
+ *
14
+ * **The decorator says what it is; the class says what it says.** There is no `content` option, and
15
+ * that is deliberate: a decorator carrying message bodies would put text in the one place it cannot
16
+ * be translated, formatted, or computed from the event. The class answers `notify(event, context)`,
17
+ * and it is built through the container, so it can ask for i18n, a repository, a URL signer.
18
+ *
19
+ * With `on`, **nobody calls the notifier**. A module emits what happened, and the notice that named
20
+ * that event decides who learns about it. The emitting module imports nothing and is never reopened
21
+ * when a channel is added, which is the whole reason this exists rather than a service call.
22
+ *
23
+ * @param options - The notice's metadata.
24
+ * @returns A class decorator.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * @Notice({
29
+ * name: 'guardianship.consent_needed',
30
+ * on: 'identity.guardian.invited.v1',
31
+ * channels: ['smtp', 'in-app']
32
+ * })
33
+ * export class ConsentNeeded {
34
+ * constructor ({ i18n }) { this.i18n = i18n }
35
+ *
36
+ * recipients (event) { return event.guardianId }
37
+ *
38
+ * notify (event, { locale }) {
39
+ * return {
40
+ * smtp: {
41
+ * subject: this.i18n.t('consent.subject', { lng: locale }),
42
+ * body: this.i18n.t('consent.body', { lng: locale, child: event.childHandle })
43
+ * },
44
+ * 'in-app': { body: this.i18n.t('consent.short', { lng: locale }) }
45
+ * }
46
+ * }
47
+ * }
48
+ * ```
49
+ */
50
+ export declare const Notice: <T extends ClassType = ClassType>(options: NoticeOptions) => ClassDecorator;
@@ -0,0 +1,34 @@
1
+ import { ClassType } from '@stone-js/core';
2
+ /**
3
+ * Declare a class as a notification channel.
4
+ *
5
+ * The declaration form for a channel of your own, next to the configuration form: this is how `sms`
6
+ * and `push` are done, and anything else a provider offers. The class is registered as a service, so
7
+ * its constructor is auto-wired like any other, and the channel is registered under the name given
8
+ * here.
9
+ *
10
+ * The class must answer `send(message, recipient)` and **return** an outcome rather than throw, which
11
+ * is the whole port. A channel that throws is treated as retryable.
12
+ *
13
+ * @param name - The name notifications refer to it by.
14
+ * @returns A class decorator.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * @NotificationChannel('sms')
19
+ * export class TwilioChannel {
20
+ * readonly name = 'sms'
21
+ *
22
+ * constructor ({ twilio }) { this.twilio = twilio }
23
+ *
24
+ * async send (message, recipient) {
25
+ * if (recipient.phone === undefined) {
26
+ * return { status: 'unreachable', retryable: false, reason: 'No phone number.' }
27
+ * }
28
+ * await this.twilio.messages.create({ to: recipient.phone, body: message.body })
29
+ * return { status: 'sent' }
30
+ * }
31
+ * }
32
+ * ```
33
+ */
34
+ export declare const NotificationChannel: <T extends ClassType = ClassType>(name: string) => ClassDecorator;
@@ -0,0 +1,26 @@
1
+ import { NotificationsConfig } from '../declarations.js';
2
+ import { ClassType } from '@stone-js/core';
3
+ /** Options for the `@Notifications` activation. */
4
+ export interface NotificationsDecoratorOptions extends NotificationsConfig {
5
+ }
6
+ /**
7
+ * Enable notifications on the application.
8
+ *
9
+ * The declarative half of the module's activation; `notificationsBlueprint` is the imperative half,
10
+ * and neither can do what the other cannot. With nothing configured, notifications go to the log and
11
+ * say so: reaching real people is a decision, so it is written down.
12
+ *
13
+ * @param options - What to configure, if anything.
14
+ * @returns A class decorator.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * @Notifications({
19
+ * default: ['smtp', 'in-app'],
20
+ * channels: [{ name: 'smtp', driver: 'smtp', from: 'Noowow <no-reply@example.test>' }]
21
+ * })
22
+ * @StoneApp()
23
+ * export class Application {}
24
+ * ```
25
+ */
26
+ export declare const Notifications: <T extends ClassType = ClassType>(options?: NotificationsDecoratorOptions) => ClassDecorator;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Where a class declares itself a notification channel.
3
+ *
4
+ * A string rather than a symbol, by the convention every first-party module follows: another package
5
+ * can read it without importing this one.
6
+ */
7
+ export declare const CHANNEL_KEY: string;
8
+ /**
9
+ * Where a class declares itself a notice.
10
+ *
11
+ * A string rather than a symbol, by the convention every first-party module follows: another package
12
+ * can read what an application declared without importing this one.
13
+ */
14
+ export declare const NOTICE_KEY: string;
@@ -0,0 +1,24 @@
1
+ import { NoticeDeclaration } from './declarations.js';
2
+ /**
3
+ * Declare a notice imperatively.
4
+ *
5
+ * The imperative half of `@Notice`, and it says exactly the same thing: metadata here, content in
6
+ * the module. Put the result on `stone.notifications.notices`.
7
+ *
8
+ * @param module - The notice: a class, or an object answering `notify`.
9
+ * @param options - The notice's metadata.
10
+ * @param isClass - Whether `module` is a class the container should build. Defaults to true.
11
+ * @returns The declaration.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * blueprint.set('stone.notifications.notices', [
16
+ * defineNotice(ConsentNeeded, { name: 'guardianship.consent_needed', on: 'identity.guardian.invited.v1' })
17
+ * ])
18
+ * ```
19
+ */
20
+ export declare function defineNotice(module: unknown, options: {
21
+ name: string;
22
+ on?: string;
23
+ channels?: string[];
24
+ }, isClass?: boolean): NoticeDeclaration;
@@ -0,0 +1,12 @@
1
+ import { IntegrationError } from '@stone-js/core';
2
+ import type { ErrorOptions } from '@stone-js/core';
3
+ /**
4
+ * Raised for a setup mistake, so a misconfigured application never looks like a failed delivery.
5
+ *
6
+ * The distinction earns its place here: a failed delivery is retried, and a channel that answered
7
+ * "provider unavailable" to "no channel named that" would be retried forever, on work that cannot
8
+ * succeed.
9
+ */
10
+ export declare class NotificationConfigurationError extends IntegrationError {
11
+ constructor(message: string, options?: ErrorOptions);
12
+ }
@@ -0,0 +1,19 @@
1
+ export * from './channels/InAppChannel.js';
2
+ export * from './channels/LogChannel.js';
3
+ export * from './channels/SmtpChannel.js';
4
+ export * from './constants.js';
5
+ export * from './declarations.js';
6
+ export * from './decorators/constants.js';
7
+ export * from './decorators/Notice.js';
8
+ export * from './decorators/NotificationChannel.js';
9
+ export * from './decorators/Notifications.js';
10
+ export * from './defineNotice.js';
11
+ export * from './errors/NotificationError.js';
12
+ export * from './jobs/DeliverNotification.js';
13
+ export * from './middleware/NoticeSubscriptionsMiddleware.js';
14
+ export * from './NoticeRegistry.js';
15
+ export * from './NotificationManager.js';
16
+ export * from './NotificationServiceProvider.js';
17
+ export * from './Notifier.js';
18
+ export * from './options/NotificationsBlueprint.js';
19
+ export * from './render.js';