@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.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist/NoticeRegistry.d.ts +44 -0
- package/dist/NotificationManager.d.ts +49 -0
- package/dist/NotificationServiceProvider.d.ts +40 -0
- package/dist/Notifier.d.ts +260 -0
- package/dist/channels/InAppChannel.d.ts +43 -0
- package/dist/channels/LogChannel.d.ts +43 -0
- package/dist/channels/SmtpChannel.d.ts +46 -0
- package/dist/constants.d.ts +6 -0
- package/dist/declarations.d.ts +345 -0
- package/dist/decorators/Notice.d.ts +50 -0
- package/dist/decorators/NotificationChannel.d.ts +34 -0
- package/dist/decorators/Notifications.d.ts +26 -0
- package/dist/decorators/constants.d.ts +14 -0
- package/dist/defineNotice.d.ts +24 -0
- package/dist/errors/NotificationError.d.ts +12 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1481 -0
- package/dist/jobs/DeliverNotification.d.ts +30 -0
- package/dist/middleware/NoticeSubscriptionsMiddleware.d.ts +25 -0
- package/dist/options/NotificationsBlueprint.d.ts +37 -0
- package/dist/render.d.ts +36 -0
- package/package.json +114 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
import { IntegrationError, classDecoratorLegacyWrapper, setMetadata, SERVICE_KEY, addBlueprint } from '@stone-js/core';
|
|
2
|
+
import { cloneValue } from '@stone-js/config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Raised for a setup mistake, so a misconfigured application never looks like a failed delivery.
|
|
6
|
+
*
|
|
7
|
+
* The distinction earns its place here: a failed delivery is retried, and a channel that answered
|
|
8
|
+
* "provider unavailable" to "no channel named that" would be retried forever, on work that cannot
|
|
9
|
+
* succeed.
|
|
10
|
+
*/
|
|
11
|
+
class NotificationConfigurationError extends IntegrationError {
|
|
12
|
+
constructor(message, options = {}) {
|
|
13
|
+
super(message, { code: 'NOTIFICATION_CONFIGURATION_ERROR', ...options });
|
|
14
|
+
this.name = 'NotificationConfigurationError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Finds the notice a name or an event refers to, and builds it.
|
|
20
|
+
*
|
|
21
|
+
* Built for one event, like everything else in the container: it is a lookup over what the blueprint
|
|
22
|
+
* declared, and holds nothing between events. A notice class is built through the container, so it
|
|
23
|
+
* gets its services, and it is the container that decides whether that instance is shared.
|
|
24
|
+
*/
|
|
25
|
+
class NoticeRegistry {
|
|
26
|
+
blueprint;
|
|
27
|
+
container;
|
|
28
|
+
/**
|
|
29
|
+
* @param dependencies - Auto-wired services.
|
|
30
|
+
*/
|
|
31
|
+
constructor({ blueprint, container }) {
|
|
32
|
+
this.blueprint = blueprint;
|
|
33
|
+
this.container = container;
|
|
34
|
+
}
|
|
35
|
+
/** Every notice this application declared, from `@Notice` and from configuration alike. */
|
|
36
|
+
all() {
|
|
37
|
+
return this.blueprint.get('stone.notifications', {}).notices ?? [];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* What was declared under this name.
|
|
41
|
+
*
|
|
42
|
+
* @param name - The notice's name.
|
|
43
|
+
* @returns The declaration, or nothing.
|
|
44
|
+
*/
|
|
45
|
+
declaration(name) {
|
|
46
|
+
return this.all().find((notice) => notice.name === name);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What reacts to this event.
|
|
50
|
+
*
|
|
51
|
+
* @param event - The domain event's name.
|
|
52
|
+
* @returns The declaration, or nothing.
|
|
53
|
+
*/
|
|
54
|
+
forEvent(event) {
|
|
55
|
+
return this.all().find((notice) => notice.on === event);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The notice itself, built.
|
|
59
|
+
*
|
|
60
|
+
* @param declaration - What was declared.
|
|
61
|
+
* @returns The notice.
|
|
62
|
+
* @throws {NotificationConfigurationError} When it cannot be built, or does not answer `notify`.
|
|
63
|
+
*/
|
|
64
|
+
build(declaration) {
|
|
65
|
+
const module = declaration.module;
|
|
66
|
+
if (module === undefined) {
|
|
67
|
+
throw new NotificationConfigurationError(`The notice '${declaration.name}' declares no module, so there is nothing to say. Declare a ` +
|
|
68
|
+
'class with `@Notice`, or pass one to `defineNotice`.');
|
|
69
|
+
}
|
|
70
|
+
const built = declaration.isClass === false || typeof module !== 'function'
|
|
71
|
+
? module
|
|
72
|
+
: this.container?.resolve?.(module, true);
|
|
73
|
+
if (built === undefined || typeof built.notify !== 'function') {
|
|
74
|
+
throw new NotificationConfigurationError(`The notice '${declaration.name}' does not answer \`notify(event, context)\`. A notice is a ` +
|
|
75
|
+
'class that says what it says: the decorator carries the metadata, the class carries the ' +
|
|
76
|
+
'content.');
|
|
77
|
+
}
|
|
78
|
+
return built;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Holds the configured channels and hands one out by name.
|
|
84
|
+
*
|
|
85
|
+
* The same shape every driver-based module in the framework uses, and the same lifetime: built for
|
|
86
|
+
* one event, like the container it belongs to. It is a registry of factories, so rebuilding it costs
|
|
87
|
+
* nothing. Nothing here holds state between events; a channel that needs a connection holds it
|
|
88
|
+
* itself, because a connection is a resource and the channel is the boundary that owns it.
|
|
89
|
+
*/
|
|
90
|
+
class NotificationManager {
|
|
91
|
+
static current;
|
|
92
|
+
channels = new Map();
|
|
93
|
+
factories = new Map();
|
|
94
|
+
/**
|
|
95
|
+
* @returns A manager.
|
|
96
|
+
*/
|
|
97
|
+
static create() {
|
|
98
|
+
return new this();
|
|
99
|
+
}
|
|
100
|
+
/** Publish the manager, so code outside the container can reach it. */
|
|
101
|
+
static setInstance(manager) {
|
|
102
|
+
NotificationManager.current = manager;
|
|
103
|
+
}
|
|
104
|
+
/** The published manager, if there is one. */
|
|
105
|
+
static getInstance() {
|
|
106
|
+
return NotificationManager.current;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Register a built channel.
|
|
110
|
+
*
|
|
111
|
+
* @param channel - The channel.
|
|
112
|
+
* @returns This manager.
|
|
113
|
+
*/
|
|
114
|
+
register(channel) {
|
|
115
|
+
this.channels.set(channel.name, channel);
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Register a channel to be built on first use.
|
|
120
|
+
*
|
|
121
|
+
* @param name - The name notifications refer to it by.
|
|
122
|
+
* @param factory - How to build it.
|
|
123
|
+
* @returns This manager.
|
|
124
|
+
*/
|
|
125
|
+
registerFactory(name, factory) {
|
|
126
|
+
this.factories.set(name, factory);
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
/** Whether a channel is registered under this name. */
|
|
130
|
+
has(name) {
|
|
131
|
+
return this.channels.has(name) || this.factories.has(name);
|
|
132
|
+
}
|
|
133
|
+
/** The names of every registered channel. */
|
|
134
|
+
names() {
|
|
135
|
+
return [...new Set([...this.channels.keys(), ...this.factories.keys()])];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The channel a notification named.
|
|
139
|
+
*
|
|
140
|
+
* @param name - The channel's name.
|
|
141
|
+
* @returns The channel.
|
|
142
|
+
* @throws {NotificationConfigurationError} When nothing is registered under that name.
|
|
143
|
+
*/
|
|
144
|
+
channel(name) {
|
|
145
|
+
const built = this.channels.get(name);
|
|
146
|
+
if (built !== undefined) {
|
|
147
|
+
return built;
|
|
148
|
+
}
|
|
149
|
+
const factory = this.factories.get(name);
|
|
150
|
+
if (factory === undefined) {
|
|
151
|
+
throw new NotificationConfigurationError(`No notification channel is registered as '${name}'. Configure it under ` +
|
|
152
|
+
'`stone.notifications.channels`, or register your own with ' +
|
|
153
|
+
'`channels: [{ name, factory }]`. Ships with \'log\', \'in-app\' and \'smtp\'.');
|
|
154
|
+
}
|
|
155
|
+
const channel = factory();
|
|
156
|
+
this.channels.set(name, channel);
|
|
157
|
+
return channel;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A template key and its params, become text in the recipient's language.
|
|
163
|
+
*
|
|
164
|
+
* **The locale is the recipient's, never the request's.** A French-speaking guardian invited by an
|
|
165
|
+
* English-speaking member of staff reads French. Getting that backwards is invisible in every test
|
|
166
|
+
* written by one person in one language, and obvious to the person who receives it.
|
|
167
|
+
*
|
|
168
|
+
* Three sources, in order: a template the application declared outright, the translation catalogue,
|
|
169
|
+
* and failing both the key itself.
|
|
170
|
+
*
|
|
171
|
+
* That last fallback is the point. A missing translation renders its **key**, never an empty string:
|
|
172
|
+
* an empty subject looks like a broken mail client and gets ignored for months, while
|
|
173
|
+
* `guardianship.consent_needed` is visibly ours and gets reported the same day.
|
|
174
|
+
*
|
|
175
|
+
* @param context - What to render, and what to render it from.
|
|
176
|
+
* @returns The rendered notification.
|
|
177
|
+
*/
|
|
178
|
+
function render(context) {
|
|
179
|
+
const { template, params, locale } = context;
|
|
180
|
+
const declared = context.templates?.[template];
|
|
181
|
+
if (declared !== undefined) {
|
|
182
|
+
const { subject, body } = fromDeclared(declared, params, locale);
|
|
183
|
+
return { template, params, locale, subject: subject ?? template, body };
|
|
184
|
+
}
|
|
185
|
+
const translated = context.translator === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: fromCatalogue(context.translator, template, params, locale);
|
|
188
|
+
return {
|
|
189
|
+
template,
|
|
190
|
+
params,
|
|
191
|
+
locale,
|
|
192
|
+
subject: translated?.subject ?? template,
|
|
193
|
+
body: translated?.body ?? template
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* What the application declared for this key.
|
|
198
|
+
*
|
|
199
|
+
* @param declared - The template.
|
|
200
|
+
* @param params - What to render it with.
|
|
201
|
+
* @param locale - The recipient's language.
|
|
202
|
+
* @returns The subject and the body.
|
|
203
|
+
*/
|
|
204
|
+
function fromDeclared(declared, params, locale) {
|
|
205
|
+
if (typeof declared === 'function') {
|
|
206
|
+
return declared(params, locale);
|
|
207
|
+
}
|
|
208
|
+
if (typeof declared === 'string') {
|
|
209
|
+
return { body: interpolate(declared, params) };
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
subject: declared.subject === undefined ? undefined : interpolate(declared.subject, params),
|
|
213
|
+
body: interpolate(declared.body, params)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* What the catalogue holds for this key, under `<key>.subject` and `<key>.body`.
|
|
218
|
+
*
|
|
219
|
+
* Two keys rather than one, because a subject and a body are translated separately by whoever
|
|
220
|
+
* translates them, and a channel with no subject simply ignores it.
|
|
221
|
+
*
|
|
222
|
+
* @param translator - The catalogue.
|
|
223
|
+
* @param template - The key.
|
|
224
|
+
* @param params - What to render it with.
|
|
225
|
+
* @param locale - The recipient's language.
|
|
226
|
+
* @returns The subject and the body, or nothing when the catalogue has neither.
|
|
227
|
+
*/
|
|
228
|
+
function fromCatalogue(translator, template, params, locale) {
|
|
229
|
+
const subjectKey = `${template}.subject`;
|
|
230
|
+
const bodyKey = `${template}.body`;
|
|
231
|
+
const subject = translator.t(subjectKey, { ...params, lng: locale });
|
|
232
|
+
const body = translator.t(bodyKey, { ...params, lng: locale });
|
|
233
|
+
// A catalogue that cannot find a key answers the key, which is exactly the signal wanted here:
|
|
234
|
+
// nothing was translated, so say nothing rather than repeating a key twice.
|
|
235
|
+
const found = subject !== subjectKey || body !== bodyKey;
|
|
236
|
+
return found
|
|
237
|
+
? { subject: subject === subjectKey ? undefined : subject, body: body === bodyKey ? undefined : body }
|
|
238
|
+
: undefined;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Fill `{{ name }}` from the params.
|
|
242
|
+
*
|
|
243
|
+
* The smallest thing that works for a declared template, and not a template engine: an application
|
|
244
|
+
* that wants one renders it itself, through the function form.
|
|
245
|
+
*
|
|
246
|
+
* @param text - The template text.
|
|
247
|
+
* @param params - What to fill it from.
|
|
248
|
+
* @returns The filled text.
|
|
249
|
+
*/
|
|
250
|
+
function interpolate(text, params) {
|
|
251
|
+
return text.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, name) => {
|
|
252
|
+
return primitive(params[name]) ?? match;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* A parameter as text, or nothing when it is not something a message can carry.
|
|
257
|
+
*
|
|
258
|
+
* An object or an array would stringify to `[object Object]` in the middle of a sentence somebody
|
|
259
|
+
* reads. Leaving the placeholder is better: it is visibly unfinished, and it gets reported.
|
|
260
|
+
*
|
|
261
|
+
* @param value - The parameter.
|
|
262
|
+
* @returns Its text, or nothing.
|
|
263
|
+
*/
|
|
264
|
+
function primitive(value) {
|
|
265
|
+
if (value === undefined || value === null) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
if (typeof value === 'object') {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
return String(value);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The queue job that performs a delivery. */
|
|
275
|
+
const DELIVERY_JOB = 'stone-js/notifications:deliver';
|
|
276
|
+
/** The channel used when nothing is configured. It delivers nothing, and says so. */
|
|
277
|
+
const DEFAULT_CHANNEL = 'log';
|
|
278
|
+
/** The event an in-app client listens for. */
|
|
279
|
+
const IN_APP_EVENT = 'notification';
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* What an application calls to reach someone.
|
|
283
|
+
*
|
|
284
|
+
* The one sentence that shapes everything here: **decide now, deliver later.** A request resolves who
|
|
285
|
+
* the person is, renders the message in their language, and hands the delivery to a queue. Reaching a
|
|
286
|
+
* mail provider takes as long as it takes, and a request that waits for one is a request that times
|
|
287
|
+
* out on a function-as-a-service platform, from the endpoint the user is watching.
|
|
288
|
+
*
|
|
289
|
+
* It also never throws at its caller. A notification is almost always a side effect of something that
|
|
290
|
+
* already succeeded: an account was created, a guardian was invited. Failing that operation because a
|
|
291
|
+
* mail provider was down would undo work that was correct. Every failure comes back as an outcome and
|
|
292
|
+
* goes to the log.
|
|
293
|
+
*/
|
|
294
|
+
class Notifier {
|
|
295
|
+
blueprint;
|
|
296
|
+
container;
|
|
297
|
+
/**
|
|
298
|
+
* @param dependencies - Auto-wired services.
|
|
299
|
+
*/
|
|
300
|
+
constructor({ blueprint, container }) {
|
|
301
|
+
this.blueprint = blueprint;
|
|
302
|
+
this.container = container;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Tell someone something.
|
|
306
|
+
*
|
|
307
|
+
* @param to - Who to tell: a recipient, an id, or several of either.
|
|
308
|
+
* @param template - The template key. Never a rendered body: what is not copied does not have to
|
|
309
|
+
* be erased, and a key survives a translation being fixed.
|
|
310
|
+
* @param params - What the template needs.
|
|
311
|
+
* @param options - Which channels, which language, whether to wait.
|
|
312
|
+
* @returns What happened, or that it was queued.
|
|
313
|
+
*/
|
|
314
|
+
async notify(to, template, params = {}, options = {}) {
|
|
315
|
+
if (await this.alreadySent(template, options.dedupe)) {
|
|
316
|
+
return { queued: false, duplicate: true, deliveries: [] };
|
|
317
|
+
}
|
|
318
|
+
const declaration = this.notices().declaration(template);
|
|
319
|
+
const recipients = await this.resolveAll([to].flat());
|
|
320
|
+
const channels = options.channels ?? declaration?.channels ?? this.options().default ?? [DEFAULT_CHANNEL];
|
|
321
|
+
this.warnOnceAboutTheDefault(channels);
|
|
322
|
+
const queue = this.queue(options);
|
|
323
|
+
if (queue !== undefined) {
|
|
324
|
+
await Promise.all(recipients.map(async (recipient) => {
|
|
325
|
+
await this.enqueue(queue, recipient, template, params, channels, options, declaration);
|
|
326
|
+
}));
|
|
327
|
+
return { queued: true, deliveries: [] };
|
|
328
|
+
}
|
|
329
|
+
const deliveries = [];
|
|
330
|
+
for (const recipient of recipients) {
|
|
331
|
+
deliveries.push(...await this.deliver({
|
|
332
|
+
recipient,
|
|
333
|
+
template,
|
|
334
|
+
params,
|
|
335
|
+
channels,
|
|
336
|
+
notice: declaration?.name,
|
|
337
|
+
locale: this.localeFor(recipient, options)
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
return { queued: false, deliveries };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Deliver what a notice says about a domain event.
|
|
344
|
+
*
|
|
345
|
+
* The entry the event subscription calls, and the reason this module is not a service somebody has
|
|
346
|
+
* to remember to call: a module emits what happened, and the notice that named that event decides
|
|
347
|
+
* who learns about it. The emitting module knows nothing about any of this.
|
|
348
|
+
*
|
|
349
|
+
* @param name - The notice's name.
|
|
350
|
+
* @param event - The domain event, as the bus delivered it.
|
|
351
|
+
* @returns What happened, or that it was queued.
|
|
352
|
+
*/
|
|
353
|
+
async deliverNotice(name, event) {
|
|
354
|
+
const declaration = this.notices().declaration(name);
|
|
355
|
+
if (declaration === undefined) {
|
|
356
|
+
// Subscribed and then removed, or renamed: worth saying, because nothing else will fail.
|
|
357
|
+
this.logger()?.warn(`[@stone-js/notifications] no notice is declared as '${name}'`);
|
|
358
|
+
return { queued: false, deliveries: [] };
|
|
359
|
+
}
|
|
360
|
+
const notice = this.notices().build(declaration);
|
|
361
|
+
if (notice.recipients === undefined) {
|
|
362
|
+
// The one thing a notice reacting to an event must answer: the event carries the account, and
|
|
363
|
+
// only the notice knows which field that is. Without it there is nobody to tell.
|
|
364
|
+
this.logger()?.error?.(`[@stone-js/notifications] the notice '${name}' reacts to an event and does not answer ` +
|
|
365
|
+
'`recipients(event)`, so nobody can be told. Add it, or drop `on` and call the notifier.');
|
|
366
|
+
return { queued: false, deliveries: [] };
|
|
367
|
+
}
|
|
368
|
+
const dedupe = notice.dedupe === undefined ? undefined : await notice.dedupe(event);
|
|
369
|
+
return await this.notify(await notice.recipients(event), name, (event ?? {}), dedupe === undefined ? {} : { dedupe });
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* What a notice would send, without sending it.
|
|
373
|
+
*
|
|
374
|
+
* For a screen that shows a member of staff what a guardian is about to receive, and for a test
|
|
375
|
+
* that checks a notice without a channel. It renders exactly what delivery would render, per
|
|
376
|
+
* channel, which is what makes it worth having rather than approximating.
|
|
377
|
+
*
|
|
378
|
+
* @param to - Who it would be for.
|
|
379
|
+
* @param template - The notice or template name.
|
|
380
|
+
* @param params - What it needs.
|
|
381
|
+
* @param options - Which channels, which language.
|
|
382
|
+
* @returns One rendered message per recipient and channel.
|
|
383
|
+
*/
|
|
384
|
+
async preview(to, template, params = {}, options = {}) {
|
|
385
|
+
const declaration = this.notices().declaration(template);
|
|
386
|
+
const recipients = await this.resolveAll([to].flat());
|
|
387
|
+
const channels = options.channels ?? declaration?.channels ?? this.options().default ?? [DEFAULT_CHANNEL];
|
|
388
|
+
const previewed = [];
|
|
389
|
+
for (const recipient of recipients) {
|
|
390
|
+
const locale = this.localeFor(recipient, options);
|
|
391
|
+
const content = await this.contentFrom(declaration, params, { locale, recipient });
|
|
392
|
+
for (const channel of channels) {
|
|
393
|
+
previewed.push({ recipient, channel, message: this.messageFor(channel, template, params, locale, content) });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return previewed;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Perform one delivery, to one person, on every channel it names.
|
|
400
|
+
*
|
|
401
|
+
* Called here when nothing is queued, and by the queue job when something is. Same code either way,
|
|
402
|
+
* which is what makes a retry mean exactly what the first attempt meant.
|
|
403
|
+
*
|
|
404
|
+
* @param payload - Who, what, where, in which language.
|
|
405
|
+
* @returns What each channel answered.
|
|
406
|
+
*/
|
|
407
|
+
async deliver(payload) {
|
|
408
|
+
const manager = this.manager();
|
|
409
|
+
const declaration = payload.notice === undefined ? undefined : this.notices().declaration(payload.notice);
|
|
410
|
+
// Asked once per recipient, never once per channel: a name in the body is rendered once, and the
|
|
411
|
+
// channel-specific body is chosen from what it answered.
|
|
412
|
+
const content = await this.contentFrom(declaration, payload.params, {
|
|
413
|
+
locale: payload.locale,
|
|
414
|
+
recipient: payload.recipient
|
|
415
|
+
});
|
|
416
|
+
const outcomes = [];
|
|
417
|
+
for (const name of payload.channels) {
|
|
418
|
+
const message = this.messageFor(name, payload.template, payload.params, payload.locale, content);
|
|
419
|
+
const outcome = await this.sendOn(manager, name, message, payload.recipient);
|
|
420
|
+
outcomes.push(outcome);
|
|
421
|
+
await this.announce(outcome, payload);
|
|
422
|
+
}
|
|
423
|
+
return outcomes;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* What the notice says, for this person, or nothing when there is no notice.
|
|
427
|
+
*
|
|
428
|
+
* Failing here must not fail the delivery: a notice is application code, and application code
|
|
429
|
+
* throws. The message then falls back to the template path, which at worst renders the key, and
|
|
430
|
+
* the failure is named rather than swallowed.
|
|
431
|
+
*
|
|
432
|
+
* @param declaration - The notice, when one is declared.
|
|
433
|
+
* @param params - The event or params it is given.
|
|
434
|
+
* @param context - Who it is for, and in which language.
|
|
435
|
+
* @returns The content, or nothing.
|
|
436
|
+
*/
|
|
437
|
+
async contentFrom(declaration, params, context) {
|
|
438
|
+
if (declaration === undefined) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
return await this.notices().build(declaration).notify(params, context);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
this.logger()?.error?.(`[@stone-js/notifications] the notice '${declaration.name}' threw`, {
|
|
446
|
+
reason: error?.message
|
|
447
|
+
});
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The message one channel receives.
|
|
453
|
+
*
|
|
454
|
+
* A notice's content wins, per channel, because that is the whole reason it returns a map: a text
|
|
455
|
+
* message is not an email, one having a subject and room to explain and the other a hundred and
|
|
456
|
+
* sixty characters. Failing that, the template path renders it.
|
|
457
|
+
*
|
|
458
|
+
* @param channel - The channel about to send.
|
|
459
|
+
* @param template - The notice or template name.
|
|
460
|
+
* @param params - What it was given.
|
|
461
|
+
* @param locale - The recipient's language.
|
|
462
|
+
* @param content - What the notice said, if anything.
|
|
463
|
+
* @returns The rendered message.
|
|
464
|
+
*/
|
|
465
|
+
messageFor(channel, template, params, locale, content) {
|
|
466
|
+
const chosen = this.contentFor(channel, content);
|
|
467
|
+
if (chosen !== undefined) {
|
|
468
|
+
return { template, params, locale, subject: chosen.subject ?? template, body: chosen.body };
|
|
469
|
+
}
|
|
470
|
+
return render({
|
|
471
|
+
template,
|
|
472
|
+
params,
|
|
473
|
+
locale,
|
|
474
|
+
templates: this.options().templates,
|
|
475
|
+
translator: this.translator()
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The content for one channel, out of what a notice returned.
|
|
480
|
+
*
|
|
481
|
+
* Three shapes, all useful: a map keyed by channel, one content for every channel, or a bare
|
|
482
|
+
* string. A map that names no content for this channel falls through to the template path rather
|
|
483
|
+
* than sending an empty body.
|
|
484
|
+
*
|
|
485
|
+
* @param channel - The channel about to send.
|
|
486
|
+
* @param content - What the notice said.
|
|
487
|
+
* @returns The content, or nothing.
|
|
488
|
+
*/
|
|
489
|
+
contentFor(channel, content) {
|
|
490
|
+
if (content === undefined) {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
if (typeof content === 'string') {
|
|
494
|
+
return { body: content };
|
|
495
|
+
}
|
|
496
|
+
if (typeof content.body === 'string') {
|
|
497
|
+
return content;
|
|
498
|
+
}
|
|
499
|
+
const forChannel = content[channel];
|
|
500
|
+
if (forChannel === undefined) {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
return typeof forChannel === 'string' ? { body: forChannel } : forChannel;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* One channel, one message, and an answer whatever happens.
|
|
507
|
+
*
|
|
508
|
+
* A channel that throws is treated as **retryable**, because a throw is an adapter bug rather than
|
|
509
|
+
* a verdict, and burying a channel's whole traffic the day a provider client changes its error
|
|
510
|
+
* shapes would be worse than one retry too many.
|
|
511
|
+
*
|
|
512
|
+
* @param manager - The channel registry.
|
|
513
|
+
* @param name - The channel to use.
|
|
514
|
+
* @param message - The rendered notification.
|
|
515
|
+
* @param recipient - Who it is for.
|
|
516
|
+
* @returns The outcome, always.
|
|
517
|
+
*/
|
|
518
|
+
async sendOn(manager, name, message, recipient) {
|
|
519
|
+
try {
|
|
520
|
+
const outcome = await manager.channel(name).send(message, recipient);
|
|
521
|
+
if (outcome.status !== 'sent') {
|
|
522
|
+
this.logger()?.warn(`[@stone-js/notifications] ${message.template} not delivered on '${name}'`, {
|
|
523
|
+
status: outcome.status,
|
|
524
|
+
retryable: outcome.retryable,
|
|
525
|
+
reason: outcome.reason
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return { ...outcome, channel: name };
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
// A setup mistake will fail identically on every attempt, so it is reported as permanent. Every
|
|
532
|
+
// other throw is an adapter bug rather than a verdict, and is worth one more try: burying a
|
|
533
|
+
// channel's whole traffic the day a provider client changes its error shapes would be worse.
|
|
534
|
+
const configuration = error instanceof NotificationConfigurationError;
|
|
535
|
+
this.logger()?.error?.(`[@stone-js/notifications] the '${name}' channel threw`, {
|
|
536
|
+
template: message.template,
|
|
537
|
+
configuration,
|
|
538
|
+
reason: error?.message
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
status: 'failed',
|
|
542
|
+
retryable: !configuration,
|
|
543
|
+
reason: String(error?.message ?? 'The channel threw.'),
|
|
544
|
+
channel: name
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Hand one delivery to the queue.
|
|
550
|
+
*
|
|
551
|
+
* Rendered arguments are **not** carried: the payload holds the key and the params, so a message
|
|
552
|
+
* queued before a translation was fixed goes out fixed, and a queue dump holds no message bodies.
|
|
553
|
+
*
|
|
554
|
+
* @param queue - The queue.
|
|
555
|
+
* @param recipient - Who it is for.
|
|
556
|
+
* @param template - The template key.
|
|
557
|
+
* @param params - What it needs.
|
|
558
|
+
* @param channels - Where to send it.
|
|
559
|
+
* @param options - What the caller asked for.
|
|
560
|
+
*/
|
|
561
|
+
async enqueue(queue, recipient, template, params, channels, options, declaration) {
|
|
562
|
+
const payload = {
|
|
563
|
+
recipient,
|
|
564
|
+
template,
|
|
565
|
+
params,
|
|
566
|
+
channels,
|
|
567
|
+
notice: declaration?.name,
|
|
568
|
+
locale: this.localeFor(recipient, options)
|
|
569
|
+
};
|
|
570
|
+
const settings = this.options();
|
|
571
|
+
const jobOptions = {
|
|
572
|
+
...(settings.queue !== undefined ? { queue: settings.queue } : {}),
|
|
573
|
+
...(settings.attempts !== undefined ? { attempts: settings.attempts } : {})
|
|
574
|
+
};
|
|
575
|
+
try {
|
|
576
|
+
// A delayed delivery is the queue's own capability, so it is asked rather than reimplemented:
|
|
577
|
+
// a timer held in a process that a cold start can end is not a reminder.
|
|
578
|
+
if (options.delay !== undefined && queue.later !== undefined) {
|
|
579
|
+
await queue.later(options.delay, DELIVERY_JOB, payload, jobOptions);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
if (options.delay !== undefined) {
|
|
583
|
+
this.logger()?.warn('[@stone-js/notifications] a delay was asked for and this queue cannot defer, so the ' +
|
|
584
|
+
'notification goes out now.', { template, delay: options.delay });
|
|
585
|
+
}
|
|
586
|
+
await queue.dispatch(DELIVERY_JOB, payload, jobOptions);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
// A queue that cannot take the work must not take down the operation that caused it. It is
|
|
591
|
+
// logged rather than raised, and loudly, because a notification nobody queued is one nobody
|
|
592
|
+
// will ever see fail.
|
|
593
|
+
this.logger()?.error?.('[@stone-js/notifications] a delivery could not be queued', {
|
|
594
|
+
template,
|
|
595
|
+
reason: error?.message
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Everyone this notification is for, as people rather than ids.
|
|
601
|
+
*
|
|
602
|
+
* An id is resolved through the application's own directory, so the address is read at send time.
|
|
603
|
+
* One that resolves to nobody is dropped with a warning: sending to an id nobody recognises is not
|
|
604
|
+
* something to guess at.
|
|
605
|
+
*
|
|
606
|
+
* @param inputs - The recipients, or their ids.
|
|
607
|
+
* @returns The recipients that could be resolved.
|
|
608
|
+
*/
|
|
609
|
+
async resolveAll(inputs) {
|
|
610
|
+
const resolver = this.options().recipients;
|
|
611
|
+
const resolved = [];
|
|
612
|
+
for (const input of inputs) {
|
|
613
|
+
if (typeof input !== 'string') {
|
|
614
|
+
resolved.push(input);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (resolver === undefined) {
|
|
618
|
+
this.logger()?.warn('[@stone-js/notifications] a notification named a recipient by id, and nothing can turn an ' +
|
|
619
|
+
'id into a person. Set `stone.notifications.recipients`, or pass the recipient itself.', { id: input });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const recipient = await resolver(input);
|
|
623
|
+
if (recipient === undefined) {
|
|
624
|
+
this.logger()?.warn('[@stone-js/notifications] a recipient could not be resolved', { id: input });
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
resolved.push(recipient);
|
|
628
|
+
}
|
|
629
|
+
return resolved;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The language to write in.
|
|
633
|
+
*
|
|
634
|
+
* The recipient's own, first and almost always. An explicit override exists because a few messages
|
|
635
|
+
* are genuinely about the sender's context, and it is rarely the right answer.
|
|
636
|
+
*
|
|
637
|
+
* @param recipient - Who it is for.
|
|
638
|
+
* @param options - What the caller asked for.
|
|
639
|
+
* @returns The locale.
|
|
640
|
+
*/
|
|
641
|
+
localeFor(recipient, options) {
|
|
642
|
+
return options.locale ??
|
|
643
|
+
recipient.locale ??
|
|
644
|
+
this.blueprint.get('stone.i18n.locale', 'en');
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* The queue to dispatch on, or nothing when this delivery happens here.
|
|
648
|
+
*
|
|
649
|
+
* @param options - What the caller asked for.
|
|
650
|
+
* @returns The queue, or nothing.
|
|
651
|
+
*/
|
|
652
|
+
queue(options) {
|
|
653
|
+
if (options.inline === true) {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
const configured = this.options().dispatch;
|
|
657
|
+
const queue = this.container?.has?.('queue') === true
|
|
658
|
+
? this.container.make('queue')
|
|
659
|
+
: undefined;
|
|
660
|
+
if (configured === 'inline') {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
if (queue?.dispatch === undefined) {
|
|
664
|
+
if (configured === 'queue') {
|
|
665
|
+
this.logger()?.warn('[@stone-js/notifications] delivery is configured to be queued, and no queue is enabled, ' +
|
|
666
|
+
'so it happens in the request instead. Enable @stone-js/queue, or set ' +
|
|
667
|
+
'`stone.notifications.dispatch` to \'inline\' to say you meant it.');
|
|
668
|
+
}
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
return queue;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Say once that nothing is being delivered.
|
|
675
|
+
*
|
|
676
|
+
* The zero-config default writes to the log and reaches nobody. That is the right default, and a
|
|
677
|
+
* module that delivered nothing while looking like one that delivers is the failure this warning
|
|
678
|
+
* exists to prevent.
|
|
679
|
+
*
|
|
680
|
+
* @param channels - The channels in use.
|
|
681
|
+
*/
|
|
682
|
+
warnOnceAboutTheDefault(channels) {
|
|
683
|
+
if (this.warned || channels.length !== 1 || channels[0] !== DEFAULT_CHANNEL) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (this.options().channels?.some((channel) => channel.name === DEFAULT_CHANNEL) === true) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.warned = true;
|
|
690
|
+
this.logger()?.warn('[@stone-js/notifications] notifications are going to the log, which reaches nobody. Configure ' +
|
|
691
|
+
'a channel and name it in `stone.notifications.default` when you want them delivered.');
|
|
692
|
+
}
|
|
693
|
+
/** Whether the default has already been reported, for this notifier. */
|
|
694
|
+
warned = false;
|
|
695
|
+
/**
|
|
696
|
+
* Whether this exact occurrence has already gone out.
|
|
697
|
+
*
|
|
698
|
+
* The answer to the most common production failure of any notification system: the same message
|
|
699
|
+
* twice, because a queue is at-least-once, because a retry half succeeded, or because two events
|
|
700
|
+
* describe one fact. Keys live in `@stone-js/cache`, so the store is the one the application
|
|
701
|
+
* already chose and this module stores nothing of its own.
|
|
702
|
+
*
|
|
703
|
+
* `add` is the atomic set-if-absent every store implements, which is what makes this a claim rather
|
|
704
|
+
* than a read followed by a hopeful write.
|
|
705
|
+
*
|
|
706
|
+
* @param template - The notice or template name, so two notices cannot collide on one key.
|
|
707
|
+
* @param key - What makes this occurrence unique, when anything does.
|
|
708
|
+
* @returns True when it was already sent.
|
|
709
|
+
*/
|
|
710
|
+
async alreadySent(template, key) {
|
|
711
|
+
if (key === undefined) {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
const cache = this.cache();
|
|
715
|
+
if (cache === undefined) {
|
|
716
|
+
// Silently sending twice is exactly what this exists to prevent, so the absence is named.
|
|
717
|
+
this.warnOnce('dedupe', '[@stone-js/notifications] a notification asked to be sent once and no cache is enabled to ' +
|
|
718
|
+
'remember it, so a repeat cannot be recognised. Enable @stone-js/cache.');
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
const ttl = this.options().dedupe?.ttl ?? 86_400;
|
|
722
|
+
try {
|
|
723
|
+
const claimed = await cache.add(`notifications:${template}:${key}`, 1, { ttl });
|
|
724
|
+
if (!claimed) {
|
|
725
|
+
this.logger()?.info?.('[@stone-js/notifications] a repeated notification was dropped', { template });
|
|
726
|
+
}
|
|
727
|
+
return !claimed;
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
// A cache that cannot answer must not stop a notification: better one duplicate than a message
|
|
731
|
+
// nobody receives. Named, because a deduplication that quietly stopped working is worth seeing.
|
|
732
|
+
this.logger()?.warn('[@stone-js/notifications] the deduplication store could not answer', {
|
|
733
|
+
template,
|
|
734
|
+
reason: error?.message
|
|
735
|
+
});
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Announce one delivery on the event bus.
|
|
741
|
+
*
|
|
742
|
+
* How an application keeps a delivery ledger without this module owning one. "Why did they never
|
|
743
|
+
* receive it" is the question a notification system exists to answer, and the answer belongs in
|
|
744
|
+
* whatever the application already queries: it listens, and writes what it needs.
|
|
745
|
+
*
|
|
746
|
+
* @param outcome - What the channel answered.
|
|
747
|
+
* @param payload - What was being delivered.
|
|
748
|
+
*/
|
|
749
|
+
async announce(outcome, payload) {
|
|
750
|
+
if (this.options().announce === false) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const bus = this.bus();
|
|
754
|
+
if (bus === undefined) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
await bus.emit(outcome.status === 'sent' ? 'notification.delivered' : 'notification.failed', {
|
|
759
|
+
template: payload.template,
|
|
760
|
+
notice: payload.notice,
|
|
761
|
+
channel: outcome.channel,
|
|
762
|
+
status: outcome.status,
|
|
763
|
+
retryable: outcome.retryable,
|
|
764
|
+
reason: outcome.reason,
|
|
765
|
+
// The recipient's id, never their address: this leaves the process, and an address in an
|
|
766
|
+
// event is an address in every log that event passes through.
|
|
767
|
+
recipientId: payload.recipient.id,
|
|
768
|
+
locale: payload.locale
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
// Announcing is a courtesy to whoever is listening, and it must not undo a delivery that
|
|
773
|
+
// already happened.
|
|
774
|
+
this.logger()?.warn('[@stone-js/notifications] a delivery could not be announced', {
|
|
775
|
+
reason: error?.message
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** Say something once per notifier, so a standing condition is stated rather than repeated. */
|
|
780
|
+
warnOnce(topic, message) {
|
|
781
|
+
if (this.said.has(topic)) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
this.said.add(topic);
|
|
785
|
+
this.logger()?.warn(message);
|
|
786
|
+
}
|
|
787
|
+
/** What has already been said. */
|
|
788
|
+
said = new Set();
|
|
789
|
+
/** The notice registry, built for this event like everything else. */
|
|
790
|
+
notices() {
|
|
791
|
+
this.registry = this.registry ?? new NoticeRegistry({ blueprint: this.blueprint, container: this.container });
|
|
792
|
+
return this.registry;
|
|
793
|
+
}
|
|
794
|
+
/** The registry, once. */
|
|
795
|
+
registry;
|
|
796
|
+
/** The cache store deduplication is remembered in, when one is bound. */
|
|
797
|
+
cache() {
|
|
798
|
+
const name = this.options().dedupe?.store;
|
|
799
|
+
if (this.container?.has?.('cacheManager') === true) {
|
|
800
|
+
return this.container.make('cacheManager').store(name);
|
|
801
|
+
}
|
|
802
|
+
return this.container?.has?.('cache') === true ? this.container.make('cache') : undefined;
|
|
803
|
+
}
|
|
804
|
+
/** The event bus, when one is bound. */
|
|
805
|
+
bus() {
|
|
806
|
+
return this.container?.has?.('eventBus') === true
|
|
807
|
+
? this.container.make('eventBus')
|
|
808
|
+
: undefined;
|
|
809
|
+
}
|
|
810
|
+
/** The `stone.notifications` bucket. */
|
|
811
|
+
options() {
|
|
812
|
+
return this.blueprint.get('stone.notifications', {});
|
|
813
|
+
}
|
|
814
|
+
/** The channel registry, from the container when there is one. */
|
|
815
|
+
manager() {
|
|
816
|
+
const fromContainer = this.container?.has?.(NotificationManager) === true
|
|
817
|
+
? this.container.make(NotificationManager)
|
|
818
|
+
: undefined;
|
|
819
|
+
return fromContainer ?? NotificationManager.getInstance() ?? NotificationManager.create();
|
|
820
|
+
}
|
|
821
|
+
/** The translation catalogue, when one is bound. */
|
|
822
|
+
translator() {
|
|
823
|
+
return this.container?.has?.('i18n') === true ? this.container.make('i18n') : undefined;
|
|
824
|
+
}
|
|
825
|
+
/** The logger, when one is bound. */
|
|
826
|
+
logger() {
|
|
827
|
+
return this.container?.has?.('logger') === true ? this.container.make('logger') : undefined;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** What an unconfigured log channel is, as one value rather than a literal rebuilt per call. */
|
|
832
|
+
const DEFAULT_CONFIG$1 = { name: 'log' };
|
|
833
|
+
/**
|
|
834
|
+
* The channel that delivers nothing, and is honest about it.
|
|
835
|
+
*
|
|
836
|
+
* The zero-config default, so an application can call `notify()` on its first day and watch what it
|
|
837
|
+
* would have sent. It is the right choice in development and in a test, and **it is not a channel**
|
|
838
|
+
* anywhere else: nobody receives anything.
|
|
839
|
+
*
|
|
840
|
+
* It exists rather than defaulting to a real one because a default that quietly sent real mail would
|
|
841
|
+
* send it from the first test run, to real people. The notifier says once, in a warning, that this is
|
|
842
|
+
* what is configured, for the same reason: a module that delivers nothing must not look like one that
|
|
843
|
+
* delivers.
|
|
844
|
+
*/
|
|
845
|
+
class LogChannel {
|
|
846
|
+
name;
|
|
847
|
+
logger;
|
|
848
|
+
/**
|
|
849
|
+
* @param config - Names the channel.
|
|
850
|
+
* @param logger - Where the message goes, when one is bound.
|
|
851
|
+
* @returns A channel.
|
|
852
|
+
*/
|
|
853
|
+
static create(config = DEFAULT_CONFIG$1, logger) {
|
|
854
|
+
return new this(config.name ?? 'log', logger);
|
|
855
|
+
}
|
|
856
|
+
constructor(name = 'log', logger) {
|
|
857
|
+
this.name = name;
|
|
858
|
+
this.logger = logger;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Write the message where the application writes everything else.
|
|
862
|
+
*
|
|
863
|
+
* @param message - The rendered notification.
|
|
864
|
+
* @param recipient - Who it was for.
|
|
865
|
+
* @returns Sent, because writing it down is all this channel promises.
|
|
866
|
+
*/
|
|
867
|
+
async send(message, recipient) {
|
|
868
|
+
this.logger?.info?.(`[@stone-js/notifications] ${message.template} -> ${this.addresseeOf(recipient)}`, {
|
|
869
|
+
subject: message.subject,
|
|
870
|
+
locale: message.locale
|
|
871
|
+
});
|
|
872
|
+
return { status: 'sent' };
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Who it was for, named without spelling out an address.
|
|
876
|
+
*
|
|
877
|
+
* A log line is read by more people than a database row, so it carries enough to follow a message
|
|
878
|
+
* and not enough to leak one.
|
|
879
|
+
*
|
|
880
|
+
* @param recipient - Who it was for.
|
|
881
|
+
* @returns Something to call them in a log.
|
|
882
|
+
*/
|
|
883
|
+
addresseeOf(recipient) {
|
|
884
|
+
if (typeof recipient.id === 'string' && recipient.id !== '') {
|
|
885
|
+
return `user:${recipient.id}`;
|
|
886
|
+
}
|
|
887
|
+
if (typeof recipient.email === 'string') {
|
|
888
|
+
return 'an email address';
|
|
889
|
+
}
|
|
890
|
+
if (typeof recipient.phone === 'string') {
|
|
891
|
+
return 'a phone number';
|
|
892
|
+
}
|
|
893
|
+
return 'someone with no address';
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Email, over SMTP.
|
|
899
|
+
*
|
|
900
|
+
* SMTP rather than a provider's API, deliberately: every provider speaks it, so shipping this one
|
|
901
|
+
* channel reaches all of them without this package choosing a vendor for you. A provider with an API
|
|
902
|
+
* you prefer is a `factory` away.
|
|
903
|
+
*
|
|
904
|
+
* `nodemailer` is an optional peer, imported lazily, so an application that sends no mail carries no
|
|
905
|
+
* mail dependency. The transport is built once per channel instance and the instance is rebuilt with
|
|
906
|
+
* the container: a transport is a **resource**, not state, and nodemailer pools its own connections,
|
|
907
|
+
* so an application under load should pass a transport it owns.
|
|
908
|
+
*/
|
|
909
|
+
class SmtpChannel {
|
|
910
|
+
name;
|
|
911
|
+
config;
|
|
912
|
+
transportPromise;
|
|
913
|
+
/**
|
|
914
|
+
* @param config - The channel's configuration.
|
|
915
|
+
* @returns A channel.
|
|
916
|
+
*/
|
|
917
|
+
static create(config) {
|
|
918
|
+
return new this(config);
|
|
919
|
+
}
|
|
920
|
+
constructor(config) {
|
|
921
|
+
this.config = config;
|
|
922
|
+
this.name = config.name ?? 'smtp';
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Send the message as mail.
|
|
926
|
+
*
|
|
927
|
+
* @param message - The rendered notification.
|
|
928
|
+
* @param recipient - Who it is for.
|
|
929
|
+
* @returns How it ended.
|
|
930
|
+
*/
|
|
931
|
+
async send(message, recipient) {
|
|
932
|
+
if (typeof recipient.email !== 'string' || recipient.email === '') {
|
|
933
|
+
// Not retryable, and not a failure of this channel: the person simply has no mailbox here.
|
|
934
|
+
// Reported rather than thrown, so the caller sees which channel could not reach them and why.
|
|
935
|
+
return { status: 'unreachable', retryable: false, reason: 'The recipient has no email address.' };
|
|
936
|
+
}
|
|
937
|
+
if (typeof this.config.from !== 'string' || this.config.from === '') {
|
|
938
|
+
// A setup mistake, and one no default could fix: a from address nobody chose would be refused
|
|
939
|
+
// by the first receiving server that checks it.
|
|
940
|
+
return {
|
|
941
|
+
status: 'failed',
|
|
942
|
+
retryable: false,
|
|
943
|
+
reason: 'The SMTP channel needs a `from` address. Set it on the channel configuration.'
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
const transport = await this.transport();
|
|
948
|
+
const sent = await transport.sendMail({
|
|
949
|
+
from: this.config.from,
|
|
950
|
+
to: recipient.email,
|
|
951
|
+
subject: message.subject,
|
|
952
|
+
text: message.body
|
|
953
|
+
});
|
|
954
|
+
return { status: 'sent', id: sent?.messageId };
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
// A setup mistake is not a delivery outcome and must not be dressed as one: it would go to the
|
|
958
|
+
// retry queue and fail identically forever. It leaves as itself, and the notifier reports it
|
|
959
|
+
// as the permanent failure it is.
|
|
960
|
+
if (error instanceof NotificationConfigurationError) {
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
// A provider being down is worth another attempt; a rejected address is not, and SMTP says
|
|
964
|
+
// which by its status code: 5xx is permanent, everything else is worth retrying.
|
|
965
|
+
const permanent = typeof error?.responseCode === 'number' && error.responseCode >= 500 && error.responseCode < 600;
|
|
966
|
+
return {
|
|
967
|
+
status: 'failed',
|
|
968
|
+
retryable: !permanent,
|
|
969
|
+
reason: String(error?.message ?? 'The mail could not be sent.')
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/** The transport, built once for this instance. */
|
|
974
|
+
async transport() {
|
|
975
|
+
this.transportPromise = this.transportPromise ?? this.build();
|
|
976
|
+
return await this.transportPromise;
|
|
977
|
+
}
|
|
978
|
+
/** Build the transport from what was configured, or say what is missing. */
|
|
979
|
+
async build() {
|
|
980
|
+
const configured = this.config.transport;
|
|
981
|
+
// A transport the application built is the application's to manage, connection pool included.
|
|
982
|
+
if (configured !== undefined && configured !== null && typeof configured.sendMail === 'function') {
|
|
983
|
+
return configured;
|
|
984
|
+
}
|
|
985
|
+
const nodemailer = await this.loadNodemailer();
|
|
986
|
+
return nodemailer.createTransport(configured ?? {});
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Load `nodemailer`, an optional peer, and say plainly when it is not there.
|
|
990
|
+
*
|
|
991
|
+
* A missing package is a setup mistake, never a delivery failure: answering "could not send" would
|
|
992
|
+
* put it in the retry queue forever, and the retry would fail identically every time.
|
|
993
|
+
*
|
|
994
|
+
* @returns The nodemailer module.
|
|
995
|
+
* @throws {NotificationConfigurationError} When the package is absent.
|
|
996
|
+
*/
|
|
997
|
+
async loadNodemailer() {
|
|
998
|
+
const missing = () => {
|
|
999
|
+
throw new NotificationConfigurationError('The SMTP channel requires "nodemailer". Install it: npm i nodemailer');
|
|
1000
|
+
};
|
|
1001
|
+
// Typed as unknown on purpose: `nodemailer` is an optional peer, so this package must compile
|
|
1002
|
+
// whether or not its types are installed, and what comes back is checked below anyway.
|
|
1003
|
+
const mod = await import('nodemailer').catch(missing);
|
|
1004
|
+
// It ships a default export, and which one a dynamic import lands on depends on the interop the
|
|
1005
|
+
// application was built with. Either is fine; neither is a delivery failure.
|
|
1006
|
+
const candidate = mod?.default ?? mod;
|
|
1007
|
+
return typeof candidate?.createTransport === 'function' ? candidate : missing();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** What an unconfigured in-app channel is, as one value rather than a literal rebuilt per call. */
|
|
1012
|
+
const DEFAULT_CONFIG = { name: 'in-app' };
|
|
1013
|
+
/**
|
|
1014
|
+
* The channel that reaches the tab someone already has open.
|
|
1015
|
+
*
|
|
1016
|
+
* This is the half that makes a notification module worth having in this framework rather than a mail
|
|
1017
|
+
* client wrapped in a service: **one `notify()` reaches the mailbox, the phone and the open screen**,
|
|
1018
|
+
* and the application wires none of the three together.
|
|
1019
|
+
*
|
|
1020
|
+
* It broadcasts through `@stone-js/realtime`, duck-typed from the container, so this package neither
|
|
1021
|
+
* imports it nor requires it. The client side is already written: whoever is subscribed to the
|
|
1022
|
+
* recipient's channel receives a `notification` event, with the same rendered message every other
|
|
1023
|
+
* channel got.
|
|
1024
|
+
*
|
|
1025
|
+
* It reports `unreachable` rather than `failed` when nobody is listening, because that is not an
|
|
1026
|
+
* error: a person who is not looking at the screen is exactly why the other channels exist.
|
|
1027
|
+
*/
|
|
1028
|
+
class InAppChannel {
|
|
1029
|
+
name;
|
|
1030
|
+
event;
|
|
1031
|
+
broadcaster;
|
|
1032
|
+
channelFor;
|
|
1033
|
+
/**
|
|
1034
|
+
* @param config - Names the channel, and how to address a recipient.
|
|
1035
|
+
* @param broadcaster - The realtime broadcaster, when one is bound.
|
|
1036
|
+
* @returns A channel.
|
|
1037
|
+
*/
|
|
1038
|
+
static create(config = DEFAULT_CONFIG, broadcaster) {
|
|
1039
|
+
return new this(config, broadcaster);
|
|
1040
|
+
}
|
|
1041
|
+
constructor(config = DEFAULT_CONFIG, broadcaster) {
|
|
1042
|
+
this.name = config.name ?? 'in-app';
|
|
1043
|
+
this.event = config.event ?? IN_APP_EVENT;
|
|
1044
|
+
this.broadcaster = broadcaster;
|
|
1045
|
+
this.channelFor = config.channelFor ?? ((recipient) => `user.${String(recipient.id)}.notifications`);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Broadcast the message on the recipient's own channel.
|
|
1049
|
+
*
|
|
1050
|
+
* @param message - The rendered notification.
|
|
1051
|
+
* @param recipient - Who it is for.
|
|
1052
|
+
* @returns How it ended.
|
|
1053
|
+
*/
|
|
1054
|
+
async send(message, recipient) {
|
|
1055
|
+
if (this.broadcaster === undefined) {
|
|
1056
|
+
// A setup gap, not a delivery failure, and it must not be retried: nothing will change on the
|
|
1057
|
+
// second attempt. Named plainly so the fix is obvious.
|
|
1058
|
+
return {
|
|
1059
|
+
status: 'failed',
|
|
1060
|
+
retryable: false,
|
|
1061
|
+
reason: 'The in-app channel needs a realtime broadcaster. Enable @stone-js/realtime, or ' +
|
|
1062
|
+
'register a channel of your own with `channels: [{ name, factory }]`.'
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof recipient.id !== 'string' || recipient.id === '') {
|
|
1066
|
+
// Without an id there is no channel to broadcast on. Not retryable: the recipient will not
|
|
1067
|
+
// grow one between attempts.
|
|
1068
|
+
return { status: 'unreachable', retryable: false, reason: 'The recipient has no id to address.' };
|
|
1069
|
+
}
|
|
1070
|
+
await this.broadcaster.to(this.channelFor(recipient)).emit(this.event, {
|
|
1071
|
+
template: message.template,
|
|
1072
|
+
params: message.params,
|
|
1073
|
+
subject: message.subject,
|
|
1074
|
+
body: message.body,
|
|
1075
|
+
locale: message.locale
|
|
1076
|
+
});
|
|
1077
|
+
return { status: 'sent' };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Binds the channels and the notifier.
|
|
1083
|
+
*
|
|
1084
|
+
* Everything is built for this event, like the rest of the container. A channel holding a connection
|
|
1085
|
+
* holds it itself, because a connection is a resource and the channel is the boundary that owns it;
|
|
1086
|
+
* nothing here keeps state between events.
|
|
1087
|
+
*/
|
|
1088
|
+
class NotificationServiceProvider {
|
|
1089
|
+
container;
|
|
1090
|
+
constructor(container) {
|
|
1091
|
+
this.container = container;
|
|
1092
|
+
}
|
|
1093
|
+
register() {
|
|
1094
|
+
const blueprint = this.container.make('blueprint');
|
|
1095
|
+
const config = blueprint.get('stone.notifications', {});
|
|
1096
|
+
const manager = NotificationManager.create();
|
|
1097
|
+
// Always available, so an application that calls `notify()` and configures nothing sees what it
|
|
1098
|
+
// would have sent instead of a configuration error.
|
|
1099
|
+
manager.registerFactory('log', () => LogChannel.create({ name: 'log' }, this.logger()));
|
|
1100
|
+
for (const channel of config.channels ?? []) {
|
|
1101
|
+
this.registerChannel(manager, channel);
|
|
1102
|
+
}
|
|
1103
|
+
NotificationManager.setInstance(manager);
|
|
1104
|
+
this.container
|
|
1105
|
+
.instanceIf(NotificationManager, manager)
|
|
1106
|
+
.alias(NotificationManager, ['notificationManager', 'channels'])
|
|
1107
|
+
.singletonIf(NoticeRegistry, () => new NoticeRegistry({ blueprint, container: this.container }))
|
|
1108
|
+
.alias(NoticeRegistry, ['notices'])
|
|
1109
|
+
.singletonIf(Notifier, () => new Notifier({ blueprint, container: this.container }))
|
|
1110
|
+
.alias(Notifier, ['notifier']);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Register one configured channel, lazily: a transport is built when first used, not at boot, so an
|
|
1114
|
+
* application configured for production does not need a mail server to start locally.
|
|
1115
|
+
*
|
|
1116
|
+
* @param manager - The registry to register into.
|
|
1117
|
+
* @param config - What the application declared.
|
|
1118
|
+
*/
|
|
1119
|
+
registerChannel(manager, config) {
|
|
1120
|
+
// A channel the application builds itself, declared where the others are. Registering it on the
|
|
1121
|
+
// manager from a provider reads well and does not survive: the container is rebuilt per event.
|
|
1122
|
+
if (typeof config.factory === 'function') {
|
|
1123
|
+
manager.registerFactory(config.name, () => config.factory?.(config));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
// A class the application declared with `@NotificationChannel`. Built through the container, so
|
|
1127
|
+
// its constructor is auto-wired: a channel needing a provider client asks for it.
|
|
1128
|
+
if (typeof config.module === 'function') {
|
|
1129
|
+
manager.registerFactory(config.name, () => this.build(config));
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const driver = config.driver ?? 'log';
|
|
1133
|
+
const factory = this.driverFor(driver);
|
|
1134
|
+
if (factory === undefined) {
|
|
1135
|
+
throw new NotificationConfigurationError(`Unknown notification driver '${driver}'. Ships with 'log', 'in-app' and 'smtp'. To reach a ` +
|
|
1136
|
+
'provider this package has never heard of, and that is how `sms` and `push` are done, ' +
|
|
1137
|
+
'declare the channel with a `factory` instead of a `driver`: ' +
|
|
1138
|
+
'`channels: [{ name: \'sms\', factory: () => myChannel }]`.');
|
|
1139
|
+
}
|
|
1140
|
+
manager.registerFactory(config.name, () => factory(config));
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* A declared channel class, built.
|
|
1144
|
+
*
|
|
1145
|
+
* @param config - What the application declared.
|
|
1146
|
+
* @returns The channel.
|
|
1147
|
+
* @throws {NotificationConfigurationError} When the class cannot be built.
|
|
1148
|
+
*/
|
|
1149
|
+
build(config) {
|
|
1150
|
+
const built = this.container.resolve?.(config.module, true);
|
|
1151
|
+
if (built === undefined || typeof built.send !== 'function') {
|
|
1152
|
+
throw new NotificationConfigurationError(`The channel declared as '${config.name}' does not answer \`send(message, recipient)\`. ` +
|
|
1153
|
+
'A channel is anything with that method, and it returns an outcome rather than throwing.');
|
|
1154
|
+
}
|
|
1155
|
+
return built;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* The builder for a driver this package ships.
|
|
1159
|
+
*
|
|
1160
|
+
* @param driver - The driver's name.
|
|
1161
|
+
* @returns The factory, or nothing when the name is not one of ours.
|
|
1162
|
+
*/
|
|
1163
|
+
driverFor(driver) {
|
|
1164
|
+
return {
|
|
1165
|
+
log: (config) => LogChannel.create(config, this.logger()),
|
|
1166
|
+
'in-app': (config) => InAppChannel.create(config, this.broadcaster()),
|
|
1167
|
+
smtp: (config) => SmtpChannel.create(config)
|
|
1168
|
+
}[driver];
|
|
1169
|
+
}
|
|
1170
|
+
/** The realtime broadcaster, when one is bound. */
|
|
1171
|
+
broadcaster() {
|
|
1172
|
+
// The container is not optional here, so `has` answers a boolean outright.
|
|
1173
|
+
return this.container.has('broadcaster')
|
|
1174
|
+
? this.container.make('broadcaster')
|
|
1175
|
+
: undefined;
|
|
1176
|
+
}
|
|
1177
|
+
/** The logger, when one is bound. */
|
|
1178
|
+
logger() {
|
|
1179
|
+
return this.container.has('logger') ? this.container.make('logger') : undefined;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Declare a notice imperatively.
|
|
1185
|
+
*
|
|
1186
|
+
* The imperative half of `@Notice`, and it says exactly the same thing: metadata here, content in
|
|
1187
|
+
* the module. Put the result on `stone.notifications.notices`.
|
|
1188
|
+
*
|
|
1189
|
+
* @param module - The notice: a class, or an object answering `notify`.
|
|
1190
|
+
* @param options - The notice's metadata.
|
|
1191
|
+
* @param isClass - Whether `module` is a class the container should build. Defaults to true.
|
|
1192
|
+
* @returns The declaration.
|
|
1193
|
+
*
|
|
1194
|
+
* @example
|
|
1195
|
+
* ```ts
|
|
1196
|
+
* blueprint.set('stone.notifications.notices', [
|
|
1197
|
+
* defineNotice(ConsentNeeded, { name: 'guardianship.consent_needed', on: 'identity.guardian.invited.v1' })
|
|
1198
|
+
* ])
|
|
1199
|
+
* ```
|
|
1200
|
+
*/
|
|
1201
|
+
function defineNotice(module, options, isClass = true) {
|
|
1202
|
+
return { ...options, module, isClass };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Where a class declares itself a notification channel.
|
|
1207
|
+
*
|
|
1208
|
+
* A string rather than a symbol, by the convention every first-party module follows: another package
|
|
1209
|
+
* can read it without importing this one.
|
|
1210
|
+
*/
|
|
1211
|
+
const CHANNEL_KEY = '@stone-js/notifications/channel';
|
|
1212
|
+
/**
|
|
1213
|
+
* Where a class declares itself a notice.
|
|
1214
|
+
*
|
|
1215
|
+
* A string rather than a symbol, by the convention every first-party module follows: another package
|
|
1216
|
+
* can read what an application declared without importing this one.
|
|
1217
|
+
*/
|
|
1218
|
+
const NOTICE_KEY = '@stone-js/notifications/notice';
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* The worker side of a notification: it performs the delivery the request decided on.
|
|
1222
|
+
*
|
|
1223
|
+
* It runs the same code the inline path runs, which is what makes a retry mean exactly what the first
|
|
1224
|
+
* attempt meant. The payload carries the template key and its params rather than a rendered body, so
|
|
1225
|
+
* a message queued before a translation was fixed goes out fixed.
|
|
1226
|
+
*
|
|
1227
|
+
* **It throws when, and only when, another attempt could work.** That is the whole contract with the
|
|
1228
|
+
* queue: a permanent failure that threw would be retried until the attempts ran out, filling the
|
|
1229
|
+
* queue with work that cannot succeed, and an unreachable recipient would look like an outage.
|
|
1230
|
+
*/
|
|
1231
|
+
class DeliverNotification {
|
|
1232
|
+
notifier;
|
|
1233
|
+
/**
|
|
1234
|
+
* @param dependencies - Auto-wired services.
|
|
1235
|
+
*/
|
|
1236
|
+
constructor({ notifier }) {
|
|
1237
|
+
this.notifier = notifier;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Deliver one notification.
|
|
1241
|
+
*
|
|
1242
|
+
* @param payload - Who, what, where, in which language.
|
|
1243
|
+
* @returns What each channel answered.
|
|
1244
|
+
* @throws {Error} When at least one channel failed in a way another attempt could fix.
|
|
1245
|
+
*/
|
|
1246
|
+
async handle(payload) {
|
|
1247
|
+
const outcomes = await this.notifier.deliver(payload);
|
|
1248
|
+
const retryable = outcomes.filter((outcome) => outcome.status === 'failed' && outcome.retryable === true);
|
|
1249
|
+
if (retryable.length > 0) {
|
|
1250
|
+
const channels = retryable.map((outcome) => `'${String(outcome.channel)}'`).join(', ');
|
|
1251
|
+
const reasons = retryable.map((outcome) => outcome.reason ?? 'no reason given').join('; ');
|
|
1252
|
+
throw new Error(`Notification '${payload.template}' failed on ${channels}: ${reasons}`);
|
|
1253
|
+
}
|
|
1254
|
+
return outcomes;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Build-phase middleware: subscribes every notice that named a domain event.
|
|
1260
|
+
*
|
|
1261
|
+
* **This is what makes calling the notifier optional.** A notice declaring `on` becomes a handler of
|
|
1262
|
+
* the light key router, the same one `@stone-js/event-bus` routes incoming domain events through. So
|
|
1263
|
+
* a module emits what happened, and the notice says who learns about it: the emitting module imports
|
|
1264
|
+
* nothing, and is never reopened when a channel is added.
|
|
1265
|
+
*
|
|
1266
|
+
* A middleware rather than a static entry, because the notices are only known once everything has
|
|
1267
|
+
* been collected: `@Notice` contributes them, and so does configuration, and both are read here.
|
|
1268
|
+
*
|
|
1269
|
+
* The entry is a plain object of the shape the key router already accepts, so nothing here depends on
|
|
1270
|
+
* `@stone-js/router`. The handler is the **factory** form, which receives the container, so the
|
|
1271
|
+
* closure carries only the notice's name and the notifier is resolved for the event that arrives.
|
|
1272
|
+
*
|
|
1273
|
+
* @param context - The blueprint context.
|
|
1274
|
+
* @param next - The next blueprint middleware.
|
|
1275
|
+
* @returns The blueprint.
|
|
1276
|
+
*/
|
|
1277
|
+
const NoticeSubscriptionsMiddleware = async (context, next) => {
|
|
1278
|
+
const blueprint = await next(context);
|
|
1279
|
+
const options = blueprint.get('stone.notifications', {});
|
|
1280
|
+
const subscribed = (options.notices ?? []).filter((notice) => typeof notice.on === 'string');
|
|
1281
|
+
if (subscribed.length === 0) {
|
|
1282
|
+
return blueprint;
|
|
1283
|
+
}
|
|
1284
|
+
blueprint.add('stone.keyRouting.handlers', subscribed.map(handlerFor));
|
|
1285
|
+
return blueprint;
|
|
1286
|
+
};
|
|
1287
|
+
/**
|
|
1288
|
+
* One key-router handler, for one notice.
|
|
1289
|
+
*
|
|
1290
|
+
* @param notice - What was declared.
|
|
1291
|
+
* @returns The handler entry.
|
|
1292
|
+
*/
|
|
1293
|
+
function handlerFor(notice) {
|
|
1294
|
+
return {
|
|
1295
|
+
key: notice.on,
|
|
1296
|
+
isFactory: true,
|
|
1297
|
+
action: 'handle',
|
|
1298
|
+
module: (container) => ({
|
|
1299
|
+
handle: async (event) => await container.make('notifier').deliverNotice(notice.name, event)
|
|
1300
|
+
})
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Meta blueprint middleware for the notice subscriptions.
|
|
1305
|
+
*/
|
|
1306
|
+
const MetaNoticeSubscriptionsMiddleware = {
|
|
1307
|
+
module: NoticeSubscriptionsMiddleware,
|
|
1308
|
+
priority: 5
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Opt-in blueprint: register it to reach people.
|
|
1313
|
+
*
|
|
1314
|
+
* The imperative half of the pair; `@Notifications()` is the declarative one. It binds the notifier
|
|
1315
|
+
* and the channels, and contributes the delivery job so a worker performs what a request decided.
|
|
1316
|
+
*
|
|
1317
|
+
* The job is declared on `stone.queue.handlers`, the array the worker scans, so nothing here depends
|
|
1318
|
+
* on the queue package. An application with no queue simply never has a worker to read it, and
|
|
1319
|
+
* delivery happens in the request instead.
|
|
1320
|
+
*
|
|
1321
|
+
* @example
|
|
1322
|
+
* ```ts
|
|
1323
|
+
* import { defineConfig, defineStoneApp } from '@stone-js/core'
|
|
1324
|
+
* import { notificationsBlueprint } from '@stone-js/notifications'
|
|
1325
|
+
*
|
|
1326
|
+
* export const App = defineStoneApp({ name: 'app' }, [notificationsBlueprint])
|
|
1327
|
+
*
|
|
1328
|
+
* export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.notifications', {
|
|
1329
|
+
* default: ['smtp', 'in-app'],
|
|
1330
|
+
* channels: [{ name: 'smtp', driver: 'smtp', from: 'Noowow <no-reply@example.test>' }],
|
|
1331
|
+
* recipients: async (id) => await accounts.contactFor(id)
|
|
1332
|
+
* }))
|
|
1333
|
+
* ```
|
|
1334
|
+
*/
|
|
1335
|
+
const notificationsBlueprint = {
|
|
1336
|
+
stone: {
|
|
1337
|
+
notifications: {},
|
|
1338
|
+
providers: [
|
|
1339
|
+
NotificationServiceProvider
|
|
1340
|
+
],
|
|
1341
|
+
blueprint: {
|
|
1342
|
+
middleware: [
|
|
1343
|
+
MetaNoticeSubscriptionsMiddleware
|
|
1344
|
+
]
|
|
1345
|
+
},
|
|
1346
|
+
queue: {
|
|
1347
|
+
handlers: [
|
|
1348
|
+
{ name: DELIVERY_JOB, module: DeliverNotification, isClass: true, action: 'handle' }
|
|
1349
|
+
]
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Declare a class as a notice: something a person receives.
|
|
1356
|
+
*
|
|
1357
|
+
* **The decorator says what it is; the class says what it says.** There is no `content` option, and
|
|
1358
|
+
* that is deliberate: a decorator carrying message bodies would put text in the one place it cannot
|
|
1359
|
+
* be translated, formatted, or computed from the event. The class answers `notify(event, context)`,
|
|
1360
|
+
* and it is built through the container, so it can ask for i18n, a repository, a URL signer.
|
|
1361
|
+
*
|
|
1362
|
+
* With `on`, **nobody calls the notifier**. A module emits what happened, and the notice that named
|
|
1363
|
+
* that event decides who learns about it. The emitting module imports nothing and is never reopened
|
|
1364
|
+
* when a channel is added, which is the whole reason this exists rather than a service call.
|
|
1365
|
+
*
|
|
1366
|
+
* @param options - The notice's metadata.
|
|
1367
|
+
* @returns A class decorator.
|
|
1368
|
+
*
|
|
1369
|
+
* @example
|
|
1370
|
+
* ```ts
|
|
1371
|
+
* @Notice({
|
|
1372
|
+
* name: 'guardianship.consent_needed',
|
|
1373
|
+
* on: 'identity.guardian.invited.v1',
|
|
1374
|
+
* channels: ['smtp', 'in-app']
|
|
1375
|
+
* })
|
|
1376
|
+
* export class ConsentNeeded {
|
|
1377
|
+
* constructor ({ i18n }) { this.i18n = i18n }
|
|
1378
|
+
*
|
|
1379
|
+
* recipients (event) { return event.guardianId }
|
|
1380
|
+
*
|
|
1381
|
+
* notify (event, { locale }) {
|
|
1382
|
+
* return {
|
|
1383
|
+
* smtp: {
|
|
1384
|
+
* subject: this.i18n.t('consent.subject', { lng: locale }),
|
|
1385
|
+
* body: this.i18n.t('consent.body', { lng: locale, child: event.childHandle })
|
|
1386
|
+
* },
|
|
1387
|
+
* 'in-app': { body: this.i18n.t('consent.short', { lng: locale }) }
|
|
1388
|
+
* }
|
|
1389
|
+
* }
|
|
1390
|
+
* }
|
|
1391
|
+
* ```
|
|
1392
|
+
*/
|
|
1393
|
+
const Notice = (options) => {
|
|
1394
|
+
return classDecoratorLegacyWrapper((target, context) => {
|
|
1395
|
+
// A service, and a notice: the container builds it, and the registry finds it by name. Registering
|
|
1396
|
+
// it rather than reaching into it is what lets the class ask for whatever it needs.
|
|
1397
|
+
setMetadata(context, SERVICE_KEY, { singleton: true, isClass: true, alias: [`notice:${options.name}`] });
|
|
1398
|
+
setMetadata(context, NOTICE_KEY, options);
|
|
1399
|
+
const declaration = { ...options, module: target, isClass: true };
|
|
1400
|
+
addBlueprint(target, context, notificationsBlueprint, {
|
|
1401
|
+
stone: { notifications: { notices: [declaration] } }
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Declare a class as a notification channel.
|
|
1408
|
+
*
|
|
1409
|
+
* The declaration form for a channel of your own, next to the configuration form: this is how `sms`
|
|
1410
|
+
* and `push` are done, and anything else a provider offers. The class is registered as a service, so
|
|
1411
|
+
* its constructor is auto-wired like any other, and the channel is registered under the name given
|
|
1412
|
+
* here.
|
|
1413
|
+
*
|
|
1414
|
+
* The class must answer `send(message, recipient)` and **return** an outcome rather than throw, which
|
|
1415
|
+
* is the whole port. A channel that throws is treated as retryable.
|
|
1416
|
+
*
|
|
1417
|
+
* @param name - The name notifications refer to it by.
|
|
1418
|
+
* @returns A class decorator.
|
|
1419
|
+
*
|
|
1420
|
+
* @example
|
|
1421
|
+
* ```ts
|
|
1422
|
+
* @NotificationChannel('sms')
|
|
1423
|
+
* export class TwilioChannel {
|
|
1424
|
+
* readonly name = 'sms'
|
|
1425
|
+
*
|
|
1426
|
+
* constructor ({ twilio }) { this.twilio = twilio }
|
|
1427
|
+
*
|
|
1428
|
+
* async send (message, recipient) {
|
|
1429
|
+
* if (recipient.phone === undefined) {
|
|
1430
|
+
* return { status: 'unreachable', retryable: false, reason: 'No phone number.' }
|
|
1431
|
+
* }
|
|
1432
|
+
* await this.twilio.messages.create({ to: recipient.phone, body: message.body })
|
|
1433
|
+
* return { status: 'sent' }
|
|
1434
|
+
* }
|
|
1435
|
+
* }
|
|
1436
|
+
* ```
|
|
1437
|
+
*/
|
|
1438
|
+
const NotificationChannel = (name) => {
|
|
1439
|
+
return classDecoratorLegacyWrapper((target, context) => {
|
|
1440
|
+
// A service, and a channel: the container builds it, and the registry finds it. Registering it
|
|
1441
|
+
// rather than reaching into it is what lets the class ask for whatever it needs.
|
|
1442
|
+
setMetadata(context, SERVICE_KEY, { singleton: true, isClass: true, alias: [`channel:${name}`] });
|
|
1443
|
+
setMetadata(context, CHANNEL_KEY, { name });
|
|
1444
|
+
const blueprint = {
|
|
1445
|
+
stone: { notifications: { channels: [{ name, module: target, isClass: true }] } }
|
|
1446
|
+
};
|
|
1447
|
+
addBlueprint(target, context, notificationsBlueprint, blueprint);
|
|
1448
|
+
});
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Enable notifications on the application.
|
|
1453
|
+
*
|
|
1454
|
+
* The declarative half of the module's activation; `notificationsBlueprint` is the imperative half,
|
|
1455
|
+
* and neither can do what the other cannot. With nothing configured, notifications go to the log and
|
|
1456
|
+
* say so: reaching real people is a decision, so it is written down.
|
|
1457
|
+
*
|
|
1458
|
+
* @param options - What to configure, if anything.
|
|
1459
|
+
* @returns A class decorator.
|
|
1460
|
+
*
|
|
1461
|
+
* @example
|
|
1462
|
+
* ```ts
|
|
1463
|
+
* @Notifications({
|
|
1464
|
+
* default: ['smtp', 'in-app'],
|
|
1465
|
+
* channels: [{ name: 'smtp', driver: 'smtp', from: 'Noowow <no-reply@example.test>' }]
|
|
1466
|
+
* })
|
|
1467
|
+
* @StoneApp()
|
|
1468
|
+
* export class Application {}
|
|
1469
|
+
* ```
|
|
1470
|
+
*/
|
|
1471
|
+
const Notifications = (options = {}) => {
|
|
1472
|
+
return classDecoratorLegacyWrapper((target, context) => {
|
|
1473
|
+
const blueprint = cloneValue(notificationsBlueprint);
|
|
1474
|
+
// The blueprint is the single source of truth for what the module declares; the decorator
|
|
1475
|
+
// overrides only what it can, its own options bucket.
|
|
1476
|
+
blueprint.stone.notifications = { ...blueprint.stone.notifications, ...options };
|
|
1477
|
+
addBlueprint(target, context, blueprint);
|
|
1478
|
+
});
|
|
1479
|
+
};
|
|
1480
|
+
|
|
1481
|
+
export { CHANNEL_KEY, DEFAULT_CHANNEL, DELIVERY_JOB, DeliverNotification, IN_APP_EVENT, InAppChannel, LogChannel, MetaNoticeSubscriptionsMiddleware, NOTICE_KEY, Notice, NoticeRegistry, NoticeSubscriptionsMiddleware, NotificationChannel, NotificationConfigurationError, NotificationManager, NotificationServiceProvider, Notifications, Notifier, SmtpChannel, defineNotice, notificationsBlueprint, render };
|