@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 +152 -0
- package/dist/index.d.mts +187 -0
- package/dist/index.mjs +429 -0
- package/package.json +51 -0
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).
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|