@classytic/arc-notifications 0.1.0 → 0.2.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 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
- ## Three pieces
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. Durable dispatch — crash-safe bulk sends (10k+)
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,221 @@
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
- import { PermissionCheck } from "@classytic/arc/permissions";
8
8
 
9
+ //#region src/inApp.d.ts
10
+ /**
11
+ * In-app notification content — the shared seam between a `notifyOn` trigger's
12
+ * declarative `inApp` template and the channels that deliver it (the DB feed
13
+ * channel + the realtime/SSE channel).
14
+ *
15
+ * A trigger declares an {@link InAppTemplate} (title/message/link/... with
16
+ * `{var}` placeholders). `dispatchTrigger` interpolates it ONCE per event
17
+ * against the trigger's `data`, then parks the rendered {@link InAppContent} on
18
+ * `payload.data[INAPP_DATA_KEY]`. Both in-app channels read it back via
19
+ * {@link readInAppContent} — so email/SMS channels ignore an extra data key
20
+ * while the feed + realtime channels get a fully-rendered payload with no
21
+ * per-channel template resolution.
22
+ *
23
+ * `{var}` (single-brace) interpolation is deliberate and matches the de-facto
24
+ * in-app convention; the email template layer keeps its own `${var}` engine.
25
+ */
26
+ /** Reserved `payload.data` key carrying the rendered in-app content. */
27
+ declare const INAPP_DATA_KEY = "__inApp";
28
+ /**
29
+ * Host realtime transport — push an event to one user within a tenant.
30
+ * The transport itself (connection registry, heartbeats, the `/stream`
31
+ * endpoint, multi-tab fan-out) stays host-owned; everything in this package
32
+ * that touches realtime ({@link SseChannel}, {@link InAppChannel}'s combined
33
+ * mode, {@link createInAppBackfill}, the feed resource's read-sync) goes
34
+ * through this one seam.
35
+ */
36
+ interface RealtimePushPort {
37
+ push(userId: string, organizationId: string, event: string, data: unknown): void | Promise<void>;
38
+ }
39
+ type InAppPriority = "low" | "normal" | "high";
40
+ /** Declarative in-app template on a trigger — `{var}` placeholders. */
41
+ interface InAppTemplate {
42
+ /** Notification title. */
43
+ readonly title: string;
44
+ /** Notification body. */
45
+ readonly message: string;
46
+ /** Frontend route (`/dashboard/orders/{orderId}`). */
47
+ readonly link?: string;
48
+ /** Entity kind for UI grouping/icons ('order' | 'transfer' | ...). */
49
+ readonly entityType?: string;
50
+ /** Entity id placeholder (`{orderId}`) for deep-linking / dedup. */
51
+ readonly entityId?: string;
52
+ }
53
+ /** Fully-rendered in-app content the channels deliver. */
54
+ interface InAppContent {
55
+ readonly title: string;
56
+ readonly message: string;
57
+ readonly link?: string;
58
+ readonly entityType?: string;
59
+ readonly entityId?: string;
60
+ readonly priority: InAppPriority;
61
+ }
62
+ /** Render a trigger's in-app template against event data + priority. */
63
+ declare function renderInApp(template: InAppTemplate, vars: Record<string, unknown>, priority?: InAppPriority): InAppContent;
64
+ /** Read the rendered in-app content a trigger parked on the send payload's data. */
65
+ declare function readInAppContent(data: Record<string, unknown> | undefined): InAppContent | null;
66
+ //#endregion
67
+ //#region src/inAppFeed.d.ts
68
+ /** A feed row the {@link InAppChannel} writes and this resource reads. */
69
+ interface InAppFeedRow {
70
+ readonly organizationId: string;
71
+ readonly userId: string;
72
+ /** Notification type (the event type by default). */
73
+ readonly type: string;
74
+ readonly title: string;
75
+ readonly message: string;
76
+ readonly data?: {
77
+ readonly link?: string;
78
+ readonly entityId?: string;
79
+ readonly entityType?: string;
80
+ readonly meta?: Record<string, unknown>;
81
+ };
82
+ readonly priority: InAppPriority;
83
+ }
84
+ interface InAppFeedListOptions {
85
+ readonly page?: number;
86
+ readonly limit?: number;
87
+ readonly unreadOnly?: boolean;
88
+ readonly type?: string;
89
+ }
90
+ interface InAppFeedPage<TRow = unknown> {
91
+ readonly data: TRow[];
92
+ readonly pagination: {
93
+ readonly page: number;
94
+ readonly limit: number;
95
+ readonly total: number;
96
+ readonly totalPages: number;
97
+ };
98
+ }
99
+ /**
100
+ * Host repository over the feed collection. The host owns the persistence
101
+ * (schema, indexes, soft-TTL); this port is the minimal surface the channel
102
+ * (write) and resource (read + lifecycle) need.
103
+ */
104
+ interface InAppFeedRepository<TRow = unknown> {
105
+ /** Insert a delivered notification (called by {@link InAppChannel}). */
106
+ create(row: InAppFeedRow): Promise<{
107
+ id: string;
108
+ }>;
109
+ /** Paginated, newest-first, user + tenant scoped. */
110
+ listForUser(organizationId: string, userId: string, options: InAppFeedListOptions): Promise<InAppFeedPage<TRow>>;
111
+ /** Unread badge count. */
112
+ countUnread(organizationId: string, userId: string): Promise<number>;
113
+ /** Mark one as read; `null` when not found / not owned. */
114
+ markRead(organizationId: string, userId: string, id: string): Promise<TRow | null>;
115
+ /** Mark all unread read; returns the modified count. */
116
+ markAllRead(organizationId: string, userId: string): Promise<number>;
117
+ /**
118
+ * OPTIONAL — atomically fetch up to `limit` rows never delivered to any live
119
+ * client AND mark them delivered, in one repo call (find-and-update).
120
+ * Powers `createInAppBackfill` (reconnect catch-up): "delivered" is a
121
+ * SEPARATE axis from read/unread — a row is *delivered* once any client has
122
+ * received it over realtime, *read* once the user acted on it. Track it
123
+ * however fits the schema (a `deliveredAt` timestamp is the convention).
124
+ * Atomicity is what keeps two near-simultaneous connects (multi-tab beyond
125
+ * the debounce window) from double-toasting the same rows.
126
+ */
127
+ claimUndelivered?(organizationId: string, userId: string, limit: number): Promise<TRow[]>;
128
+ /**
129
+ * OPTIONAL — delete one row (user dismissed it for good); `false` when not
130
+ * found / not owned. When implemented, the feed resource registers
131
+ * `DELETE /:id`. Hard vs soft delete is the host's call.
132
+ */
133
+ delete?(organizationId: string, userId: string, id: string): Promise<boolean>;
134
+ }
135
+ /**
136
+ * Cache seam for the unread badge count — the hottest query in the system
137
+ * (every open tab polls it). Structural + possibly-async so a host can back it
138
+ * with Redis; {@link createUnreadCountCache} is the in-process default.
139
+ *
140
+ * Share ONE instance between `inAppFeedResource({ unreadCache })` (reads
141
+ * through it, invalidates on read-state mutations) and
142
+ * `InAppChannel({ unreadCache })` (invalidates on new-row writes) — otherwise
143
+ * new notifications ride the TTL before the badge moves.
144
+ */
145
+ interface UnreadCountCache {
146
+ get(organizationId: string, userId: string): number | undefined | Promise<number | undefined>;
147
+ set(organizationId: string, userId: string, count: number): void | Promise<void>;
148
+ invalidate(organizationId: string, userId: string): void | Promise<void>;
149
+ }
150
+ interface CreateUnreadCountCacheOptions {
151
+ /** Entry freshness window. Default 30_000ms. */
152
+ readonly ttlMs?: number;
153
+ /** Entry cap — oldest entries evicted past it (bounds memory). Default 10_000. */
154
+ readonly maxEntries?: number;
155
+ }
156
+ /**
157
+ * In-process TTL cache for unread counts. Per-process: invalidations don't
158
+ * cross workers, so a multi-process host either accepts up-to-TTL staleness on
159
+ * the other workers or brings a Redis-backed {@link UnreadCountCache}.
160
+ */
161
+ declare function createUnreadCountCache(options?: CreateUnreadCountCacheOptions): UnreadCountCache;
162
+ /** The current user + tenant, resolved off a request by the host. */
163
+ interface InAppFeedScope {
164
+ readonly userId: string;
165
+ readonly organizationId: string;
166
+ }
167
+ interface InAppFeedResourceOptions {
168
+ readonly repository: InAppFeedRepository;
169
+ /** Read `{ userId, organizationId }` off the request (host auth/scoping). */
170
+ readonly resolveScope: (req: FastifyRequest) => InAppFeedScope;
171
+ /** Gate for every feed route (a signed-in user reading their own feed). */
172
+ readonly permissions: PermissionCheck;
173
+ /** Resource name. Default "in-app-notification". */
174
+ readonly name?: string;
175
+ /** Route prefix. Default "/notifications". */
176
+ readonly prefix?: string;
177
+ /** Max page size (list `limit` cap). Default 50. */
178
+ readonly maxLimit?: number;
179
+ /**
180
+ * Realtime port for cross-tab read-sync: mark-read / mark-all-read / delete
181
+ * push a `readSyncEventName` event to the user's OTHER open tabs so badges
182
+ * and toasts drop instantly instead of on the next poll. Best-effort —
183
+ * a push failure never fails the request.
184
+ */
185
+ readonly port?: RealtimePushPort;
186
+ /** Read-sync event name. Default "notification:read". */
187
+ readonly readSyncEventName?: string;
188
+ /**
189
+ * Unread badge-count cache — see {@link UnreadCountCache}. Share the same
190
+ * instance with `InAppChannel` so new-row writes invalidate too.
191
+ */
192
+ readonly unreadCache?: UnreadCountCache;
193
+ }
194
+ declare function inAppFeedResource(options: InAppFeedResourceOptions): import("@classytic/arc").ResourceDefinition<import("@classytic/arc").AnyRecord>;
195
+ //#endregion
196
+ //#region src/backfill.d.ts
197
+ interface InAppBackfillOptions {
198
+ /** Feed repository — MUST implement the optional `claimUndelivered`. */
199
+ readonly repository: InAppFeedRepository;
200
+ /** Host realtime transport the bulk event is pushed through. */
201
+ readonly port: RealtimePushPort;
202
+ /** Bulk event name. Default "notification:backfill". */
203
+ readonly eventName?: string;
204
+ /** Max rows claimed per connect. Default 100. */
205
+ readonly limit?: number;
206
+ /** Multi-tab coalescing window. Default 2000ms. */
207
+ readonly debounceMs?: number;
208
+ /** Claim/push failure sink (backfill is best-effort). Default: swallow. */
209
+ readonly onError?: (error: unknown) => void;
210
+ }
211
+ interface InAppBackfill {
212
+ /** Call from the host's SSE/WS connection handler. Sync, never throws. */
213
+ onConnect(userId: string, organizationId: string): void;
214
+ /** Cancel pending debounces (graceful shutdown / tests). */
215
+ close(): void;
216
+ }
217
+ declare function createInAppBackfill(options: InAppBackfillOptions): InAppBackfill;
218
+ //#endregion
9
219
  //#region src/dbFirstResolver.d.ts
