@voltro/plugin-notifications 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ import { Context } from 'effect';
2
+ import { DataStore } from '@voltro/database';
3
+ import { Schema } from 'effect';
4
+ import { VoltroPlugin } from '@voltro/protocol';
5
+
6
+ export declare interface BroadcastResult {
7
+ /** How many topic subscribers the broadcast fanned out to. */
8
+ readonly recipients: number;
9
+ /** The per-recipient `send` outcomes, in subscriber order. */
10
+ readonly results: ReadonlyArray<SendResult>;
11
+ }
12
+
13
+ export declare const buildNotificationService: (deps: {
14
+ readonly channels: ReadonlyArray<Channel>;
15
+ readonly store: NotificationStore;
16
+ readonly now?: () => string;
17
+ /** Digest rollup window in ms. When > 0, sends whose category is NOT forced to
18
+ * a specific `channels:` list coalesce into one digest per subject per window.
19
+ * 0 (default) disables digesting — every send goes out immediately. */
20
+ readonly digestWindowMs?: number;
21
+ }) => NotificationServiceShape;
22
+
23
+ /** A delivery channel. `id` is the channel key prefs target (e.g. 'email',
24
+ * 'slack', 'sms', 'push', 'inApp'). */
25
+ export declare interface Channel {
26
+ readonly id: string;
27
+ readonly deliver: (msg: ChannelMessage) => Promise<void>;
28
+ }
29
+
30
+ /** A built notification to deliver on one channel. */
31
+ export declare interface ChannelMessage {
32
+ readonly to: string;
33
+ readonly category: string;
34
+ readonly title: string;
35
+ readonly body: string;
36
+ readonly data?: Record<string, unknown>;
37
+ readonly tenantId?: string | null;
38
+ }
39
+
40
+ /** Per-subject preference: which channels are on for a category. A missing
41
+ * entry = default-on for every configured channel. */
42
+ export declare interface ChannelPreference {
43
+ readonly subjectId: string;
44
+ readonly category: string;
45
+ readonly channel: string;
46
+ readonly enabled: boolean;
47
+ }
48
+
49
+ /** Logs to the console — dev default + a safe fallback. */
50
+ export declare const consoleChannel: () => Channel;
51
+
52
+ /** Bring-your-own channel (push/APNs/FCM/anything). */
53
+ export declare const customChannel: (id: string, deliver: (msg: ChannelMessage) => Promise<void>) => Channel;
54
+
55
+ /**
56
+ * Durable NotificationStore over the framework DataStore. Persists the in-app
57
+ * inbox, per-subject channel preferences, and the delivery log in three
58
+ * plugin-contributed tables (`notification_inbox` / `notification_preferences`
59
+ * / `notification_deliveries`). `notificationsPlugin` binds this automatically
60
+ * once the app's store exists (unless an explicit `store` was passed), so the
61
+ * inbox/unreadCount/markRead routes work on real, migrated data.
62
+ */
63
+ export declare const dataStoreNotificationStore: (store: DataStore) => NotificationStore;
64
+
65
+ /** Per-channel delivery outcome (for the delivery log). */
66
+ export declare interface DeliveryRecord {
67
+ readonly id: string;
68
+ readonly to: string;
69
+ readonly category: string;
70
+ readonly channel: string;
71
+ readonly status: 'sent' | 'failed' | 'skipped';
72
+ readonly error?: string;
73
+ readonly at: string;
74
+ }
75
+
76
+ /** Email channel — the app supplies the sender (compose with
77
+ * `@voltro/plugin-mail`'s `MailService.send`). Keeps the mail dep out of here. */
78
+ export declare const emailChannel: (send: (msg: {
79
+ to: string;
80
+ subject: string;
81
+ html: string;
82
+ text: string;
83
+ }) => Promise<void>) => Channel;
84
+
85
+ /** One notification held for later delivery — a digest window rollup or a
86
+ * quiet-hours deferral. `flushAt` is the ISO instant it becomes deliverable
87
+ * (the window boundary / the digest flush tick). `kind` marks WHY it was held
88
+ * so the flush can coalesce digest rows but deliver quiet-hours rows as-is. */
89
+ export declare interface HeldNotification {
90
+ readonly id: string;
91
+ readonly kind: 'digest' | 'quiet';
92
+ readonly subjectId: string;
93
+ readonly send: SendInput;
94
+ readonly flushAt: string;
95
+ }
96
+
97
+ /** The in-app channel — delivers by writing to the inbox store. */
98
+ export declare const inAppChannel: (store: NotificationStore) => Channel;
99
+
100
+ /** An in-app inbox item. */
101
+ export declare interface InboxItem {
102
+ readonly id: string;
103
+ readonly subjectId: string;
104
+ readonly category: string;
105
+ readonly title: string;
106
+ readonly body: string;
107
+ readonly data: Record<string, unknown>;
108
+ readonly readAt: string | null;
109
+ readonly createdAt: string;
110
+ readonly tenantId: string | null;
111
+ }
112
+
113
+ /** True if `at` is inside the subject's DND window (handles a window that WRAPS
114
+ * midnight, e.g. 22:00→08:00). An empty window (start === end) is never active. */
115
+ export declare const inQuietHours: (qh: QuietHours, at: Date) => boolean;
116
+
117
+ export declare const memoryNotificationStore: () => NotificationStore;
118
+
119
+ /** The minute-of-day (0–1439) `at` falls on in the IANA `tz`. Uses `Intl` so no
120
+ * date-math library is needed; an unknown zone falls back to UTC. */
121
+ export declare const minuteOfDayInZone: (at: Date, tz: string) => number;
122
+
123
+ export declare class NotificationService extends NotificationService_base {
124
+ }
125
+
126
+ declare const NotificationService_base: Context.TagClass<NotificationService, "@voltro/plugin-notifications/NotificationService", NotificationServiceShape>;
127
+
128
+ export declare interface NotificationServiceShape {
129
+ readonly send: (input: SendInput) => Promise<SendResult>;
130
+ readonly inbox: (subjectId: string, opts?: {
131
+ unreadOnly?: boolean;
132
+ limit?: number;
133
+ }) => Promise<ReadonlyArray<InboxItem>>;
134
+ readonly unreadCount: (subjectId: string) => Promise<number>;
135
+ readonly markRead: (id: string, subjectId: string) => Promise<boolean>;
136
+ readonly getPreferences: (subjectId: string) => Promise<ReadonlyArray<ChannelPreference>>;
137
+ readonly setPreference: (pref: ChannelPreference) => Promise<void>;
138
+ readonly subscribe: (topic: string, subjectId: string, tenantId?: string | null) => Promise<void>;
139
+ readonly unsubscribe: (topic: string, subjectId: string) => Promise<void>;
140
+ readonly broadcast: (topic: string, input: Omit<SendInput, 'to'>) => Promise<BroadcastResult>;
141
+ readonly setQuietHours: (qh: QuietHours) => Promise<void>;
142
+ readonly clearQuietHours: (subjectId: string) => Promise<void>;
143
+ readonly getQuietHours: (subjectId: string) => Promise<QuietHours | null>;
144
+ readonly flushDue: (now?: Date) => Promise<number>;
145
+ }
146
+
147
+ export declare const notificationsPlugin: (options?: NotificationsPluginOptions) => VoltroPlugin;
148
+
149
+ export declare interface NotificationsPluginOptions {
150
+ /** Delivery channels. The in-app inbox channel is added automatically unless
151
+ * you pass your own `inApp` channel. Default `[consoleChannel()]` + in-app. */
152
+ readonly channels?: ReadonlyArray<Channel>;
153
+ /** Inbox / prefs / delivery-log store. Default in-memory (swap for a durable
154
+ * custom store in production). */
155
+ readonly store?: NotificationStore;
156
+ /** Digest/batching rollup window in ms. When > 0, multiple sends to the same
157
+ * subject within the window coalesce into ONE digest delivery (flushed on the
158
+ * boundary by the scheduled flush). A send that forces its own `channels:`
159
+ * bypasses the digest. 0 (default) = every send delivers immediately. */
160
+ readonly digestWindowMs?: number;
161
+ /** How often the scheduled flush drains due digest windows + quiet-hours
162
+ * deferrals. Default 30s. Only runs once the store is bound at boot. */
163
+ readonly flushIntervalMs?: number;
164
+ readonly name?: string;
165
+ }
166
+
167
+ export declare interface NotificationStore {
168
+ readonly addInbox: (item: Omit<InboxItem, 'id' | 'readAt' | 'createdAt'> & {
169
+ id: string;
170
+ createdAt: string;
171
+ }) => Promise<void>;
172
+ readonly listInbox: (subjectId: string, opts?: {
173
+ unreadOnly?: boolean;
174
+ limit?: number;
175
+ }) => Promise<ReadonlyArray<InboxItem>>;
176
+ readonly markRead: (id: string, subjectId: string) => Promise<boolean>;
177
+ readonly unreadCount: (subjectId: string) => Promise<number>;
178
+ readonly getPreferences: (subjectId: string) => Promise<ReadonlyArray<ChannelPreference>>;
179
+ readonly setPreference: (pref: ChannelPreference) => Promise<void>;
180
+ readonly recordDelivery: (record: DeliveryRecord) => Promise<void>;
181
+ readonly listDeliveries: (opts?: {
182
+ limit?: number;
183
+ }) => Promise<ReadonlyArray<DeliveryRecord>>;
184
+ readonly subscribeTopic: (sub: TopicSubscription) => Promise<void>;
185
+ readonly unsubscribeTopic: (topic: string, subjectId: string) => Promise<void>;
186
+ readonly listTopicSubscribers: (topic: string) => Promise<ReadonlyArray<TopicSubscription>>;
187
+ readonly getQuietHours: (subjectId: string) => Promise<QuietHours | null>;
188
+ readonly setQuietHours: (qh: QuietHours) => Promise<void>;
189
+ readonly clearQuietHours: (subjectId: string) => Promise<void>;
190
+ readonly enqueueHeld: (held: HeldNotification) => Promise<void>;
191
+ /** Pending digest holds for a subject whose window has NOT yet elapsed — the
192
+ * coalescing check: if any exist, this send joins the open window instead of
193
+ * opening a new one. */
194
+ readonly pendingDigest: (subjectId: string) => Promise<ReadonlyArray<HeldNotification>>;
195
+ /** Every held notification now due (`flushAt <= now`), oldest-first — the
196
+ * scheduled flush drains these and clears them. */
197
+ readonly dueHeld: (now: string) => Promise<ReadonlyArray<HeldNotification>>;
198
+ readonly clearHeld: (ids: ReadonlyArray<string>) => Promise<void>;
199
+ }
200
+
201
+ /** A first-class push channel (APNs / FCM shape) — the built-in alternative to
202
+ * hand-rolling `customChannel` for mobile push. You supply two seams:
203
+ *
204
+ * - `tokensFor(subjectId)` → the subject's device tokens (from your own
205
+ * device-registration table); a subject with no tokens is a no-op deliver.
206
+ * - `transport(payload)` → hand ONE formatted `PushPayload` to APNs / FCM /
207
+ * Expo. Throw `PushTokenRejected({ token, reason })` on an unregistered /
208
+ * invalid token so the fan-out records the delivery `failed` and your app
209
+ * can prune the token. The push AUTH secret (APNs key / FCM server key)
210
+ * lives in YOUR transport closure — it never enters this package and is
211
+ * never logged.
212
+ *
213
+ * `deliver` formats the message into a `PushPayload` per token and sends each;
214
+ * a rejected token surfaces as a `PushTokenRejected` (naming the token, not the
215
+ * secret). Default `id` is `'push'`. */
216
+ export declare const pushChannel: (opts: {
217
+ readonly id?: string;
218
+ readonly tokensFor: (subjectId: string) => Promise<ReadonlyArray<string>>;
219
+ readonly transport: (payload: PushPayload) => Promise<void>;
220
+ }) => Channel;
221
+
222
+ /** The provider-agnostic push payload `pushChannel` builds per device token —
223
+ * the shape an APNs `aps` / FCM `notification` transport consumes. */
224
+ export declare interface PushPayload {
225
+ readonly token: string;
226
+ readonly title: string;
227
+ readonly body: string;
228
+ /** Best-effort unread/badge count if the app supplies it via `msg.data.badge`. */
229
+ readonly badge?: number;
230
+ /** The message `data` bag, forwarded as the push data/custom section. */
231
+ readonly data: Record<string, unknown>;
232
+ }
233
+
234
+ /** A push transport rejected a device token (unregistered / invalid / expired —
235
+ * the APNs `Unregistered` / FCM `UNREGISTERED` class). Thrown by `pushChannel`'s
236
+ * `deliver` so the fan-out records the delivery `failed` with the token that was
237
+ * rejected — the app can prune it from its device-token table. `token` carries
238
+ * the rejected token itself; a push AUTH secret (APNs key / FCM server key) is
239
+ * never part of this error and is never logged. */
240
+ export declare class PushTokenRejected extends PushTokenRejected_base {
241
+ }
242
+
243
+ declare const PushTokenRejected_base: Schema.TaggedErrorClass<PushTokenRejected, "PushTokenRejected", {
244
+ readonly _tag: Schema.tag<"PushTokenRejected">;
245
+ } & {
246
+ token: typeof Schema.String;
247
+ reason: typeof Schema.String;
248
+ }>;
249
+
250
+ /** A per-subject Do-Not-Disturb window, expressed in minutes-of-day in the
251
+ * subject's `tz` (an IANA zone). A window may WRAP midnight (`startMinute` >
252
+ * `endMinute`, e.g. 22:00→08:00). `policy` decides what a send during the
253
+ * window does: `hold` (default — deferred and delivered after the window) or
254
+ * `drop` (silently discarded). */
255
+ export declare interface QuietHours {
256
+ readonly subjectId: string;
257
+ /** Minutes past local midnight the DND window opens (0–1439). */
258
+ readonly startMinute: number;
259
+ /** Minutes past local midnight the DND window closes (0–1439). */
260
+ readonly endMinute: number;
261
+ /** IANA timezone the window is evaluated in (e.g. 'Europe/Berlin'). Default 'UTC'. */
262
+ readonly tz?: string;
263
+ /** What a send during the window does. Default 'hold'. */
264
+ readonly policy?: 'hold' | 'drop';
265
+ }
266
+
267
+ /** The next instant the DND window CLOSES, at or after `at` — where a
268
+ * held-during-quiet-hours notification becomes deliverable. */
269
+ export declare const quietHoursEnd: (qh: QuietHours, at: Date) => Date;
270
+
271
+ /**
272
+ * Resolve which channel ids to deliver on. Order: caller-requested `channels`
273
+ * (intersected with configured) ELSE all configured; then drop any the
274
+ * subject has turned OFF for this category. A missing preference = on.
275
+ */
276
+ export declare const resolveChannels: (configured: ReadonlyArray<string>, category: string, requested: ReadonlyArray<string> | undefined, prefs: ReadonlyArray<ChannelPreference>, subjectId: string) => ReadonlyArray<string>;
277
+
278
+ /** What a caller asks to send. `channels` overrides the default routing. */
279
+ export declare interface SendInput {
280
+ readonly to: string;
281
+ readonly category: string;
282
+ readonly title: string;
283
+ readonly body: string;
284
+ readonly data?: Record<string, unknown>;
285
+ readonly channels?: ReadonlyArray<string>;
286
+ readonly tenantId?: string | null;
287
+ }
288
+
289
+ export declare interface SendResult {
290
+ readonly delivered: ReadonlyArray<string>;
291
+ readonly failed: ReadonlyArray<{
292
+ channel: string;
293
+ error: string;
294
+ }>;
295
+ readonly skipped: ReadonlyArray<string>;
296
+ /** Set when the send did NOT go out immediately: `digest` (joined/opened a
297
+ * rollup window), `quiet-held` (deferred past a DND window), or `quiet-drop`
298
+ * (discarded per the `drop` policy). Absent on an immediate delivery. */
299
+ readonly held?: 'digest' | 'quiet-held' | 'quiet-drop';
300
+ }
301
+
302
+ /** SMS via a generic REST sender (Twilio etc.) — app supplies the send fn. */
303
+ export declare const smsChannel: (send: (to: string, body: string) => Promise<void>) => Channel;
304
+
305
+ /** A subscription of a subject to a broadcast topic. `broadcast(topic, …)` fans
306
+ * a single send out to every subscriber of that topic. */
307
+ export declare interface TopicSubscription {
308
+ readonly topic: string;
309
+ readonly subjectId: string;
310
+ readonly tenantId?: string | null;
311
+ }
312
+
313
+ /** POST the message to an incoming webhook (Slack / Discord / Teams / generic).
314
+ * `format` shapes the JSON body; default Slack-style `{ text }`. */
315
+ export declare const webhookChannel: (opts: {
316
+ readonly id?: string;
317
+ readonly url: string;
318
+ readonly format?: (msg: ChannelMessage) => unknown;
319
+ readonly headers?: Record<string, string>;
320
+ }) => Channel;
321
+
322
+ export { }