@substrat-run/engine-notifications 0.0.0-stage → 0.3.1

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,889 @@
1
+ /**
2
+ * engine-notifications — composed **by call**, not by event.
3
+ *
4
+ * The in-scope functions are the surface: `notify`, which turns one occasion into mail to the
5
+ * people it concerns, and the preference functions behind the operations below. A vertical
6
+ * decides WHEN to notify — usually in its own consumer of a domain event, e.g. Handlebar's
7
+ * consumer of `workorder.assigned` — and calls `notify(ctx, …)` there, inside its own
8
+ * transaction, with the template it owns. The engine owns everything after that call: the
9
+ * mute and required check, the address, the escaped render, the send intent, and a delivery
10
+ * row that follows the send to its outcome.
11
+ *
12
+ * Placement (K-47): notifications are domain logic over the kernel's primitives, so they are
13
+ * an engine. The kernel's whole part is the transactional send intent (`requestEmail`, #2102)
14
+ * and the outcome events its settle writes back; the transport behind that intent is
15
+ * `adapter-email`. Nothing here reaches a provider.
16
+ *
17
+ * The registration is one frozen constant like every other engine's. Its only consumers are the
18
+ * three email outcome events, which settle the deliveries it wrote; what triggers a notification
19
+ * is the vertical's code, so nothing about that appears here.
20
+ *
21
+ * Digests are the person's choice, per topic: a topic they take as `daily-digest` is held
22
+ * rather than mailed. The engine's own schedule (`notifications/send-digests`, run by its system
23
+ * principal every `DIGEST_TICK_MINUTES`) sends each person whose oldest held message is a day old
24
+ * one mail of up to `DIGEST_MAX_ITEMS` of them, oldest first, through the same send intent and
25
+ * delivery row as any notification, one person at a time and never more at once than the
26
+ * scope's send backlog has room for. A mute still wins, and a required topic always goes out at
27
+ * once.
28
+ *
29
+ * Deferred, and stated so an absence reads as intent: channels other than email (SMS, push,
30
+ * in-app — each a transport and a `notificationChannel` value), tenant-edited or per-locale
31
+ * templates (a table keyed by topic overriding the template the caller passes, which is also
32
+ * where a digest's own wording would come from), quiet hours and a digest's time of day, other
33
+ * digest cadences (today any cadence row holds a topic for the one daily schedule: a second
34
+ * cadence needs a `digestCadence` value, the cadence recorded on each held item, and a schedule
35
+ * that sends only its own).
36
+ */
37
+ import { z } from 'zod';
38
+ import { EMAIL_DEAD_LETTERED, EMAIL_REFUSED, EMAIL_SENT, dataSubjectId, listsDeclaredBy, mapPage, moduleManifest, permissionKey, subjectErasureOf, substratError, inList, isSubstratError, MAX_PENDING_PLATFORM_REQUESTS, } from '@substrat-run/contracts';
39
+ import { assertAllowed, operationsFor, requestEmail, ulid, } from '@substrat-run/kernel';
40
+ /**
41
+ * The conflict reasons this engine raises, narrowing the platform's `conflict` code (#113).
42
+ * Additive only: new reasons may appear, existing ones do not change spelling.
43
+ */
44
+ export const NOTIFICATIONS_CONFLICT_REASONS = ['topic_required'];
45
+ const conflict = (reason, message) => substratError('conflict', message, { reason });
46
+ export { notificationsEntities, deliveryRow, myDeliveryRow } from './entities.js';
47
+ import { deliveryRow, digestItemRow, myDeliveryRow, notificationsEntities } from './entities.js';
48
+ export { notificationsOperations, NOTIFICATIONS_PERMISSIONS, notificationCadence, notificationChannel, myPreferences, topicPreference, } from './operations.js';
49
+ import { myPreferences, notificationsOperations, } from './operations.js';
50
+ export {} from './events.js';
51
+ import { emitNotificationsEvent, notificationsEmitDeclarations } from './events.js';
52
+ export {} from './render.js';
53
+ import { digestOf, render } from './render.js';
54
+ import { columnsOf, returns } from './seam.js';
55
+ export const NOTIFICATIONS_PERM = {
56
+ self: permissionKey.parse('notifications:self'),
57
+ read: permissionKey.parse('notifications:read'),
58
+ sendDigests: permissionKey.parse('notifications:send-digests'),
59
+ };
60
+ /**
61
+ * The topic of a digest's own delivery. A vertical's topic is a lowercase slug, so none can be
62
+ * spelled this way: filtering the delivery list on it lists digests and nothing else.
63
+ */
64
+ export const DIGEST_TOPIC = '@digest';
65
+ /** The outcome events the kernel's settle writes for a send intent (#2102), and what each settles to. */
66
+ const EMAIL_OUTCOMES = {
67
+ [EMAIL_SENT]: 'sent',
68
+ [EMAIL_REFUSED]: 'refused',
69
+ [EMAIL_DEAD_LETTERED]: 'dead_lettered',
70
+ };
71
+ const CHANNEL = 'email';
72
+ /**
73
+ * How often the digest schedule looks for people whose digest is due. Each pass is one indexed
74
+ * query when nobody is; a backlog bigger than one pass's room drains over the next passes.
75
+ */
76
+ export const DIGEST_TICK_MINUTES = 15;
77
+ /** A `daily-digest` person is due once their oldest held message is this old. */
78
+ const DIGEST_WINDOW_MS = 24 * 60 * 60_000;
79
+ /**
80
+ * The pending-send slots a digest pass leaves free, of the scope's
81
+ * `MAX_PENDING_PLATFORM_REQUESTS`: digests can wait for the drain, and mail that must not —
82
+ * a required topic above all — is never refused because digests filled the backlog.
83
+ */
84
+ export const DIGEST_RESERVE = 8;
85
+ /**
86
+ * The most held occasions one digest carries, oldest first. The rest stay held and are due by
87
+ * the same rule as any — once the oldest of them is a window old — so a person with more held
88
+ * than this gets several digests rather than one unbounded mail, and a pass reads a bounded
89
+ * number of rows for each person.
90
+ */
91
+ export const DIGEST_MAX_ITEMS = 50;
92
+ /** The most set-aside rows a pass reads again. Each is a parse and an update, never a send. */
93
+ const RELEASE_BATCH = 200;
94
+ // ============================================================================
95
+ // Manifest and migrations
96
+ // ============================================================================
97
+ export const notificationsManifest = moduleManifest.parse({
98
+ id: '@substrat-run/engine-notifications',
99
+ // Versions the manifest SHAPE, not the package (#976), and read by `src/model.ts`.
100
+ version: '0.0.2',
101
+ kernelContract: '^0.0.1',
102
+ permissions: [
103
+ { key: 'notifications:self', description: 'Set where your own notifications go and which topics reach you, and read what became of them' },
104
+ { key: 'notifications:read', description: 'Read what became of every notification in the scope' },
105
+ {
106
+ key: 'notifications:send-digests',
107
+ description: "Send the digests that are due now — the digest schedule's key, held by the engine's system principal and never by a person",
108
+ // A person holding it could send the scope's due digests whenever they liked.
109
+ holder: 'system',
110
+ },
111
+ ],
112
+ events: {
113
+ emits: notificationsEmitDeclarations,
114
+ // The send outcomes, which settle this engine's deliveries — never a vertical's events.
115
+ consumes: Object.keys(EMAIL_OUTCOMES).map((type) => ({ type, schemaVersion: 1 })),
116
+ },
117
+ migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' },
118
+ attachmentTargets: [],
119
+ lists: listsDeclaredBy(notificationsOperations, notificationsEntities),
120
+ // K-46: derived, so the recipient row's `delete` erasure is in the manifest a host reads.
121
+ erasure: subjectErasureOf(notificationsEntities),
122
+ entitlementKey: 'notifications',
123
+ // Digests (#118), under the engine's system principal. The schedule ticks often and each
124
+ // person is due on their own clock (`sendDigests`), so a backlog drains across passes instead
125
+ // of waiting a day, and nobody's digest is tied to when the scope's schedule happened to run.
126
+ schedules: [
127
+ {
128
+ operation: 'notifications/send-digests',
129
+ cadence: { everyMinutes: DIGEST_TICK_MINUTES },
130
+ permissions: ['notifications:send-digests'],
131
+ },
132
+ ],
133
+ });
134
+ export const notificationsMigrations = [
135
+ {
136
+ version: '0001-init',
137
+ sql: `
138
+ CREATE TABLE notifications_topics (
139
+ topic TEXT PRIMARY KEY,
140
+ description TEXT NOT NULL,
141
+ required INTEGER NOT NULL CHECK (required IN (0, 1)),
142
+ recorded_at TEXT NOT NULL
143
+ );
144
+ CREATE TABLE notifications_recipients (
145
+ principal TEXT PRIMARY KEY,
146
+ email TEXT NOT NULL,
147
+ updated_at TEXT NOT NULL
148
+ );
149
+ CREATE TABLE notifications_mutes (
150
+ principal TEXT NOT NULL,
151
+ topic TEXT NOT NULL,
152
+ channel TEXT NOT NULL,
153
+ created_at TEXT NOT NULL,
154
+ PRIMARY KEY (principal, topic, channel)
155
+ );
156
+ CREATE TABLE notifications_deliveries (
157
+ id TEXT PRIMARY KEY,
158
+ topic TEXT NOT NULL,
159
+ dedupe_key TEXT NOT NULL,
160
+ principal TEXT NOT NULL,
161
+ channel TEXT NOT NULL,
162
+ status TEXT NOT NULL CHECK (status IN
163
+ ('requested','skipped','sent','refused','dead_lettered')),
164
+ reason TEXT,
165
+ request_id TEXT UNIQUE,
166
+ about_type TEXT,
167
+ about_id TEXT,
168
+ created_at TEXT NOT NULL,
169
+ settled_at TEXT,
170
+ UNIQUE (dedupe_key, topic, principal, channel)
171
+ );
172
+ `,
173
+ },
174
+ {
175
+ // Digests (#118). Two new tables and nothing else: no existing row changes meaning, since a
176
+ // person with no cadence row takes every topic at once, as everyone did before.
177
+ version: '0002-digests',
178
+ sql: `
179
+ CREATE TABLE notifications_cadences (
180
+ principal TEXT NOT NULL,
181
+ topic TEXT NOT NULL,
182
+ channel TEXT NOT NULL,
183
+ cadence TEXT NOT NULL,
184
+ updated_at TEXT NOT NULL,
185
+ PRIMARY KEY (principal, topic, channel)
186
+ );
187
+ CREATE TABLE notifications_digest_items (
188
+ id TEXT PRIMARY KEY,
189
+ principal TEXT NOT NULL,
190
+ channel TEXT NOT NULL,
191
+ topic TEXT NOT NULL,
192
+ dedupe_key TEXT NOT NULL,
193
+ subject TEXT NOT NULL,
194
+ text TEXT NOT NULL,
195
+ html TEXT NOT NULL,
196
+ about_type TEXT,
197
+ about_id TEXT,
198
+ queued_at TEXT NOT NULL,
199
+ failed_at TEXT,
200
+ quarantined_at TEXT
201
+ );
202
+ CREATE INDEX notifications_digest_items_by_person
203
+ ON notifications_digest_items (principal, channel, queued_at);
204
+ `,
205
+ },
206
+ ];
207
+ // ============================================================================
208
+ // notify — the in-scope function a vertical calls
209
+ // ============================================================================
210
+ const TOPIC_KEY = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*$/;
211
+ export const notifyInput = z.object({
212
+ /** A lowercase slug — the key a person's mute names. */
213
+ topic: z.string().regex(TOPIC_KEY, 'a topic is a lowercase slug'),
214
+ /** What a person reads beside the topic's switch on their preferences screen. */
215
+ description: z.string().min(1),
216
+ /**
217
+ * What makes this occasion one occasion. Called from a consumer, pass the event's `id`:
218
+ * delivery is at least once, and a redelivered event then mails nobody twice. Called from an
219
+ * operation, pass a key unique to what is being announced (the entity's id, plus a version if
220
+ * it can recur). A repeat with the same key, topic and person writes nothing.
221
+ */
222
+ dedupeKey: z.string().min(1),
223
+ /** Who hears about it: principal ids (ULIDs), never addresses. Duplicates are sent once. */
224
+ recipients: z.array(dataSubjectId),
225
+ /** The template's variables. Every placeholder must have one, or the call throws. */
226
+ vars: z.record(z.string(), z.string()).default({}),
227
+ /** What the mail is about; its timeline carries the outcome. */
228
+ about: z.object({ entityType: z.string().min(1), entityId: z.string().min(1) }).nullable().default(null),
229
+ template: z.object({ subject: z.string().min(1), text: z.string().min(1), html: z.string().min(1).optional() }),
230
+ /**
231
+ * A required topic cannot be muted, nor held for a digest — reserve it for mail a person must
232
+ * not be able to miss, or receive late. With `description`, part of the topic's ONE
233
+ * declaration: every call for a topic passes the same pair (see `recordTopic`).
234
+ */
235
+ required: z.boolean().default(false),
236
+ });
237
+ /**
238
+ * Notify `recipients` about one occasion, inside the caller's transaction.
239
+ *
240
+ * For each person: a delivery this `dedupeKey` already made for them is left alone; a muted
241
+ * topic (unless `required`) or a missing address records a `skipped` delivery; a topic they
242
+ * take as a digest (unless `required`) records a `skipped` delivery with reason `digest` and
243
+ * holds the rendered message for `sendDigests`; otherwise a send intent is written with
244
+ * `requestEmail`, naming the person as its `subjectId`, and a `requested` delivery records it.
245
+ * The platform's outcome event settles that delivery later (the engine's own consumers).
246
+ *
247
+ * **It throws `validation_failed`, writing nothing, when the template cannot be filled** — a
248
+ * placeholder with no variable, or a message every word of which was a blank variable — or a
249
+ * recipient is not a principal id. That is a defect in the caller, and the throw rolls back the
250
+ * caller's whole transaction: from a consumer, the event dead-letters in the kernel's delivery
251
+ * journal with the topic and the reason in its error; from an operation, the operation fails.
252
+ * A blank mail is never the fallback.
253
+ *
254
+ * No permission check: like every by-call engine function, the caller checks first. A consumer
255
+ * calling it runs under the kernel's consumer authority.
256
+ */
257
+ export function notify(ctx, rawInput) {
258
+ const parsed = notifyInput.safeParse(rawInput);
259
+ if (!parsed.success) {
260
+ throw substratError('validation_failed', `notify: ${parsed.error.issues.map((i) => i.message).join('; ')}`);
261
+ }
262
+ const input = parsed.data;
263
+ // One message for every recipient: the variables describe the occasion, not the person.
264
+ const rendered = render(input.template, input.vars);
265
+ if (!rendered.ok) {
266
+ throw substratError('validation_failed', `notify: topic '${input.topic}' cannot be rendered: ${rendered.reason}`);
267
+ }
268
+ recordTopic(ctx, input.topic, input.description, input.required);
269
+ return [...new Set(input.recipients)].map((principal) => {
270
+ const already = ctx.sql.query('SELECT 1 FROM notifications_deliveries WHERE dedupe_key = ? AND topic = ? AND principal = ? AND channel = ?', [input.dedupeKey, input.topic, principal, CHANNEL]);
271
+ if (already.length > 0)
272
+ return { principal, status: 'skipped', reason: 'duplicate', request: null, deliveryId: null };
273
+ const record = (status, reason, request) => recordDelivery(ctx, { ...input, principal, status, reason, request });
274
+ if (!input.required && isMuted(ctx, principal, input.topic))
275
+ return record('skipped', 'muted', null);
276
+ const to = addressOf(ctx, principal);
277
+ if (to === null)
278
+ return record('skipped', 'no_address', null);
279
+ if (!input.required && storedCadenceOf(ctx, principal, input.topic) !== null) {
280
+ const held = record('skipped', 'digest', null);
281
+ holdForDigest(ctx, held.deliveryId, { ...input, principal }, rendered.message);
282
+ return held;
283
+ }
284
+ const request = requestEmail(ctx, {
285
+ to,
286
+ ...rendered.message,
287
+ ...(input.about ? { about: input.about } : {}),
288
+ // The intent holds the address: naming the subject is how an erasure cancels it.
289
+ subjectId: principal,
290
+ });
291
+ return record('requested', null, request);
292
+ });
293
+ }
294
+ /** Every event here is about one principal: pseudonymous, and keyed on them for erasure. */
295
+ const aboutPrincipal = (principal) => ({ piiClass: 'pseudonymous', subjectId: dataSubjectId.parse(principal) });
296
+ const addressOf = (ctx, principal) => ctx.sql.query('SELECT email FROM notifications_recipients WHERE principal = ?', [principal])[0]
297
+ ?.email ?? null;
298
+ const isMuted = (ctx, principal, topic) => ctx.sql.query('SELECT 1 FROM notifications_mutes WHERE principal = ? AND topic = ? AND channel = ?', [
299
+ principal,
300
+ topic,
301
+ CHANNEL,
302
+ ]).length > 0;
303
+ /** The digest cadence a person chose for a topic, or null: no row is `immediate`. */
304
+ const storedCadenceOf = (ctx, principal, topic) => ctx.sql.query('SELECT cadence FROM notifications_cadences WHERE principal = ? AND topic = ? AND channel = ?', [principal, topic, CHANNEL])[0]?.cadence ?? null;
305
+ /**
306
+ * Hold one rendered occasion for the person's next digest, under its own delivery's id.
307
+ *
308
+ * Holding is only half of a digest; sending is the other, and where it does not happen,
309
+ * messages wait and nothing fails. The schedule may not run: a scope provisioned before this
310
+ * engine declared it holds no grant for it until its provision runs again, and a self-hosted
311
+ * server must run schedules at all. Or it runs and cannot send: the scope's sends are not being
312
+ * drained, so every pass finds no room, or this person's digest keeps failing. So a hold that
313
+ * finds this person's oldest message (set-aside rows aside) twice a window old says so in a
314
+ * warning line, whatever the reason.
315
+ */
316
+ function holdForDigest(ctx, deliveryId, occasion, message) {
317
+ const [waiting] = ctx.sql.query(`SELECT MIN(queued_at) AS oldest FROM notifications_digest_items
318
+ WHERE principal = ? AND channel = ? AND quarantined_at IS NULL`, [occasion.principal, CHANNEL]);
319
+ const stale = new Date(Date.parse(ctx.now()) - 2 * DIGEST_WINDOW_MS).toISOString();
320
+ if (waiting?.oldest && waiting.oldest < stale) {
321
+ ctx.log.warn('digest messages for {principal} have waited since {oldest}: is the digest schedule running, and are sends being drained?', { principal: occasion.principal, oldest: waiting.oldest });
322
+ }
323
+ ctx.sql.exec(`INSERT INTO notifications_digest_items
324
+ (id, principal, channel, topic, dedupe_key, subject, text, html, about_type, about_id, queued_at)
325
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
326
+ deliveryId,
327
+ occasion.principal,
328
+ CHANNEL,
329
+ occasion.topic,
330
+ occasion.dedupeKey,
331
+ message.subject,
332
+ message.text,
333
+ message.html,
334
+ occasion.about?.entityType ?? null,
335
+ occasion.about?.entityId ?? null,
336
+ ctx.now(),
337
+ ]);
338
+ }
339
+ /**
340
+ * A topic becomes known to the preferences screen the first time it is used, and follows the
341
+ * caller's latest description and `required` from then on, so a deploy that changes them updates
342
+ * the row an older deploy wrote. Written only when it changes, so an ordinary notification adds
343
+ * no topic event.
344
+ *
345
+ * That is only coherent under **one topic, one declaration**: every call site naming a topic
346
+ * passes the same description and `required`. Two that disagree make the row flip with
347
+ * whichever ran last, and a mute be refused or allowed accordingly. Keep a topic's declaration
348
+ * in one constant the call sites spread, and change it there.
349
+ */
350
+ function recordTopic(ctx, topic, description, required) {
351
+ const changed = ctx.sql.query(`INSERT INTO notifications_topics (topic, description, required, recorded_at) VALUES (?, ?, ?, ?)
352
+ ON CONFLICT (topic) DO UPDATE SET description = excluded.description, required = excluded.required,
353
+ recorded_at = excluded.recorded_at
354
+ WHERE description IS NOT excluded.description OR required IS NOT excluded.required
355
+ RETURNING topic`, [topic, description, required ? 1 : 0, ctx.now()]);
356
+ if (changed.length === 0)
357
+ return;
358
+ emitNotificationsEvent(ctx, {
359
+ type: 'notifications.topic-recorded',
360
+ entity: { entityType: 'notification-topic', entityId: topic },
361
+ piiClass: 'none',
362
+ payload: { topic, description, required },
363
+ });
364
+ }
365
+ function recordDelivery(ctx, d) {
366
+ const id = ulid();
367
+ ctx.sql.exec(`INSERT INTO notifications_deliveries
368
+ (id, topic, dedupe_key, principal, channel, status, reason, request_id, about_type, about_id,
369
+ created_at, settled_at)
370
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
371
+ id,
372
+ d.topic,
373
+ d.dedupeKey,
374
+ d.principal,
375
+ CHANNEL,
376
+ d.status,
377
+ d.reason,
378
+ d.request,
379
+ d.about?.entityType ?? null,
380
+ d.about?.entityId ?? null,
381
+ ctx.now(),
382
+ ]);
383
+ emitNotificationsEvent(ctx, {
384
+ type: 'notifications.delivery-recorded',
385
+ entity: { entityType: 'notification-delivery', entityId: id },
386
+ ...aboutPrincipal(d.principal),
387
+ payload: {
388
+ deliveryId: id,
389
+ topic: d.topic,
390
+ principal: d.principal,
391
+ channel: CHANNEL,
392
+ status: d.status,
393
+ reason: d.reason,
394
+ request: d.request,
395
+ dedupeKey: d.dedupeKey,
396
+ about: d.about,
397
+ },
398
+ });
399
+ return { principal: d.principal, status: d.status, reason: d.reason, request: d.request, deliveryId: id };
400
+ }
401
+ // ============================================================================
402
+ // Preferences — a person's own
403
+ // ============================================================================
404
+ /** The caller's address and every known topic's switch and cadence. */
405
+ export function myPreferencesOf(ctx) {
406
+ const muted = new Set(ctx.sql
407
+ .query('SELECT topic FROM notifications_mutes WHERE principal = ? AND channel = ?', [
408
+ ctx.principal,
409
+ CHANNEL,
410
+ ])
411
+ .map((r) => r.topic));
412
+ const cadences = new Map(ctx.sql
413
+ .query('SELECT topic, cadence FROM notifications_cadences WHERE principal = ? AND channel = ?', [ctx.principal, CHANNEL])
414
+ .map((r) => [r.topic, r.cadence]));
415
+ const topics = ctx.sql.query('SELECT topic, description, required FROM notifications_topics ORDER BY topic');
416
+ return returns(myPreferences, 'notifications/my-preferences', {
417
+ email: addressOf(ctx, ctx.principal),
418
+ topics: topics.map((t) => ({
419
+ topic: t.topic,
420
+ description: t.description,
421
+ channel: CHANNEL,
422
+ required: t.required === 1,
423
+ enabled: t.required === 1 || !muted.has(t.topic),
424
+ // A required topic goes out at once whatever was chosen before it became required.
425
+ cadence: t.required === 1 ? 'immediate' : (cadences.get(t.topic) ?? 'immediate'),
426
+ })),
427
+ });
428
+ }
429
+ /** Set or clear where the caller's notifications go. */
430
+ export function setMyAddress(ctx, input) {
431
+ if (input.email === null) {
432
+ ctx.sql.exec('DELETE FROM notifications_recipients WHERE principal = ?', [ctx.principal]);
433
+ }
434
+ else {
435
+ ctx.sql.exec(`INSERT INTO notifications_recipients (principal, email, updated_at) VALUES (?, ?, ?)
436
+ ON CONFLICT (principal) DO UPDATE SET email = excluded.email, updated_at = excluded.updated_at`, [ctx.principal, input.email, ctx.now()]);
437
+ }
438
+ emitNotificationsEvent(ctx, {
439
+ type: 'notifications.recipient-updated',
440
+ entity: { entityType: 'notification-recipient', entityId: ctx.principal },
441
+ ...aboutPrincipal(ctx.principal),
442
+ payload: { principal: ctx.principal, hasAddress: input.email !== null },
443
+ });
444
+ return myPreferencesOf(ctx);
445
+ }
446
+ /**
447
+ * Switch one topic on or off for the caller, and with `cadence`, choose whether it arrives at
448
+ * once or in their daily digest (absent leaves that as it is). The topic must be one this scope
449
+ * has used (`not_found` otherwise). A required topic refuses to be switched off, and refuses a
450
+ * digest: mail a person must not miss does not wait a day either.
451
+ *
452
+ * Switching a topic off withdraws its items still held for the caller's digest — a mute wins
453
+ * over the queue. Each withdrawn occasion's delivery is settled `skipped`/`muted`, as if the mute
454
+ * had come first, and the event names them. Switching a digest back to `immediate` leaves what is
455
+ * already held to go out with the next digest.
456
+ */
457
+ export function setMyPreference(ctx, input) {
458
+ const topic = ctx.sql.query('SELECT required FROM notifications_topics WHERE topic = ?', [
459
+ input.topic,
460
+ ])[0];
461
+ if (!topic)
462
+ throw substratError('not_found', `no notification topic '${input.topic}'`);
463
+ if (topic.required === 1 && !input.enabled) {
464
+ throw conflict('topic_required', `'${input.topic}' is a required notification and cannot be switched off`);
465
+ }
466
+ if (topic.required === 1 && input.cadence !== undefined && input.cadence !== 'immediate') {
467
+ throw conflict('topic_required', `'${input.topic}' is a required notification and always goes out at once`);
468
+ }
469
+ const mine = [ctx.principal, input.topic, input.channel];
470
+ let withdrawn = [];
471
+ if (input.enabled) {
472
+ ctx.sql.exec('DELETE FROM notifications_mutes WHERE principal = ? AND topic = ? AND channel = ?', mine);
473
+ }
474
+ else {
475
+ ctx.sql.exec(`INSERT INTO notifications_mutes (principal, topic, channel, created_at) VALUES (?, ?, ?, ?)
476
+ ON CONFLICT DO NOTHING`, [...mine, ctx.now()]);
477
+ withdrawn = ctx.sql
478
+ .query(`UPDATE notifications_deliveries SET reason = 'muted', settled_at = ?
479
+ WHERE id IN (SELECT id FROM notifications_digest_items
480
+ WHERE principal = ? AND topic = ? AND channel = ? AND quarantined_at IS NULL)
481
+ RETURNING id`, [ctx.now(), ...mine])
482
+ .map((r) => r.id)
483
+ .sort();
484
+ ctx.sql.exec('DELETE FROM notifications_digest_items WHERE principal = ? AND topic = ? AND channel = ? AND quarantined_at IS NULL', mine);
485
+ }
486
+ if (input.cadence === 'immediate') {
487
+ ctx.sql.exec('DELETE FROM notifications_cadences WHERE principal = ? AND topic = ? AND channel = ?', mine);
488
+ }
489
+ else if (input.cadence !== undefined) {
490
+ ctx.sql.exec(`INSERT INTO notifications_cadences (principal, topic, channel, cadence, updated_at) VALUES (?, ?, ?, ?, ?)
491
+ ON CONFLICT (principal, topic, channel) DO UPDATE SET cadence = excluded.cadence, updated_at = excluded.updated_at`, [...mine, input.cadence, ctx.now()]);
492
+ }
493
+ // The cadence the event announces is the one the caller now reads back: one rule, one place.
494
+ const preferences = myPreferencesOf(ctx);
495
+ emitNotificationsEvent(ctx, {
496
+ type: 'notifications.preference-set',
497
+ entity: { entityType: 'notification-recipient', entityId: ctx.principal },
498
+ ...aboutPrincipal(ctx.principal),
499
+ payload: {
500
+ principal: ctx.principal,
501
+ topic: input.topic,
502
+ channel: input.channel,
503
+ enabled: input.enabled,
504
+ cadence: preferences.topics.find((t) => t.topic === input.topic).cadence,
505
+ ...(withdrawn.length > 0 ? { withdrawn } : {}),
506
+ },
507
+ });
508
+ return preferences;
509
+ }
510
+ /** What became of each notification, newest first. */
511
+ export function listDeliveries(ctx, page) {
512
+ return mapPage(ctx.page('notification-delivery', page), (row) => returns(deliveryRow, 'notifications/list-deliveries', row));
513
+ }
514
+ /**
515
+ * What became of each notification sent to the caller, newest first: the same page as
516
+ * `listDeliveries`, with the `principal` filter pinned to `ctx.principal`, last, so nothing in
517
+ * `page` can name anyone else.
518
+ *
519
+ * Every delivery of theirs is listed, including the ones that were not mailed on their own — a
520
+ * topic they muted (`skipped`/`muted`), a missing address (`skipped`/`no_address`), an occasion
521
+ * held for their digest (`skipped`/`digest`, settled once the digest that carried it is
522
+ * written) — and the digest itself, which is a delivery of its own under `DIGEST_TOPIC`. A held
523
+ * message set aside as unreadable reads `skipped`/`quarantined` until it is released.
524
+ *
525
+ * The row is `myDeliveryRow`, not the administrator's: it names neither the caller's idempotency
526
+ * key nor the platform's send intent, and a `refused` or `dead_lettered` row carries no `reason`,
527
+ * since that is the platform's outcome code (a missing sender grant, a provider refusal) and
528
+ * belongs to whoever reads the whole log. The `reason` of a `skipped` row is the engine's own
529
+ * word for it.
530
+ *
531
+ * No permission check: like every by-call engine function, the caller checks first.
532
+ */
533
+ export function listMyDeliveries(ctx, page) {
534
+ const mine = ctx.page('notification-delivery', {
535
+ ...page,
536
+ filters: { ...page.filters, principal: ctx.principal },
537
+ });
538
+ // Parsed once, by the person's shape: a stored row that left it throws under this surface's name.
539
+ return mapPage(mine, (row) => returns(myDeliveryRow, 'notifications/my-deliveries', {
540
+ ...row,
541
+ reason: row.status === 'skipped' ? row.reason : null,
542
+ }));
543
+ }
544
+ // ============================================================================
545
+ // Digests — what was held, sent as one mail per person
546
+ // ============================================================================
547
+ const DIGEST_ITEM_COLUMNS = columnsOf(digestItemRow);
548
+ /**
549
+ * Whether a held row's topic is switched off for its person: a mute on a topic that is not
550
+ * required, the rule `notify` holds by. A column of the query that reads the row, so the check
551
+ * costs no read of its own, and whatever path brought a muted row here — a set-aside row muted
552
+ * since, or one no switch-off reached — it is withdrawn, never mailed.
553
+ */
554
+ const MUTED = `EXISTS (
555
+ SELECT 1 FROM notifications_mutes m
556
+ WHERE m.principal = notifications_digest_items.principal
557
+ AND m.topic = notifications_digest_items.topic
558
+ AND m.channel = notifications_digest_items.channel
559
+ AND NOT EXISTS (SELECT 1 FROM notifications_topics t WHERE t.topic = m.topic AND t.required = 1)
560
+ ) AS muted`;
561
+ /**
562
+ * Send each person whose digest is due one mail of what is held for them.
563
+ *
564
+ * **Who is due.** A person is due once their oldest held message is a whole window old (a day,
565
+ * for `daily-digest`). So nothing waits much longer than a day, and while no more than
566
+ * `DIGEST_MAX_ITEMS` are held, a person gets one digest a day: everything held after it is
567
+ * younger. Oldest first, so a backlog drains in the order it built up. A person whose last
568
+ * attempt failed waits a window before the next.
569
+ *
570
+ * **How many people.** Each mail is a platform send intent, and a scope may hold only
571
+ * `MAX_PENDING_PLATFORM_REQUESTS` of those waiting for the drain — this transaction's own
572
+ * included. A pass therefore sends to at most the room that is left, less `DIGEST_RESERVE`
573
+ * slots kept for mail that must not wait, and leaves everyone else held for the next pass. It
574
+ * never asks for more room than there is, so it never meets the cap's refusal.
575
+ *
576
+ * **How much each.** One digest carries at most `DIGEST_MAX_ITEMS` occasions, oldest first. The
577
+ * rest stay held, and are due by the same rule: as soon as the oldest of them is a window old —
578
+ * on the very next pass if it already is. That is the one case where a person gets more than one
579
+ * digest a day.
580
+ *
581
+ * **What does not read.** A held row that no longer matches the published shape is set aside
582
+ * (`quarantine`) rather than mailed or allowed to block the rest: the person's readable messages
583
+ * go out, and the set-aside row counts toward nothing — not who is due, not a switch-off's
584
+ * withdrawal, not the stale-hold warning. Its cause may pass (a version that stored a shape this
585
+ * one rejects is rolled forward again), so each set-aside row is read again once a window
586
+ * (`releaseReadable`, at the start of a pass) and held once more when it reads — unless its topic
587
+ * was switched off meanwhile, when it is withdrawn instead.
588
+ *
589
+ * **A mute wins, last of all.** The read that composes a digest carries each row's mute
590
+ * (`MUTED`), and a held message whose topic is switched off is withdrawn (`withdrawMuted`), never
591
+ * mailed, whatever path brought it there.
592
+ *
593
+ * **One person at a time.** Each person's digest is its own `ctx.atomic`: one mail written with
594
+ * `requestEmail`, one delivery under `DIGEST_TOPIC`, the carried occasions settled and no longer
595
+ * held. If any of that throws, none of it happened for that person — their messages stay held,
596
+ * `notifications.digest-failed` and an error line say so — and everyone else's digest still goes
597
+ * out. A person who cleared their address since gets a `skipped` (`no_address`) digest, as an
598
+ * immediate notification would have.
599
+ *
600
+ * The engine's schedule runs it through `notifications/send-digests` every
601
+ * `DIGEST_TICK_MINUTES`; a vertical may also call it, after its own permission check, like every
602
+ * by-call function here. Either way it is one pass, and it sends only the people who are due.
603
+ */
604
+ export async function sendDigests(ctx) {
605
+ const now = ctx.now();
606
+ const cutoff = new Date(Date.parse(now) - DIGEST_WINDOW_MS).toISOString();
607
+ // First, so a row released now is sent in this very pass if its person is due.
608
+ const { released, withdrawn } = releaseReadable(ctx, now, cutoff);
609
+ const due = ctx.sql.query(`SELECT principal FROM notifications_digest_items WHERE channel = ? AND quarantined_at IS NULL
610
+ GROUP BY principal
611
+ HAVING MIN(queued_at) <= ? AND COALESCE(MAX(failed_at), '') <= ?
612
+ ORDER BY MIN(queued_at), principal`, [CHANNEL, cutoff, cutoff]);
613
+ const pending = ctx.platformRequests({ status: 'pending', limit: MAX_PENDING_PLATFORM_REQUESTS }).length;
614
+ let room = MAX_PENDING_PLATFORM_REQUESTS - DIGEST_RESERVE - pending;
615
+ const pass = { digests: 0, items: 0, failed: 0, deferred: 0, quarantined: 0, released, withdrawn };
616
+ for (const [i, { principal }] of due.entries()) {
617
+ if (room <= 0) {
618
+ pass.deferred = due.length - i;
619
+ ctx.log.info('{deferred} due digest(s) wait for room: {pending} send(s) are waiting on the platform drain', {
620
+ deferred: pass.deferred,
621
+ pending: MAX_PENDING_PLATFORM_REQUESTS - DIGEST_RESERVE - room,
622
+ });
623
+ break;
624
+ }
625
+ try {
626
+ const rows = ctx.sql.query(`SELECT rowid AS row_id, ${MUTED}, ${DIGEST_ITEM_COLUMNS} FROM notifications_digest_items
627
+ WHERE principal = ? AND channel = ? AND quarantined_at IS NULL ORDER BY queued_at, id LIMIT ?`, [principal, CHANNEL, DIGEST_MAX_ITEMS]);
628
+ const held = [];
629
+ const muted = [];
630
+ const unreadable = [];
631
+ for (const { row_id: rowId, muted: isMuted, ...row } of rows) {
632
+ try {
633
+ const item = returns(digestItemRow, 'notifications/send-digests', row);
634
+ if (isMuted)
635
+ muted.push(item.id);
636
+ else
637
+ held.push(item);
638
+ }
639
+ catch (err) {
640
+ unreadable.push({ rowId, id: row.id, error: err instanceof Error ? err.message : String(err) });
641
+ }
642
+ }
643
+ if (unreadable.length > 0) {
644
+ await ctx.atomic(() => quarantine(ctx, principal, unreadable, now));
645
+ pass.quarantined += unreadable.length;
646
+ }
647
+ // A mute wins at the last moment too: whatever brought a switched-off topic here, it is not mailed.
648
+ if (muted.length > 0) {
649
+ await ctx.atomic(() => withdrawMuted(ctx, principal, muted, now));
650
+ pass.withdrawn += muted.length;
651
+ }
652
+ if (held.length === 0)
653
+ continue;
654
+ const requested = await ctx.atomic(() => sendDigestOf(ctx, principal, held, now));
655
+ pass.digests += 1;
656
+ pass.items += held.length;
657
+ if (requested)
658
+ room -= 1;
659
+ }
660
+ catch (err) {
661
+ pass.failed += 1;
662
+ recordDigestFailure(ctx, principal, now, err);
663
+ }
664
+ }
665
+ return pass;
666
+ }
667
+ /**
668
+ * Read again the set-aside rows whose last reading is a window old, at most `RELEASE_BATCH` a
669
+ * pass, oldest reading first. Each that reads now is held once more — `quarantined_at` cleared,
670
+ * its delivery back to `skipped`/`digest` and unsettled — and announced per person by
671
+ * `notifications.digest-released`. One whose person has switched its topic off since is not
672
+ * released but withdrawn (`withdrawMuted`): a switch-off leaves set-aside rows alone, so this is
673
+ * where its mute is honoured. Each that still does not read is marked read now, so it waits
674
+ * another window, and is not announced again. Never throws over a row.
675
+ */
676
+ function releaseReadable(ctx, now, cutoff) {
677
+ const rows = ctx.sql.query(`SELECT rowid AS row_id, ${MUTED}, ${DIGEST_ITEM_COLUMNS} FROM notifications_digest_items
678
+ WHERE channel = ? AND quarantined_at IS NOT NULL AND quarantined_at <= ?
679
+ ORDER BY quarantined_at, rowid LIMIT ?`, [CHANNEL, cutoff, RELEASE_BATCH]);
680
+ const released = new Map();
681
+ const muted = new Map();
682
+ for (const { row_id: rowId, muted: isMuted, ...row } of rows) {
683
+ let item;
684
+ try {
685
+ item = returns(digestItemRow, 'notifications/send-digests', row);
686
+ }
687
+ catch {
688
+ ctx.sql.exec('UPDATE notifications_digest_items SET quarantined_at = ? WHERE rowid = ?', [now, rowId]);
689
+ continue;
690
+ }
691
+ if (isMuted) {
692
+ muted.set(item.principal, [...(muted.get(item.principal) ?? []), item.id]);
693
+ continue;
694
+ }
695
+ ctx.sql.exec('UPDATE notifications_digest_items SET quarantined_at = NULL WHERE rowid = ?', [rowId]);
696
+ ctx.sql.exec(`UPDATE notifications_deliveries SET reason = 'digest', settled_at = NULL
697
+ WHERE id = ? AND status = 'skipped' AND reason = 'quarantined'`, [item.id]);
698
+ released.set(item.principal, [...(released.get(item.principal) ?? []), item.id]);
699
+ }
700
+ for (const [principal, occasions] of released) {
701
+ emitNotificationsEvent(ctx, {
702
+ type: 'notifications.digest-released',
703
+ entity: { entityType: 'notification-recipient', entityId: principal },
704
+ ...aboutPrincipal(principal),
705
+ payload: { principal, channel: CHANNEL, occasions },
706
+ });
707
+ }
708
+ for (const [principal, occasions] of muted)
709
+ withdrawMuted(ctx, principal, occasions, now);
710
+ const count = (m) => [...m.values()].reduce((n, o) => n + o.length, 0);
711
+ return { released: count(released), withdrawn: count(muted) };
712
+ }
713
+ /**
714
+ * Withdraw held occasions whose person has switched their topic off since they were held, as a
715
+ * switch-off withdraws what it finds: the rows go, unsent, and each delivery settles
716
+ * `skipped`/`muted`. Announced by `notifications.digest-withdrawn`, since no switch-off happened
717
+ * now to announce it.
718
+ */
719
+ function withdrawMuted(ctx, principal, occasions, now) {
720
+ const ids = inList(occasions);
721
+ ctx.sql.exec(`UPDATE notifications_deliveries SET reason = 'muted', settled_at = ?
722
+ WHERE id IN ${ids.sql} AND status = 'skipped' AND (reason = 'digest' OR reason = 'quarantined')`, [now, ...ids.params]);
723
+ ctx.sql.exec(`DELETE FROM notifications_digest_items WHERE id IN ${ids.sql}`, [...ids.params]);
724
+ emitNotificationsEvent(ctx, {
725
+ type: 'notifications.digest-withdrawn',
726
+ entity: { entityType: 'notification-recipient', entityId: principal },
727
+ ...aboutPrincipal(principal),
728
+ payload: { principal, channel: CHANNEL, occasions },
729
+ });
730
+ }
731
+ /** One person's digest: the held messages given, as one mail and one delivery. Whether a send was written. */
732
+ function sendDigestOf(ctx, principal, held, now) {
733
+ const to = addressOf(ctx, principal);
734
+ const request = to === null ? null : requestEmail(ctx, { to, ...digestOf(held), subjectId: dataSubjectId.parse(principal) });
735
+ const outcome = recordDelivery(ctx, {
736
+ topic: DIGEST_TOPIC,
737
+ dedupeKey: ulid(),
738
+ principal,
739
+ about: null,
740
+ status: request === null ? 'skipped' : 'requested',
741
+ reason: request === null ? 'no_address' : null,
742
+ request,
743
+ });
744
+ // The occasions are settled into this digest: still `skipped`/`digest`, now with a time.
745
+ const ids = inList(held.map((h) => h.id));
746
+ ctx.sql.exec(`UPDATE notifications_deliveries SET settled_at = ? WHERE id IN ${ids.sql}`, [now, ...ids.params]);
747
+ ctx.sql.exec(`DELETE FROM notifications_digest_items WHERE id IN ${ids.sql}`, [...ids.params]);
748
+ emitNotificationsEvent(ctx, {
749
+ type: 'notifications.digest-sent',
750
+ entity: { entityType: 'notification-delivery', entityId: outcome.deliveryId },
751
+ ...aboutPrincipal(principal),
752
+ payload: {
753
+ deliveryId: outcome.deliveryId,
754
+ principal,
755
+ channel: CHANNEL,
756
+ cadence: 'daily-digest',
757
+ status: outcome.status,
758
+ reason: outcome.reason,
759
+ request: outcome.request,
760
+ items: held.map((h) => ({
761
+ deliveryId: h.id,
762
+ topic: h.topic,
763
+ dedupeKey: h.dedupe_key,
764
+ about: h.about_type !== null && h.about_id !== null ? { entityType: h.about_type, entityId: h.about_id } : null,
765
+ queuedAt: h.queued_at,
766
+ })),
767
+ },
768
+ });
769
+ return request !== null;
770
+ }
771
+ /**
772
+ * Set aside held rows found unreadable for the first time. Each is marked `quarantined_at` —
773
+ * not sent, and not counted when deciding who is due — until it reads again (`releaseReadable`)
774
+ * or an erasure of the person deletes it. Each occasion whose id still reads has its delivery
775
+ * settled `skipped` with reason `quarantined`, which is what the delivery log shows.
776
+ * `notifications.digest-quarantined` and an error line name the occasions, never what the rows
777
+ * held.
778
+ */
779
+ function quarantine(ctx, principal, rows, now) {
780
+ const occasions = [];
781
+ for (const row of rows) {
782
+ ctx.sql.exec('UPDATE notifications_digest_items SET quarantined_at = ? WHERE rowid = ?', [now, row.rowId]);
783
+ if (typeof row.id !== 'string')
784
+ continue;
785
+ occasions.push(row.id);
786
+ ctx.sql.exec(`UPDATE notifications_deliveries SET reason = 'quarantined', settled_at = ?
787
+ WHERE id = ? AND status = 'skipped' AND reason = 'digest'`, [now, row.id]);
788
+ }
789
+ ctx.log.error('{count} message(s) held for the digest of {principal} no longer read and are set aside: {occasions} ({error})', {
790
+ count: rows.length,
791
+ principal,
792
+ occasions: occasions.join(', '),
793
+ error: rows[0].error,
794
+ });
795
+ emitNotificationsEvent(ctx, {
796
+ type: 'notifications.digest-quarantined',
797
+ entity: { entityType: 'notification-recipient', entityId: principal },
798
+ ...aboutPrincipal(principal),
799
+ payload: { principal, channel: CHANNEL, occasions, unidentified: rows.length - occasions.length },
800
+ });
801
+ }
802
+ /**
803
+ * A person's digest could not be composed. Their messages stay held, marked so the next attempt
804
+ * waits a window rather than failing again on every pass, and the failure is said twice: an
805
+ * event on the spine, naming the person and how many messages wait, and an error line carrying
806
+ * the cause. Not the cause on the event: an error's text is not a frozen payload.
807
+ */
808
+ function recordDigestFailure(ctx, principal, now, err) {
809
+ const [held] = ctx.sql.query(`SELECT COUNT(*) AS n FROM notifications_digest_items
810
+ WHERE principal = ? AND channel = ? AND quarantined_at IS NULL`, [principal, CHANNEL]);
811
+ ctx.sql.exec('UPDATE notifications_digest_items SET failed_at = ? WHERE principal = ? AND channel = ? AND quarantined_at IS NULL', [now, principal, CHANNEL]);
812
+ const code = isSubstratError(err) ? err.code : 'internal';
813
+ const retryAt = new Date(Date.parse(now) + DIGEST_WINDOW_MS).toISOString();
814
+ ctx.log.error('the digest for {principal} could not be sent; {items} message(s) stay held until {retryAt}: {error}', {
815
+ principal,
816
+ items: held?.n ?? 0,
817
+ retryAt,
818
+ error: err instanceof Error ? err.message : String(err),
819
+ });
820
+ emitNotificationsEvent(ctx, {
821
+ type: 'notifications.digest-failed',
822
+ entity: { entityType: 'notification-recipient', entityId: principal },
823
+ ...aboutPrincipal(principal),
824
+ payload: { principal, channel: CHANNEL, cadence: 'daily-digest', items: held?.n ?? 0, code, retryAt },
825
+ });
826
+ }
827
+ // ============================================================================
828
+ // Settle — the platform says what became of a send
829
+ // ============================================================================
830
+ // The engine's own view of the outcome payload (#2102) — what it needs, parsed here.
831
+ const emailOutcome = z.object({ request: z.string().min(1), code: z.string().min(1).nullable() });
832
+ function settleConsumer(status) {
833
+ return async (ctx, event) => {
834
+ const { request, code } = emailOutcome.parse(event.payload);
835
+ // Only a delivery still waiting moves: another module's mail has no row here, and a
836
+ // repeated outcome finds its row already settled.
837
+ const [row] = ctx.sql.query(`UPDATE notifications_deliveries SET status = ?, reason = ?, settled_at = ?
838
+ WHERE request_id = ? AND status = 'requested'
839
+ RETURNING id, topic, principal, channel`, [status, code, ctx.now(), request]);
840
+ if (!row)
841
+ return;
842
+ emitNotificationsEvent(ctx, {
843
+ type: 'notifications.delivery-settled',
844
+ entity: { entityType: 'notification-delivery', entityId: row.id },
845
+ ...aboutPrincipal(row.principal),
846
+ payload: { deliveryId: row.id, topic: row.topic, principal: row.principal, channel: row.channel, status, code, request },
847
+ });
848
+ };
849
+ }
850
+ // ============================================================================
851
+ // The registration
852
+ // ============================================================================
853
+ export const notificationsModule = {
854
+ manifest: notificationsManifest,
855
+ migrations: notificationsMigrations,
856
+ consumers: Object.fromEntries(Object.entries(EMAIL_OUTCOMES).map(([type, status]) => [type, settleConsumer(status)])),
857
+ ...operationsFor(notificationsOperations)({
858
+ 'notifications/my-preferences': async (ctx) => {
859
+ assertAllowed(await ctx.check(NOTIFICATIONS_PERM.self));
860
+ return myPreferencesOf(ctx);
861
+ },
862
+ 'notifications/set-my-address': async (ctx, input) => {
863
+ assertAllowed(await ctx.check(NOTIFICATIONS_PERM.self));
864
+ return setMyAddress(ctx, input);
865
+ },
866
+ 'notifications/set-my-preference': async (ctx, input) => {
867
+ assertAllowed(await ctx.check(NOTIFICATIONS_PERM.self));
868
+ return setMyPreference(ctx, input);
869
+ },
870
+ 'notifications/list-deliveries': async (ctx, input) => {
871
+ assertAllowed(await ctx.check(NOTIFICATIONS_PERM.read));
872
+ // `input` is absent when invoked with no body at all (`inputOptional`).
873
+ return listDeliveries(ctx, {
874
+ ...input,
875
+ filters: { status: input?.status, topic: input?.topic, principal: input?.principal },
876
+ });
877
+ },
878
+ 'notifications/my-deliveries': async (ctx, input) => {
879
+ assertAllowed(await ctx.check(NOTIFICATIONS_PERM.self));
880
+ // `input` is absent when invoked with no body at all (`inputOptional`).
881
+ return listMyDeliveries(ctx, { ...input, filters: { status: input?.status, topic: input?.topic } });
882
+ },
883
+ 'notifications/send-digests': async (ctx) => {
884
+ assertAllowed(await ctx.check(NOTIFICATIONS_PERM.sendDigests));
885
+ return sendDigests(ctx);
886
+ },
887
+ }),
888
+ };
889
+ //# sourceMappingURL=index.js.map