@voltro/plugin-notifications 0.80.0 → 0.82.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,53 @@
1
+ import { Schema } from 'effect';
2
+
3
+ export declare const defineNotification: <Audience, Payload>(config: DefineNotificationConfig<Audience, Payload>) => NotificationDescriptor<Audience, Payload>;
4
+
5
+ export declare interface DefineNotificationConfig<Audience, Payload> {
6
+ /** Stable name (`order.shipped`); also the default category. */
7
+ readonly name: string;
8
+ /** The notifications category preferences are keyed by. Default: `name`. */
9
+ readonly category?: string;
10
+ readonly audience: Schema.Schema<Audience, any>;
11
+ readonly payload: Schema.Schema<Payload, any>;
12
+ /** PURE: payload + locale → what the recipient sees. No IO. */
13
+ readonly render: (payload: Payload, ctx: RenderContext) => RenderedNotification;
14
+ /** Restrict to these channels (ids); default: every configured channel,
15
+ * subject to the recipient's preferences. */
16
+ readonly channels?: ReadonlyArray<string>;
17
+ }
18
+
19
+ export declare interface NotificationDescriptor<Audience = unknown, Payload = unknown> {
20
+ readonly _tag: 'VoltroNotification';
21
+ readonly name: string;
22
+ readonly category: string;
23
+ readonly audience: Schema.Schema<Audience, any>;
24
+ readonly payload: Schema.Schema<Payload, any>;
25
+ readonly render: (payload: Payload, ctx: RenderContext) => RenderedNotification;
26
+ readonly channels?: ReadonlyArray<string>;
27
+ }
28
+
29
+ export declare interface RenderContext {
30
+ /** The recipient's locale (from the device row, the user, or the app's default). */
31
+ readonly locale: string;
32
+ readonly recipientId: string;
33
+ }
34
+
35
+ export declare interface RenderedNotification {
36
+ readonly title: string;
37
+ readonly body: string;
38
+ /** Carried to every channel; `deepLink` is what a tap opens (web push, the
39
+ * mobile app's link table, the desktop shell). */
40
+ readonly data?: Readonly<Record<string, unknown>> & {
41
+ readonly deepLink?: string;
42
+ };
43
+ /** Rich push: an image url the push shows (Expo `richContent`, FCM
44
+ * `notification.image`, APNs through a mutable-content extension). */
45
+ readonly image?: string;
46
+ /** Rich push: action buttons. Each `id` comes back as the tapped action. */
47
+ readonly actions?: ReadonlyArray<{
48
+ readonly id: string;
49
+ readonly title: string;
50
+ }>;
51
+ }
52
+
53
+ export { }
package/dist/define.js ADDED
@@ -0,0 +1,15 @@
1
+ //#region src/define.ts
2
+ var e = (e) => {
3
+ if (!e.name || !/^[a-z][a-zA-Z0-9._-]*$/.test(e.name)) throw Error(`defineNotification: name must be a dotted identifier (got ${JSON.stringify(e.name)})`);
4
+ return {
5
+ _tag: "VoltroNotification",
6
+ name: e.name,
7
+ category: e.category ?? e.name,
8
+ audience: e.audience,
9
+ payload: e.payload,
10
+ render: e.render,
11
+ ...e.channels === void 0 ? {} : { channels: e.channels }
12
+ };
13
+ };
14
+ //#endregion
15
+ export { e as defineNotification };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,49 @@ import { Schema } from 'effect';
6
6
  import { Table } from '@voltro/database';
7
7
  import { VoltroPlugin } from '@voltro/protocol';
8
8
 
