@classytic/arc-notifications 0.1.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 ADDED
@@ -0,0 +1,152 @@
1
+ # @classytic/arc-notifications
2
+
3
+ The blessed seam from `@classytic/arc` domain events to `@classytic/notifications`.
4
+ Every arc app used to re-derive the same 40 lines — build a `NotificationService`,
5
+ subscribe `app.events`, map event payloads to recipients, thread idempotency for
6
+ at-least-once transports, expose a template-drafting admin surface. This package
7
+ is that wiring, once.
8
+
9
+ ```bash
10
+ npm i @classytic/arc-notifications @classytic/notifications
11
+ # peers: @classytic/arc >=2.20, @classytic/primitives >=0.9, @classytic/repo-core >=0.7
12
+ ```
13
+
14
+ ## Three pieces
15
+
16
+ ### 1. `notificationsModule` — events → notifications
17
+
18
+ Composed into `createApp({ modules })`. Declarative `notifyOn` triggers subscribe
19
+ arc's event bus through `subscribeWithBoundary` (fire-and-forget; handler failures
20
+ logged, not thrown), with idempotency keys defaulted from event identity so
21
+ at-least-once redelivery never double-sends.
22
+
23
+ ```ts
24
+ import { createApp } from "@classytic/arc/factory";
25
+ import { getModuleExports } from "@classytic/arc/factory";
26
+ import { notificationsModule, createDbFirstResolver } from "@classytic/arc-notifications";
27
+ import { EmailChannel } from "@classytic/notifications";
28
+
29
+ const app = await createApp({
30
+ modules: [
31
+ notificationsModule({
32
+ channels: [new EmailChannel({ from: "App <no-reply@app.com>", transport: smtp })],
33
+ templates: createDbFirstResolver({ store: templateStore, fallback: codeTemplates }),
34
+ notifyOn: [
35
+ {
36
+ event: "approval.requested", // arc event pattern (wildcards ok)
37
+ template: "approval-requested",
38
+ recipient: (e) => directory.contact(e.payload.approverId),
39
+ data: (e) => ({ title: e.payload.title, link: `/approvals/${e.payload.id}` }),
40
+ channels: ["email"],
41
+ },
42
+ ],
43
+ }),
44
+ ],
45
+ });
46
+
47
+ // service is decorated + exported
48
+ app.notifications?.send({ event: "manual", template: "welcome", recipient: { email }, data: {} });
49
+ const svc = getModuleExports<NotificationService>(app, "notifications");
50
+ ```
51
+
52
+ A `notifyOn` trigger declared without arc's `eventPlugin` registered **fails at
53
+ boot** (not silently at delivery time).
54
+
55
+ ### 2. `createDbFirstResolver` — runtime-draftable templates
56
+
57
+ Admins draft templates at runtime; an **active** DB row wins over the code-defined
58
+ fallback, and a store outage falls back to code (transactional email never stops).
59
+ 60s read-through cache, invalidated by the templates resource on save.
60
+
61
+ ```ts
62
+ const resolver = createDbFirstResolver({
63
+ store: { getByKey: (key) => templateRepo.getByKey(key) }, // any by-key lookup
64
+ fallback: createSimpleResolver(codeTemplates), // from @classytic/notifications
65
+ cacheTtlMs: 60_000,
66
+ });
67
+ ```
68
+
69
+ ### 3. `templatesResource` — the drafting surface
70
+
71
+ A normal arc resource over any `RepositoryLike` (mongokit / sqlitekit / prismakit /
72
+ custom) — no kit adapter needed. Fail-closed: every op + route takes one `permissions`
73
+ gate. Mounts admin CRUD plus `GET /defaults` (code templates to customize from) and
74
+ `POST /preview` (stateless draft render). Write hooks invalidate the resolver cache so
75
+ a saved draft applies within one request.
76
+
77
+ ```ts
78
+ templatesResource({
79
+ repository: templateRepo,
80
+ permissions: platformAdminOnly(),
81
+ defaults: codeTemplates,
82
+ resolver, // the createDbFirstResolver instance — its cache is invalidated on save
83
+ });
84
+ ```
85
+
86
+ ### 4. Durable dispatch — crash-safe bulk sends (10k+)
87
+
88
+ The service's default queue is in-memory: a 10k birthday/newsletter send that
89
+ crashes at #4000 loses the rest. **There is one durable path — arc's jobs
90
+ plugin.** arc-notifications does not own a queue; background work in arc goes
91
+ through `@classytic/arc/integrations/jobs` (an `fp()` plugin that decorates
92
+ `fastify.jobs`, owns the BullMQ queues/workers, and adds an arc-level semaphore,
93
+ cron reconciliation, event bridging, dead-letter queues, `GET /jobs/:id/status`,
94
+ and `onClose` teardown). The bridge routes sends through it — so a durable
95
+ notification send reuses the *same* job subsystem the rest of your app already
96
+ runs. No second BullMQ, no second connection pool.
97
+
98
+ ```ts
99
+ import { jobsPlugin } from "@classytic/arc/integrations/jobs";
100
+ import { notificationsJob, notificationsModule, createRepositoryDeliveryLog } from "@classytic/arc-notifications";
101
+
102
+ const notify = notificationsJob({ concurrency: 5, rateLimit: { max: 10, duration: 1000 } });
103
+
104
+ await createApp({
105
+ plugins: async (f) => f.register(jobsPlugin, { connection: redis, jobs: [notify.job] }),
106
+ modules: [
107
+ notificationsModule({
108
+ channels: [new EmailChannel({ from, transport })],
109
+ queue: (f) => notify.toQueueAdapter(f.jobs), // resolved at bootstrap — fastify.jobs exists by then
110
+ deliveryLog: createRepositoryDeliveryLog(deliveryRepo),
111
+ notifyOn: [ /* ... */ ],
112
+ }),
113
+ ],
114
+ });
115
+ ```
116
+
117
+ **Works without Redis** — just don't wire the queue: omit `queue` (and don't
118
+ register `jobsPlugin`) and sends run on the service's in-memory queue. The module
119
+ warns loudly at boot if you declare `notifyOn` triggers without a durable queue, so
120
+ "in-memory in production" is never silent.
121
+
122
+ **Why a queue, not a workflow engine:** a bulk send is a fan-out, not a multi-step
123
+ workflow. One durable job **per recipient** (never one job for 10k) keyed by
124
+ `idempotencyKey` (→ BullMQ `jobId` via `fastify.jobs`, so crash-recovery redelivery
125
+ is a no-op, not a duplicate). Streamline (a workflow engine) is the right tool only
126
+ when the campaign itself has steps/waits/approval gates — it would then orchestrate
127
+ and delegate the sending to the jobs plugin exactly like this.
128
+
129
+ **Durability levels:** in-memory → lost on crash (dev only). `fastify.jobs` (BullMQ)
130
+ → survives app crashes (add Redis AOF for Redis-crash safety). Plus a persistent
131
+ `DeliveryLog` → observable + resumable (query the failed set, re-drive only those).
132
+
133
+ **Composing with arc's outbox (strongest).** The outbox and the queue are
134
+ *different* layers and compose — they are not merged. Arc's `EventOutbox` guarantees
135
+ the domain *event* survives (transactional same-DB-txn write + relay); the jobs
136
+ queue guarantees the *send* survives. Wire the trigger to an outbox-relayed event and
137
+ the whole chain is at-least-once end to end: `business write + outbox event (one txn)`
138
+ → `relay → event bus` → `notifyOn fires` → `durable send`. `idempotencyKey` (defaulted
139
+ from event identity) dedupes the at-least-once overlap at both hops.
140
+
141
+ BullMQ is a peer of arc's jobs plugin, not of arc-notifications — this package has no
142
+ direct queue dependency.
143
+
144
+ ## Design rules
145
+
146
+ - Channels and stores come from `@classytic/notifications` (peer) — this package
147
+ adds the arc-native durable `QueueAdapter` and repo-backed `DeliveryLog`, plus the
148
+ event seam and templates; it never re-exports notifications' channels.
149
+ - One-way dependency: `@classytic/arc` never depends on this package.
150
+
151
+ Generalized from be-prod's production `shared/notifications` (db-first resolver) +
152
+ `resources/notifications` (email-template model, admin resource, event-trigger registry).
@@ -0,0 +1,187 @@
1
+ import { DeliveryLog, DeliveryLogEntry, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
2
+ import { JobDefinition, JobDispatcher } from "@classytic/arc/integrations/jobs";
3
+ import { ArcModule } from "@classytic/arc/factory";
4
+ import { DomainEvent } from "@classytic/primitives/events";
5
+ import { FastifyInstance } from "fastify";
6
+ import { RepositoryLike } from "@classytic/repo-core/adapter";
7
+ import { PermissionCheck } from "@classytic/arc/permissions";
8
+
9
+ //#region src/dbFirstResolver.d.ts
10
+ /** One draftable template row — the shape be-prod's EmailTemplate model proved. */
11
+ interface StoredTemplate {
12
+ readonly key: string;
13
+ readonly subject?: string;
14
+ readonly html?: string;
15
+ readonly text?: string;
16
+ /** Inactive rows are ignored (fall back to code templates). Default: active. */
17
+ readonly isActive?: boolean;
18
+ readonly [extra: string]: unknown;
19
+ }
20
+ /** Structural store port — any repository with a by-key lookup satisfies it. */
21
+ interface TemplateStore {
22
+ getByKey(key: string): Promise<StoredTemplate | null>;
23
+ }
24
+ interface DbFirstResolverOptions {
25
+ readonly store: TemplateStore;
26
+ /**
27
+ * Code-defined resolver consulted when the store has no ACTIVE row for the
28
+ * key, or when the store errors (fail-open — a template-DB outage must not
29
+ * stop transactional email). Without a fallback, a miss throws.
30
+ */
31
+ readonly fallback?: TemplateResolver;
32
+ /** Read-through cache TTL. Default 60s (be-prod's window). `0` disables. */
33
+ readonly cacheTtlMs?: number;
34
+ /** Forwarded to the renderer: HTML-escape interpolated values in `html`. Default true. */
35
+ readonly escape?: boolean;
36
+ /** Observe store failures (they otherwise silently fall back). */
37
+ readonly onError?: (err: unknown, templateId: string) => void;
38
+ }
39
+ /** The resolver, plus cache invalidation for template-resource write hooks. */
40
+ interface DbFirstTemplateResolver {
41
+ (templateId: string, data: Record<string, unknown>): ReturnType<TemplateResolver> | Promise<Awaited<ReturnType<TemplateResolver>>>;
42
+ /** Drop one key (after a draft is saved) or the whole cache. */
43
+ invalidate(key?: string): void;
44
+ }
45
+ declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
46
+ //#endregion
47
+ //#region src/jobsQueue.d.ts
48
+ interface NotificationsJobOptions {
49
+ /** Job/queue name registered with `jobsPlugin`. Default "notifications". */
50
+ readonly name?: string;
51
+ /** Retries per send. Default 3 (arc's job default). */
52
+ readonly retries?: number;
53
+ /** Worker concurrency (parallel sends). */
54
+ readonly concurrency?: number;
55
+ /** Per-worker rate limit — match your ESP tier. */
56
+ readonly rateLimit?: {
57
+ max: number;
58
+ duration: number;
59
+ };
60
+ /** Backoff strategy. Default exponential 1000ms. */
61
+ readonly backoff?: {
62
+ type: "exponential" | "fixed";
63
+ delay: number;
64
+ };
65
+ /**
66
+ * arc-level semaphore — max simultaneous handler runs when the constraint is
67
+ * a downstream resource (ESP connection cap), distinct from BullMQ concurrency.
68
+ */
69
+ readonly maxConcurrent?: number;
70
+ }
71
+ interface NotificationsJob {
72
+ /** Register this with `jobsPlugin({ jobs: [...] })` in the plugins phase. */
73
+ readonly job: JobDefinition<NotificationPayload>;
74
+ /** Build the `QueueAdapter` bound to the host's `fastify.jobs`. */
75
+ toQueueAdapter(dispatcher: JobDispatcher): QueueAdapter;
76
+ }
77
+ /**
78
+ * Create the notifications job + its `QueueAdapter` factory. The job delegates
79
+ * to the notification service's processor (attached later); the adapter routes
80
+ * `enqueue`/`getJob`/`size` through `fastify.jobs`, keying `enqueue` on
81
+ * `idempotencyKey` so crash-recovery / at-least-once redelivery dedupes.
82
+ */
83
+ declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
84
+ //#endregion
85
+ //#region src/module.d.ts
86
+ interface NotifyTrigger<TPayload = unknown> {
87
+ /** Event pattern to subscribe (wildcards per arc's `matchEventPattern`). */
88
+ readonly event: string;
89
+ /** Template id. Default: the concrete event type. */
90
+ readonly template?: string;
91
+ /** Restrict to specific channel names. Omit = all matching channels. */
92
+ readonly channels?: readonly string[];
93
+ /**
94
+ * Map the event to recipient(s). Return `null`/`undefined`/`[]` to skip
95
+ * (the filter seam — "payment failed only", "opted-in users only", ...).
96
+ */
97
+ readonly recipient: (event: DomainEvent<TPayload>) => Recipient | readonly Recipient[] | null | undefined | Promise<Recipient | readonly Recipient[] | null | undefined>;
98
+ /** Template variables. Default: the event payload (when object-shaped). */
99
+ readonly data?: (event: DomainEvent<TPayload>) => Record<string, unknown> | Promise<Record<string, unknown>>;
100
+ /**
101
+ * Dedup key for at-least-once transports. Default:
102
+ * `meta.idempotencyKey ?? "<type>:<meta.id>"`, suffixed per recipient on
103
+ * fan-out. Return `undefined` to disable dedup for this trigger.
104
+ */
105
+ readonly idempotencyKey?: (event: DomainEvent<TPayload>) => string | undefined;
106
+ /** Compose-time kill switch (env-gated triggers). Default true. */
107
+ readonly enabled?: boolean;
108
+ }
109
+ interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "queue"> {
110
+ /** Module name (registry + export-map key). Default "notifications". */
111
+ readonly name?: string;
112
+ /** Bring-your-own service — skips construction; service config fields ignored. */
113
+ readonly service?: NotificationService;
114
+ /** Declarative event → notification triggers, wired in `afterResources`. */
115
+ readonly notifyOn?: readonly NotifyTrigger[];
116
+ /** Decorate `fastify.<name>` with the service. Default "notifications"; `false` to skip. */
117
+ readonly decorate?: string | false;
118
+ /**
119
+ * Queue backing. Either a ready `QueueAdapter`, or a resolver that receives
120
+ * the booted Fastify instance — use the function form for the `fastify.jobs`
121
+ * bridge (`(f) => notificationsJob(...).toQueueAdapter(f.jobs)`), since
122
+ * `fastify.jobs` only exists after the plugins phase. Omit for the service's
123
+ * in-memory default.
124
+ */
125
+ readonly queue?: QueueAdapter | ((fastify: FastifyInstance) => QueueAdapter);
126
+ }
127
+ declare module "fastify" {
128
+ interface FastifyInstance {
129
+ /** Populated by `notificationsModule` (default `decorate` name). */
130
+ notifications?: NotificationService;
131
+ }
132
+ }
133
+ declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
134
+ /** One event → N sends. Exported for direct unit testing. */
135
+ declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent): Promise<void>;
136
+ //#endregion
137
+ //#region src/repositoryDeliveryLog.d.ts
138
+ type EntryStatus = DeliveryLogEntry["status"];
139
+ /** The persisted document shape (also what the resource/UI reads). */
140
+ interface DeliveryLogDoc {
141
+ readonly id?: string;
142
+ readonly timestamp: Date;
143
+ readonly event: string;
144
+ readonly recipientId?: string;
145
+ readonly recipientEmail?: string;
146
+ readonly channels: string[];
147
+ readonly results: SendResult[];
148
+ readonly status: EntryStatus;
149
+ readonly duration: number;
150
+ readonly metadata?: Record<string, unknown>;
151
+ }
152
+ interface RepositoryDeliveryLogOptions {
153
+ /** Cap for `query()` when the caller gives no limit. Default 100. */
154
+ readonly defaultLimit?: number;
155
+ }
156
+ declare function createRepositoryDeliveryLog(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions): DeliveryLog;
157
+ //#endregion
158
+ //#region src/templatesResource.d.ts
159
+ /** Code-defined template map (same shape `createSimpleResolver` accepts). */
160
+ type CodeTemplates = Record<string, {
161
+ readonly subject?: string;
162
+ readonly html?: string;
163
+ readonly text?: string;
164
+ }>;
165
+ interface TemplatesResourceOptions {
166
+ /** Repository over the templates collection/table. */
167
+ readonly repository: RepositoryLike;
168
+ /**
169
+ * One gate for every operation (CRUD + routes) — template drafting is an
170
+ * admin surface; be-prod runs it platform-admin-only. REQUIRED: an omitted
171
+ * permission would mount public template editing.
172
+ */
173
+ readonly permissions: PermissionCheck;
174
+ /** Resource name. Default "notification-template". */
175
+ readonly name?: string;
176
+ /** Route prefix. Default "/notifications/templates". */
177
+ readonly prefix?: string;
178
+ /** Code-defined templates surfaced by `GET /defaults` (the customize-from list). */
179
+ readonly defaults?: CodeTemplates;
180
+ /** Live resolver — write hooks invalidate its cache so drafts apply within one save. */
181
+ readonly resolver?: Pick<DbFirstTemplateResolver, "invalidate">;
182
+ /** Tenant field. Default `false` — templates are company-wide (be-prod convention). */
183
+ readonly tenantField?: string | false;
184
+ }
185
+ declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
186
+ //#endregion
187
+ export { type CodeTemplates, type DbFirstResolverOptions, type DbFirstTemplateResolver, type DeliveryLogDoc, type NotificationsJob, type NotificationsJobOptions, type NotificationsModuleConfig, type NotifyTrigger, type RepositoryDeliveryLogOptions, type StoredTemplate, type TemplateStore, type TemplatesResourceOptions, createDbFirstResolver, createRepositoryDeliveryLog, dispatchTrigger, notificationsJob, notificationsModule, templatesResource };
package/dist/index.mjs ADDED
@@ -0,0 +1,429 @@
1
+ import { NotificationService, createSimpleResolver } from "@classytic/notifications";
2
+ import { defineJob } from "@classytic/arc/integrations/jobs";
3
+ import { subscribeWithBoundary } from "@classytic/arc/events";
4
+ import { defineModule } from "@classytic/arc/factory";
5
+ import { defineResource } from "@classytic/arc";
6
+ //#region src/dbFirstResolver.ts
7
+ /**
8
+ * DB-first template resolver — generalized from be-prod's proven
9
+ * `createDbFirstResolver` (shared/notifications). Templates are DRAFTABLE at
10
+ * runtime: an active DB row for a template key wins; anything else falls back
11
+ * to the code-defined resolver, so a bad draft can never take sends down.
12
+ *
13
+ * Storage-agnostic: `TemplateStore` is a one-method structural port
14
+ * (`getByKey`) satisfied by any kit repository or hand-rolled lookup.
15
+ */
16
+ function createDbFirstResolver(options) {
17
+ const { store, fallback, cacheTtlMs = 6e4, escape: escapeHtml = true, onError } = options;
18
+ const cache = /* @__PURE__ */ new Map();
19
+ const lookup = async (key) => {
20
+ if (cacheTtlMs > 0) {
21
+ const hit = cache.get(key);
22
+ if (hit && Date.now() - hit.at < cacheTtlMs) return hit.doc;
23
+ }
24
+ const doc = await store.getByKey(key);
25
+ if (cacheTtlMs > 0) cache.set(key, {
26
+ doc,
27
+ at: Date.now()
28
+ });
29
+ return doc;
30
+ };
31
+ const resolver = (async (templateId, data) => {
32
+ let doc = null;
33
+ try {
34
+ doc = await lookup(templateId);
35
+ } catch (err) {
36
+ onError?.(err, templateId);
37
+ if (!fallback) throw err;
38
+ doc = null;
39
+ }
40
+ if (doc && doc.isActive !== false) return createSimpleResolver({ [templateId]: {
41
+ ...doc.subject !== void 0 ? { subject: doc.subject } : {},
42
+ ...doc.html !== void 0 ? { html: doc.html } : {},
43
+ ...doc.text !== void 0 ? { text: doc.text } : {}
44
+ } }, { escape: escapeHtml })(templateId, data);
45
+ if (!fallback) throw new Error(`[arc-notifications] template "${templateId}" has no active store row and no fallback resolver is configured.`);
46
+ return fallback(templateId, data);
47
+ });
48
+ resolver.invalidate = (key) => {
49
+ if (key === void 0) cache.clear();
50
+ else cache.delete(key);
51
+ };
52
+ return resolver;
53
+ }
54
+ //#endregion
55
+ //#region src/jobsQueue.ts
56
+ /**
57
+ * `fastify.jobs` bridge — reuse arc's job subsystem instead of standing up a
58
+ * second one.
59
+ *
60
+ * arc already ships a proper Fastify jobs plugin (`@classytic/arc/integrations/jobs`):
61
+ * an `fp()`-wrapped plugin that decorates `fastify.jobs`, owns the BullMQ
62
+ * queues/workers, and adds an arc-level semaphore, cron reconciliation, event
63
+ * bridging, dead-letter queues, a `GET /jobs/:id/status` endpoint, and
64
+ * `onClose` teardown. A host using that plugin should NOT also run
65
+ * `createDurableQueue` (a parallel BullMQ setup with its own connection pool
66
+ * and workers). This bridge maps the notifications `QueueAdapter` onto the
67
+ * host's existing `fastify.jobs`, so sends flow through the one job subsystem.
68
+ *
69
+ * ```ts
70
+ * import { jobsPlugin } from "@classytic/arc/integrations/jobs";
71
+ * import { notificationsJob, notificationsModule } from "@classytic/arc-notifications";
72
+ *
73
+ * const notify = notificationsJob({ concurrency: 5, rateLimit: { max: 10, duration: 1000 } });
74
+ *
75
+ * await createApp({
76
+ * plugins: async (f) => f.register(jobsPlugin, { connection: redis, jobs: [notify.job] }),
77
+ * modules: [
78
+ * notificationsModule({
79
+ * channels: [...],
80
+ * queue: (f) => notify.toQueueAdapter(f.jobs), // resolved at bootstrap
81
+ * notifyOn: [...],
82
+ * }),
83
+ * ],
84
+ * });
85
+ * ```
86
+ *
87
+ * The job's handler and the notification processor are wired through a shared
88
+ * closure ref: `notificationsJob()` registers the job with `jobsPlugin` in the
89
+ * plugins phase (handler known up front), and the service attaches its
90
+ * processor later via `queue.process()`. By the time a job actually runs the
91
+ * ref is set — a boot-phase enqueue before attach is the only error case.
92
+ */
93
+ function mapState(state) {
94
+ switch (state) {
95
+ case "active": return "processing";
96
+ case "completed": return "completed";
97
+ case "failed": return "failed";
98
+ default: return "pending";
99
+ }
100
+ }
101
+ /**
102
+ * Create the notifications job + its `QueueAdapter` factory. The job delegates
103
+ * to the notification service's processor (attached later); the adapter routes
104
+ * `enqueue`/`getJob`/`size` through `fastify.jobs`, keying `enqueue` on
105
+ * `idempotencyKey` so crash-recovery / at-least-once redelivery dedupes.
106
+ */
107
+ function notificationsJob(options = {}) {
108
+ const { name = "notifications", retries = 3, concurrency, rateLimit, backoff, maxConcurrent } = options;
109
+ const ref = {};
110
+ const job = defineJob({
111
+ name,
112
+ retries,
113
+ ...concurrency !== void 0 ? { concurrency } : {},
114
+ ...rateLimit ? { rateLimit } : {},
115
+ ...backoff ? { backoff } : {},
116
+ ...maxConcurrent !== void 0 ? { maxConcurrent } : {},
117
+ handler: async (data) => {
118
+ if (!ref.processor) throw new Error("[arc-notifications] notifications job fired before the service attached its processor — ensure the notificationsModule using this job is composed in the same app.");
119
+ await ref.processor(data);
120
+ }
121
+ });
122
+ const toQueueAdapter = (dispatcher) => ({
123
+ async enqueue(payload, opts) {
124
+ const { jobId } = await dispatcher.dispatch(name, payload, {
125
+ ...payload.idempotencyKey ? { jobId: payload.idempotencyKey } : {},
126
+ ...opts?.delay ? { delay: opts.delay } : {}
127
+ });
128
+ return jobId;
129
+ },
130
+ process(processor) {
131
+ ref.processor = processor;
132
+ },
133
+ async getJob(id) {
134
+ const status = await dispatcher.getStatus(id);
135
+ if (!status) return null;
136
+ const created = new Date(status.timestamp ?? Date.now());
137
+ return {
138
+ id: status.id,
139
+ payload: {
140
+ event: status.name,
141
+ recipient: {},
142
+ data: {}
143
+ },
144
+ status: mapState(status.state),
145
+ attempts: 0,
146
+ maxAttempts: retries,
147
+ retryDelay: backoff?.delay ?? 1e3,
148
+ maxRetryDelay: 3e4,
149
+ createdAt: created,
150
+ updatedAt: new Date(status.finishedOn ?? status.processedOn ?? created.getTime()),
151
+ ...status.failedReason ? { error: status.failedReason } : {}
152
+ };
153
+ },
154
+ async size() {
155
+ const q = (await dispatcher.getStats())[name];
156
+ return q ? q.waiting + q.active + q.delayed : 0;
157
+ },
158
+ pause() {},
159
+ resume() {},
160
+ drain() {}
161
+ });
162
+ return {
163
+ job,
164
+ toQueueAdapter
165
+ };
166
+ }
167
+ //#endregion
168
+ //#region src/module.ts
169
+ /**
170
+ * `notificationsModule` — the blessed seam from arc domain events to
171
+ * user-facing notifications.
172
+ *
173
+ * Every arc app used to re-derive the same wiring: build a
174
+ * NotificationService, subscribe `app.events`, map event payloads to
175
+ * recipients, thread idempotency for at-least-once transports. This module is
176
+ * that wiring, once:
177
+ *
178
+ * ```ts
179
+ * const app = await createApp({
180
+ * modules: [
181
+ * notificationsModule({
182
+ * channels: [new EmailChannel({ from, transport })],
183
+ * templates: createDbFirstResolver({ store, fallback }),
184
+ * notifyOn: [{
185
+ * event: "approval.requested",
186
+ * template: "approval-requested",
187
+ * recipient: (e) => directory.contact(e.payload.approverId),
188
+ * data: (e) => ({ title: e.payload.title }),
189
+ * }],
190
+ * }),
191
+ * ],
192
+ * });
193
+ * app.notifications?.send({ ... }); // decorated service
194
+ * const svc = getModuleExports<NotificationService>(app, "notifications");
195
+ * ```
196
+ *
197
+ * Triggers subscribe through arc's `subscribeWithBoundary` — handler failures
198
+ * are logged and swallowed (fire-and-forget, matching arc's event gotcha), and
199
+ * `idempotencyKey` defaults from event identity because Redis Streams are
200
+ * at-least-once: redelivery must not double-send.
201
+ */
202
+ function notificationsModule(config) {
203
+ const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, ...serviceConfig } = config;
204
+ let service;
205
+ const unsubscribes = [];
206
+ return defineModule({
207
+ name,
208
+ bootstrap(fastify) {
209
+ const resolvedQueue = typeof queue === "function" ? queue(fastify) : queue;
210
+ service = provided ?? new NotificationService({
211
+ ...serviceConfig,
212
+ ...resolvedQueue ? { queue: resolvedQueue } : {}
213
+ });
214
+ 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.`);
215
+ if (decorate !== false && !(decorate in fastify)) fastify.decorate(decorate, service);
216
+ return service;
217
+ },
218
+ async afterResources(fastify) {
219
+ const triggers = notifyOn.filter((t) => t.enabled !== false);
220
+ if (triggers.length === 0) return;
221
+ const bus = fastify;
222
+ if (!bus.events) throw new Error(`[arc-notifications] module "${name}" declares ${triggers.length} notifyOn trigger(s) but \`fastify.events\` is not decorated — register arc's eventPlugin (or drop notifyOn).`);
223
+ for (const trigger of triggers) {
224
+ const unsubscribe = await subscribeWithBoundary(bus, trigger.event, async (event) => dispatchTrigger(service, trigger, event), { name: `arc-notifications:${trigger.event}` });
225
+ unsubscribes.push(unsubscribe);
226
+ }
227
+ fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
228
+ },
229
+ async onClose() {
230
+ for (const unsubscribe of unsubscribes) unsubscribe();
231
+ unsubscribes.length = 0;
232
+ }
233
+ });
234
+ }
235
+ /** One event → N sends. Exported for direct unit testing. */
236
+ async function dispatchTrigger(service, trigger, event) {
237
+ const resolved = await trigger.recipient(event);
238
+ if (!resolved) return;
239
+ const recipients = Array.isArray(resolved) ? resolved : [resolved];
240
+ if (recipients.length === 0) return;
241
+ const payload = event.payload;
242
+ const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
243
+ const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
244
+ for (const [index, recipient] of recipients.entries()) {
245
+ const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
246
+ await service.send({
247
+ event: event.type,
248
+ template: trigger.template ?? event.type,
249
+ recipient,
250
+ data,
251
+ ...trigger.channels ? { channels: [...trigger.channels] } : {},
252
+ ...idempotencyKey !== void 0 ? { idempotencyKey } : {},
253
+ metadata: {
254
+ source: "arc-notifications",
255
+ eventId: event.meta.id
256
+ }
257
+ });
258
+ }
259
+ }
260
+ //#endregion
261
+ //#region src/repositoryDeliveryLog.ts
262
+ function deriveStatus(dispatch) {
263
+ if (dispatch.sent > 0 && dispatch.failed === 0) return "delivered";
264
+ if (dispatch.sent > 0 && dispatch.failed > 0) return "partial";
265
+ if (dispatch.failed > 0) return "failed";
266
+ return "skipped";
267
+ }
268
+ function toEntry(doc) {
269
+ return {
270
+ id: String(doc.id ?? doc._id ?? ""),
271
+ timestamp: doc.timestamp instanceof Date ? doc.timestamp : new Date(doc.timestamp),
272
+ event: doc.event,
273
+ ...doc.recipientId ? { recipientId: doc.recipientId } : {},
274
+ ...doc.recipientEmail ? { recipientEmail: doc.recipientEmail } : {},
275
+ channels: doc.channels ?? [],
276
+ results: doc.results ?? [],
277
+ status: doc.status,
278
+ duration: doc.duration ?? 0,
279
+ ...doc.metadata ? { metadata: doc.metadata } : {}
280
+ };
281
+ }
282
+ function createRepositoryDeliveryLog(repository, options = {}) {
283
+ const { defaultLimit = 100 } = options;
284
+ return {
285
+ async record(payload, dispatch) {
286
+ const doc = {
287
+ timestamp: /* @__PURE__ */ new Date(),
288
+ event: payload.event,
289
+ ...payload.recipient.id ? { recipientId: payload.recipient.id } : {},
290
+ ...payload.recipient.email ? { recipientEmail: payload.recipient.email } : {},
291
+ channels: dispatch.results.map((r) => r.channel),
292
+ results: dispatch.results,
293
+ status: deriveStatus(dispatch),
294
+ duration: dispatch.duration,
295
+ ...payload.metadata ? { metadata: payload.metadata } : {}
296
+ };
297
+ await repository.create(doc);
298
+ },
299
+ async query(filter) {
300
+ const equality = {};
301
+ if (filter.recipientId) equality.recipientId = filter.recipientId;
302
+ if (filter.recipientEmail) equality.recipientEmail = filter.recipientEmail;
303
+ if (filter.event) equality.event = filter.event;
304
+ if (filter.status) equality.status = filter.status;
305
+ const limit = filter.limit ?? defaultLimit;
306
+ const result = await repository.getAll({
307
+ filter: equality,
308
+ limit
309
+ }, void 0);
310
+ let entries = (Array.isArray(result) ? result : result.data ?? []).map((r) => toEntry(r));
311
+ const { after, before, channel } = filter;
312
+ if (after) entries = entries.filter((e) => e.timestamp >= after);
313
+ if (before) entries = entries.filter((e) => e.timestamp <= before);
314
+ if (channel) entries = entries.filter((e) => e.channels.includes(channel));
315
+ return entries.slice(0, limit);
316
+ },
317
+ async get(id) {
318
+ const doc = await repository.getById(id);
319
+ return doc ? toEntry(doc) : null;
320
+ }
321
+ };
322
+ }
323
+ //#endregion
324
+ //#region src/templatesResource.ts
325
+ /**
326
+ * `templatesResource` — DB-draftable notification templates as a normal arc
327
+ * resource. Generalized from be-prod's email-template resource: admins draft
328
+ * subject/html/text rows keyed by template id; the DB-first resolver picks up
329
+ * ACTIVE rows over code defaults; `/defaults` lists the code templates a UI
330
+ * can "customize into" a row; `/preview` renders a draft statelessly.
331
+ *
332
+ * Kit-less: pass any `RepositoryLike` (mongokit/sqlitekit/prismakit/custom) —
333
+ * the resource is built on a plain `{ type: 'custom' }` adapter.
334
+ */
335
+ function templatesResource(options) {
336
+ const { repository, permissions, name = "notification-template", prefix = "/notifications/templates", defaults = {}, resolver, tenantField = false } = options;
337
+ const invalidateFrom = (ctx) => {
338
+ const key = ctx.data?.key;
339
+ if (typeof key === "string") resolver?.invalidate(key);
340
+ else resolver?.invalidate();
341
+ };
342
+ return defineResource({
343
+ name,
344
+ prefix,
345
+ tenantField,
346
+ adapter: {
347
+ type: "custom",
348
+ name: `${name}-store`,
349
+ repository
350
+ },
351
+ permissions: {
352
+ list: permissions,
353
+ get: permissions,
354
+ create: permissions,
355
+ update: permissions,
356
+ delete: permissions
357
+ },
358
+ schemaOptions: { fieldRules: {
359
+ key: {
360
+ type: "string",
361
+ required: true,
362
+ immutable: true,
363
+ pattern: "^[a-z0-9_-]+$",
364
+ maxLength: 100,
365
+ description: "Template id this row overrides (matches notify()/notifyOn template)."
366
+ },
367
+ subject: {
368
+ type: "string",
369
+ required: true,
370
+ maxLength: 300
371
+ },
372
+ html: {
373
+ type: "string",
374
+ required: true,
375
+ maxLength: 1e5
376
+ },
377
+ text: {
378
+ type: "string",
379
+ maxLength: 2e4
380
+ },
381
+ description: {
382
+ type: "string",
383
+ maxLength: 500
384
+ },
385
+ variables: {
386
+ type: "array",
387
+ description: "UI hint — variable names the template interpolates. Not enforced."
388
+ },
389
+ isActive: {
390
+ type: "boolean",
391
+ default: true,
392
+ description: "Inactive rows fall back to the code-defined template."
393
+ }
394
+ } },
395
+ hooks: {
396
+ afterCreate: invalidateFrom,
397
+ afterUpdate: invalidateFrom,
398
+ afterDelete: () => resolver?.invalidate()
399
+ },
400
+ routes: [{
401
+ method: "GET",
402
+ path: "/defaults",
403
+ permissions,
404
+ summary: "Code-defined templates available to customize into DB rows",
405
+ handler: async () => ({ data: Object.entries(defaults).map(([key, t]) => ({
406
+ key,
407
+ subject: t.subject ?? "",
408
+ hasHtml: t.html !== void 0,
409
+ hasText: t.text !== void 0,
410
+ source: "code"
411
+ })) })
412
+ }, {
413
+ method: "POST",
414
+ path: "/preview",
415
+ permissions,
416
+ summary: "Stateless render of a draft (subject/html/text + sample data)",
417
+ handler: async (ctx) => {
418
+ const body = ctx.body ?? {};
419
+ return { data: await createSimpleResolver({ preview: {
420
+ ...body.subject !== void 0 ? { subject: body.subject } : {},
421
+ ...body.html !== void 0 ? { html: body.html } : {},
422
+ ...body.text !== void 0 ? { text: body.text } : {}
423
+ } })("preview", body.data ?? {}) };
424
+ }
425
+ }]
426
+ });
427
+ }
428
+ //#endregion
429
+ export { createDbFirstResolver, createRepositoryDeliveryLog, dispatchTrigger, notificationsJob, notificationsModule, templatesResource };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@classytic/arc-notifications",
3
+ "version": "0.1.0",
4
+ "description": "Notifications module for @classytic/arc — domain events → multi-channel notifications via @classytic/notifications, with DB-draftable templates, repo-backed delivery log, and a templates resource",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "default": "./dist/index.mjs"
10
+ }
11
+ },
12
+ "main": "./dist/index.mjs",
13
+ "types": "./dist/index.d.mts",
14
+ "sideEffects": false,
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "scripts": {
25
+ "build": "tsdown",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "biome check src/",
28
+ "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
29
+ },
30
+ "peerDependencies": {
31
+ "@classytic/arc": ">=2.20.0",
32
+ "@classytic/notifications": ">=2.2.0",
33
+ "@classytic/primitives": ">=0.9.0",
34
+ "@classytic/repo-core": ">=0.7.0"
35
+ },
36
+ "keywords": [
37
+ "arc",
38
+ "fastify",
39
+ "notifications",
40
+ "email",
41
+ "templates",
42
+ "classytic"
43
+ ],
44
+ "license": "MIT",
45
+ "author": "Classytic",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/classytic/arc-ecosystem.git",
49
+ "directory": "packages/arc-notifications"
50
+ }
51
+ }