10
220
  /** One draftable template row — the shape be-prod's EmailTemplate model proved. */
11
221
  interface StoredTemplate {
@@ -44,6 +254,40 @@ interface DbFirstTemplateResolver {
44
254
  }
45
255
  declare function createDbFirstResolver(options: DbFirstResolverOptions): DbFirstTemplateResolver;
46
256
  //#endregion
257
+ //#region src/inAppChannel.d.ts
258
+ interface InAppChannelConfig extends ChannelConfig {
259
+ /** Feed store the channel writes rows to (host-owned persistence). */
260
+ readonly repository: InAppFeedRepository;
261
+ /**
262
+ * Combined mode: also push the notification to the user's live connection
263
+ * AFTER the feed write, with the persisted row `id` included — so a live
264
+ * toast can be marked read / deep-linked without a refetch. Prefer this over
265
+ * registering a separate {@link SseChannel} when you persist: the kernel
266
+ * dispatches channels in parallel, so a standalone realtime channel can
267
+ * never carry the row id. Best-effort — a push failure never fails the
268
+ * durable write (`metadata.realtimeDelivered` reports it).
269
+ */
270
+ readonly port?: RealtimePushPort;
271
+ /** Realtime event name in combined mode. Default "notification". */
272
+ readonly eventName?: string;
273
+ /**
274
+ * Unread badge-count cache to invalidate on every feed write. Share the
275
+ * same instance with `inAppFeedResource({ unreadCache })`.
276
+ */
277
+ readonly unreadCache?: UnreadCountCache;
278
+ }
279
+ declare class InAppChannel extends BaseChannel<InAppChannelConfig> {
280
+ private readonly repository;
281
+ private readonly port?;
282
+ private readonly eventName;
283
+ private readonly unreadCache?;
284
+ constructor(config: InAppChannelConfig);
285
+ /** Skip (not fail) when this payload isn't an in-app notification. */
286
+ canSend(payload: NotificationPayload): SendResult | null;
287
+ send(payload: NotificationPayload): Promise<SendResult>;
288
+ private orgOf;
289
+ }
290
+ //#endregion
47
291
  //#region src/jobsQueue.d.ts
48
292
  interface NotificationsJobOptions {
49
293
  /** Job/queue name registered with `jobsPlugin`. Default "notifications". */
@@ -83,6 +327,13 @@ interface NotificationsJob {
83
327
  declare function notificationsJob(options?: NotificationsJobOptions): NotificationsJob;
84
328
  //#endregion
85
329
  //#region src/module.d.ts
330
+ /**
331
+ * Resolve a trigger's host-typed `audience` selector to concrete recipients —
332
+ * the shared seam so branch-role → members (or team → users, tenant → admins)
333
+ * logic lives in ONE injected resolver, not duplicated across every trigger's
334
+ * `recipient` function. `null`/`undefined`/`[]` skips the dispatch.
335
+ */
336
+ type RecipientResolver = (audience: unknown, event: DomainEvent) => readonly Recipient[] | null | undefined | Promise<readonly Recipient[] | null | undefined>;
86
337
  interface NotifyTrigger<TPayload = unknown> {
87
338
  /** Event pattern to subscribe (wildcards per arc's `matchEventPattern`). */
88
339
  readonly event: string;
@@ -91,10 +342,26 @@ interface NotifyTrigger<TPayload = unknown> {
91
342
  /** Restrict to specific channel names. Omit = all matching channels. */
92
343
  readonly channels?: readonly string[];
93
344
  /**
94
- * Map the event to recipient(s). Return `null`/`undefined`/`[]` to skip
95
- * (the filter seam — "payment failed only", "opted-in users only", ...).
345
+ * Map the event to recipient(s) directly. Return `null`/`undefined`/`[]` to
346
+ * skip. Mutually exclusive with {@link audience}: provide this OR an
347
+ * `audience` + a module-level {@link NotificationsModuleConfig.recipientResolver}.
348
+ */
349
+ readonly recipient?: (event: DomainEvent<TPayload>) => Recipient | readonly Recipient[] | null | undefined | Promise<Recipient | readonly Recipient[] | null | undefined>;
350
+ /**
351
+ * Host-typed audience selector (e.g. `{ roles, organizationId, exclude }`)
352
+ * fed to the module's {@link NotificationsModuleConfig.recipientResolver}.
353
+ * Return `null`/`undefined` to skip. Use this to keep recipient resolution
354
+ * (role → members) in one injected place instead of per trigger.
96
355
  */
97
- readonly recipient: (event: DomainEvent<TPayload>) => Recipient | readonly Recipient[] | null | undefined | Promise<Recipient | readonly Recipient[] | null | undefined>;
356
+ readonly audience?: (event: DomainEvent<TPayload>) => unknown | Promise<unknown>;
357
+ /**
358
+ * In-app notification template (`{var}` placeholders). When set, the feed +
359
+ * realtime channels render and deliver it — rendered ONCE per event and
360
+ * parked on the send payload so channels need no template lookup.
361
+ */
362
+ readonly inApp?: InAppTemplate;
363
+ /** In-app priority (badge intensity / sort). Default "normal". */
364
+ readonly priority?: InAppPriority;
98
365
  /** Template variables. Default: the event payload (when object-shaped). */
99
366
  readonly data?: (event: DomainEvent<TPayload>) => Record<string, unknown> | Promise<Record<string, unknown>>;
100
367
  /**
@@ -113,6 +380,12 @@ interface NotificationsModuleConfig extends Omit<NotificationServiceConfig, "que
113
380
  readonly service?: NotificationService;
114
381
  /** Declarative event → notification triggers, wired in `afterResources`. */
115
382
  readonly notifyOn?: readonly NotifyTrigger[];
383
+ /**
384
+ * Resolves a trigger's `audience` selector to recipients (the shared
385
+ * role → members seam). Required only if any trigger uses `audience` instead
386
+ * of an explicit `recipient` function.
387
+ */
388
+ readonly recipientResolver?: RecipientResolver;
116
389
  /** Decorate `fastify.<name>` with the service. Default "notifications"; `false` to skip. */
117
390
  readonly decorate?: string | false;
118
391
  /**
@@ -132,7 +405,9 @@ declare module "fastify" {
132
405
  }
133
406
  declare function notificationsModule(config: NotificationsModuleConfig): ArcModule<NotificationService>;
134
407
  /** One event → N sends. Exported for direct unit testing. */
135
- declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent): Promise<void>;
408
+ declare function dispatchTrigger(service: NotificationService, trigger: NotifyTrigger, event: DomainEvent, opts?: {
409
+ recipientResolver?: RecipientResolver;
410
+ }): Promise<void>;
136
411
  //#endregion
137
412
  //#region src/repositoryDeliveryLog.d.ts
138
413
  type EntryStatus = DeliveryLogEntry["status"];
@@ -155,6 +430,22 @@ interface RepositoryDeliveryLogOptions {
155
430
  }
156
431
  declare function createRepositoryDeliveryLog(repository: RepositoryLike, options?: RepositoryDeliveryLogOptions): DeliveryLog;
157
432
  //#endregion
433
+ //#region src/sseChannel.d.ts
434
+ interface SseChannelConfig extends ChannelConfig {
435
+ /** Host realtime transport (be-prod's SSE manager, a WS hub, ...). */
436
+ readonly port: RealtimePushPort;
437
+ /** SSE event name pushed to the client. Default "notification". */
438
+ readonly eventName?: string;
439
+ }
440
+ declare class SseChannel extends BaseChannel<SseChannelConfig> {
441
+ private readonly port;
442
+ private readonly eventName;
443
+ constructor(config: SseChannelConfig);
444
+ canSend(payload: NotificationPayload): SendResult | null;
445
+ send(payload: NotificationPayload): Promise<SendResult>;
446
+ private orgOf;
447
+ }
448
+ //#endregion
158
449
  //#region src/templatesResource.d.ts
159
450
  /** Code-defined template map (same shape `createSimpleResolver` accepts). */
160
451
  type CodeTemplates = Record<string, {
@@ -184,4 +475,4 @@ interface TemplatesResourceOptions {
184
475
  }
185
476
  declare function templatesResource(options: TemplatesResourceOptions): import("@classytic/arc").ResourceDefinition<unknown>;
186
477
  //#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 };
478
+ 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
- import { defineResource } from "@classytic/arc";
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}${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 }), { 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
- const resolved = await trigger.recipient(event);
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.1.0",
3
+ "version": "0.2.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": {
@@ -28,10 +28,10 @@
28
28
  "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
29
29
  },
30
30
  "peerDependencies": {
31
- "@classytic/arc": ">=2.20.0",
31
+ "@classytic/arc": ">=2.22.0",
32
32
  "@classytic/notifications": ">=2.2.0",
33
- "@classytic/primitives": ">=0.9.0",
34
- "@classytic/repo-core": ">=0.7.0"
33
+ "@classytic/primitives": ">=0.11.0",
34
+ "@classytic/repo-core": ">=0.13.0"
35
35
  },
36
36
  "keywords": [
37
37
  "arc",