@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/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 };