@classytic/arc-notifications 0.9.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 +321 -62
- package/dist/index.mjs +594 -247
- 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
|
*
|
|
@@ -234,8 +251,7 @@ function evaluateSourceChannel(sourceChannels, sourceChannel) {
|
|
|
234
251
|
reason: "source_channel_mismatch"
|
|
235
252
|
};
|
|
236
253
|
}
|
|
237
|
-
|
|
238
|
-
//#region src/channelCatalog.ts
|
|
254
|
+
// src/channelCatalog.ts
|
|
239
255
|
/**
|
|
240
256
|
* The NOTIFICATION CHANNEL CATALOG — which channels this deployment can deliver on.
|
|
241
257
|
*
|
|
@@ -294,49 +310,9 @@ function createNotificationChannelCatalog(connectorChannels = []) {
|
|
|
294
310
|
has: (channel) => lookup.has(channel)
|
|
295
311
|
};
|
|
296
312
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
* DB-first template resolver — generalized from be-prod's proven
|
|
301
|
-
* `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
|
|
302
|
-
* runtime: an active DB row for a template key wins; anything else falls back
|
|
303
|
-
* to the code-defined resolver, so a bad draft can never take sends down.
|
|
304
|
-
*
|
|
305
|
-
* Storage-agnostic: `TemplateStore` is a one-method structural port
|
|
306
|
-
* (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
|
|
307
|
-
*/
|
|
308
|
-
function createDbFirstResolver(options) {
|
|
309
|
-
const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
|
|
310
|
-
/**
|
|
311
|
-
* Single-flight memo: one event's channel expansions resolve the same key in
|
|
312
|
-
* the same tick, and a `null` row (no draft) is an answer worth keeping. A
|
|
313
|
-
* store failure rejects every waiter and caches nothing.
|
|
314
|
-
*/
|
|
315
|
-
const lookup = createTtlMemo((key) => store.getByKey(key), { ttlMs: cacheTtlMs });
|
|
316
|
-
const resolver = (async (templateId, data) => {
|
|
317
|
-
let doc = null;
|
|
318
|
-
try {
|
|
319
|
-
doc = await lookup.get(templateId);
|
|
320
|
-
} catch (err) {
|
|
321
|
-
onError?.(err, templateId);
|
|
322
|
-
if (!fallback) throw err;
|
|
323
|
-
doc = null;
|
|
324
|
-
}
|
|
325
|
-
if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
|
|
326
|
-
...doc.subject !== void 0 ? { subject: doc.subject } : {},
|
|
327
|
-
...doc.html !== void 0 ? { html: doc.html } : {},
|
|
328
|
-
...doc.text !== void 0 ? { text: doc.text } : {}
|
|
329
|
-
} }, { escape: escapeHtml })(templateId, data);
|
|
330
|
-
if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
|
|
331
|
-
return fallback(templateId, data);
|
|
332
|
-
});
|
|
333
|
-
resolver.invalidate = (key) => {
|
|
334
|
-
lookup.invalidate(key);
|
|
335
|
-
};
|
|
336
|
-
return resolver;
|
|
337
|
-
}
|
|
338
|
-
//#endregion
|
|
339
|
-
//#region src/inApp.ts
|
|
313
|
+
// src/clock.ts
|
|
314
|
+
const systemNotificationClock = { now: () => /* @__PURE__ */ new Date() };
|
|
315
|
+
// src/inApp.ts
|
|
340
316
|
/**
|
|
341
317
|
* In-app notification content — the shared seam between a `notifyOn` trigger's
|
|
342
318
|
* declarative `inApp` template and the channels that deliver it (the DB feed
|
|
@@ -406,8 +382,468 @@ function readInAppContent(data) {
|
|
|
406
382
|
if (typeof c.title !== "string" || typeof c.message !== "string") return null;
|
|
407
383
|
return c;
|
|
408
384
|
}
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
411
847
|
var InAppChannel = class extends BaseChannel {
|
|
412
848
|
repository;
|
|
413
849
|
port;
|
|
@@ -501,8 +937,7 @@ var InAppChannel = class extends BaseChannel {
|
|
|
501
937
|
return typeof org === "string" && org ? org : void 0;
|
|
502
938
|
}
|
|
503
939
|
};
|
|
504
|
-
|
|
505
|
-
//#region src/inAppFeed.ts
|
|
940
|
+
// src/inAppFeed.ts
|
|
506
941
|
/**
|
|
507
942
|
* `inAppFeedResource` — the user-facing notification inbox as a normal arc
|
|
508
943
|
* resource. Generalized from be-prod's `notification.resource`: a per-user,
|
|
@@ -523,6 +958,7 @@ var InAppChannel = class extends BaseChannel {
|
|
|
523
958
|
*/
|
|
524
959
|
function createUnreadCountCache(options = {}) {
|
|
525
960
|
const { ttlMs = 3e4, maxEntries = 1e4 } = options;
|
|
961
|
+
const clock = options.clock ?? systemNotificationClock;
|
|
526
962
|
const entries = /* @__PURE__ */ new Map();
|
|
527
963
|
const keyOf = (organizationId, userId) => `${organizationId}\u0000${userId}`;
|
|
528
964
|
return {
|
|
@@ -530,7 +966,7 @@ function createUnreadCountCache(options = {}) {
|
|
|
530
966
|
const key = keyOf(organizationId, userId);
|
|
531
967
|
const entry = entries.get(key);
|
|
532
968
|
if (!entry) return void 0;
|
|
533
|
-
if (entry.expiresAt <=
|
|
969
|
+
if (entry.expiresAt <= clock.now().getTime()) {
|
|
534
970
|
entries.delete(key);
|
|
535
971
|
return;
|
|
536
972
|
}
|
|
@@ -543,7 +979,7 @@ function createUnreadCountCache(options = {}) {
|
|
|
543
979
|
}
|
|
544
980
|
entries.set(keyOf(organizationId, userId), {
|
|
545
981
|
count,
|
|
546
|
-
expiresAt:
|
|
982
|
+
expiresAt: clock.now().getTime() + ttlMs
|
|
547
983
|
});
|
|
548
984
|
},
|
|
549
985
|
invalidate(organizationId, userId) {
|
|
@@ -551,12 +987,36 @@ function createUnreadCountCache(options = {}) {
|
|
|
551
987
|
}
|
|
552
988
|
};
|
|
553
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
|
+
}
|
|
554
1013
|
function toInt(value, fallback) {
|
|
555
1014
|
const n = Number(value);
|
|
556
1015
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
557
1016
|
}
|
|
558
1017
|
function inAppFeedResource(options) {
|
|
559
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";
|
|
560
1020
|
/** Fire-and-forget: read-sync + cache invalidation must never fail a request. */
|
|
561
1021
|
const afterReadStateChange = async (scope, data) => {
|
|
562
1022
|
try {
|
|
@@ -606,7 +1066,7 @@ function inAppFeedResource(options) {
|
|
|
606
1066
|
unreadOnly: q.unreadOnly === "true",
|
|
607
1067
|
...typeof q.type === "string" && q.type ? { type: q.type } : {}
|
|
608
1068
|
});
|
|
609
|
-
return reply.send(result);
|
|
1069
|
+
return reply.send(envelope === "offset" ? feedListResult(result) : result);
|
|
610
1070
|
}
|
|
611
1071
|
},
|
|
612
1072
|
{
|
|
@@ -687,11 +1147,41 @@ function inAppFeedResource(options) {
|
|
|
687
1147
|
return reply.send({ deleted: true });
|
|
688
1148
|
}
|
|
689
1149
|
}] : []
|
|
690
|
-
]
|
|
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
|
+
} } : {}
|
|
691
1182
|
});
|
|
692
1183
|
}
|
|
693
|
-
|
|
694
|
-
//#region src/jobsQueue.ts
|
|
1184
|
+
// src/jobsQueue.ts
|
|
695
1185
|
/**
|
|
696
1186
|
* `fastify.jobs` bridge — reuse arc's job subsystem instead of standing up a
|
|
697
1187
|
* second one.
|
|
@@ -803,179 +1293,7 @@ function notificationsJob(options = {}) {
|
|
|
803
1293
|
toQueueAdapter
|
|
804
1294
|
};
|
|
805
1295
|
}
|
|
806
|
-
|
|
807
|
-
//#region src/module.ts
|
|
808
|
-
/**
|
|
809
|
-
* `notificationsModule` — the blessed seam from arc domain events to
|
|
810
|
-
* user-facing notifications.
|
|
811
|
-
*
|
|
812
|
-
* Every arc app used to re-derive the same wiring: build a
|
|
813
|
-
* NotificationService, subscribe `app.events`, map event payloads to
|
|
814
|
-
* recipients, thread idempotency for at-least-once transports. This module is
|
|
815
|
-
* that wiring, once:
|
|
816
|
-
*
|
|
817
|
-
* ```ts
|
|
818
|
-
* const app = await createApp({
|
|
819
|
-
* modules: [
|
|
820
|
-
* notificationsModule({
|
|
821
|
-
* channels: [new EmailChannel({ from, transport })],
|
|
822
|
-
* templates: createDbFirstResolver({ store, fallback }),
|
|
823
|
-
* notifyOn: [{
|
|
824
|
-
* event: "approval.requested",
|
|
825
|
-
* template: "approval-requested",
|
|
826
|
-
* recipient: (e) => directory.contact(e.payload.approverId),
|
|
827
|
-
* data: (e) => ({ title: e.payload.title }),
|
|
828
|
-
* }],
|
|
829
|
-
* }),
|
|
830
|
-
* ],
|
|
831
|
-
* });
|
|
832
|
-
* app.notifications?.send({ ... }); // decorated service
|
|
833
|
-
* const svc = getModuleExports<NotificationService>(app, "notifications");
|
|
834
|
-
* ```
|
|
835
|
-
*
|
|
836
|
-
* Triggers are contributed through arc's `eventHandlers` module arm (2.24), so
|
|
837
|
-
* arc owns subscription ordering AND teardown: it unsubscribes every handler at
|
|
838
|
-
* shutdown BEFORE any module `onClose` runs, while the service and its queue
|
|
839
|
-
* are still alive. A hand-rolled `afterResources` subscribe cannot promise that
|
|
840
|
-
* — its own `onClose` races the modules it depends on.
|
|
841
|
-
*
|
|
842
|
-
* Each handler declares `boundary: true`, so arc contains failures — logged and
|
|
843
|
-
* swallowed (fire-and-forget, matching arc's event gotcha), because a template
|
|
844
|
-
* or channel failure must not block the transport ack and DLQ the event. And
|
|
845
|
-
* `idempotencyKey` defaults from event identity because Redis Streams are
|
|
846
|
-
* at-least-once: redelivery must not double-send.
|
|
847
|
-
*/
|
|
848
|
-
function notificationsModule(config) {
|
|
849
|
-
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, channels, ...serviceConfig } = config;
|
|
850
|
-
let service;
|
|
851
|
-
return defineModule({
|
|
852
|
-
name,
|
|
853
|
-
async bootstrap(fastify) {
|
|
854
|
-
const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
|
|
855
|
-
const resolvedChannels = typeof channels === "function" ? await channels(fastify) : channels;
|
|
856
|
-
service = provided ?? new NotificationService({
|
|
857
|
-
...serviceConfig,
|
|
858
|
-
...resolvedChannels ? { channels: resolvedChannels } : {},
|
|
859
|
-
...resolvedQueue ? { queue: resolvedQueue } : {}
|
|
860
|
-
});
|
|
861
|
-
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.`);
|
|
862
|
-
if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
|
|
863
|
-
return service;
|
|
864
|
-
},
|
|
865
|
-
eventHandlers: (fastify) => {
|
|
866
|
-
const handlers = notifyTriggerHandlers({
|
|
867
|
-
name,
|
|
868
|
-
service: () => service,
|
|
869
|
-
triggers: notifyOn,
|
|
870
|
-
...recipientResolver ? { recipientResolver } : {}
|
|
871
|
-
});
|
|
872
|
-
if (handlers.length > 0) fastify.log.debug(`[arc-notifications] ${handlers.length} trigger(s) subscribed`);
|
|
873
|
-
return handlers;
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Triggers as arc `eventHandlers` — what `notifyOn` subscribes, exported so a BRIDGE module can own
|
|
879
|
-
* the triggers for one producer (`dependsOn: [producer, "notifications"]`) and vanish with it.
|
|
880
|
-
* `service` is read per dispatch, so a bridge can resolve it from the notifications module's export.
|
|
881
|
-
*/
|
|
882
|
-
function notifyTriggerHandlers(options) {
|
|
883
|
-
const { name, service, recipientResolver } = options;
|
|
884
|
-
return options.triggers.filter((t) => t.enabled !== false).map((trigger, index) => ({
|
|
885
|
-
name: `${name}:${trigger.event}#${index}`,
|
|
886
|
-
event: trigger.event,
|
|
887
|
-
boundary: true,
|
|
888
|
-
handler: async (event) => dispatchTrigger(service(), trigger, event, recipientResolver ? { recipientResolver } : {})
|
|
889
|
-
}));
|
|
890
|
-
}
|
|
891
|
-
const audienceSlot = Symbol("arc-notifications.resolvedAudience");
|
|
892
|
-
/** Content key for an audience — key order must not fragment the memo. */
|
|
893
|
-
function audienceKey(audience) {
|
|
894
|
-
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);
|
|
895
|
-
}
|
|
896
|
-
/**
|
|
897
|
-
* Resolve an audience ONCE per dispatch, shared by every expansion that asks.
|
|
898
|
-
*
|
|
899
|
-
* The resolver is a database read in every real host (members by role, then
|
|
900
|
-
* users for contact); N channel expansions of one trigger hand it the same
|
|
901
|
-
* audience. Keyed on CONTENT, so two triggers asking the same question share
|
|
902
|
-
* too. A rejected resolution is dropped so the next expansion retries.
|
|
903
|
-
* Outside a work scope nothing is shared.
|
|
904
|
-
*/
|
|
905
|
-
function resolveAudience(resolver, audience, event) {
|
|
906
|
-
const memo = scopedValue(audienceSlot, () => /* @__PURE__ */ new Map());
|
|
907
|
-
if (!memo) return Promise.resolve(resolver(audience, event));
|
|
908
|
-
const key = audienceKey(audience);
|
|
909
|
-
const pending = memo.get(key);
|
|
910
|
-
if (pending) return pending;
|
|
911
|
-
const created = Promise.resolve(resolver(audience, event));
|
|
912
|
-
memo.set(key, created);
|
|
913
|
-
created.catch(() => memo.delete(key));
|
|
914
|
-
return created;
|
|
915
|
-
}
|
|
916
|
-
/** The `organizationId` an audience names (`{ roles, organizationId, … }`), when it names one. */
|
|
917
|
-
function audienceOrganization(audience) {
|
|
918
|
-
if (audience === null || typeof audience !== "object") return void 0;
|
|
919
|
-
const org = audience.organizationId;
|
|
920
|
-
return typeof org === "string" && org !== "" ? org : void 0;
|
|
921
|
-
}
|
|
922
|
-
/** One event → N sends. Exported for direct unit testing. */
|
|
923
|
-
async function dispatchTrigger(service, trigger, event, opts) {
|
|
924
|
-
let resolved;
|
|
925
|
-
let organizationId;
|
|
926
|
-
if (trigger.recipient) resolved = await trigger.recipient(event);
|
|
927
|
-
else if (trigger.audience) {
|
|
928
|
-
const audience = await trigger.audience(event);
|
|
929
|
-
if (audience === null || audience === void 0) return;
|
|
930
|
-
organizationId = audienceOrganization(audience);
|
|
931
|
-
resolved = opts?.recipientResolver ? await resolveAudience(opts.recipientResolver, audience, event) : void 0;
|
|
932
|
-
}
|
|
933
|
-
if (!resolved) return;
|
|
934
|
-
const recipients = Array.isArray(resolved) ? resolved : [resolved];
|
|
935
|
-
if (recipients.length === 0) return;
|
|
936
|
-
const payload = event.payload;
|
|
937
|
-
const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
|
|
938
|
-
const sendData = trigger.inApp ? {
|
|
939
|
-
...data,
|
|
940
|
-
[INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
|
|
941
|
-
} : data;
|
|
942
|
-
const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
|
|
943
|
-
for (const [index, recipient] of recipients.entries()) {
|
|
944
|
-
const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
|
|
945
|
-
const subject = trigger.subject?.(event);
|
|
946
|
-
await service.send({
|
|
947
|
-
event: event.type,
|
|
948
|
-
template: trigger.template ?? event.type,
|
|
949
|
-
recipient,
|
|
950
|
-
data: sendData,
|
|
951
|
-
...trigger.channels ? { channels: [...trigger.channels] } : {},
|
|
952
|
-
...idempotencyKey !== void 0 ? { idempotencyKey } : {},
|
|
953
|
-
...subject ? { subject } : {},
|
|
954
|
-
metadata: {
|
|
955
|
-
source: "arc-notifications",
|
|
956
|
-
eventId: event.meta.id,
|
|
957
|
-
...organizationId !== void 0 ? { organizationId } : {},
|
|
958
|
-
/**
|
|
959
|
-
* The NOTIFICATION OCCURRENCE — `baseKey` BEFORE the per-recipient suffix.
|
|
960
|
-
*
|
|
961
|
-
* Stated here because only the dispatcher knows it. A channel that has to
|
|
962
|
-
* infer "is this the same notification?" from the payload gets it wrong in
|
|
963
|
-
* both directions, and both were shipped: keying on the event TYPE
|
|
964
|
-
* suppressed every order after the first, and keying on `meta.id`
|
|
965
|
-
* (absent when a host re-publishes an enriched event) deduped nothing and
|
|
966
|
-
* sent the ops group two copies of every alert.
|
|
967
|
-
*
|
|
968
|
-
* Unique per occurrence, IDENTICAL across that occurrence's recipient
|
|
969
|
-
* fan-out — which is exactly the distinction a destination-collapsing
|
|
970
|
-
* channel needs and cannot derive for itself.
|
|
971
|
-
*/
|
|
972
|
-
...baseKey !== void 0 ? { notificationId: baseKey } : {}
|
|
973
|
-
}
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
//#endregion
|
|
978
|
-
//#region src/repositoryDeliveryLog.ts
|
|
1296
|
+
// src/repositoryDeliveryLog.ts
|
|
979
1297
|
function deriveStatus(dispatch) {
|
|
980
1298
|
if (dispatch.sent > 0 && dispatch.failed === 0) return "delivered";
|
|
981
1299
|
if (dispatch.sent > 0 && dispatch.failed > 0) return "partial";
|
|
@@ -1007,10 +1325,17 @@ function organizationOf(payload) {
|
|
|
1007
1325
|
}
|
|
1008
1326
|
function createRepositoryDeliveryLog(repository, options = {}) {
|
|
1009
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
|
+
});
|
|
1010
1333
|
return {
|
|
1011
1334
|
async record(payload, dispatch) {
|
|
1335
|
+
const ruleId = resolveRuleId(payload);
|
|
1336
|
+
const extraFields = options.extraFields?.(payload, dispatch);
|
|
1012
1337
|
const doc = {
|
|
1013
|
-
timestamp:
|
|
1338
|
+
timestamp: clock.now(),
|
|
1014
1339
|
event: payload.event,
|
|
1015
1340
|
...payload.recipient.id ? { recipientId: payload.recipient.id } : {},
|
|
1016
1341
|
...payload.recipient.email ? { recipientEmail: payload.recipient.email } : {},
|
|
@@ -1020,7 +1345,9 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
1020
1345
|
duration: dispatch.duration,
|
|
1021
1346
|
...payload.metadata ? { metadata: payload.metadata } : {},
|
|
1022
1347
|
...payload.subject ? { subject: payload.subject } : {},
|
|
1023
|
-
organizationId: organizationOf(payload)
|
|
1348
|
+
organizationId: organizationOf(payload),
|
|
1349
|
+
...ruleId ? { ruleId } : {},
|
|
1350
|
+
...extraFields ? { extraFields } : {}
|
|
1024
1351
|
};
|
|
1025
1352
|
await repository.create(doc);
|
|
1026
1353
|
},
|
|
@@ -1048,6 +1375,7 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
1048
1375
|
const limit = filter.limit ?? defaultLimit;
|
|
1049
1376
|
const result = await repository.getAll({
|
|
1050
1377
|
filter: equality,
|
|
1378
|
+
filters: equality,
|
|
1051
1379
|
limit
|
|
1052
1380
|
}, void 0);
|
|
1053
1381
|
let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => toEntry(r));
|
|
@@ -1057,14 +1385,36 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
1057
1385
|
if (channel) entries = entries.filter((e) => e.channels.includes(channel));
|
|
1058
1386
|
return entries.slice(0, limit);
|
|
1059
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
|
+
},
|
|
1060
1411
|
async get(id) {
|
|
1061
1412
|
const doc = await repository.getById(id);
|
|
1062
1413
|
return doc ? toEntry(doc) : null;
|
|
1063
1414
|
}
|
|
1064
1415
|
};
|
|
1065
1416
|
}
|
|
1066
|
-
|
|
1067
|
-
//#region src/sseChannel.ts
|
|
1417
|
+
// src/sseChannel.ts
|
|
1068
1418
|
var SseChannel = class extends BaseChannel {
|
|
1069
1419
|
port;
|
|
1070
1420
|
eventName;
|
|
@@ -1124,8 +1474,7 @@ var SseChannel = class extends BaseChannel {
|
|
|
1124
1474
|
return typeof org === "string" && org ? org : void 0;
|
|
1125
1475
|
}
|
|
1126
1476
|
};
|
|
1127
|
-
|
|
1128
|
-
//#region src/templatesResource.ts
|
|
1477
|
+
// src/templatesResource.ts
|
|
1129
1478
|
/**
|
|
1130
1479
|
* `templatesResource` — DB-draftable notification templates as a normal arc
|
|
1131
1480
|
* resource. Generalized from be-prod's email-template resource: admins draft
|
|
@@ -1229,8 +1578,7 @@ function templatesResource(options) {
|
|
|
1229
1578
|
}]
|
|
1230
1579
|
});
|
|
1231
1580
|
}
|
|
1232
|
-
|
|
1233
|
-
//#region src/triggerAdapter.ts
|
|
1581
|
+
// src/triggerAdapter.ts
|
|
1234
1582
|
/**
|
|
1235
1583
|
* Trigger registry → `notifyOn` config.
|
|
1236
1584
|
*
|
|
@@ -1590,5 +1938,4 @@ function getRecipientMatrix(triggers) {
|
|
|
1590
1938
|
for (const t of triggers) matrix[t.type ?? t.event] = t.recipients;
|
|
1591
1939
|
return matrix;
|
|
1592
1940
|
}
|
|
1593
|
-
|
|
1594
|
-
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, notifyTriggerHandlers, 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 };
|