@classytic/arc-notifications 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/index.d.mts +384 -61
- package/dist/index.mjs +643 -229
- package/llms.txt +29 -0
- package/package.json +8 -6
- package/skills/arc-notifications/SKILL.md +129 -0
- package/skills/arc-notifications/references/date-rules.md +55 -0
- package/skills/arc-notifications/references/delivery-log.md +33 -0
- package/skills/arc-notifications/references/feed.md +55 -0
- package/skills/arc-notifications/references/triggers-templates.md +36 -0
package/dist/index.mjs
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
import { createTtlMemo } from "@classytic/repo-core/cache";
|
|
2
|
+
import { scopedValue } from "@classytic/arc/context";
|
|
3
|
+
import { defineModule } from "@classytic/arc/factory";
|
|
2
4
|
import { BaseChannel, NotificationService, createSimpleResolver } from "@classytic/notifications";
|
|
3
5
|
import { defineResource } from "@classytic/arc";
|
|
4
6
|
import { NotFoundError } from "@classytic/arc/utils";
|
|
5
7
|
import { defineJob } from "@classytic/arc/integrations/jobs";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
// src/backfill.ts
|
|
9
|
+
const rowId = (row) => {
|
|
10
|
+
const r = row;
|
|
11
|
+
const id = r?.id ?? r?._id;
|
|
12
|
+
return id === void 0 || id === null ? void 0 : String(id);
|
|
13
|
+
};
|
|
9
14
|
function createInAppBackfill(options) {
|
|
10
15
|
const { repository, port, eventName = "notification:backfill", limit = 100, onError } = options;
|
|
11
16
|
const debounceMs = options.debounceMs ?? 2e3;
|
|
12
17
|
if (typeof repository.claimUndelivered !== "function") throw new Error("createInAppBackfill requires a repository implementing claimUndelivered() (atomic fetch-undelivered-and-mark-delivered). Add it to your feed repository, or skip backfill and let clients refetch GET /notifications/ on connect.");
|
|
13
18
|
const claimUndelivered = repository.claimUndelivered.bind(repository);
|
|
14
19
|
const pending = /* @__PURE__ */ new Map();
|
|
20
|
+
const sinces = /* @__PURE__ */ new Map();
|
|
21
|
+
const listSince = repository.listSince?.bind(repository);
|
|
15
22
|
let closed = false;
|
|
16
|
-
const run = async (userId, organizationId) => {
|
|
23
|
+
const run = async (userId, organizationId, since) => {
|
|
17
24
|
try {
|
|
18
|
-
const
|
|
25
|
+
const claimed = await claimUndelivered(organizationId, userId, limit);
|
|
26
|
+
const replayed = since && listSince ? await listSince(organizationId, userId, since, limit) : [];
|
|
27
|
+
const seen = new Set(replayed.map(rowId));
|
|
28
|
+
const rows = [...replayed, ...claimed.filter((row) => !seen.has(rowId(row)))];
|
|
19
29
|
if (!rows.length || closed) return;
|
|
20
30
|
await port.push(userId, organizationId, eventName, { notifications: rows });
|
|
21
31
|
} catch (error) {
|
|
@@ -23,24 +33,31 @@ function createInAppBackfill(options) {
|
|
|
23
33
|
}
|
|
24
34
|
};
|
|
25
35
|
return {
|
|
26
|
-
onConnect(userId, organizationId) {
|
|
36
|
+
onConnect(userId, organizationId, connect) {
|
|
27
37
|
if (closed) return;
|
|
28
38
|
const key = `${organizationId} ${userId}`;
|
|
39
|
+
const since = connect?.since === void 0 ? void 0 : new Date(connect.since);
|
|
40
|
+
if (since && !Number.isNaN(since.getTime())) {
|
|
41
|
+
const earlier = sinces.get(key);
|
|
42
|
+
if (!earlier || since < earlier) sinces.set(key, since);
|
|
43
|
+
}
|
|
29
44
|
if (pending.has(key)) return;
|
|
30
45
|
pending.set(key, setTimeout(() => {
|
|
31
46
|
pending.delete(key);
|
|
32
|
-
|
|
47
|
+
const replayFrom = sinces.get(key);
|
|
48
|
+
sinces.delete(key);
|
|
49
|
+
run(userId, organizationId, replayFrom);
|
|
33
50
|
}, debounceMs));
|
|
34
51
|
},
|
|
35
52
|
close() {
|
|
36
53
|
closed = true;
|
|
37
54
|
for (const timer of pending.values()) clearTimeout(timer);
|
|
38
55
|
pending.clear();
|
|
56
|
+
sinces.clear();
|
|
39
57
|
}
|
|
40
58
|
};
|
|
41
59
|
}
|
|
42
|
-
|
|
43
|
-
//#region src/triggerOverride.ts
|
|
60
|
+
// src/triggerOverride.ts
|
|
44
61
|
/**
|
|
45
62
|
* Notification trigger OVERRIDES — the admin-editable half of the registry.
|
|
46
63
|
*
|
|
@@ -164,11 +181,36 @@ function resolveEffectiveSettings(triggerType, organizationId, rows, defaults) {
|
|
|
164
181
|
const title = pick("title") ?? defaults.title;
|
|
165
182
|
const message = pick("message") ?? defaults.message;
|
|
166
183
|
const priority = pick("priority") ?? defaults.priority;
|
|
184
|
+
/**
|
|
185
|
+
* Operator row, then the trigger's own default, then unrestricted — the same
|
|
186
|
+
* ladder every field above uses. It used to stop at `?? null`, discarding a
|
|
187
|
+
* code default that therefore could not be written at all.
|
|
188
|
+
*
|
|
189
|
+
* `?? null`, never `?? []` — the two must stay distinguishable all the way
|
|
190
|
+
* through. `null` means unrestricted; `[]` would mean "match nothing", and
|
|
191
|
+
* collapsing one into the other is exactly the widening this field prevents.
|
|
192
|
+
* A `null` stored by an operator is a deliberate "every source", and it wins
|
|
193
|
+
* over the code default precisely because they set it.
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* PRESENCE, not `??` — the one field where a stored `null` is a decision.
|
|
197
|
+
*
|
|
198
|
+
* `pick` collapses `null` into "absent", which is right for every other
|
|
199
|
+
* setting and wrong here: `null` means "every source", and an operator who
|
|
200
|
+
* opens a filter must not have it re-narrowed by the code default on the next
|
|
201
|
+
* read. That is Rule 1 — a default is the fallback for an UNSET value, never
|
|
202
|
+
* an override of an explicit one.
|
|
203
|
+
*
|
|
204
|
+
* Scoped row wins over app-wide, same precedence as `pick`.
|
|
205
|
+
*/
|
|
206
|
+
const rowWithField = [scoped, appWide].find((r) => r && "sourceChannels" in r);
|
|
207
|
+
const sourceChannels = rowWithField ? rowWithField.sourceChannels ?? null : defaults.sourceChannels ? [...defaults.sourceChannels] : null;
|
|
167
208
|
return {
|
|
168
209
|
enabled,
|
|
169
210
|
recipients,
|
|
211
|
+
recipientEmails: pick("recipientEmails") ?? [],
|
|
170
212
|
channels,
|
|
171
|
-
sourceChannels
|
|
213
|
+
sourceChannels,
|
|
172
214
|
title,
|
|
173
215
|
message,
|
|
174
216
|
...priority ? { priority } : {}
|
|
@@ -209,8 +251,7 @@ function evaluateSourceChannel(sourceChannels, sourceChannel) {
|
|
|
209
251
|
reason: "source_channel_mismatch"
|
|
210
252
|
};
|
|
211
253
|
}
|
|
212
|
-
|
|
213
|
-
//#region src/channelCatalog.ts
|
|
254
|
+
// src/channelCatalog.ts
|
|
214
255
|
/**
|
|
215
256
|
* The NOTIFICATION CHANNEL CATALOG — which channels this deployment can deliver on.
|
|
216
257
|
*
|
|
@@ -269,49 +310,9 @@ function createNotificationChannelCatalog(connectorChannels = []) {
|
|
|
269
310
|
has: (channel) => lookup.has(channel)
|
|
270
311
|
};
|
|
271
312
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
* DB-first template resolver — generalized from be-prod's proven
|
|
276
|
-
* `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
|
|
277
|
-
* runtime: an active DB row for a template key wins; anything else falls back
|
|
278
|
-
* to the code-defined resolver, so a bad draft can never take sends down.
|
|
279
|
-
*
|
|
280
|
-
* Storage-agnostic: `TemplateStore` is a one-method structural port
|
|
281
|
-
* (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
|
|
282
|
-
*/
|
|
283
|
-
function createDbFirstResolver(options) {
|
|
284
|
-
const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
|
|
285
|
-
/**
|
|
286
|
-
* Single-flight memo: one event's channel expansions resolve the same key in
|
|
287
|
-
* the same tick, and a `null` row (no draft) is an answer worth keeping. A
|
|
288
|
-
* store failure rejects every waiter and caches nothing.
|
|
289
|
-
*/
|
|
290
|
-
const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
|
|
291
|
-
const resolver = (async (templateId, data) => {
|
|
292
|
-
let doc = null;
|
|
293
|
-
try {
|
|
294
|
-
doc = await lookup.get(templateId);
|
|
295
|
-
} catch (err) {
|
|
296
|
-
onError?.(err, templateId);
|
|
297
|
-
if (!fallback) throw err;
|
|
298
|
-
doc = null;
|
|
299
|
-
}
|
|
300
|
-
if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
|
|
301
|
-
...doc.subject !== void 0 ? { subject: doc.subject } : {},
|
|
302
|
-
...doc.html !== void 0 ? { html: doc.html } : {},
|
|
303
|
-
...doc.text !== void 0 ? { text: doc.text } : {}
|
|
304
|
-
} }, { escape: escapeHtml })(templateId, data);
|
|
305
|
-
if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
|
|
306
|
-
return fallback(templateId, data);
|
|
307
|
-
});
|
|
308
|
-
resolver.invalidate = (key) => {
|
|
309
|
-
lookup.invalidate(key);
|
|
310
|
-
};
|
|
311
|
-
return resolver;
|
|
312
|
-
}
|
|
313
|
-
//#endregion
|
|
314
|
-
//#region src/inApp.ts
|
|
313
|
+
// src/clock.ts
|
|
314
|
+
const systemNotificationClock = { now: () => /* @__PURE__ */ new Date() };
|
|
315
|
+
// src/inApp.ts
|
|
315
316
|
/**
|
|
316
317
|
* In-app notification content — the shared seam between a `notifyOn` trigger's
|
|
317
318
|
* declarative `inApp` template and the channels that deliver it (the DB feed
|
|
@@ -381,8 +382,468 @@ function readInAppContent(data) {
|
|
|
381
382
|
if (typeof c.title !== "string" || typeof c.message !== "string") return null;
|
|
382
383
|
return c;
|
|
383
384
|
}
|
|
384
|
-
|
|
385
|
-
|
|
385
|
+
// src/module.ts
|
|
386
|
+
/**
|
|
387
|
+
* `notificationsModule` — the blessed seam from arc domain events to
|
|
388
|
+
* user-facing notifications.
|
|
389
|
+
*
|
|
390
|
+
* Every arc app used to re-derive the same wiring: build a
|
|
391
|
+
* NotificationService, subscribe `app.events`, map event payloads to
|
|
392
|
+
* recipients, thread idempotency for at-least-once transports. This module is
|
|
393
|
+
* that wiring, once:
|
|
394
|
+
*
|
|
395
|
+
* ```ts
|
|
396
|
+
* const app = await createApp({
|
|
397
|
+
* modules: [
|
|
398
|
+
* notificationsModule({
|
|
399
|
+
* channels: [new EmailChannel({ from, transport })],
|
|
400
|
+
* templates: createDbFirstResolver({ store, fallback }),
|
|
401
|
+
* notifyOn: [{
|
|
402
|
+
* event: "approval.requested",
|
|
403
|
+
* template: "approval-requested",
|
|
404
|
+
* recipient: (e) => directory.contact(e.payload.approverId),
|
|
405
|
+
* data: (e) => ({ title: e.payload.title }),
|
|
406
|
+
* }],
|
|
407
|
+
* }),
|
|
408
|
+
* ],
|
|
409
|
+
* });
|
|
410
|
+
* app.notifications?.send({ ... }); // decorated service
|
|
411
|
+
* const svc = getModuleExports<NotificationService>(app, "notifications");
|
|
412
|
+
* ```
|
|
413
|
+
*
|
|
414
|
+
* Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
|
|
415
|
+
* arc owns subscription ordering AND teardown: it unsubscribes every handler at
|
|
416
|
+
* shutdown BEFORE any module `onClose` runs, while the service and its queue
|
|
417
|
+
* are still alive. A hand-rolled `afterResources` subscribe cannot promise that
|
|
418
|
+
* — its own `onClose` races the modules it depends on.
|
|
419
|
+
*
|
|
420
|
+
* Each handler declares `boundary: true`, so arc contains failures — logged and
|
|
421
|
+
* swallowed (fire-and-forget, matching arc's event gotcha), because a template
|
|
422
|
+
* or channel failure must not block the transport ack and DLQ the event. And
|
|
423
|
+
* `idempotencyKey` defaults from event identity because Redis Streams are
|
|
424
|
+
* at-least-once: redelivery must not double-send.
|
|
425
|
+
*/
|
|
426
|
+
function notificationsModule(config) {
|
|
427
|
+
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
|
|
428
|
+
let service;
|
|
429
|
+
return defineModule({
|
|
430
|
+
name,
|
|
431
|
+
async bootstrap(fastify) {
|
|
432
|
+
const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
|
|
433
|
+
const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
|
|
434
|
+
service = provided ?? new NotificationService({
|
|
435
|
+
...serviceConfig,
|
|
436
|
+
...resolvedChannels ? { channels: resolvedChannels } : {},
|
|
437
|
+
...resolvedQueue ? { queue: resolvedQueue } : {}
|
|
438
|
+
});
|
|
439
|
+
if (!resolvedQueue && !provided && notifyOn.some((t) => t.enabled !== false)) fastify.log.warn(`[arc-notifications] module "${name}" wires notifyOn trigger(s) with NO durable queue — sends run on the in-memory queue and are lost on restart / not shared across instances. Wire \`queue: (f) => notificationsJob(...).toQueueAdapter(f.jobs)\` (with arc's jobsPlugin) for durability.`);
|
|
440
|
+
if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
|
|
441
|
+
return service;
|
|
442
|
+
},
|
|
443
|
+
eventHandlers: (fastify) => {
|
|
444
|
+
const handlers = notifyTriggerHandlers({
|
|
445
|
+
name,
|
|
446
|
+
service: () => service,
|
|
447
|
+
triggers: notifyOn,
|
|
448
|
+
...recipientResolver ? { recipientResolver } : {}
|
|
449
|
+
});
|
|
450
|
+
if (handlers.length > 0) fastify.log.debug(`[arc-notifications] ${handlers.length} trigger(s) subscribed`);
|
|
451
|
+
return handlers;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
|
|
457
|
+
* the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
|
|
458
|
+
* `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
|
|
459
|
+
*/
|
|
460
|
+
function notifyTriggerHandlers(options) {
|
|
461
|
+
const { name, service, recipientResolver } = options;
|
|
462
|
+
return options.triggers.filter((t) => t.enabled !== false).map((trigger, index) => ({
|
|
463
|
+
name: `${name}:${trigger.event}#${index}`,
|
|
464
|
+
event: trigger.event,
|
|
465
|
+
boundary: true,
|
|
466
|
+
handler: async (event) => dispatchTrigger(service(), trigger, event, recipientResolver ? { recipientResolver } : {})
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
const audienceSlot = Symbol("arc-notifications.resolvedAudience");
|
|
470
|
+
/** Content key for an audience — key order must not fragment the memo. */
|
|
471
|
+
function audienceKey(audience) {
|
|
472
|
+
return JSON.stringify(audience, (_k, v) => v !== null && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Resolve an audience ONCE per dispatch, shared by every expansion that asks.
|
|
476
|
+
*
|
|
477
|
+
* The resolver is a database read in every real host (members by role, then
|
|
478
|
+
* users for contact); N channel expansions of one trigger hand it the same
|
|
479
|
+
* audience. Keyed on CONTENT, so two triggers asking the same question share
|
|
480
|
+
* too. A rejected resolution is dropped so the next expansion retries.
|
|
481
|
+
* Outside a work scope nothing is shared.
|
|
482
|
+
*/
|
|
483
|
+
function resolveAudience(resolver, audience, event) {
|
|
484
|
+
const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
|
|
485
|
+
if (!memo) return Promise.resolve(resolver(audience, event));
|
|
486
|
+
const key = audienceKey(audience);
|
|
487
|
+
const pending = memo.get(key);
|
|
488
|
+
if (pending) return pending;
|
|
489
|
+
const created = Promise.resolve(resolver(audience, event));
|
|
490
|
+
memo.set(key, created);
|
|
491
|
+
created.catch(() => memo.delete(key));
|
|
492
|
+
return created;
|
|
493
|
+
}
|
|
494
|
+
/** The `organizationId` an audience names (`{ roles, organizationId, … }`), when it names one. */
|
|
495
|
+
function audienceOrganization(audience) {
|
|
496
|
+
if (audience === null || typeof audience !== "object") return void 0;
|
|
497
|
+
const org = audience.organizationId;
|
|
498
|
+
return typeof org === "string" && org !== "" ? org : void 0;
|
|
499
|
+
}
|
|
500
|
+
/** One event → N sends. Exported for direct unit testing. */
|
|
501
|
+
async function dispatchTrigger(service, trigger, event, opts) {
|
|
502
|
+
let resolved;
|
|
503
|
+
let organizationId;
|
|
504
|
+
if (trigger.recipient) resolved = await trigger.recipient(event);
|
|
505
|
+
else if (trigger.audience) {
|
|
506
|
+
const audience = await trigger.audience(event);
|
|
507
|
+
if (audience === null || audience === void 0) return;
|
|
508
|
+
organizationId = audienceOrganization(audience);
|
|
509
|
+
resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
|
|
510
|
+
}
|
|
511
|
+
if (!resolved) return;
|
|
512
|
+
const recipients = Array.isArray(resolved) ? resolved : [resolved];
|
|
513
|
+
if (recipients.length === 0) return;
|
|
514
|
+
const payload = event.payload;
|
|
515
|
+
const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
|
|
516
|
+
const sendData = trigger.inApp ? {
|
|
517
|
+
...data,
|
|
518
|
+
[INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
|
|
519
|
+
} : data;
|
|
520
|
+
const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
|
|
521
|
+
for (const [index, recipient] of recipients.entries()) {
|
|
522
|
+
const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
|
|
523
|
+
const subject = trigger.subject?.(event);
|
|
524
|
+
await service.send({
|
|
525
|
+
event: event.type,
|
|
526
|
+
template: trigger.template ?? event.type,
|
|
527
|
+
recipient,
|
|
528
|
+
data: sendData,
|
|
529
|
+
...trigger.channels ? { channels: [...trigger.channels] } : {},
|
|
530
|
+
...idempotencyKey !== void 0 ? { idempotencyKey } : {},
|
|
531
|
+
...subject ? { subject } : {},
|
|
532
|
+
metadata: {
|
|
533
|
+
source: "arc-notifications",
|
|
534
|
+
eventId: event.meta.id,
|
|
535
|
+
...organizationId !== void 0 ? { organizationId } : {},
|
|
536
|
+
/**
|
|
537
|
+
* The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
|
|
538
|
+
*
|
|
539
|
+
* Stated here because only the dispatcher knows it. A channel that has to
|
|
540
|
+
* infer "is this the same notification?" from the payload gets it wrong in
|
|
541
|
+
* both directions, and both were shipped: keying on the event TYPE
|
|
542
|
+
* suppressed every order after the first, and keying on `meta.id`
|
|
543
|
+
* (absent when a host re-publishes an enriched event) deduped nothing and
|
|
544
|
+
* sent the ops group two copies of every alert.
|
|
545
|
+
*
|
|
546
|
+
* Unique per occurrence, IDENTICAL across that occurrence's recipient
|
|
547
|
+
* fan-out — which is exactly the distinction a destination-collapsing
|
|
548
|
+
* channel needs and cannot derive for itself.
|
|
549
|
+
*/
|
|
550
|
+
...baseKey !== void 0 ? { notificationId: baseKey } : {}
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// src/dateRules.ts
|
|
556
|
+
const CIVIL = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
557
|
+
const DAY_MS = 864e5;
|
|
558
|
+
/** True for a real calendar date in `YYYY-MM-DD` form. */
|
|
559
|
+
function isCivilDateString(value) {
|
|
560
|
+
if (typeof value !== "string") return false;
|
|
561
|
+
const m = CIVIL.exec(value);
|
|
562
|
+
if (!m) return false;
|
|
563
|
+
return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).toISOString().slice(0, 10) === value;
|
|
564
|
+
}
|
|
565
|
+
function toDayNumber(value) {
|
|
566
|
+
if (!isCivilDateString(value)) throw new TypeError(`not a civil date (YYYY-MM-DD): ${value}`);
|
|
567
|
+
return Date.parse(`${value}T00:00:00Z`) / DAY_MS;
|
|
568
|
+
}
|
|
569
|
+
function fromDayNumber(day) {
|
|
570
|
+
return (/* @__PURE__ */ new Date(day * DAY_MS)).toISOString().slice(0, 10);
|
|
571
|
+
}
|
|
572
|
+
/** `date` moved by `days` (negative = earlier). */
|
|
573
|
+
function addCivilDays(date, days) {
|
|
574
|
+
return fromDayNumber(toDayNumber(date) + days);
|
|
575
|
+
}
|
|
576
|
+
/** Whole days from `a` to `b` (`b - a`). */
|
|
577
|
+
function civilDaysBetween(a, b) {
|
|
578
|
+
return toDayNumber(b) - toDayNumber(a);
|
|
579
|
+
}
|
|
580
|
+
function createMemoryDateRuleLedger() {
|
|
581
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
582
|
+
return {
|
|
583
|
+
async claim(key) {
|
|
584
|
+
if (claimed.has(key)) return false;
|
|
585
|
+
claimed.add(key);
|
|
586
|
+
return true;
|
|
587
|
+
},
|
|
588
|
+
async release(key) {
|
|
589
|
+
claimed.delete(key);
|
|
590
|
+
},
|
|
591
|
+
keys: () => [...claimed]
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/** Problems with a schedule, as messages; empty = valid. */
|
|
595
|
+
function validateDateRuleSchedule(schedule) {
|
|
596
|
+
const problems = [];
|
|
597
|
+
if (!Number.isInteger(schedule.offsetDays)) problems.push("offsetDays must be a whole number of days");
|
|
598
|
+
if (schedule.repeatEveryDays !== void 0 && (!Number.isInteger(schedule.repeatEveryDays) || schedule.repeatEveryDays < 1)) problems.push("repeatEveryDays must be a whole number of days, at least 1");
|
|
599
|
+
const until = schedule.until;
|
|
600
|
+
if (until?.occurrences !== void 0 && (!Number.isInteger(until.occurrences) || until.occurrences < 1)) problems.push("until.occurrences must be at least 1");
|
|
601
|
+
if (until?.offsetDays !== void 0) {
|
|
602
|
+
if (!Number.isInteger(until.offsetDays)) problems.push("until.offsetDays must be a whole number of days");
|
|
603
|
+
else if (until.offsetDays < schedule.offsetDays) problems.push("until.offsetDays cannot be before offsetDays");
|
|
604
|
+
}
|
|
605
|
+
if (until && schedule.repeatEveryDays === void 0) problems.push("until applies only to a repeating rule");
|
|
606
|
+
return problems;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The occurrence of `schedule` for an item dated `date` that a sweep on `today`
|
|
610
|
+
* should send, or `null`. The latest occurrence on or before `today` is due when it
|
|
611
|
+
* is at most `graceDays` old — so a missed sweep catches up, but a rule created
|
|
612
|
+
* today does not replay years of history.
|
|
613
|
+
*/
|
|
614
|
+
function dueOccurrence(schedule, date, today, graceDays = 0) {
|
|
615
|
+
const first = addCivilDays(date, schedule.offsetDays);
|
|
616
|
+
const elapsed = civilDaysBetween(first, today);
|
|
617
|
+
if (elapsed < 0) return null;
|
|
618
|
+
const every = schedule.repeatEveryDays;
|
|
619
|
+
let index = every ? Math.floor(elapsed / every) : 0;
|
|
620
|
+
if (every) {
|
|
621
|
+
const max = schedule.until?.occurrences;
|
|
622
|
+
if (max !== void 0 && index > max - 1) index = max - 1;
|
|
623
|
+
const lastOffset = schedule.until?.offsetDays;
|
|
624
|
+
if (lastOffset !== void 0) {
|
|
625
|
+
const cap = Math.floor((lastOffset - schedule.offsetDays) / every);
|
|
626
|
+
if (index > cap) index = cap;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const on = addCivilDays(first, every ? index * every : 0);
|
|
630
|
+
return civilDaysBetween(on, today) <= graceDays ? {
|
|
631
|
+
index,
|
|
632
|
+
on
|
|
633
|
+
} : null;
|
|
634
|
+
}
|
|
635
|
+
/** The `[from, to]` range of item dates a sweep on `today` must ask a source for. */
|
|
636
|
+
function dateRuleWindow(schedule, today, graceDays = 0) {
|
|
637
|
+
const to = addCivilDays(today, -schedule.offsetDays);
|
|
638
|
+
if (!schedule.repeatEveryDays) return {
|
|
639
|
+
from: addCivilDays(to, -graceDays),
|
|
640
|
+
to
|
|
641
|
+
};
|
|
642
|
+
const every = schedule.repeatEveryDays;
|
|
643
|
+
const spans = [];
|
|
644
|
+
if (schedule.until?.occurrences !== void 0) spans.push((schedule.until.occurrences - 1) * every);
|
|
645
|
+
if (schedule.until?.offsetDays !== void 0) spans.push(schedule.until.offsetDays - schedule.offsetDays);
|
|
646
|
+
if (spans.length === 0) return { to };
|
|
647
|
+
return {
|
|
648
|
+
from: addCivilDays(to, -(Math.min(...spans) + graceDays)),
|
|
649
|
+
to
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/** Ledger key of one occurrence. */
|
|
653
|
+
const dateRuleOccurrenceKey = (ruleId, itemId, on) => `daterule:${ruleId}:${itemId}:${on}`;
|
|
654
|
+
async function collect(found) {
|
|
655
|
+
if (Symbol.asyncIterator in found) {
|
|
656
|
+
const out = [];
|
|
657
|
+
for await (const item of found) out.push(item);
|
|
658
|
+
return out;
|
|
659
|
+
}
|
|
660
|
+
return [...await found];
|
|
661
|
+
}
|
|
662
|
+
function createDateRuleEngine(options) {
|
|
663
|
+
const sources = /* @__PURE__ */ new Map();
|
|
664
|
+
for (const s of options.sources) {
|
|
665
|
+
if (sources.has(s.key)) throw new Error(`[arc-notifications] duplicate date-rule source "${s.key}"`);
|
|
666
|
+
sources.set(s.key, s);
|
|
667
|
+
}
|
|
668
|
+
const ledger = options.ledger ?? createMemoryDateRuleLedger();
|
|
669
|
+
const graceDays = options.graceDays ?? 0;
|
|
670
|
+
const validate = (rule) => {
|
|
671
|
+
const problems = validateDateRuleSchedule(rule.schedule);
|
|
672
|
+
const source = sources.get(rule.source);
|
|
673
|
+
if (!source) problems.unshift(`unknown source "${rule.source}"`);
|
|
674
|
+
else if (!source.dateFields.some((f) => f.key === rule.dateField)) problems.unshift(`source "${rule.source}" has no date field "${rule.dateField}"`);
|
|
675
|
+
return problems;
|
|
676
|
+
};
|
|
677
|
+
/** Items due for `rule` on `today`, each with its occurrence. */
|
|
678
|
+
const dueItems = async (rule, today) => {
|
|
679
|
+
const source = sources.get(rule.source);
|
|
680
|
+
if (!source) throw new Error(`unknown date-rule source "${rule.source}"`);
|
|
681
|
+
const window = dateRuleWindow(rule.schedule, today, graceDays);
|
|
682
|
+
const items = await collect(source.find({
|
|
683
|
+
dateField: rule.dateField,
|
|
684
|
+
...window.from !== void 0 ? { from: window.from } : {},
|
|
685
|
+
to: window.to,
|
|
686
|
+
...rule.organizationId !== void 0 ? { organizationId: rule.organizationId } : {}
|
|
687
|
+
}));
|
|
688
|
+
const out = [];
|
|
689
|
+
for (const item of items) {
|
|
690
|
+
if (!isCivilDateString(item.date)) continue;
|
|
691
|
+
const occurrence = dueOccurrence(rule.schedule, item.date, today, graceDays);
|
|
692
|
+
if (occurrence) out.push({
|
|
693
|
+
...item,
|
|
694
|
+
occurrence
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
};
|
|
699
|
+
const sweep = async (today) => {
|
|
700
|
+
if (!isCivilDateString(today)) throw new TypeError(`sweep date must be YYYY-MM-DD: ${today}`);
|
|
701
|
+
const rules = (await options.rules()).filter((r) => r.enabled !== false);
|
|
702
|
+
let due = 0;
|
|
703
|
+
let fired = 0;
|
|
704
|
+
let duplicates = 0;
|
|
705
|
+
let noRecipients = 0;
|
|
706
|
+
let failed = 0;
|
|
707
|
+
for (const rule of rules) {
|
|
708
|
+
let items;
|
|
709
|
+
try {
|
|
710
|
+
const problems = validate(rule);
|
|
711
|
+
if (problems.length > 0) throw new Error(`invalid date rule: ${problems.join("; ")}`);
|
|
712
|
+
items = await dueItems(rule, today);
|
|
713
|
+
} catch (err) {
|
|
714
|
+
failed++;
|
|
715
|
+
options.onError?.(err, { ruleId: rule.id });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
for (const item of items) {
|
|
719
|
+
due++;
|
|
720
|
+
const { occurrence } = item;
|
|
721
|
+
const key = dateRuleOccurrenceKey(rule.id, item.id, occurrence.on);
|
|
722
|
+
if (!await ledger.claim(key, {
|
|
723
|
+
ruleId: rule.id,
|
|
724
|
+
itemId: item.id,
|
|
725
|
+
occurrenceOn: occurrence.on,
|
|
726
|
+
index: occurrence.index,
|
|
727
|
+
sweptOn: today
|
|
728
|
+
})) {
|
|
729
|
+
duplicates++;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const recipients = await options.resolveAudience(rule.audience, {
|
|
734
|
+
rule,
|
|
735
|
+
item,
|
|
736
|
+
occurrence
|
|
737
|
+
});
|
|
738
|
+
if (!recipients || recipients.length === 0) {
|
|
739
|
+
noRecipients++;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const type = rule.template ?? `${rule.source}.${rule.dateField}`;
|
|
743
|
+
const data = {
|
|
744
|
+
...item.data,
|
|
745
|
+
dueOn: item.date,
|
|
746
|
+
occurrenceOn: occurrence.on,
|
|
747
|
+
occurrence: occurrence.index + 1,
|
|
748
|
+
daysFromDate: civilDaysBetween(item.date, today),
|
|
749
|
+
...options.data?.(rule, item) ?? {}
|
|
750
|
+
};
|
|
751
|
+
const event = {
|
|
752
|
+
type,
|
|
753
|
+
payload: data,
|
|
754
|
+
meta: {
|
|
755
|
+
id: key,
|
|
756
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
757
|
+
idempotencyKey: key
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
await dispatchTrigger(options.service(), {
|
|
761
|
+
event: type,
|
|
762
|
+
template: type,
|
|
763
|
+
recipient: () => recipients,
|
|
764
|
+
data: () => data,
|
|
765
|
+
...rule.channels ? { channels: rule.channels } : {},
|
|
766
|
+
...rule.inApp ? { inApp: rule.inApp } : {},
|
|
767
|
+
...rule.priority ? { priority: rule.priority } : {},
|
|
768
|
+
...item.subject ? { subject: () => item.subject } : {}
|
|
769
|
+
}, event);
|
|
770
|
+
fired++;
|
|
771
|
+
} catch (err) {
|
|
772
|
+
failed++;
|
|
773
|
+
await ledger.release(key).catch(() => void 0);
|
|
774
|
+
options.onError?.(err, {
|
|
775
|
+
ruleId: rule.id,
|
|
776
|
+
itemId: item.id
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
date: today,
|
|
783
|
+
rules: rules.length,
|
|
784
|
+
due,
|
|
785
|
+
fired,
|
|
786
|
+
duplicates,
|
|
787
|
+
noRecipients,
|
|
788
|
+
failed
|
|
789
|
+
};
|
|
790
|
+
};
|
|
791
|
+
return {
|
|
792
|
+
sweep,
|
|
793
|
+
preview: (rule, today) => dueItems(rule, today),
|
|
794
|
+
catalog: () => [...sources.values()].map((s) => ({
|
|
795
|
+
key: s.key,
|
|
796
|
+
label: s.label ?? s.key,
|
|
797
|
+
dateFields: s.dateFields.map((f) => ({
|
|
798
|
+
key: f.key,
|
|
799
|
+
label: f.label ?? f.key
|
|
800
|
+
})),
|
|
801
|
+
variables: [...s.variables ?? []]
|
|
802
|
+
})),
|
|
803
|
+
validate
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
// src/dbFirstResolver.ts
|
|
807
|
+
/**
|
|
808
|
+
* DB-first template resolver — generalized from be-prod's proven
|
|
809
|
+
* `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
|
|
810
|
+
* runtime: an active DB row for a template key wins; anything else falls back
|
|
811
|
+
* to the code-defined resolver, so a bad draft can never take sends down.
|
|
812
|
+
*
|
|
813
|
+
* Storage-agnostic: `TemplateStore` is a one-method structural port
|
|
814
|
+
* (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
|
|
815
|
+
*/
|
|
816
|
+
function createDbFirstResolver(options) {
|
|
817
|
+
const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
|
|
818
|
+
/**
|
|
819
|
+
* Single-flight memo: one event's channel expansions resolve the same key in
|
|
820
|
+
* the same tick, and a `null` row (no draft) is an answer worth keeping. A
|
|
821
|
+
* store failure rejects every waiter and caches nothing.
|
|
822
|
+
*/
|
|
823
|
+
const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
|
|
824
|
+
const resolver = (async (templateId, data) => {
|
|
825
|
+
let doc = null;
|
|
826
|
+
try {
|
|
827
|
+
doc = await lookup.get(templateId);
|
|
828
|
+
} catch (err) {
|
|
829
|
+
onError?.(err, templateId);
|
|
830
|
+
if (!fallback) throw err;
|
|
831
|
+
doc = null;
|
|
832
|
+
}
|
|
833
|
+
if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
|
|
834
|
+
...doc.subject !== void 0 ? { subject: doc.subject } : {},
|
|
835
|
+
...doc.html !== void 0 ? { html: doc.html } : {},
|
|
836
|
+
...doc.text !== void 0 ? { text: doc.text } : {}
|
|
837
|
+
} }, { escape: escapeHtml })(templateId, data);
|
|
838
|
+
if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
|
|
839
|
+
return fallback(templateId, data);
|
|
840
|
+
});
|
|
841
|
+
resolver.invalidate = (key) => {
|
|
842
|
+
lookup.invalidate(key);
|
|
843
|
+
};
|
|
844
|
+
return resolver;
|
|
845
|
+
}
|
|
846
|
+
// src/inAppChannel.ts
|
|
386
847
|
var InAppChannel = class extends BaseChannel {
|
|
387
848
|
repository;
|
|
388
849
|
port;
|
|
@@ -476,8 +937,7 @@ var InAppChannel = class extends BaseChannel {
|
|
|
476
937
|
return typeof org === "string" && org ? org : void 0;
|
|
477
938
|
}
|
|
478
939
|
};
|
|
479
|
-
|
|
480
|
-
//#region src/inAppFeed.ts
|
|
940
|
+
// src/inAppFeed.ts
|
|
481
941
|
/**
|
|
482
942
|
* `inAppFeedResource` — the user-facing notification inbox as a normal arc
|
|
483
943
|
* resource. Generalized from be-prod's `notification.resource`: a per-user,
|
|
@@ -498,6 +958,7 @@ var InAppChannel = class extends BaseChannel {
|
|
|
498
958
|
*/
|
|
499
959
|
function createUnreadCountCache(options = {}) {
|
|
500
960
|
const { ttlMs = 3e4, maxEntries = 1e4 } = options;
|
|
961
|
+
const clock = options.clock ?? systemNotificationClock;
|
|
501
962
|
const entries = /* @__PURE__ */ new Map();
|
|
502
963
|
const keyOf = (organizationId, userId) => `${organizationId}\u0000${userId}`;
|
|
503
964
|
return {
|
|
@@ -505,7 +966,7 @@ function createUnreadCountCache(options = {}) {
|
|
|
505
966
|
const key = keyOf(organizationId, userId);
|
|
506
967
|
const entry = entries.get(key);
|
|
507
968
|
if (!entry) return void 0;
|
|
508
|
-
if (entry.expiresAt <=
|
|
969
|
+
if (entry.expiresAt <= clock.now().getTime()) {
|
|
509
970
|
entries.delete(key);
|
|
510
971
|
return;
|
|
511
972
|
}
|
|
@@ -518,7 +979,7 @@ function createUnreadCountCache(options = {}) {
|
|
|
518
979
|
}
|
|
519
980
|
entries.set(keyOf(organizationId, userId), {
|
|
520
981
|
count,
|
|
521
|
-
expiresAt:
|
|
982
|
+
expiresAt: clock.now().getTime() + ttlMs
|
|
522
983
|
});
|
|
523
984
|
},
|
|
524
985
|
invalidate(organizationId, userId) {
|
|
@@ -526,12 +987,36 @@ function createUnreadCountCache(options = {}) {
|
|
|
526
987
|
}
|
|
527
988
|
};
|
|
528
989
|
}
|
|
990
|
+
/** The action-router verbs {@link InAppFeedResourceOptions.actions} mounts. */
|
|
991
|
+
const FEED_ACTION_VERBS = {
|
|
992
|
+
markRead: "markRead",
|
|
993
|
+
markAllRead: "markAllRead"
|
|
994
|
+
};
|
|
995
|
+
/**
|
|
996
|
+
* A feed page in repo-core's canonical offset envelope. Pure and exported so a host that
|
|
997
|
+
* builds its own list route (or asserts the envelope in a test) converts the same way the
|
|
998
|
+
* resource does. `hasNext`/`hasPrev` follow from the page arithmetic the port already did.
|
|
999
|
+
*/
|
|
1000
|
+
function feedListResult(page) {
|
|
1001
|
+
const { page: current, limit, total, totalPages } = page.pagination;
|
|
1002
|
+
return {
|
|
1003
|
+
method: "offset",
|
|
1004
|
+
data: page.data,
|
|
1005
|
+
page: current,
|
|
1006
|
+
limit,
|
|
1007
|
+
total,
|
|
1008
|
+
pages: totalPages,
|
|
1009
|
+
hasNext: current < totalPages,
|
|
1010
|
+
hasPrev: current > 1
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
529
1013
|
function toInt(value, fallback) {
|
|
530
1014
|
const n = Number(value);
|
|
531
1015
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
532
1016
|
}
|
|
533
1017
|
function inAppFeedResource(options) {
|
|
534
1018
|
const { repository, resolveScope, permissions, name = "in-app-notification", prefix = "/notifications", maxLimit = 50, port, readSyncEventName = "notification:read", unreadCache } = options;
|
|
1019
|
+
const envelope = options.listEnvelope ?? "native";
|
|
535
1020
|
/** Fire-and-forget: read-sync + cache invalidation must never fail a request. */
|
|
536
1021
|
const afterReadStateChange = async (scope, data) => {
|
|
537
1022
|
try {
|
|
@@ -581,7 +1066,7 @@ function inAppFeedResource(options) {
|
|
|
581
1066
|
unreadOnly: q.unreadOnly === "true",
|
|
582
1067
|
...typeof q.type === "string" && q.type ? { type: q.type } : {}
|
|
583
1068
|
});
|
|
584
|
-
return reply.send(result);
|
|
1069
|
+
return reply.send(envelope === "offset" ? feedListResult(result) : result);
|
|
585
1070
|
}
|
|
586
1071
|
},
|
|
587
1072
|
{
|
|
@@ -662,11 +1147,41 @@ function inAppFeedResource(options) {
|
|
|
662
1147
|
return reply.send({ deleted: true });
|
|
663
1148
|
}
|
|
664
1149
|
}] : []
|
|
665
|
-
]
|
|
1150
|
+
],
|
|
1151
|
+
...options.actions ? { actions: {
|
|
1152
|
+
[FEED_ACTION_VERBS.markRead]: {
|
|
1153
|
+
permissions,
|
|
1154
|
+
description: "Mark one of my notifications read",
|
|
1155
|
+
target: {
|
|
1156
|
+
scope: "handler",
|
|
1157
|
+
reason: "feed rows are looked up by (organization, user, id) in the repository"
|
|
1158
|
+
},
|
|
1159
|
+
handler: async (id, _data, req) => {
|
|
1160
|
+
const scope = resolveScope(req);
|
|
1161
|
+
const updated = await repository.markRead(scope.organizationId, scope.userId, id);
|
|
1162
|
+
if (!updated) throw new NotFoundError("Notification");
|
|
1163
|
+
await afterReadStateChange(scope, { id });
|
|
1164
|
+
return updated;
|
|
1165
|
+
}
|
|
1166
|
+
},
|
|
1167
|
+
[FEED_ACTION_VERBS.markAllRead]: {
|
|
1168
|
+
permissions,
|
|
1169
|
+
id: false,
|
|
1170
|
+
description: "Mark all my notifications read",
|
|
1171
|
+
handler: async (_id, _data, req) => {
|
|
1172
|
+
const scope = resolveScope(req);
|
|
1173
|
+
const modifiedCount = await repository.markAllRead(scope.organizationId, scope.userId);
|
|
1174
|
+
await afterReadStateChange(scope, {
|
|
1175
|
+
all: true,
|
|
1176
|
+
modifiedCount
|
|
1177
|
+
});
|
|
1178
|
+
return { modifiedCount };
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
} } : {}
|
|
666
1182
|
});
|
|
667
1183
|
}
|
|
668
|
-
|
|
669
|
-
//#region src/jobsQueue.ts
|
|
1184
|
+
// src/jobsQueue.ts
|
|
670
1185
|
/**
|
|
671
1186
|
* `fastify.jobs` bridge — reuse arc's job subsystem instead of standing up a
|
|
672
1187
|
* second one.
|
|
@@ -778,157 +1293,7 @@ function notificationsJob(options = {}) {
|
|
|
778
1293
|
toQueueAdapter
|
|
779
1294
|
};
|
|
780
1295
|
}
|
|
781
|
-
|
|
782
|
-
//#region src/module.ts
|
|
783
|
-
/**
|
|
784
|
-
* `notificationsModule` — the blessed seam from arc domain events to
|
|
785
|
-
* user-facing notifications.
|
|
786
|
-
*
|
|
787
|
-
* Every arc app used to re-derive the same wiring: build a
|
|
788
|
-
* NotificationService, subscribe `app.events`, map event payloads to
|
|
789
|
-
* recipients, thread idempotency for at-least-once transports. This module is
|
|
790
|
-
* that wiring, once:
|
|
791
|
-
*
|
|
792
|
-
* ```ts
|
|
793
|
-
* const app = await createApp({
|
|
794
|
-
* modules: [
|
|
795
|
-
* notificationsModule({
|
|
796
|
-
* channels: [new EmailChannel({ from, transport })],
|
|
797
|
-
* templates: createDbFirstResolver({ store, fallback }),
|
|
798
|
-
* notifyOn: [{
|
|
799
|
-
* event: "approval.requested",
|
|
800
|
-
* template: "approval-requested",
|
|
801
|
-
* recipient: (e) => directory.contact(e.payload.approverId),
|
|
802
|
-
* data: (e) => ({ title: e.payload.title }),
|
|
803
|
-
* }],
|
|
804
|
-
* }),
|
|
805
|
-
* ],
|
|
806
|
-
* });
|
|
807
|
-
* app.notifications?.send({ ... }); // decorated service
|
|
808
|
-
* const svc = getModuleExports<NotificationService>(app, "notifications");
|
|
809
|
-
* ```
|
|
810
|
-
*
|
|
811
|
-
* Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
|
|
812
|
-
* arc owns subscription ordering AND teardown: it unsubscribes every handler at
|
|
813
|
-
* shutdown BEFORE any module `onClose` runs, while the service and its queue
|
|
814
|
-
* are still alive. A hand-rolled `afterResources` subscribe cannot promise that
|
|
815
|
-
* — its own `onClose` races the modules it depends on.
|
|
816
|
-
*
|
|
817
|
-
* Each handler declares `boundary: true`, so arc contains failures — logged and
|
|
818
|
-
* swallowed (fire-and-forget, matching arc's event gotcha), because a template
|
|
819
|
-
* or channel failure must not block the transport ack and DLQ the event. And
|
|
820
|
-
* `idempotencyKey` defaults from event identity because Redis Streams are
|
|
821
|
-
* at-least-once: redelivery must not double-send.
|
|
822
|
-
*/
|
|
823
|
-
function notificationsModule(config) {
|
|
824
|
-
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
|
|
825
|
-
let service;
|
|
826
|
-
return defineModule({
|
|
827
|
-
name,
|
|
828
|
-
async bootstrap(fastify) {
|
|
829
|
-
const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
|
|
830
|
-
const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
|
|
831
|
-
service = provided ?? new NotificationService({
|
|
832
|
-
...serviceConfig,
|
|
833
|
-
...resolvedChannels ? { channels: resolvedChannels } : {},
|
|
834
|
-
...resolvedQueue ? { queue: resolvedQueue } : {}
|
|
835
|
-
});
|
|
836
|
-
if (!resolvedQueue && !provided && notifyOn.some((t) => t.enabled !== false)) fastify.log.warn(`[arc-notifications] module "${name}" wires notifyOn trigger(s) with NO durable queue — sends run on the in-memory queue and are lost on restart / not shared across instances. Wire \`queue: (f) => notificationsJob(...).toQueueAdapter(f.jobs)\` (with arc's jobsPlugin) for durability.`);
|
|
837
|
-
if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
|
|
838
|
-
return service;
|
|
839
|
-
},
|
|
840
|
-
eventHandlers: (fastify) => {
|
|
841
|
-
const triggers = notifyOn.filter((t) => t.enabled !== false);
|
|
842
|
-
if (triggers.length === 0) return [];
|
|
843
|
-
fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
|
|
844
|
-
return triggers.map((trigger, index) => ({
|
|
845
|
-
name: `${name}:${trigger.event}#${index}`,
|
|
846
|
-
event: trigger.event,
|
|
847
|
-
boundary: true,
|
|
848
|
-
handler: async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} })
|
|
849
|
-
}));
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
const audienceSlot = Symbol("arc-notifications.resolvedAudience");
|
|
854
|
-
/** Content key for an audience — key order must not fragment the memo. */
|
|
855
|
-
function audienceKey(audience) {
|
|
856
|
-
return JSON.stringify(audience, (_k, v) => v !== null && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : 1)) : v);
|
|
857
|
-
}
|
|
858
|
-
/**
|
|
859
|
-
* Resolve an audience ONCE per dispatch, shared by every expansion that asks.
|
|
860
|
-
*
|
|
861
|
-
* The resolver is a database read in every real host (members by role, then
|
|
862
|
-
* users for contact); N channel expansions of one trigger hand it the same
|
|
863
|
-
* audience. Keyed on CONTENT, so two triggers asking the same question share
|
|
864
|
-
* too. A rejected resolution is dropped so the next expansion retries.
|
|
865
|
-
* Outside a work scope nothing is shared.
|
|
866
|
-
*/
|
|
867
|
-
function resolveAudience(resolver, audience, event) {
|
|
868
|
-
const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
|
|
869
|
-
if (!memo) return Promise.resolve(resolver(audience, event));
|
|
870
|
-
const key = audienceKey(audience);
|
|
871
|
-
const pending = memo.get(key);
|
|
872
|
-
if (pending) return pending;
|
|
873
|
-
const created = Promise.resolve(resolver(audience, event));
|
|
874
|
-
memo.set(key, created);
|
|
875
|
-
created.catch(() => memo.delete(key));
|
|
876
|
-
return created;
|
|
877
|
-
}
|
|
878
|
-
/** One event → N sends. Exported for direct unit testing. */
|
|
879
|
-
async function dispatchTrigger(service, trigger, event, opts) {
|
|
880
|
-
let resolved;
|
|
881
|
-
if (trigger.recipient) resolved = await trigger.recipient(event);
|
|
882
|
-
else if (trigger.audience) {
|
|
883
|
-
const audience = await trigger.audience(event);
|
|
884
|
-
if (audience === null || audience === void 0) return;
|
|
885
|
-
resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
|
|
886
|
-
}
|
|
887
|
-
if (!resolved) return;
|
|
888
|
-
const recipients = Array.isArray(resolved) ? resolved : [resolved];
|
|
889
|
-
if (recipients.length === 0) return;
|
|
890
|
-
const payload = event.payload;
|
|
891
|
-
const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
|
|
892
|
-
const sendData = trigger.inApp ? {
|
|
893
|
-
...data,
|
|
894
|
-
[INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
|
|
895
|
-
} : data;
|
|
896
|
-
const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
|
|
897
|
-
for (const [index, recipient] of recipients.entries()) {
|
|
898
|
-
const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
|
|
899
|
-
const subject = trigger.subject?.(event);
|
|
900
|
-
await service.send({
|
|
901
|
-
event: event.type,
|
|
902
|
-
template: trigger.template ?? event.type,
|
|
903
|
-
recipient,
|
|
904
|
-
data: sendData,
|
|
905
|
-
...trigger.channels ? { channels: [...trigger.channels] } : {},
|
|
906
|
-
...idempotencyKey !== void 0 ? { idempotencyKey } : {},
|
|
907
|
-
...subject ? { subject } : {},
|
|
908
|
-
metadata: {
|
|
909
|
-
source: "arc-notifications",
|
|
910
|
-
eventId: event.meta.id,
|
|
911
|
-
/**
|
|
912
|
-
* The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
|
|
913
|
-
*
|
|
914
|
-
* Stated here because only the dispatcher knows it. A channel that has to
|
|
915
|
-
* infer "is this the same notification?" from the payload gets it wrong in
|
|
916
|
-
* both directions, and both were shipped: keying on the event TYPE
|
|
917
|
-
* suppressed every order after the first, and keying on `meta.id`
|
|
918
|
-
* (absent when a host re-publishes an enriched event) deduped nothing and
|
|
919
|
-
* sent the ops group two copies of every alert.
|
|
920
|
-
*
|
|
921
|
-
* Unique per occurrence, IDENTICAL across that occurrence's recipient
|
|
922
|
-
* fan-out — which is exactly the distinction a destination-collapsing
|
|
923
|
-
* channel needs and cannot derive for itself.
|
|
924
|
-
*/
|
|
925
|
-
...baseKey !== void 0 ? { notificationId: baseKey } : {}
|
|
926
|
-
}
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
//#endregion
|
|
931
|
-
//#region src/repositoryDeliveryLog.ts
|
|
1296
|
+
// src/repositoryDeliveryLog.ts
|
|
932
1297
|
function deriveStatus(dispatch) {
|
|
933
1298
|
if (dispatch.sent > 0 && dispatch.failed === 0) return "delivered";
|
|
934
1299
|
if (dispatch.sent > 0 && dispatch.failed > 0) return "partial";
|
|
@@ -947,15 +1312,30 @@ function toEntry(doc) {
|
|
|
947
1312
|
status: doc.status,
|
|
948
1313
|
duration: doc.duration ?? 0,
|
|
949
1314
|
...doc.metadata ? { metadata: doc.metadata } : {},
|
|
950
|
-
...doc.subject ? { subject: { ...doc.subject } } : {}
|
|
1315
|
+
...doc.subject ? { subject: { ...doc.subject } } : {},
|
|
1316
|
+
...doc.organizationId ? { organizationId: doc.organizationId } : {}
|
|
951
1317
|
};
|
|
952
1318
|
}
|
|
1319
|
+
/** The payload's organization: its own metadata first, then the recipient's resolver stamp. */
|
|
1320
|
+
function organizationOf(payload) {
|
|
1321
|
+
const fromPayload = payload.metadata?.organizationId;
|
|
1322
|
+
if (typeof fromPayload === "string" && fromPayload !== "") return fromPayload;
|
|
1323
|
+
const fromRecipient = payload.recipient.metadata?.organizationId;
|
|
1324
|
+
return typeof fromRecipient === "string" && fromRecipient !== "" ? fromRecipient : null;
|
|
1325
|
+
}
|
|
953
1326
|
function createRepositoryDeliveryLog(repository, options = {}) {
|
|
954
1327
|
const { defaultLimit = 100 } = options;
|
|
1328
|
+
const clock = options.clock ?? systemNotificationClock;
|
|
1329
|
+
const resolveRuleId = options.ruleId ?? ((payload) => {
|
|
1330
|
+
const fromMetadata = payload.metadata?.ruleId;
|
|
1331
|
+
return typeof fromMetadata === "string" && fromMetadata !== "" ? fromMetadata : void 0;
|
|
1332
|
+
});
|
|
955
1333
|
return {
|
|
956
1334
|
async record(payload, dispatch) {
|
|
1335
|
+
const ruleId = resolveRuleId(payload);
|
|
1336
|
+
const extraFields = options.extraFields?.(payload, dispatch);
|
|
957
1337
|
const doc = {
|
|
958
|
-
timestamp:
|
|
1338
|
+
timestamp: clock.now(),
|
|
959
1339
|
event: payload.event,
|
|
960
1340
|
...payload.recipient.id ? { recipientId: payload.recipient.id } : {},
|
|
961
1341
|
...payload.recipient.email ? { recipientEmail: payload.recipient.email } : {},
|
|
@@ -964,7 +1344,10 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
964
1344
|
status: deriveStatus(dispatch),
|
|
965
1345
|
duration: dispatch.duration,
|
|
966
1346
|
...payload.metadata ? { metadata: payload.metadata } : {},
|
|
967
|
-
...payload.subject ? { subject: payload.subject } : {}
|
|
1347
|
+
...payload.subject ? { subject: payload.subject } : {},
|
|
1348
|
+
organizationId: organizationOf(payload),
|
|
1349
|
+
...ruleId ? { ruleId } : {},
|
|
1350
|
+
...extraFields ? { extraFields } : {}
|
|
968
1351
|
};
|
|
969
1352
|
await repository.create(doc);
|
|
970
1353
|
},
|
|
@@ -992,6 +1375,7 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
992
1375
|
const limit = filter.limit ?? defaultLimit;
|
|
993
1376
|
const result = await repository.getAll({
|
|
994
1377
|
filter: equality,
|
|
1378
|
+
filters: equality,
|
|
995
1379
|
limit
|
|
996
1380
|
}, void 0);
|
|
997
1381
|
let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => toEntry(r));
|
|
@@ -1001,14 +1385,36 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
1001
1385
|
if (channel) entries = entries.filter((e) => e.channels.includes(channel));
|
|
1002
1386
|
return entries.slice(0, limit);
|
|
1003
1387
|
},
|
|
1388
|
+
async listByRule(ruleId, filter = {}) {
|
|
1389
|
+
const equality = { ruleId };
|
|
1390
|
+
if (filter.status) equality.status = filter.status;
|
|
1391
|
+
const limit = filter.limit ?? defaultLimit;
|
|
1392
|
+
const result = await repository.getAll({
|
|
1393
|
+
filter: equality,
|
|
1394
|
+
filters: equality,
|
|
1395
|
+
sort: { timestamp: -1 },
|
|
1396
|
+
limit
|
|
1397
|
+
}, void 0);
|
|
1398
|
+
let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => {
|
|
1399
|
+
const doc = r;
|
|
1400
|
+
return {
|
|
1401
|
+
...toEntry(doc),
|
|
1402
|
+
...doc.ruleId ? { ruleId: doc.ruleId } : {},
|
|
1403
|
+
...doc.extraFields ? { extraFields: doc.extraFields } : {}
|
|
1404
|
+
};
|
|
1405
|
+
});
|
|
1406
|
+
if (filter.after) entries = entries.filter((e) => e.timestamp >= filter.after);
|
|
1407
|
+
if (filter.before) entries = entries.filter((e) => e.timestamp <= filter.before);
|
|
1408
|
+
entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
1409
|
+
return entries.slice(0, limit);
|
|
1410
|
+
},
|
|
1004
1411
|
async get(id) {
|
|
1005
1412
|
const doc = await repository.getById(id);
|
|
1006
1413
|
return doc ? toEntry(doc) : null;
|
|
1007
1414
|
}
|
|
1008
1415
|
};
|
|
1009
1416
|
}
|
|
1010
|
-
|
|
1011
|
-
//#region src/sseChannel.ts
|
|
1417
|
+
// src/sseChannel.ts
|
|
1012
1418
|
var SseChannel = class extends BaseChannel {
|
|
1013
1419
|
port;
|
|
1014
1420
|
eventName;
|
|
@@ -1068,8 +1474,7 @@ var SseChannel = class extends BaseChannel {
|
|
|
1068
1474
|
return typeof org === "string" && org ? org : void 0;
|
|
1069
1475
|
}
|
|
1070
1476
|
};
|
|
1071
|
-
|
|
1072
|
-
//#region src/templatesResource.ts
|
|
1477
|
+
// src/templatesResource.ts
|
|
1073
1478
|
/**
|
|
1074
1479
|
* `templatesResource` — DB-draftable notification templates as a normal arc
|
|
1075
1480
|
* resource. Generalized from be-prod's email-template resource: admins draft
|
|
@@ -1173,8 +1578,7 @@ function templatesResource(options) {
|
|
|
1173
1578
|
}]
|
|
1174
1579
|
});
|
|
1175
1580
|
}
|
|
1176
|
-
|
|
1177
|
-
//#region src/triggerAdapter.ts
|
|
1581
|
+
// src/triggerAdapter.ts
|
|
1178
1582
|
/**
|
|
1179
1583
|
* Trigger registry → `notifyOn` config.
|
|
1180
1584
|
*
|
|
@@ -1398,7 +1802,8 @@ function toGatedNotifyTriggers(t, options, overrides) {
|
|
|
1398
1802
|
channels: declaredChannels(t),
|
|
1399
1803
|
title: t.template.title,
|
|
1400
1804
|
message: t.template.message,
|
|
1401
|
-
...t.priority ? { priority: t.priority } : {}
|
|
1805
|
+
...t.priority ? { priority: t.priority } : {},
|
|
1806
|
+
...t.sourceChannels ? { sourceChannels: t.sourceChannels } : {}
|
|
1402
1807
|
};
|
|
1403
1808
|
/**
|
|
1404
1809
|
* The effective settings resolve ONCE per event per trigger, shared by every
|
|
@@ -1473,10 +1878,20 @@ function toGatedNotifyTriggers(t, options, overrides) {
|
|
|
1473
1878
|
organizationId: ex.organizationId
|
|
1474
1879
|
};
|
|
1475
1880
|
}
|
|
1476
|
-
|
|
1881
|
+
/**
|
|
1882
|
+
* An operator who cleared the list means nobody — skip rather than
|
|
1883
|
+
* dispatching to an empty audience the resolver would widen.
|
|
1884
|
+
*
|
|
1885
|
+
* Literal addresses count as an audience. Someone who wants order mail at
|
|
1886
|
+
* `ops@` and nowhere else clears the roles and types the address; treating
|
|
1887
|
+
* that as "no recipients" would silence exactly the configuration they
|
|
1888
|
+
* just built, and report it as their own doing.
|
|
1889
|
+
*/
|
|
1890
|
+
if (eff.recipients.length === 0 && eff.recipientEmails.length === 0) return skip("no_recipients");
|
|
1477
1891
|
return {
|
|
1478
1892
|
roles: eff.recipients,
|
|
1479
1893
|
organizationId: ex.organizationId,
|
|
1894
|
+
...eff.recipientEmails.length > 0 ? { recipientEmails: eff.recipientEmails } : {},
|
|
1480
1895
|
...ex.triggeredBy ? { excludeUserId: ex.triggeredBy } : {}
|
|
1481
1896
|
};
|
|
1482
1897
|
}
|
|
@@ -1523,5 +1938,4 @@ function getRecipientMatrix(triggers) {
|
|
|
1523
1938
|
for (const t of triggers) matrix[t.type ?? t.event] = t.recipients;
|
|
1524
1939
|
return matrix;
|
|
1525
1940
|
}
|
|
1526
|
-
|
|
1527
|
-
export { DEFAULT_ENTITY_ID_VARS, INAPP_DATA_KEY, IN_APP_CHANNEL, InAppChannel, SseChannel, createCachedTriggerOverrideStore, createDbFirstResolver, createInAppBackfill, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, evaluateSourceChannel, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, resolveEffectiveSettings, templatesResource, toNotifyTrigger, toNotifyTriggers };
|
|
1941
|
+
export { DEFAULT_ENTITY_ID_VARS, FEED_ACTION_VERBS, INAPP_DATA_KEY, IN_APP_CHANNEL, InAppChannel, SseChannel, addCivilDays, civilDaysBetween, createCachedTriggerOverrideStore, createDateRuleEngine, createDbFirstResolver, createInAppBackfill, createMemoryDateRuleLedger, createNotificationChannelCatalog, createRepositoryDeliveryLog, createUnreadCountCache, dateRuleOccurrenceKey, dateRuleWindow, dispatchTrigger, dueOccurrence, evaluateSourceChannel, feedListResult, getRecipientMatrix, getRegisteredEvents, getTriggerByEvent, getTriggersByEvent, inAppFeedResource, isCivilDateString, notificationsJob, notificationsModule, notifyTriggerHandlers, readInAppContent, renderInApp, resolveEffectiveSettings, systemNotificationClock, templatesResource, toNotifyTrigger, toNotifyTriggers, validateDateRuleSchedule };
|