@classytic/arc-notifications 0.1.0 → 0.3.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 +49 -2
- package/dist/index.d.mts +299 -9
- package/dist/index.mjs +435 -8
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ npm i @classytic/arc-notifications @classytic/notifications
|
|
|
11
11
|
# peers: @classytic/arc >=2.20, @classytic/primitives >=0.9, @classytic/repo-core >=0.7
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## The pieces
|
|
15
15
|
|
|
16
16
|
### 1. `notificationsModule` — events → notifications
|
|
17
17
|
|
|
@@ -83,7 +83,54 @@ templatesResource({
|
|
|
83
83
|
});
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
### 4.
|
|
86
|
+
### 4. In-app feed + realtime — the full inbox lifecycle
|
|
87
|
+
|
|
88
|
+
`InAppChannel` persists notifications into a host-owned feed repository;
|
|
89
|
+
`inAppFeedResource` is the inbox REST surface (list / unread-count / mark-read /
|
|
90
|
+
mark-all-read, plus `DELETE /:id` when the repository implements `delete`).
|
|
91
|
+
Grounded against Puter's NotificationService — the lifecycle pieces a bare
|
|
92
|
+
"push to a socket" setup lacks:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import {
|
|
96
|
+
InAppChannel, inAppFeedResource,
|
|
97
|
+
createUnreadCountCache, createInAppBackfill,
|
|
98
|
+
} from "@classytic/arc-notifications";
|
|
99
|
+
|
|
100
|
+
const unreadCache = createUnreadCountCache(); // badge count — hottest query, now cached
|
|
101
|
+
const port = mySseHub; // host-owned RealtimePushPort
|
|
102
|
+
|
|
103
|
+
const channel = new InAppChannel({
|
|
104
|
+
repository: feedRepo,
|
|
105
|
+
port, // combined mode: push AFTER persist, WITH the row id on the wire
|
|
106
|
+
unreadCache, // new rows move the badge immediately
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const feed = inAppFeedResource({
|
|
110
|
+
repository: feedRepo,
|
|
111
|
+
resolveScope: (req) => ({ userId: ..., organizationId: ... }),
|
|
112
|
+
permissions: requireAuth(),
|
|
113
|
+
port, // mark-read/-all/delete push "notification:read" → other tabs sync instantly
|
|
114
|
+
unreadCache, // GET /unread-count reads through the shared cache
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Reconnect catch-up: push everything persisted while the user was offline.
|
|
118
|
+
// Requires feedRepo.claimUndelivered (ATOMIC fetch-and-mark-delivered).
|
|
119
|
+
const backfill = createInAppBackfill({ repository: feedRepo, port });
|
|
120
|
+
sseHub.onConnect((userId, orgId) => backfill.onConnect(userId, orgId)); // debounced 2s (multi-tab)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **Combined mode over a separate `SseChannel`** when you persist: the kernel
|
|
124
|
+
dispatches channels in parallel, so only persist-then-push can carry the feed-row
|
|
125
|
+
`id` — which is what lets a live toast be marked read without a refetch.
|
|
126
|
+
`SseChannel` remains for realtime-only setups.
|
|
127
|
+
- **Delivered ≠ read.** `claimUndelivered` tracks "has any client received this
|
|
128
|
+
over realtime" (backfill's axis); the read flag tracks the user's action (the
|
|
129
|
+
badge's axis). Both stay host-owned in the repository.
|
|
130
|
+
- All realtime is **best-effort on top of the durable feed** — a dead socket never
|
|
131
|
+
fails a request or a send; clients converge via `GET /notifications/`.
|
|
132
|
+
|
|
133
|
+
### 5. Durable dispatch — crash-safe bulk sends (10k+)
|
|
87
134
|
|
|
88
135
|
The service's default queue is in-memory: a 10k birthday/newsletter send that
|
|
89
136
|
crashes at #4000 loses the rest. **There is one durable path — arc's jobs
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,220 @@
|
|
|
1
|
-
import { DeliveryLog, DeliveryLogEntry, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
|
|
1
|
+
import { BaseChannel, ChannelConfig, DeliveryLog, DeliveryLogEntry, NotificationPayload, NotificationService, NotificationServiceConfig, QueueAdapter, Recipient, SendResult, TemplateResolver } from "@classytic/notifications";
|
|
2
2
|
import { JobDefinition, JobDispatcher } from "@classytic/arc/integrations/jobs";
|
|
3
3
|
import { ArcModule } from "@classytic/arc/factory";
|
|
4
|
+
import { PermissionCheck } from "@classytic/arc/permissions";
|
|
5
|
+
import { FastifyInstance, FastifyRequest } from "fastify";
|
|
4
6
|
import { DomainEvent } from "@classytic/primitives/events";
|
|
5
|
-
import { FastifyInstance } from "fastify";
|
|
6
7
|
import { RepositoryLike } from "@classytic/repo-core/adapter";
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
//#region src/inApp.d.ts
|
|
9
|
+
/**
|
|
10
|
+
* In-app notification content — the shared seam between a `notifyOn` trigger's
|
|
11
|
+
* declarative `inApp` template and the channels that deliver it (the DB feed
|
|
12
|
+
* channel + the realtime/SSE channel).
|
|
13
|
+
*
|
|
14
|
+
* A trigger declares an {@link InAppTemplate} (title/message/link/... with
|
|
15
|
+
* `{var}` placeholders). `dispatchTrigger` interpolates it ONCE per event
|
|
16
|
+
* against the trigger's `data`, then parks the rendered {@link InAppContent} on
|
|
17
|
+
* `payload.data[INAPP_DATA_KEY]`. Both in-app channels read it back via
|
|
18
|
+
* {@link readInAppContent} — so email/SMS channels ignore an extra data key
|
|
19
|
+
* while the feed + realtime channels get a fully-rendered payload with no
|
|
20
|
+
* per-channel template resolution.
|
|
21
|
+
*
|
|
22
|
+
* `{var}` (single-brace) interpolation is deliberate and matches the de-facto
|
|
23
|
+
* in-app convention; the email template layer keeps its own `${var}` engine.
|
|
24
|
+
*/
|
|
25
|
+
/** Reserved `payload.data` key carrying the rendered in-app content. */
|
|
26
|
+
declare const INAPP_DATA_KEY = "__inApp";
|
|
27
|
+
/**
|
|
28
|
+
* Host realtime transport — push an event to one user within a tenant.
|
|
29
|
+
* The transport itself (connection registry, heartbeats, the `/stream`
|
|
30
|
+
* endpoint, multi-tab fan-out) stays host-owned; everything in this package
|
|
31
|
+
* that touches realtime ({@link SseChannel}, {@link InAppChannel}'s combined
|
|
32
|
+
* mode, {@link createInAppBackfill}, the feed resource's read-sync) goes
|
|
33
|
+
* through this one seam.
|
|
34
|
+
*/
|
|
35
|
+
interface RealtimePushPort {
|
|
36
|
+
push(userId: string, organizationId: string, event: string, data: unknown): void | Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
type InAppPriority = "low" | "normal" | "high";
|
|
39
|
+
/** Declarative in-app template on a trigger — `{var}` placeholders. */
|
|
40
|
+
interface InAppTemplate {
|
|
41
|
+
/** Notification title. */
|
|
42
|
+
readonly title: string;
|
|
43
|
+
/** Notification body. */
|
|
44
|
+
readonly message: string;
|
|
45
|
+
/** Frontend route (`/dashboard/orders/{orderId}`). */
|
|
46
|
+
readonly link?: string;
|
|
47
|
+
/** Entity kind for UI grouping/icons ('order' | 'transfer' | ...). */
|
|
48
|
+
readonly entityType?: string;
|
|
49
|
+
/** Entity id placeholder (`{orderId}`) for deep-linking / dedup. */
|
|
50
|
+
readonly entityId?: string;
|
|
51
|
+
}
|
|
52
|
+
/** Fully-rendered in-app content the channels deliver. */
|
|
53
|
+
interface InAppContent {
|
|
54
|
+
readonly title: string;
|
|
55
|
+
readonly message: string;
|
|
56
|
+
readonly link?: string;
|
|
57
|
+
readonly entityType?: string;
|
|
58
|
+
readonly entityId?: string;
|
|
59
|
+
readonly priority: InAppPriority;
|
|
60
|
+
}
|
|
61
|
+
/** Render a trigger's in-app template against event data + priority. */
|
|
62
|
+
declare function renderInApp(template: InAppTemplate, vars: Record<string, unknown>, priority?: InAppPriority): InAppContent;
|
|
63
|
+
/** Read the rendered in-app content a trigger parked on the send payload's data. */
|
|
64
|
+
declare function readInAppContent(data: Record<string, unknown> | undefined): InAppContent | null;
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/inAppFeed.d.ts
|
|
67
|
+
/** A feed row the {@link InAppChannel} writes and this resource reads. */
|
|
68
|
+
interface InAppFeedRow {
|
|
69
|
+
readonly organizationId: string;
|
|
70
|
+
readonly userId: string;
|
|
71
|
+
/** Notification type (the event type by default). */
|
|
72
|
+
readonly type: string;
|
|
73
|
+
readonly title: string;
|
|
74
|
+
readonly message: string;
|
|
75
|
+
readonly data?: {
|
|
76
|
+
readonly link?: string;
|
|
77
|
+
readonly entityId?: string;
|
|
78
|
+
readonly entityType?: string;
|
|
79
|
+
readonly meta?: Record<string, unknown>;
|
|
80
|
+
};
|
|
81
|
+
readonly priority: InAppPriority;
|
|
82
|
+
}
|
|
83
|
+
interface InAppFeedListOptions {
|
|
84
|
+
readonly page?: number;
|
|
85
|
+
readonly limit?: number;
|
|
86
|
+
readonly unreadOnly?: boolean;
|
|
87
|
+
readonly type?: string;
|
|
88
|
+
}
|
|
89
|
+
interface InAppFeedPage<TRow = unknown> {
|
|
90
|
+
readonly data: TRow[];
|
|
91
|
+
readonly pagination: {
|
|
92
|
+
readonly page: number;
|
|
93
|
+
readonly limit: number;
|
|
94
|
+
readonly total: number;
|
|
95
|
+
readonly totalPages: number;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Host repository over the feed collection. The host owns the persistence
|
|
100
|
+
* (schema, indexes, soft-TTL); this port is the minimal surface the channel
|
|
101
|
+
* (write) and resource (read + lifecycle) need.
|
|
102
|
+
*/
|
|
103
|
+
interface InAppFeedRepository<TRow = unknown> {
|
|
104
|
+
/** Insert a delivered notification (called by {@link InAppChannel}). */
|
|
105
|
+
create(row: InAppFeedRow): Promise<{
|
|
106
|
+
id: string;
|
|
107
|
+
}>;
|
|
108
|
+
/** Paginated, newest-first, user + tenant scoped. */
|
|
109
|
+
listForUser(organizationId: string, userId: string, options: InAppFeedListOptions): Promise<InAppFeedPage<TRow>>;
|
|
110
|
+
/** Unread badge count. */
|
|
111
|
+
countUnread(organizationId: string, userId: string): Promise<number>;
|
|
112
|
+
/** Mark one as read; `null` when not found / not owned. */
|
|
113
|
+
markRead(organizationId: string, userId: string, id: string): Promise<TRow | null>;
|
|
114
|
+
/** Mark all unread read; returns the modified count. */
|
|
115
|
+
markAllRead(organizationId: string, userId: string): Promise<number>;
|
|
116
|
+
/**
|
|
117
|
+
* OPTIONAL — atomically fetch up to `limit` rows never delivered to any live
|
|
118
|
+
* client AND mark them delivered, in one repo call (find-and-update).
|
|
119
|
+
* Powers `createInAppBackfill` (reconnect catch-up): "delivered" is a
|
|
120
|
+
* SEPARATE axis from read/unread — a row is *delivered* once any client has
|
|
121
|
+
* received it over realtime, *read* once the user acted on it. Track it
|
|
122
|
+
* however fits the schema (a `deliveredAt` timestamp is the convention).
|
|
123
|
+
* Atomicity is what keeps two near-simultaneous connects (multi-tab beyond
|
|
124
|
+
* the debounce window) from double-toasting the same rows.
|
|
125
|
+
*/
|
|
126
|
+
claimUndelivered?(organizationId: string, userId: string, limit: number): Promise<TRow[]>;
|
|
127
|
+
/**
|
|
128
|
+
* OPTIONAL — delete one row (user dismissed it for good); `false` when not
|
|
129
|
+
* found / not owned. When implemented, the feed resource registers
|
|
130
|
+
* `DELETE /:id`. Hard vs soft delete is the host's call.
|
|
131
|
+
*/
|
|
132
|
+
delete?(organizationId: string, userId: string, id: string): Promise<boolean>;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Cache seam for the unread badge count — the hottest query in the system
|
|
136
|
+
* (every open tab polls it). Structural + possibly-async so a host can back it
|
|
137
|
+
* with Redis; {@link createUnreadCountCache} is the in-process default.
|
|
138
|
+
*
|
|
139
|
+
* Share ONE instance between `inAppFeedResource({ unreadCache })` (reads
|
|
140
|
+
* through it, invalidates on read-state mutations) and
|
|
141
|
+
* `InAppChannel({ unreadCache })` (invalidates on new-row writes) — otherwise
|
|
142
|
+
* new notifications ride the TTL before the badge moves.
|
|
143
|
+
*/
|
|
144
|
+
interface UnreadCountCache {
|
|
145
|
+
get(organizationId: string, userId: string): number | undefined | Promise<number | undefined>;
|
|
146
|
+
set(organizationId: string, userId: string, count: number): void | Promise<void>;
|
|
147
|
+
invalidate(organizationId: string, userId: string): void | Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
interface CreateUnreadCountCacheOptions {
|
|
150
|
+
/** Entry freshness window. Default 30_000ms. */
|
|
151
|
+
readonly ttlMs?: number;
|
|
152
|
+
/** Entry cap — oldest entries evicted past it (bounds memory). Default 10_000. */
|
|
153
|
+
readonly maxEntries?: number;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* In-process TTL cache for unread counts. Per-process: invalidations don't
|
|
157
|
+
* cross workers, so a multi-process host either accepts up-to-TTL staleness on
|
|
158
|
+
* the other workers or brings a Redis-backed {@link UnreadCountCache}.
|
|
159
|
+
*/
|
|
160
|
+
declare function createUnreadCountCache(options?: CreateUnreadCountCacheOptions): UnreadCountCache;
|
|
161
|
+
/** The current user + tenant, resolved off a request by the host. */
|
|
162
|
+
interface InAppFeedScope {
|
|
163
|
+
readonly userId: string;
|
|
164
|
+
readonly organizationId: string;
|
|
165
|
+
}
|
|
166
|
+
interface InAppFeedResourceOptions {
|
|
167
|
+
readonly repository: InAppFeedRepository;
|
|
168
|
+
/** Read `{ userId, organizationId }` off the request (host auth/scoping). */
|
|
169
|
+
readonly resolveScope: (req: FastifyRequest) => InAppFeedScope;
|
|
170
|
+
/** Gate for every feed route (a signed-in user reading their own feed). */
|
|
171
|
+
readonly permissions: PermissionCheck;
|
|
172
|
+
/** Resource name. Default "in-app-notification". */
|
|
173
|
+
readonly name?: string;
|
|
174
|
+
/** Route prefix. Default "/notifications". */
|
|
175
|
+
readonly prefix?: string;
|
|
176
|
+
/** Max page size (list `limit` cap). Default 50. */
|
|
177
|
+
readonly maxLimit?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Realtime port for cross-tab read-sync: mark-read / mark-all-read / delete
|
|
180
|
+
* push a `readSyncEventName` event to the user's OTHER open tabs so badges
|
|
181
|
+
* and toasts drop instantly instead of on the next poll. Best-effort —
|
|
182
|
+
* a push failure never fails the request.
|
|
183
|
+
*/
|
|
184
|
+
readonly port?: RealtimePushPort;
|
|
185
|
+
/** Read-sync event name. Default "notification:read". */
|
|
186
|
+
readonly readSyncEventName?: string;
|
|
187
|
+
/**
|
|
188
|
+
* Unread badge-count cache — see {@link UnreadCountCache}. Share the same
|
|
189
|
+
* instance with `InAppChannel` so new-row writes invalidate too.
|
|
190
|
+
*/
|
|
191
|
+
readonly unreadCache?: UnreadCountCache;
|
|
192
|
+
}
|
|
193
|
+
declare function inAppFeedResource(options: InAppFeedResourceOptions): import("@classytic/arc").ResourceDefinition<import("@classytic/arc").AnyRecord>;
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/backfill.d.ts
|
|
196
|
+
interface InAppBackfillOptions {
|
|
197
|
+
/** Feed repository — MUST implement the optional `claimUndelivered`. */
|
|
198
|
+
readonly repository: InAppFeedRepository;
|
|
199
|
+
/** Host realtime transport the bulk event is pushed through. */
|
|
200
|
+
readonly port: RealtimePushPort;
|
|
201
|
+
/** Bulk event name. Default "notification:backfill". */
|
|
202
|
+
readonly eventName?: string;
|
|
203
|
+
/** Max rows claimed per connect. Default 100. */
|
|
204
|
+
readonly limit?: number;
|
|
205
|
+
/** Multi-tab coalescing window. Default 2000ms. */
|
|
206
|
+
readonly debounceMs?: number;
|
|
207
|
+
/** Claim/push failure sink (backfill is best-effort). Default: swallow. */
|
|
208
|
+
readonly onError?: (error: unknown) => void;
|
|
209
|
+
}
|
|
210
|
+
interface InAppBackfill {
|
|
211
|
+
/** Call from the host's SSE/WS connection handler. Sync, never throws. */
|
|
212
|
+
onConnect(userId: string, organizationId: string): void;
|
|
213
|
+
/** Cancel pending debounces (graceful shutdown / tests). */
|
|
214
|
+
close(): void;
|
|
215
|
+
}
|
|
216
|
+
declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
|
|
217
|
+
//#endregion
|
|
9
218
|
//#region src/dbFirstResolver.d.ts
|
|
10
219
|
/** One draftable template row — the shape be-prod's EmailTemplate model proved. */
|
|
11
220
|
interface StoredTemplate {
|
|
@@ -44,6 +253,40 @@ interface DbFirstTemplateResolver {
|
|
|
44
253
|
}
|
|
45
254
|
declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
|
|
46
255
|
//#endregion
|
|
256
|
+
//#region src/inAppChannel.d.ts
|
|
257
|
+
interface InAppChannelConfig extends ChannelConfig {
|
|
258
|
+
/** Feed store the channel writes rows to (host-owned persistence). */
|
|
259
|
+
readonly repository: InAppFeedRepository;
|
|
260
|
+
/**
|
|
261
|
+
* Combined mode: also push the notification to the user's live connection
|
|
262
|
+
* AFTER the feed write, with the persisted row `id` included — so a live
|
|
263
|
+
* toast can be marked read / deep-linked without a refetch. Prefer this over
|
|
264
|
+
* registering a separate {@link SseChannel} when you persist: the kernel
|
|
265
|
+
* dispatches channels in parallel, so a standalone realtime channel can
|
|
266
|
+
* never carry the row id. Best-effort — a push failure never fails the
|
|
267
|
+
* durable write (`metadata.realtimeDelivered` reports it).
|
|
268
|
+
*/
|
|
269
|
+
readonly port?: RealtimePushPort;
|
|
270
|
+
/** Realtime event name in combined mode. Default "notification". */
|
|
271
|
+
readonly eventName?: string;
|
|
272
|
+
/**
|
|
273
|
+
* Unread badge-count cache to invalidate on every feed write. Share the
|
|
274
|
+
* same instance with `inAppFeedResource({ unreadCache })`.
|
|
275
|
+
*/
|
|
276
|
+
readonly unreadCache?: UnreadCountCache;
|
|
277
|
+
}
|
|
278
|
+
declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
|
|
279
|
+
private readonly repository;
|
|
280
|
+
private readonly port;
|
|
281
|
+
private readonly eventName;
|
|
282
|
+
private readonly unreadCache;
|
|
283
|
+
constructor(config: InAppChannelConfig);
|
|
284
|
+
/** Skip (not fail) when this payload isn't an in-app notification. */
|
|
285
|
+
canSend(payload: NotificationPayload): SendResult | null;
|
|
286
|
+
send(payload: NotificationPayload): Promise<SendResult>;
|
|
287
|
+
private orgOf;
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
47
290
|
//#region src/jobsQueue.d.ts
|
|
48
291
|
interface NotificationsJobOptions {
|
|
49
292
|
/** Job/queue name registered with `jobsPlugin`. Default "notifications". */
|
|
@@ -83,6 +326,13 @@ interface NotificationsJob {
|
|
|
83
326
|
declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
|
|
84
327
|
//#endregion
|
|
85
328
|
//#region src/module.d.ts
|
|
329
|
+
/**
|
|
330
|
+
* Resolve a trigger's host-typed `audience` selector to concrete recipients —
|
|
331
|
+
* the shared seam so branch-role → members (or team → users, tenant → admins)
|
|
332
|
+
* logic lives in ONE injected resolver, not duplicated across every trigger's
|
|
333
|
+
* `recipient` function. `null`/`undefined`/`[]` skips the dispatch.
|
|
334
|
+
*/
|
|
335
|
+
type RecipientResolver = (audience: unknown, event: DomainEvent) => readonly Recipient[] | null | undefined | Promise<readonly Recipient[] | null | undefined>;
|
|
86
336
|
interface NotifyTrigger<TPayload = unknown> {
|
|
87
337
|
/** Event pattern to subscribe (wildcards per arc's `matchEventPattern`). */
|
|
88
338
|
readonly event: string;
|
|
@@ -91,10 +341,26 @@ interface NotifyTrigger<TPayload = unknown> {
|
|
|
91
341
|
/** Restrict to specific channel names. Omit = all matching channels. */
|
|
92
342
|
readonly channels?: readonly string[];
|
|
93
343
|
/**
|
|
94
|
-
* Map the event to recipient(s). Return `null`/`undefined`/`[]` to
|
|
95
|
-
*
|
|
344
|
+
* Map the event to recipient(s) directly. Return `null`/`undefined`/`[]` to
|
|
345
|
+
* skip. Mutually exclusive with {@link audience}: provide this OR an
|
|
346
|
+
* `audience` + a module-level {@link NotificationsModuleConfig.recipientResolver}.
|
|
347
|
+
*/
|
|
348
|
+
readonly recipient?: (event: DomainEvent<TPayload>) => Recipient | readonly Recipient[] | null | undefined | Promise<Recipient | readonly Recipient[] | null | undefined>;
|
|
349
|
+
/**
|
|
350
|
+
* Host-typed audience selector (e.g. `{ roles, organizationId, exclude }`)
|
|
351
|
+
* fed to the module's {@link NotificationsModuleConfig.recipientResolver}.
|
|
352
|
+
* Return `null`/`undefined` to skip. Use this to keep recipient resolution
|
|
353
|
+
* (role → members) in one injected place instead of per trigger.
|
|
96
354
|
*/
|
|
97
|
-
readonly
|
|
355
|
+
readonly audience?: (event: DomainEvent<TPayload>) => unknown | Promise<unknown>;
|
|
356
|
+
/**
|
|
357
|
+
* In-app notification template (`{var}` placeholders). When set, the feed +
|
|
358
|
+
* realtime channels render and deliver it — rendered ONCE per event and
|
|
359
|
+
* parked on the send payload so channels need no template lookup.
|
|
360
|
+
*/
|
|
361
|
+
readonly inApp?: InAppTemplate;
|
|
362
|
+
/** In-app priority (badge intensity / sort). Default "normal". */
|
|
363
|
+
readonly priority?: InAppPriority;
|
|
98
364
|
/** Template variables. Default: the event payload (when object-shaped). */
|
|
99
365
|
readonly data?: (event: DomainEvent<TPayload>) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
100
366
|
/**
|
|
@@ -113,6 +379,12 @@ interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "que
|
|
|
113
379
|
readonly service?: NotificationService;
|
|
114
380
|
/** Declarative event → notification triggers, wired in `afterResources`. */
|
|
115
381
|
readonly notifyOn?: readonly NotifyTrigger[];
|
|
382
|
+
/**
|
|
383
|
+
* Resolves a trigger's `audience` selector to recipients (the shared
|
|
384
|
+
* role → members seam). Required only if any trigger uses `audience` instead
|
|
385
|
+
* of an explicit `recipient` function.
|
|
386
|
+
*/
|
|
387
|
+
readonly recipientResolver?: RecipientResolver;
|
|
116
388
|
/** Decorate `fastify.<name>` with the service. Default "notifications"; `false` to skip. */
|
|
117
389
|
readonly decorate?: string | false;
|
|
118
390
|
/**
|
|
@@ -132,7 +404,9 @@ declare module "fastify" {
|
|
|
132
404
|
}
|
|
133
405
|
declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
|
|
134
406
|
/** One event → N sends. Exported for direct unit testing. */
|
|
135
|
-
declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent
|
|
407
|
+
declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
|
|
408
|
+
recipientResolver?: RecipientResolver;
|
|
409
|
+
}): Promise<void>;
|
|
136
410
|
//#endregion
|
|
137
411
|
//#region src/repositoryDeliveryLog.d.ts
|
|
138
412
|
type EntryStatus = DeliveryLogEntry["status"];
|
|
@@ -155,6 +429,22 @@ interface RepositoryDeliveryLogOptions {
|
|
|
155
429
|
}
|
|
156
430
|
declare function createRepositoryDeliveryLog(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions): DeliveryLog;
|
|
157
431
|
//#endregion
|
|
432
|
+
//#region src/sseChannel.d.ts
|
|
433
|
+
interface SseChannelConfig extends ChannelConfig {
|
|
434
|
+
/** Host realtime transport (be-prod's SSE manager, a WS hub, ...). */
|
|
435
|
+
readonly port: RealtimePushPort;
|
|
436
|
+
/** SSE event name pushed to the client. Default "notification". */
|
|
437
|
+
readonly eventName?: string;
|
|
438
|
+
}
|
|
439
|
+
declare class SseChannel extends BaseChannel<SseChannelConfig> {
|
|
440
|
+
private readonly port;
|
|
441
|
+
private readonly eventName;
|
|
442
|
+
constructor(config: SseChannelConfig);
|
|
443
|
+
canSend(payload: NotificationPayload): SendResult | null;
|
|
444
|
+
send(payload: NotificationPayload): Promise<SendResult>;
|
|
445
|
+
private orgOf;
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
158
448
|
//#region src/templatesResource.d.ts
|
|
159
449
|
/** Code-defined template map (same shape `createSimpleResolver` accepts). */
|
|
160
450
|
type CodeTemplates = Record<string, {
|
|
@@ -184,4 +474,4 @@ interface TemplatesResourceOptions {
|
|
|
184
474
|
}
|
|
185
475
|
declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
|
|
186
476
|
//#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 };
|
|
477
|
+
export { type CodeTemplates, type CreateUnreadCountCacheOptions, type DbFirstResolverOptions, type DbFirstTemplateResolver, type DeliveryLogDoc, INAPP_DATA_KEY, type InAppBackfill, type InAppBackfillOptions, InAppChannel, type InAppChannelConfig, type InAppContent, type InAppFeedListOptions, type InAppFeedPage, type InAppFeedRepository, type InAppFeedResourceOptions, type InAppFeedRow, type InAppFeedScope, type InAppPriority, type InAppTemplate, type NotificationsJob, type NotificationsJobOptions, type NotificationsModuleConfig, type NotifyTrigger, type RealtimePushPort, type RecipientResolver, type RepositoryDeliveryLogOptions, SseChannel, type SseChannelConfig, type StoredTemplate, type TemplateStore, type TemplatesResourceOptions, type UnreadCountCache, createDbFirstResolver, createInAppBackfill, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, templatesResource };
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
|
-
import { NotificationService, createSimpleResolver } from "@classytic/notifications";
|
|
1
|
+
import { BaseChannel, NotificationService, createSimpleResolver } from "@classytic/notifications";
|
|
2
|
+
import { defineResource } from "@classytic/arc";
|
|
3
|
+
import { NotFoundError } from "@classytic/arc/utils";
|
|
2
4
|
import { defineJob } from "@classytic/arc/integrations/jobs";
|
|
3
5
|
import { subscribeWithBoundary } from "@classytic/arc/events";
|
|
4
6
|
import { defineModule } from "@classytic/arc/factory";
|
|
5
|
-
|
|
7
|
+
//#region src/backfill.ts
|
|
8
|
+
function createInAppBackfill(options) {
|
|
9
|
+
const { repository, port, eventName = "notification:backfill", limit = 100, onError } = options;
|
|
10
|
+
const debounceMs = options.debounceMs ?? 2e3;
|
|
11
|
+
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.");
|
|
12
|
+
const claimUndelivered = repository.claimUndelivered.bind(repository);
|
|
13
|
+
const pending = /* @__PURE__ */ new Map();
|
|
14
|
+
let closed = false;
|
|
15
|
+
const run = async (userId, organizationId) => {
|
|
16
|
+
try {
|
|
17
|
+
const rows = await claimUndelivered(organizationId, userId, limit);
|
|
18
|
+
if (!rows.length || closed) return;
|
|
19
|
+
await port.push(userId, organizationId, eventName, { notifications: rows });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
onError?.(error);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
onConnect(userId, organizationId) {
|
|
26
|
+
if (closed) return;
|
|
27
|
+
const key = `${organizationId} ${userId}`;
|
|
28
|
+
if (pending.has(key)) return;
|
|
29
|
+
pending.set(key, setTimeout(() => {
|
|
30
|
+
pending.delete(key);
|
|
31
|
+
run(userId, organizationId);
|
|
32
|
+
}, debounceMs));
|
|
33
|
+
},
|
|
34
|
+
close() {
|
|
35
|
+
closed = true;
|
|
36
|
+
for (const timer of pending.values()) clearTimeout(timer);
|
|
37
|
+
pending.clear();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
6
42
|
//#region src/dbFirstResolver.ts
|
|
7
43
|
/**
|
|
8
44
|
* DB-first template resolver — generalized from be-prod's proven
|
|
@@ -52,6 +88,326 @@ function createDbFirstResolver(options) {
|
|
|
52
88
|
return resolver;
|
|
53
89
|
}
|
|
54
90
|
//#endregion
|
|
91
|
+
//#region src/inApp.ts
|
|
92
|
+
/**
|
|
93
|
+
* In-app notification content — the shared seam between a `notifyOn` trigger's
|
|
94
|
+
* declarative `inApp` template and the channels that deliver it (the DB feed
|
|
95
|
+
* channel + the realtime/SSE channel).
|
|
96
|
+
*
|
|
97
|
+
* A trigger declares an {@link InAppTemplate} (title/message/link/... with
|
|
98
|
+
* `{var}` placeholders). `dispatchTrigger` interpolates it ONCE per event
|
|
99
|
+
* against the trigger's `data`, then parks the rendered {@link InAppContent} on
|
|
100
|
+
* `payload.data[INAPP_DATA_KEY]`. Both in-app channels read it back via
|
|
101
|
+
* {@link readInAppContent} — so email/SMS channels ignore an extra data key
|
|
102
|
+
* while the feed + realtime channels get a fully-rendered payload with no
|
|
103
|
+
* per-channel template resolution.
|
|
104
|
+
*
|
|
105
|
+
* `{var}` (single-brace) interpolation is deliberate and matches the de-facto
|
|
106
|
+
* in-app convention; the email template layer keeps its own `${var}` engine.
|
|
107
|
+
*/
|
|
108
|
+
/** Reserved `payload.data` key carrying the rendered in-app content. */
|
|
109
|
+
const INAPP_DATA_KEY = "__inApp";
|
|
110
|
+
/** `{var}` interpolation — unknown keys are left as-is (visible, not silently blanked). */
|
|
111
|
+
function interpolate(template, vars) {
|
|
112
|
+
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
113
|
+
const v = vars[key];
|
|
114
|
+
return v === void 0 || v === null ? `{${key}}` : String(v);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** Render a trigger's in-app template against event data + priority. */
|
|
118
|
+
function renderInApp(template, vars, priority = "normal") {
|
|
119
|
+
return {
|
|
120
|
+
title: interpolate(template.title, vars),
|
|
121
|
+
message: interpolate(template.message, vars),
|
|
122
|
+
...template.link !== void 0 ? { link: interpolate(template.link, vars) } : {},
|
|
123
|
+
...template.entityType !== void 0 ? { entityType: interpolate(template.entityType, vars) } : {},
|
|
124
|
+
...template.entityId !== void 0 ? { entityId: interpolate(template.entityId, vars) } : {},
|
|
125
|
+
priority
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Read the rendered in-app content a trigger parked on the send payload's data. */
|
|
129
|
+
function readInAppContent(data) {
|
|
130
|
+
const raw = data?.[INAPP_DATA_KEY];
|
|
131
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
132
|
+
const c = raw;
|
|
133
|
+
if (typeof c.title !== "string" || typeof c.message !== "string") return null;
|
|
134
|
+
return c;
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/inAppChannel.ts
|
|
138
|
+
var InAppChannel = class extends BaseChannel {
|
|
139
|
+
repository;
|
|
140
|
+
port;
|
|
141
|
+
eventName;
|
|
142
|
+
unreadCache;
|
|
143
|
+
constructor(config) {
|
|
144
|
+
super({
|
|
145
|
+
name: "in-app",
|
|
146
|
+
...config
|
|
147
|
+
});
|
|
148
|
+
this.repository = config.repository;
|
|
149
|
+
this.port = config.port;
|
|
150
|
+
this.eventName = config.eventName ?? "notification";
|
|
151
|
+
this.unreadCache = config.unreadCache;
|
|
152
|
+
}
|
|
153
|
+
/** Skip (not fail) when this payload isn't an in-app notification. */
|
|
154
|
+
canSend(payload) {
|
|
155
|
+
if (!payload.recipient.id) return {
|
|
156
|
+
status: "skipped",
|
|
157
|
+
channel: this.name,
|
|
158
|
+
error: "no recipient.id (user)"
|
|
159
|
+
};
|
|
160
|
+
if (!this.orgOf(payload)) return {
|
|
161
|
+
status: "skipped",
|
|
162
|
+
channel: this.name,
|
|
163
|
+
error: "no organizationId in recipient.metadata"
|
|
164
|
+
};
|
|
165
|
+
if (!readInAppContent(payload.data)) return {
|
|
166
|
+
status: "skipped",
|
|
167
|
+
channel: this.name,
|
|
168
|
+
error: "no in-app content on payload"
|
|
169
|
+
};
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
async send(payload) {
|
|
173
|
+
const content = readInAppContent(payload.data);
|
|
174
|
+
const userId = payload.recipient.id;
|
|
175
|
+
const organizationId = this.orgOf(payload);
|
|
176
|
+
if (!content || !userId || !organizationId) return {
|
|
177
|
+
status: "skipped",
|
|
178
|
+
channel: this.name,
|
|
179
|
+
error: "incomplete in-app payload"
|
|
180
|
+
};
|
|
181
|
+
const type = payload.template ?? payload.event;
|
|
182
|
+
const data = {
|
|
183
|
+
...content.link !== void 0 ? { link: content.link } : {},
|
|
184
|
+
...content.entityId !== void 0 ? { entityId: content.entityId } : {},
|
|
185
|
+
...content.entityType !== void 0 ? { entityType: content.entityType } : {}
|
|
186
|
+
};
|
|
187
|
+
const { id } = await this.repository.create({
|
|
188
|
+
organizationId,
|
|
189
|
+
userId,
|
|
190
|
+
type,
|
|
191
|
+
title: content.title,
|
|
192
|
+
message: content.message,
|
|
193
|
+
priority: content.priority,
|
|
194
|
+
data
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
await this.unreadCache?.invalidate(organizationId, userId);
|
|
198
|
+
} catch {}
|
|
199
|
+
if (!this.port) return {
|
|
200
|
+
status: "sent",
|
|
201
|
+
channel: this.name,
|
|
202
|
+
metadata: { notificationId: id }
|
|
203
|
+
};
|
|
204
|
+
let realtimeDelivered = true;
|
|
205
|
+
try {
|
|
206
|
+
await this.port.push(userId, organizationId, this.eventName, {
|
|
207
|
+
id,
|
|
208
|
+
type,
|
|
209
|
+
title: content.title,
|
|
210
|
+
message: content.message,
|
|
211
|
+
priority: content.priority,
|
|
212
|
+
data
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
realtimeDelivered = false;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
status: "sent",
|
|
219
|
+
channel: this.name,
|
|
220
|
+
metadata: {
|
|
221
|
+
notificationId: id,
|
|
222
|
+
realtimeDelivered
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
orgOf(payload) {
|
|
227
|
+
const org = payload.recipient.metadata?.organizationId;
|
|
228
|
+
return typeof org === "string" && org ? org : void 0;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/inAppFeed.ts
|
|
233
|
+
/**
|
|
234
|
+
* `inAppFeedResource` — the user-facing notification inbox as a normal arc
|
|
235
|
+
* resource. Generalized from be-prod's `notification.resource`: a per-user,
|
|
236
|
+
* per-tenant feed of notifications with unread-count, mark-read, and
|
|
237
|
+
* mark-all-read. Rows are WRITTEN by the {@link InAppChannel} (a delivery
|
|
238
|
+
* channel), so this resource is read + lifecycle only — no create/delete REST.
|
|
239
|
+
*
|
|
240
|
+
* Kit-less: the host passes an {@link InAppFeedRepository} (its Mongoose/
|
|
241
|
+
* mongokit/sqlite repo, owning the schema, indexes, and TTL) plus a
|
|
242
|
+
* {@link InAppFeedScope} resolver (how to read the current user + tenant off a
|
|
243
|
+
* request). The realtime `/stream` (SSE/WebSocket) endpoint stays host-owned —
|
|
244
|
+
* it is transport-specific; this resource is the queryable feed.
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* In-process TTL cache for unread counts. Per-process: invalidations don't
|
|
248
|
+
* cross workers, so a multi-process host either accepts up-to-TTL staleness on
|
|
249
|
+
* the other workers or brings a Redis-backed {@link UnreadCountCache}.
|
|
250
|
+
*/
|
|
251
|
+
function createUnreadCountCache(options = {}) {
|
|
252
|
+
const { ttlMs = 3e4, maxEntries = 1e4 } = options;
|
|
253
|
+
const entries = /* @__PURE__ */ new Map();
|
|
254
|
+
const keyOf = (organizationId, userId) => `${organizationId}\u0000${userId}`;
|
|
255
|
+
return {
|
|
256
|
+
get(organizationId, userId) {
|
|
257
|
+
const key = keyOf(organizationId, userId);
|
|
258
|
+
const entry = entries.get(key);
|
|
259
|
+
if (!entry) return void 0;
|
|
260
|
+
if (entry.expiresAt <= Date.now()) {
|
|
261
|
+
entries.delete(key);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
return entry.count;
|
|
265
|
+
},
|
|
266
|
+
set(organizationId, userId, count) {
|
|
267
|
+
if (entries.size >= maxEntries) {
|
|
268
|
+
const oldest = entries.keys().next().value;
|
|
269
|
+
if (oldest !== void 0) entries.delete(oldest);
|
|
270
|
+
}
|
|
271
|
+
entries.set(keyOf(organizationId, userId), {
|
|
272
|
+
count,
|
|
273
|
+
expiresAt: Date.now() + ttlMs
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
invalidate(organizationId, userId) {
|
|
277
|
+
entries.delete(keyOf(organizationId, userId));
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function toInt(value, fallback) {
|
|
282
|
+
const n = Number(value);
|
|
283
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
284
|
+
}
|
|
285
|
+
function inAppFeedResource(options) {
|
|
286
|
+
const { repository, resolveScope, permissions, name = "in-app-notification", prefix = "/notifications", maxLimit = 50, port, readSyncEventName = "notification:read", unreadCache } = options;
|
|
287
|
+
/** Fire-and-forget: read-sync + cache invalidation must never fail a request. */
|
|
288
|
+
const afterReadStateChange = async (scope, data) => {
|
|
289
|
+
try {
|
|
290
|
+
await unreadCache?.invalidate(scope.organizationId, scope.userId);
|
|
291
|
+
} catch {}
|
|
292
|
+
if (!port) return;
|
|
293
|
+
try {
|
|
294
|
+
await port.push(scope.userId, scope.organizationId, readSyncEventName, data);
|
|
295
|
+
} catch {}
|
|
296
|
+
};
|
|
297
|
+
return defineResource({
|
|
298
|
+
name,
|
|
299
|
+
prefix,
|
|
300
|
+
disableDefaultRoutes: true,
|
|
301
|
+
routes: [
|
|
302
|
+
{
|
|
303
|
+
method: "GET",
|
|
304
|
+
path: "/",
|
|
305
|
+
permissions,
|
|
306
|
+
raw: true,
|
|
307
|
+
summary: "List my notifications (newest first, paginated)",
|
|
308
|
+
schema: { querystring: {
|
|
309
|
+
type: "object",
|
|
310
|
+
properties: {
|
|
311
|
+
page: {
|
|
312
|
+
type: "integer",
|
|
313
|
+
minimum: 1
|
|
314
|
+
},
|
|
315
|
+
limit: {
|
|
316
|
+
type: "integer",
|
|
317
|
+
minimum: 1,
|
|
318
|
+
maximum: maxLimit
|
|
319
|
+
},
|
|
320
|
+
unreadOnly: {
|
|
321
|
+
type: "string",
|
|
322
|
+
enum: ["true", "false"]
|
|
323
|
+
},
|
|
324
|
+
type: { type: "string" }
|
|
325
|
+
},
|
|
326
|
+
additionalProperties: true
|
|
327
|
+
} },
|
|
328
|
+
handler: async (req, reply) => {
|
|
329
|
+
const { userId, organizationId } = resolveScope(req);
|
|
330
|
+
const q = req.query ?? {};
|
|
331
|
+
const result = await repository.listForUser(organizationId, userId, {
|
|
332
|
+
page: toInt(q.page, 1),
|
|
333
|
+
limit: Math.min(toInt(q.limit, 20), maxLimit),
|
|
334
|
+
unreadOnly: q.unreadOnly === "true",
|
|
335
|
+
...typeof q.type === "string" && q.type ? { type: q.type } : {}
|
|
336
|
+
});
|
|
337
|
+
return reply.send(result);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
method: "GET",
|
|
342
|
+
path: "/unread-count",
|
|
343
|
+
permissions,
|
|
344
|
+
raw: true,
|
|
345
|
+
summary: "Unread notification count (badge)",
|
|
346
|
+
handler: async (req, reply) => {
|
|
347
|
+
const { userId, organizationId } = resolveScope(req);
|
|
348
|
+
if (unreadCache) {
|
|
349
|
+
const cached = await unreadCache.get(organizationId, userId);
|
|
350
|
+
if (cached !== void 0) return reply.send({ count: cached });
|
|
351
|
+
}
|
|
352
|
+
const count = await repository.countUnread(organizationId, userId);
|
|
353
|
+
if (unreadCache) try {
|
|
354
|
+
await unreadCache.set(organizationId, userId, count);
|
|
355
|
+
} catch {}
|
|
356
|
+
return reply.send({ count });
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
method: "PATCH",
|
|
361
|
+
path: "/:id/read",
|
|
362
|
+
permissions,
|
|
363
|
+
raw: true,
|
|
364
|
+
summary: "Mark a single notification read",
|
|
365
|
+
handler: async (req, reply) => {
|
|
366
|
+
const scope = resolveScope(req);
|
|
367
|
+
const id = req.params.id;
|
|
368
|
+
const updated = await repository.markRead(scope.organizationId, scope.userId, id);
|
|
369
|
+
if (!updated) throw new NotFoundError("Notification");
|
|
370
|
+
await afterReadStateChange(scope, { id });
|
|
371
|
+
return reply.send(updated);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
method: "PATCH",
|
|
376
|
+
path: "/read-all",
|
|
377
|
+
permissions,
|
|
378
|
+
raw: true,
|
|
379
|
+
summary: "Mark all my notifications read",
|
|
380
|
+
handler: async (req, reply) => {
|
|
381
|
+
const scope = resolveScope(req);
|
|
382
|
+
const modifiedCount = await repository.markAllRead(scope.organizationId, scope.userId);
|
|
383
|
+
await afterReadStateChange(scope, {
|
|
384
|
+
all: true,
|
|
385
|
+
modifiedCount
|
|
386
|
+
});
|
|
387
|
+
return reply.send({ modifiedCount });
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
...typeof repository.delete === "function" ? [{
|
|
391
|
+
method: "DELETE",
|
|
392
|
+
path: "/:id",
|
|
393
|
+
permissions,
|
|
394
|
+
raw: true,
|
|
395
|
+
summary: "Delete (dismiss) a notification",
|
|
396
|
+
handler: async (req, reply) => {
|
|
397
|
+
const scope = resolveScope(req);
|
|
398
|
+
const id = req.params.id;
|
|
399
|
+
if (!await repository.delete(scope.organizationId, scope.userId, id)) throw new NotFoundError("Notification");
|
|
400
|
+
await afterReadStateChange(scope, {
|
|
401
|
+
id,
|
|
402
|
+
deleted: true
|
|
403
|
+
});
|
|
404
|
+
return reply.send({ deleted: true });
|
|
405
|
+
}
|
|
406
|
+
}] : []
|
|
407
|
+
]
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
//#endregion
|
|
55
411
|
//#region src/jobsQueue.ts
|
|
56
412
|
/**
|
|
57
413
|
* `fastify.jobs` bridge — reuse arc's job subsystem instead of standing up a
|
|
@@ -200,7 +556,7 @@ function notificationsJob(options = {}) {
|
|
|
200
556
|
* at-least-once: redelivery must not double-send.
|
|
201
557
|
*/
|
|
202
558
|
function notificationsModule(config) {
|
|
203
|
-
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, ...serviceConfig } = config;
|
|
559
|
+
const { name = "notifications", service: provided, notifyOn = [], decorate = "notifications", queue, recipientResolver, ...serviceConfig } = config;
|
|
204
560
|
let service;
|
|
205
561
|
const unsubscribes = [];
|
|
206
562
|
return defineModule({
|
|
@@ -221,7 +577,7 @@ function notificationsModule(config) {
|
|
|
221
577
|
const bus = fastify;
|
|
222
578
|
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
579
|
for (const trigger of triggers) {
|
|
224
|
-
const unsubscribe = await subscribeWithBoundary(bus, trigger.event, async (event) => dispatchTrigger(service, trigger, event), { name: `arc-notifications:${trigger.event}` });
|
|
580
|
+
const unsubscribe = await subscribeWithBoundary(bus, trigger.event, async (event) => dispatchTrigger(service, trigger, event, { ...recipientResolver ? { recipientResolver } : {} }), { name: `arc-notifications:${trigger.event}` });
|
|
225
581
|
unsubscribes.push(unsubscribe);
|
|
226
582
|
}
|
|
227
583
|
fastify.log.debug(`[arc-notifications] ${triggers.length} trigger(s) subscribed`);
|
|
@@ -233,13 +589,23 @@ function notificationsModule(config) {
|
|
|
233
589
|
});
|
|
234
590
|
}
|
|
235
591
|
/** One event → N sends. Exported for direct unit testing. */
|
|
236
|
-
async function dispatchTrigger(service, trigger, event) {
|
|
237
|
-
|
|
592
|
+
async function dispatchTrigger(service, trigger, event, opts) {
|
|
593
|
+
let resolved;
|
|
594
|
+
if (trigger.recipient) resolved = await trigger.recipient(event);
|
|
595
|
+
else if (trigger.audience) {
|
|
596
|
+
const audience = await trigger.audience(event);
|
|
597
|
+
if (audience === null || audience === void 0) return;
|
|
598
|
+
resolved = opts?.recipientResolver ? await opts.recipientResolver(audience, event) : void 0;
|
|
599
|
+
}
|
|
238
600
|
if (!resolved) return;
|
|
239
601
|
const recipients = Array.isArray(resolved) ? resolved : [resolved];
|
|
240
602
|
if (recipients.length === 0) return;
|
|
241
603
|
const payload = event.payload;
|
|
242
604
|
const data = await trigger.data?.(event) ?? (payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : {});
|
|
605
|
+
const sendData = trigger.inApp ? {
|
|
606
|
+
...data,
|
|
607
|
+
[INAPP_DATA_KEY]: renderInApp(trigger.inApp, data, trigger.priority)
|
|
608
|
+
} : data;
|
|
243
609
|
const baseKey = trigger.idempotencyKey ? trigger.idempotencyKey(event) : event.meta.idempotencyKey ?? `${event.type}:${event.meta.id}`;
|
|
244
610
|
for (const [index, recipient] of recipients.entries()) {
|
|
245
611
|
const idempotencyKey = baseKey === void 0 ? void 0 : recipients.length === 1 ? baseKey : `${baseKey}:${recipient.id ?? recipient.email ?? index}`;
|
|
@@ -247,7 +613,7 @@ async function dispatchTrigger(service, trigger, event) {
|
|
|
247
613
|
event: event.type,
|
|
248
614
|
template: trigger.template ?? event.type,
|
|
249
615
|
recipient,
|
|
250
|
-
data,
|
|
616
|
+
data: sendData,
|
|
251
617
|
...trigger.channels ? { channels: [...trigger.channels] } : {},
|
|
252
618
|
...idempotencyKey !== void 0 ? { idempotencyKey } : {},
|
|
253
619
|
metadata: {
|
|
@@ -321,6 +687,67 @@ function createRepositoryDeliveryLog(repository, options = {}) {
|
|
|
321
687
|
};
|
|
322
688
|
}
|
|
323
689
|
//#endregion
|
|
690
|
+
//#region src/sseChannel.ts
|
|
691
|
+
var SseChannel = class extends BaseChannel {
|
|
692
|
+
port;
|
|
693
|
+
eventName;
|
|
694
|
+
constructor(config) {
|
|
695
|
+
super({
|
|
696
|
+
name: "sse",
|
|
697
|
+
...config
|
|
698
|
+
});
|
|
699
|
+
this.port = config.port;
|
|
700
|
+
this.eventName = config.eventName ?? "notification";
|
|
701
|
+
}
|
|
702
|
+
canSend(payload) {
|
|
703
|
+
if (!payload.recipient.id) return {
|
|
704
|
+
status: "skipped",
|
|
705
|
+
channel: this.name,
|
|
706
|
+
error: "no recipient.id (user)"
|
|
707
|
+
};
|
|
708
|
+
if (!this.orgOf(payload)) return {
|
|
709
|
+
status: "skipped",
|
|
710
|
+
channel: this.name,
|
|
711
|
+
error: "no organizationId in recipient.metadata"
|
|
712
|
+
};
|
|
713
|
+
if (!readInAppContent(payload.data)) return {
|
|
714
|
+
status: "skipped",
|
|
715
|
+
channel: this.name,
|
|
716
|
+
error: "no in-app content on payload"
|
|
717
|
+
};
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
async send(payload) {
|
|
721
|
+
const content = readInAppContent(payload.data);
|
|
722
|
+
const userId = payload.recipient.id;
|
|
723
|
+
const organizationId = this.orgOf(payload);
|
|
724
|
+
if (!content || !userId || !organizationId) return {
|
|
725
|
+
status: "skipped",
|
|
726
|
+
channel: this.name,
|
|
727
|
+
error: "incomplete in-app payload"
|
|
728
|
+
};
|
|
729
|
+
await this.port.push(userId, organizationId, this.eventName, {
|
|
730
|
+
type: payload.template ?? payload.event,
|
|
731
|
+
title: content.title,
|
|
732
|
+
message: content.message,
|
|
733
|
+
priority: content.priority,
|
|
734
|
+
data: {
|
|
735
|
+
...content.link !== void 0 ? { link: content.link } : {},
|
|
736
|
+
...content.entityId !== void 0 ? { entityId: content.entityId } : {},
|
|
737
|
+
...content.entityType !== void 0 ? { entityType: content.entityType } : {}
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
return {
|
|
741
|
+
status: "sent",
|
|
742
|
+
channel: this.name
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
orgOf(payload) {
|
|
746
|
+
const org = payload.recipient.metadata?.organizationId;
|
|
747
|
+
return typeof org === "string" && org ? org : void 0;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
//#endregion
|
|
324
751
|
//#region src/templatesResource.ts
|
|
325
752
|
/**
|
|
326
753
|
* `templatesResource` — DB-draftable notification templates as a normal arc
|
|
@@ -426,4 +853,4 @@ function templatesResource(options) {
|
|
|
426
853
|
});
|
|
427
854
|
}
|
|
428
855
|
//#endregion
|
|
429
|
-
export { createDbFirstResolver, createRepositoryDeliveryLog, dispatchTrigger, notificationsJob, notificationsModule, templatesResource };
|
|
856
|
+
export { INAPP_DATA_KEY, InAppChannel, SseChannel, createDbFirstResolver, createInAppBackfill, createRepositoryDeliveryLog, createUnreadCountCache, dispatchTrigger, inAppFeedResource, notificationsJob, notificationsModule, readInAppContent, renderInApp, templatesResource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-notifications",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,14 +24,15 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "tsdown",
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run --root ../.. packages/arc-notifications/tests",
|
|
27
28
|
"lint": "biome check src/",
|
|
28
29
|
"prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
|
-
"@classytic/arc": ">=2.
|
|
32
|
+
"@classytic/arc": ">=2.23.0",
|
|
32
33
|
"@classytic/notifications": ">=2.2.0",
|
|
33
|
-
"@classytic/primitives": ">=0.
|
|
34
|
-
"@classytic/repo-core": ">=0.
|
|
34
|
+
"@classytic/primitives": ">=0.14.0",
|
|
35
|
+
"@classytic/repo-core": ">=0.14.0"
|
|
35
36
|
},
|
|
36
37
|
"keywords": [
|
|
37
38
|
"arc",
|