9
+ export declare const APNS_HOSTS: {
10
+ readonly production: "https://api.push.apple.com";
11
+ readonly sandbox: "https://api.sandbox.push.apple.com";
12
+ };
13
+
14
+ /** Reasons Apple answers that mean "this token will never work again". */
15
+ export declare const APNS_TOKEN_REJECTIONS: ReadonlySet<string>;
16
+
17
+ /** The APNs request body from the channel's payload. */
18
+ export declare const apnsBody: (payload: PushPayload) => Record<string, unknown>;
19
+
20
+ /** The ES256 provider token — `{ alg, kid }` . `{ iss, iat }` . signature. */
21
+ export declare const apnsProviderToken: (options: Pick<ApnsTransportOptions, "keyId" | "teamId" | "privateKey">, issuedAtMs: number) => string;
22
+
23
+ /** One HTTP/2 POST. Default: `node:http2`, one session per send. */
24
+ export declare type ApnsRequest = (input: ApnsRequestInput) => Promise<ApnsResponse>;
25
+
26
+ export declare interface ApnsRequestInput {
27
+ readonly url: string;
28
+ readonly headers: Readonly<Record<string, string>>;
29
+ readonly body: string;
30
+ }
31
+
32
+ export declare interface ApnsResponse {
33
+ readonly status: number;
34
+ readonly body: string;
35
+ }
36
+
37
+ export declare const apnsTransport: (options: ApnsTransportOptions) => ((payload: PushPayload) => Promise<void>);
38
+
39
+ export declare interface ApnsTransportOptions {
40
+ /** The 10-character key id from the Apple developer portal. */
41
+ readonly keyId: string;
42
+ readonly teamId: string;
43
+ /** The .p8 file's contents (a PEM-encoded private key, as Apple issues it). */
44
+ readonly privateKey: string;
45
+ /** The app's bundle id — the `apns-topic`. */
46
+ readonly bundleId: string;
47
+ readonly environment?: 'production' | 'sandbox';
48
+ readonly request?: ApnsRequest;
49
+ readonly now?: () => number;
50
+ }
51
+
9
52
  export declare interface BroadcastResult {
10
53
  /** How many topic subscribers the broadcast fanned out to. */
11
54
  readonly recipients: number;
@@ -85,6 +128,22 @@ export declare const decryptWebPushPayload: (body: Buffer, receiver: {
85
128
  readonly authSecret: Buffer;
86
129
  }) => Buffer;
87
130
 
131
+ export declare const defineNotification: <Audience, Payload>(config: DefineNotificationConfig<Audience, Payload>) => NotificationDescriptor<Audience, Payload>;
132
+
133
+ export declare interface DefineNotificationConfig<Audience, Payload> {
134
+ /** Stable name (`order.shipped`); also the default category. */
135
+ readonly name: string;
136
+ /** The notifications category preferences are keyed by. Default: `name`. */
137
+ readonly category?: string;
138
+ readonly audience: Schema.Schema<Audience, any>;
139
+ readonly payload: Schema.Schema<Payload, any>;
140
+ /** PURE: payload + locale → what the recipient sees. No IO. */
141
+ readonly render: (payload: Payload, ctx: RenderContext) => RenderedNotification;
142
+ /** Restrict to these channels (ids); default: every configured channel,
143
+ * subject to the recipient's preferences. */
144
+ readonly channels?: ReadonlyArray<string>;
145
+ }
146
+
88
147
  export declare const deliveriesTable: Table<"_voltro_notification_deliveries", FieldDefinitions<{
89
148
  readonly id: ColumnBuilder<string, "id", boolean>;
90
149
  readonly recipient: ColumnBuilder<string, "text", boolean>;
@@ -118,6 +177,14 @@ export declare interface DeliveryRecord {
118
177
  readonly clickedAt?: string | null;
119
178
  }
120
179
 
180
+ export declare interface DurableNotificationPayload {
181
+ readonly notification: string;
182
+ readonly recipientId: string;
183
+ readonly locale: string;
184
+ readonly payload: unknown;
185
+ readonly tenantId: string | null;
186
+ }
187
+
121
188
  /** Email channel — the app supplies the sender (compose with
122
189
  * `@voltro/plugin-mail`'s `MailService.send`). Keeps the mail dep out of here. */
123
190
  export declare const emailChannel: (send: (msg: {
@@ -156,10 +223,72 @@ declare interface EndpointDelivery {
156
223
 
157
224
  export declare const endpointHashOf: (endpoint: string) => string;
158
225
 
226
+ export declare interface EnqueuedNotification {
227
+ readonly recipientId: string;
228
+ readonly locale: string;
229
+ readonly outboxId: string;
230
+ }
231
+
232
+ /** One outbox row per recipient; rendering happens at delivery, in the recipient's locale as known NOW. */
233
+ export declare const enqueueNotification: <Audience, Payload>(outbox: OutboxEnqueue, descriptor: NotificationDescriptor<Audience, Payload>, options: EnqueueNotificationOptions<Audience, Payload>) => Promise<ReadonlyArray<EnqueuedNotification>>;
234
+
235
+ export declare interface EnqueueNotificationOptions<Audience, Payload> extends SendNotificationOptions<Audience, Payload> {
236
+ /** Dedupes per recipient as `<notification>:<recipient>:<key>` — a retried mutation enqueues once. */
237
+ readonly idempotencyKey?: string;
238
+ readonly maxAttempts?: number;
239
+ readonly delayMs?: number;
240
+ }
241
+
242
+ export declare const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
243
+
244
+ export declare const expoPushTransport: (options?: ExpoPushTransportOptions) => ((payload: PushPayload) => Promise<void>);
245
+
246
+ export declare interface ExpoPushTransportOptions {
247
+ /** Expo access token (`EXPO_ACCESS_TOKEN`); required for enhanced security
248
+ * push, optional otherwise. Never logged. */
249
+ readonly accessToken?: string;
250
+ readonly fetch?: typeof fetch;
251
+ readonly endpoint?: string;
252
+ }
253
+
254
+ export declare const FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
255
+
256
+ /** FCM error codes that mean the token is gone for good. */
257
+ export declare const FCM_TOKEN_REJECTIONS: ReadonlySet<string>;
258
+
259
+ /** The RS256 JWT assertion Google's token endpoint exchanges for an access token. */
260
+ export declare const fcmAssertion: (sa: FcmServiceAccount, issuedAtMs: number) => string;
261
+
262
+ /** The HTTP v1 message from the channel's payload — data values must be strings. */
263
+ export declare const fcmMessage: (payload: PushPayload) => Record<string, unknown>;
264
+
265
+ export declare const fcmSendEndpoint: (projectId: string) => string;
266
+
267
+ export declare interface FcmServiceAccount {
268
+ readonly project_id: string;
269
+ readonly client_email: string;
270
+ /** The service account's PEM-encoded private key, as Google issues it. */
271
+ readonly private_key: string;
272
+ readonly token_uri?: string;
273
+ }
274
+
275
+ export declare const fcmTransport: (options: FcmTransportOptions) => ((payload: PushPayload) => Promise<void>);
276
+
277
+ export declare interface FcmTransportOptions {
278
+ /** The service-account JSON, parsed or as the string Google issued. */
279
+ readonly serviceAccount: FcmServiceAccount | string;
280
+ readonly fetch?: typeof fetch;
281
+ readonly now?: () => number;
282
+ /** Override the send endpoint (tests). */
283
+ readonly endpoint?: string;
284
+ }
285
+
159
286
  /** Mint a fresh P-256 private scalar, base64url — what the dev secret mint and
160
287
  * `voltro secret generate` produce for `VOLTRO_VAPID_PRIVATE_KEY`. */
161
288
  export declare const generateVapidPrivateKey: () => string;
162
289
 
290
+ export declare const GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token";
291
+
163
292
  /** One notification held for later delivery — a digest window rollup or a
164
293
  * quiet-hours deferral. `flushAt` is the ISO instant it becomes deliverable
165
294
  * (the window boundary / the digest flush tick). `kind` marks WHY it was held
@@ -225,6 +354,8 @@ export declare const inboxTable: Table<"_voltro_notification_inbox", FieldDefini
225
354
  * midnight, e.g. 22:00→08:00). An empty window (start === end) is never active. */
226
355
  export declare const inQuietHours: (qh: QuietHours, at: Date) => boolean;
227
356
 
357
+ export declare const isExpoPushToken: (token: string) => boolean;
358
+
228
359
  /**
229
360
  * WHO an inbox belongs to.
230
361
  *
@@ -276,6 +407,38 @@ export declare const memoryPushSubscriptionStore: () => PushSubscriptionStore;
276
407
  * date-math library is needed; an unknown zone falls back to UTC. */
277
408
  export declare const minuteOfDayInZone: (at: Date, tz: string) => number;
278
409
 
410
+ export declare const NOTIFICATION_SEND_EFFECT = "voltro.notification.send";
411
+
412
+ export declare interface NotificationDescriptor<Audience = unknown, Payload = unknown> {
413
+ readonly _tag: 'VoltroNotification';
414
+ readonly name: string;
415
+ readonly category: string;
416
+ readonly audience: Schema.Schema<Audience, any>;
417
+ readonly payload: Schema.Schema<Payload, any>;
418
+ readonly render: (payload: Payload, ctx: RenderContext) => RenderedNotification;
419
+ readonly channels?: ReadonlyArray<string>;
420
+ }
421
+
422
+ /** The `*.outbox.ts` handler: render the row's descriptor for its recipient and send. */
423
+ export declare const notificationOutboxHandler: (options: NotificationOutboxHandlerOptions) => {
424
+ readonly effect: typeof NOTIFICATION_SEND_EFFECT;
425
+ readonly maxAttempts: number;
426
+ readonly handler: (ctx: {
427
+ readonly payload: Record<string, unknown>;
428
+ }) => Promise<SendResult>;
429
+ };
430
+
431
+ export declare interface NotificationOutboxHandlerOptions {
432
+ /** The notification service of THIS process; `undefined` fails the attempt loudly rather than dropping the row. */
433
+ readonly service: () => {
434
+ readonly send: (input: SendInput) => Promise<SendResult>;
435
+ } | undefined;
436
+ /** Every descriptor a row may name — an unknown name is a dead row, not a silent skip. */
437
+ readonly notifications: ReadonlyArray<NotificationDescriptor<any, any>>;
438
+ /** Default 5. */
439
+ readonly maxAttempts?: number;
440
+ }
441
+
279
442
  export declare class NotificationService extends NotificationService_base {
280
443
  }
281
444
 
@@ -475,6 +638,17 @@ export declare interface NotificationStore {
475
638
  readonly clearHeld: (ids: ReadonlyArray<string>) => Promise<void>;
476
639
  }
477
640
 
641
+ /** Structural: `ctx.outbox` from a handler, or `makeOutboxFacade`'s result. */
642
+ export declare interface OutboxEnqueue {
643
+ readonly enqueue: (effect: string, payload: Record<string, unknown>, options?: {
644
+ readonly idempotencyKey?: string;
645
+ readonly maxAttempts?: number;
646
+ readonly delayMs?: number;
647
+ }) => Promise<string>;
648
+ }
649
+
650
+ export declare const parseServiceAccount: (value: FcmServiceAccount | string) => FcmServiceAccount;
651
+
478
652
  export declare const preferencesTable: Table<"_voltro_notification_preferences", FieldDefinitions<{
479
653
  readonly id: ColumnBuilder<string, "id", boolean>;
480
654
  readonly subjectId: ColumnBuilder<string, "text", boolean>;
@@ -518,6 +692,13 @@ export declare interface PushPayload {
518
692
  readonly badge?: number;
519
693
  /** The message `data` bag, forwarded as the push data/custom section. */
520
694
  readonly data: Record<string, unknown>;
695
+ /** Rich push: from `msg.data.image` — an image url. */
696
+ readonly image?: string;
697
+ /** Rich push: from `msg.data.actions` — action buttons by id. */
698
+ readonly actions?: ReadonlyArray<{
699
+ readonly id: string;
700
+ readonly title: string;
701
+ }>;
521
702
  }
522
703
 
523
704
  /** A browser push subscription's crypto material, as `PushSubscription.toJSON()`
@@ -614,6 +795,30 @@ export declare const quietHoursTable: Table<"_voltro_notification_quiet_hours",
614
795
  readonly policy: ColumnBuilder<string, "text", true>;
615
796
  }>, true, never>;
616
797
 
798
+ export declare interface RenderContext {
799
+ /** The recipient's locale (from the device row, the user, or the app's default). */
800
+ readonly locale: string;
801
+ readonly recipientId: string;
802
+ }
803
+
804
+ export declare interface RenderedNotification {
805
+ readonly title: string;
806
+ readonly body: string;
807
+ /** Carried to every channel; `deepLink` is what a tap opens (web push, the
808
+ * mobile app's link table, the desktop shell). */
809
+ readonly data?: Readonly<Record<string, unknown>> & {
810
+ readonly deepLink?: string;
811
+ };
812
+ /** Rich push: an image url the push shows (Expo `richContent`, FCM
813
+ * `notification.image`, APNs through a mutable-content extension). */
814
+ readonly image?: string;
815
+ /** Rich push: action buttons. Each `id` comes back as the tapped action. */
816
+ readonly actions?: ReadonlyArray<{
817
+ readonly id: string;
818
+ readonly title: string;
819
+ }>;
820
+ }
821
+
617
822
  /** Test seam. */
618
823
  export declare const resetWebPushStoreForTest: () => void;
619
824
 
@@ -624,6 +829,15 @@ export declare const resetWebPushStoreForTest: () => void;
624
829
  */
625
830
  export declare const resolveChannels: (configured: ReadonlyArray<string>, category: string, requested: ReadonlyArray<string> | undefined, prefs: ReadonlyArray<ChannelPreference>, subjectId: string) => ReadonlyArray<string>;
626
831
 
832
+ /** Rich-push fields as a message's `data` carries them, or nothing. */
833
+ export declare const richPushOf: (data: Readonly<Record<string, unknown>> | undefined) => {
834
+ readonly image?: string;
835
+ readonly actions?: ReadonlyArray<{
836
+ readonly id: string;
837
+ readonly title: string;
838
+ }>;
839
+ };
840
+
627
841
  /** What a caller asks to send. `channels` overrides the default routing. */
628
842
  export declare interface SendInput {
629
843
  readonly to: string;
@@ -635,6 +849,21 @@ export declare interface SendInput {
635
849
  readonly tenantId?: string | null;
636
850
  }
637
851
 
852
+ export declare const sendNotification: <Audience, Payload>(service: {
853
+ readonly send: (input: SendInput) => Promise<SendResult>;
854
+ }, descriptor: NotificationDescriptor<Audience, Payload>, options: SendNotificationOptions<Audience, Payload>) => Promise<ReadonlyArray<SentNotification>>;
855
+
856
+ export declare interface SendNotificationOptions<Audience, Payload> {
857
+ readonly audience: Audience;
858
+ readonly payload: Payload;
859
+ /** Audience → recipient subject ids. Default: `{ userId }` or `{ userIds }`. */
860
+ readonly recipients?: (audience: Audience) => Promise<ReadonlyArray<string>> | ReadonlyArray<string>;
861
+ /** The recipient's locale; null → `defaultLocale`. Default: none known. */
862
+ readonly localeFor?: (recipientId: string) => Promise<string | null>;
863
+ readonly defaultLocale?: string;
864
+ readonly tenantId?: string | null;
865
+ }
866
+
638
867
  export declare interface SendResult {
639
868
  readonly delivered: ReadonlyArray<string>;
640
869
  readonly failed: ReadonlyArray<{
@@ -651,6 +880,12 @@ export declare interface SendResult {
651
880
  /** POST one encrypted payload to one subscription's push service. */
652
881
  export declare const sendWebPush: (target: WebPushTarget, payload: Buffer, options: WebPushSendOptions) => Promise<WebPushOutcome>;
653
882
 
883
+ export declare interface SentNotification {
884
+ readonly recipientId: string;
885
+ readonly locale: string;
886
+ readonly result: SendResult;
887
+ }
888
+
654
889
  /** SMS via a generic REST sender (Twilio etc.) — app supplies the send fn. */
655
890
  export declare const smsChannel: (send: (to: string, body: string) => Promise<void>) => Channel;
656
891