@voltro/plugin-notifications 0.85.0 → 0.87.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/dist/rpc.d.ts CHANGED
@@ -22,6 +22,14 @@ export declare const archiveDescriptor: MutationProcedureDescriptor<"notificatio
22
22
  */
23
23
  export declare const BASE_NAME = "@voltro/plugin-notifications";
24
24
 
25
+ /** One channel switch of the calling subject for one category. */
26
+ export declare const ChannelPreferenceSchema: Schema.Struct<{
27
+ readonly subjectId: Schema.String;
28
+ readonly category: Schema.String;
29
+ readonly channel: Schema.String;
30
+ readonly enabled: Schema.Boolean;
31
+ }>;
32
+
25
33
  export declare const clearQuietHoursDescriptor: MutationProcedureDescriptor<"notifications.clearQuietHours", Schema.Struct<{}>, Schema.Struct<{
26
34
  readonly ok: Schema.Boolean;
27
35
  }>, Schema.Never>;
@@ -55,7 +63,15 @@ export declare const markUnreadDescriptor: MutationProcedureDescriptor<"notifica
55
63
  * emitted into the generated client group. Kept in lockstep with the exports. */
56
64
  export declare const notificationsRpcClientImports: ReadonlyArray<PluginRpcClientDescriptor>;
57
65
 
58
- export declare const preferencesDescriptor: QueryProcedureDescriptor<"notifications.preferences", Schema.Struct<{}>, Schema.$Array<Schema.$Record<Schema.String, Schema.Unknown>>, Schema.Never>;
66
+ export declare const preferencesDescriptor: QueryProcedureDescriptor<"notifications.preferences", Schema.Struct<{}>, Schema.$Array<Schema.Struct<{
67
+ readonly subjectId: Schema.String;
68
+ readonly category: Schema.String;
69
+ readonly channel: Schema.String;
70
+ readonly enabled: Schema.Boolean;
71
+ }>>, Schema.Never>;
72
+
73
+ /** Every preference write publishes this channel, so `notifications.preferences` subscribers see the change live. */
74
+ export declare const preferencesFeed: ReactivityChannel;
59
75
 
60
76
  export declare const setPreferenceDescriptor: MutationProcedureDescriptor<"notifications.setPreference", Schema.Struct<{
61
77
  readonly category: Schema.String;
package/dist/rpc.js CHANGED
@@ -1,7 +1,12 @@
1
1
  import { Schema as e } from "effect";
2
2
  import { defineMutation as t, defineQuery as n, reactivityChannel as r } from "@voltro/protocol";
3
3
  //#region src/rpc.ts
4
- var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
4
+ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = r("notifications-preferences"), s = e.Struct({
5
+ subjectId: e.String,
6
+ category: e.String,
7
+ channel: e.String,
8
+ enabled: e.Boolean
9
+ }), c = n({
5
10
  name: "notifications.inbox",
6
11
  source: a,
7
12
  input: e.Struct({
@@ -10,43 +15,44 @@ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
10
15
  }),
11
16
  output: e.Array(e.Record(e.String, e.Unknown)),
12
17
  openAccess: "self-scoped read: returns only the calling subject's own inbox items"
13
- }), s = n({
18
+ }), l = n({
14
19
  name: "notifications.unreadCount",
15
20
  source: a,
16
21
  input: e.Struct({}),
17
22
  output: e.Struct({ count: e.Number }),
18
23
  openAccess: "self-scoped read: counts only the calling subject's own unread items"
19
- }), c = t({
24
+ }), u = t({
20
25
  name: "notifications.markRead",
21
26
  input: e.Struct({ id: e.String }),
22
27
  output: e.Struct({ ok: e.Boolean }),
23
28
  openAccess: "self-scoped write: marks an item read only within the calling subject's own inbox"
24
- }), l = t({
29
+ }), d = t({
25
30
  name: "notifications.markUnread",
26
31
  input: e.Struct({ id: e.String }),
27
32
  output: e.Struct({ ok: e.Boolean }),
28
33
  openAccess: "self-scoped write: marks an item unread only within the calling subject's own inbox"
29
- }), u = t({
34
+ }), f = t({
30
35
  name: "notifications.markAllRead",
31
36
  input: e.Struct({}),
32
37
  output: e.Struct({ count: e.Number }),
33
38
  openAccess: "self-scoped write: clears unread state only in the calling subject's own inbox"
34
- }), d = t({
39
+ }), p = t({
35
40
  name: "notifications.archive",
36
41
  input: e.Struct({ id: e.String }),
37
42
  output: e.Struct({ ok: e.Boolean }),
38
43
  openAccess: "self-scoped write: archives an item only within the calling subject's own inbox"
39
- }), f = t({
44
+ }), m = t({
40
45
  name: "notifications.unarchive",
41
46
  input: e.Struct({ id: e.String }),
42
47
  output: e.Struct({ ok: e.Boolean }),
43
48
  openAccess: "self-scoped write: unarchives an item only within the calling subject's own inbox"
44
- }), p = n({
49
+ }), h = n({
45
50
  name: "notifications.preferences",
51
+ source: o,
46
52
  input: e.Struct({}),
47
- output: e.Array(e.Record(e.String, e.Unknown)),
53
+ output: e.Array(s),
48
54
  openAccess: "self-scoped read: returns only the calling subject's own channel preferences"
49
- }), m = t({
55
+ }), g = t({
50
56
  name: "notifications.setPreference",
51
57
  input: e.Struct({
52
58
  category: e.String,
@@ -55,17 +61,17 @@ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
55
61
  }),
56
62
  output: e.Struct({ ok: e.Boolean }),
57
63
  openAccess: "self-scoped write: toggles a channel only on the calling subject's own preferences"
58
- }), h = t({
64
+ }), _ = t({
59
65
  name: "notifications.subscribe",
60
66
  input: e.Struct({ topic: e.String }),
61
67
  output: e.Struct({ ok: e.Boolean }),
62
68
  openAccess: "self-service: subscribes the calling subject itself to a topic (the subject is never caller-supplied)"
63
- }), g = t({
69
+ }), v = t({
64
70
  name: "notifications.unsubscribe",
65
71
  input: e.Struct({ topic: e.String }),
66
72
  output: e.Struct({ ok: e.Boolean }),
67
73
  openAccess: "self-service: unsubscribes the calling subject itself from a topic"
68
- }), _ = t({
74
+ }), y = t({
69
75
  name: "notifications.setQuietHours",
70
76
  input: e.Struct({
71
77
  startMinute: e.Number,
@@ -75,17 +81,17 @@ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
75
81
  }),
76
82
  output: e.Struct({ ok: e.Boolean }),
77
83
  openAccess: "self-scoped write: sets only the calling subject's own quiet-hours window"
78
- }), v = t({
84
+ }), b = t({
79
85
  name: "notifications.clearQuietHours",
80
86
  input: e.Struct({}),
81
87
  output: e.Struct({ ok: e.Boolean }),
82
88
  openAccess: "self-scoped write: clears only the calling subject's own quiet-hours window"
83
- }), y = n({
89
+ }), x = n({
84
90
  name: "notifications.webPushPublicKey",
85
91
  input: e.Struct({}),
86
92
  output: e.Struct({ key: e.String }),
87
93
  openAccess: "public application-server key — the value every subscribing browser is handed by design"
88
- }), b = t({
94
+ }), S = t({
89
95
  name: "notifications.webPushSubscribe",
90
96
  input: e.Struct({
91
97
  endpoint: e.String,
@@ -95,17 +101,17 @@ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
95
101
  }),
96
102
  output: e.Struct({ ok: e.Boolean }),
97
103
  openAccess: "self-scoped write: registers the calling subject's own browser subscription (the subject is never caller-supplied)"
98
- }), x = t({
104
+ }), C = t({
99
105
  name: "notifications.webPushUnsubscribe",
100
106
  input: e.Struct({ endpoint: e.String }),
101
107
  output: e.Struct({ ok: e.Boolean }),
102
108
  openAccess: "self-scoped write: removes only a subscription the calling subject owns"
103
- }), S = n({
109
+ }), w = n({
104
110
  name: "notifications.webPushStatus",
105
111
  input: e.Struct({}),
106
112
  output: e.Struct({ endpoints: e.Number }),
107
113
  openAccess: "self-scoped read: counts only the calling subject's own subscriptions"
108
- }), C = [
114
+ }), T = [
109
115
  {
110
116
  tag: "notifications.webPushPublicKey",
111
117
  kind: "query",
@@ -138,7 +144,7 @@ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
138
144
  name: "webPushStatusDescriptor"
139
145
  }
140
146
  }
141
- ], w = [
147
+ ], E = [
142
148
  {
143
149
  tag: "notifications.inbox",
144
150
  kind: "query",
@@ -245,4 +251,4 @@ var i = "@voltro/plugin-notifications", a = r("notifications-inbox"), o = n({
245
251
  }
246
252
  ];
247
253
  //#endregion
248
- export { i as BASE_NAME, d as archiveDescriptor, v as clearQuietHoursDescriptor, o as inboxDescriptor, a as inboxFeed, u as markAllReadDescriptor, c as markReadDescriptor, l as markUnreadDescriptor, w as notificationsRpcClientImports, p as preferencesDescriptor, m as setPreferenceDescriptor, _ as setQuietHoursDescriptor, h as subscribeDescriptor, f as unarchiveDescriptor, s as unreadCountDescriptor, g as unsubscribeDescriptor, y as webPushPublicKeyDescriptor, C as webPushRpcClientImports, S as webPushStatusDescriptor, b as webPushSubscribeDescriptor, x as webPushUnsubscribeDescriptor };
254
+ export { i as BASE_NAME, s as ChannelPreferenceSchema, p as archiveDescriptor, b as clearQuietHoursDescriptor, c as inboxDescriptor, a as inboxFeed, f as markAllReadDescriptor, u as markReadDescriptor, d as markUnreadDescriptor, E as notificationsRpcClientImports, h as preferencesDescriptor, o as preferencesFeed, g as setPreferenceDescriptor, y as setQuietHoursDescriptor, _ as subscribeDescriptor, m as unarchiveDescriptor, l as unreadCountDescriptor, v as unsubscribeDescriptor, x as webPushPublicKeyDescriptor, T as webPushRpcClientImports, w as webPushStatusDescriptor, S as webPushSubscribeDescriptor, C as webPushUnsubscribeDescriptor };
@@ -0,0 +1,42 @@
1
+ import { Schema as e } from "effect";
2
+ //#region src/sendNotification.ts
3
+ var t = (e) => {
4
+ if (typeof e == "object" && e) {
5
+ let t = e;
6
+ if (typeof t.userId == "string") return [t.userId];
7
+ if (Array.isArray(t.userIds)) return t.userIds.filter((e) => typeof e == "string");
8
+ }
9
+ throw Error("sendNotification: pass `recipients` — the audience has neither `userId` nor `userIds`");
10
+ }, n = async (n, i, a) => {
11
+ let o = e.decodeUnknownSync(i.audience)(a.audience), s = e.decodeUnknownSync(i.payload)(a.payload), c = a.recipients ? await a.recipients(o) : t(o), l = [];
12
+ for (let e of c) {
13
+ let t = (a.localeFor ? await a.localeFor(e) : null) ?? a.defaultLocale ?? "en", o = await n.send(r(i, s, e, t, a.tenantId));
14
+ l.push({
15
+ recipientId: e,
16
+ locale: t,
17
+ result: o
18
+ });
19
+ }
20
+ return l;
21
+ }, r = (e, t, n, r, i) => {
22
+ let a = e.render(t, {
23
+ locale: r,
24
+ recipientId: n
25
+ });
26
+ return {
27
+ to: n,
28
+ category: e.category,
29
+ title: a.title,
30
+ body: a.body,
31
+ data: {
32
+ ...a.data,
33
+ notification: e.name,
34
+ ...a.image === void 0 ? {} : { image: a.image },
35
+ ...a.actions === void 0 ? {} : { actions: a.actions }
36
+ },
37
+ ...e.channels === void 0 ? {} : { channels: e.channels },
38
+ ...i === void 0 ? {} : { tenantId: i }
39
+ };
40
+ };
41
+ //#endregion
42
+ export { t as defaultRecipients, r as sendInputFor, n as sendNotification };
@@ -0,0 +1,246 @@
1
+ import { resolveChannels as e } from "./channels.js";
2
+ import { randomUUID as t } from "node:crypto";
3
+ //#region src/service.ts
4
+ var n = (e) => ({
5
+ id: "inApp",
6
+ deliver: async (n) => {
7
+ await e.addInbox({
8
+ id: t(),
9
+ subjectId: n.to,
10
+ category: n.category,
11
+ title: n.title,
12
+ body: n.body,
13
+ data: n.data ?? {},
14
+ tenantId: n.tenantId ?? null,
15
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
16
+ });
17
+ }
18
+ }), r = (e, t) => {
19
+ try {
20
+ let n = new Intl.DateTimeFormat("en-US", {
21
+ timeZone: t,
22
+ hour: "2-digit",
23
+ minute: "2-digit",
24
+ hour12: !1
25
+ }).formatToParts(e), r = Number(n.find((e) => e.type === "hour")?.value ?? "0") % 24, i = Number(n.find((e) => e.type === "minute")?.value ?? "0");
26
+ return r * 60 + i;
27
+ } catch {
28
+ return e.getUTCHours() * 60 + e.getUTCMinutes();
29
+ }
30
+ }, i = (e, t) => {
31
+ if (e.startMinute === e.endMinute) return !1;
32
+ let n = r(t, e.tz ?? "UTC");
33
+ return e.startMinute < e.endMinute ? n >= e.startMinute && n < e.endMinute : n >= e.startMinute || n < e.endMinute;
34
+ }, a = (e, t) => {
35
+ let n = r(t, e.tz ?? "UTC"), i = e.endMinute - n;
36
+ return i <= 0 && (i += 1440), new Date(t.getTime() + i * 6e4);
37
+ }, o = (n) => {
38
+ let r = new Map(n.channels.map((e) => [e.id, e])), o = n.channels.map((e) => e.id), c = n.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()), l = n.digestWindowMs ?? 0, u = new Map(Object.entries(n.deliveryGuards ?? {}));
39
+ if (u.size > 256 || [...u].some(([e, t]) => !/^[a-z][a-zA-Z0-9._-]{0,127}$/.test(e) || typeof t != "function")) throw Error("Invalid notification delivery guard registry");
40
+ let d = async (e) => {
41
+ let t = e.deliveryGuard;
42
+ if (t === void 0) return !0;
43
+ if (!t || typeof t.id != "string" || !t.payload || typeof t.payload != "object" || Array.isArray(t.payload)) throw Error("Invalid notification delivery guard reference");
44
+ let n = u.get(t.id);
45
+ if (!n) throw Error("Notification delivery guard unavailable");
46
+ let r = JSON.stringify(t.payload);
47
+ if (typeof r != "string" || r.length > 8192) throw Error("Notification delivery guard payload exceeds budget");
48
+ let i = JSON.parse(r);
49
+ if (typeof i != "object" || !i || Array.isArray(i)) throw Error("Invalid notification delivery guard payload");
50
+ let a = await n({
51
+ recipientId: e.to,
52
+ tenantId: e.tenantId ?? null,
53
+ payload: i
54
+ });
55
+ if (a !== !0 && a !== !1) throw Error("Invalid notification delivery guard decision");
56
+ return a;
57
+ }, f = () => ({
58
+ delivered: [],
59
+ failed: [],
60
+ skipped: [...o]
61
+ }), p = async (i) => {
62
+ if (!await d(i)) return f();
63
+ let a = await n.store.getPreferences(i.to), s = e(o, i.category, i.channels, a, i.to), l = o.filter((e) => !s.includes(e)), u = [], p = [], m = {
64
+ to: i.to,
65
+ category: i.category,
66
+ title: i.title,
67
+ body: i.body,
68
+ ...i.data ? { data: i.data } : {},
69
+ tenantId: i.tenantId ?? null
70
+ };
71
+ for (let e of s) {
72
+ if (!await d(i)) {
73
+ l.push(e);
74
+ continue;
75
+ }
76
+ let a = r.get(e);
77
+ if (!a) continue;
78
+ if (a.deliverDetailed !== void 0) {
79
+ try {
80
+ let r = await a.deliverDetailed(m);
81
+ for (let a of r) await n.store.recordDelivery({
82
+ id: t(),
83
+ to: i.to,
84
+ category: i.category,
85
+ channel: e,
86
+ at: c(),
87
+ status: a.status,
88
+ endpoint: a.endpoint,
89
+ ...a.error === void 0 ? {} : { error: a.error },
90
+ ...a.clickToken === void 0 ? {} : { clickToken: a.clickToken }
91
+ });
92
+ let o = r.filter((e) => e.status === "failed");
93
+ r.length === 0 || o.length < r.length ? u.push(e) : p.push({
94
+ channel: e,
95
+ error: o[0]?.error ?? "all endpoints failed"
96
+ });
97
+ } catch (r) {
98
+ let a = r instanceof Error ? r.message : String(r);
99
+ p.push({
100
+ channel: e,
101
+ error: a
102
+ }), await n.store.recordDelivery({
103
+ id: t(),
104
+ to: i.to,
105
+ category: i.category,
106
+ channel: e,
107
+ at: c(),
108
+ status: "failed",
109
+ error: a
110
+ });
111
+ }
112
+ continue;
113
+ }
114
+ let o = {
115
+ id: t(),
116
+ to: i.to,
117
+ category: i.category,
118
+ channel: e,
119
+ at: c()
120
+ };
121
+ try {
122
+ await a.deliver(m), u.push(e), await n.store.recordDelivery({
123
+ ...o,
124
+ status: "sent"
125
+ });
126
+ } catch (t) {
127
+ let r = t instanceof Error ? t.message : String(t);
128
+ p.push({
129
+ channel: e,
130
+ error: r
131
+ }), await n.store.recordDelivery({
132
+ ...o,
133
+ status: "failed",
134
+ error: r
135
+ });
136
+ }
137
+ }
138
+ return {
139
+ delivered: u,
140
+ failed: p,
141
+ skipped: l
142
+ };
143
+ }, m = async (e) => {
144
+ let r = structuredClone(e);
145
+ if (!await d(r)) return f();
146
+ let o = await n.store.getQuietHours(r.to), s = new Date(c());
147
+ if (o && i(o, s)) return (o.policy ?? "hold") === "drop" ? {
148
+ delivered: [],
149
+ failed: [],
150
+ skipped: [],
151
+ held: "quiet-drop"
152
+ } : (await n.store.enqueueHeld({
153
+ id: t(),
154
+ kind: "quiet",
155
+ subjectId: r.to,
156
+ send: r,
157
+ flushAt: a(o, s).toISOString()
158
+ }), {
159
+ delivered: [],
160
+ failed: [],
161
+ skipped: [],
162
+ held: "quiet-held"
163
+ });
164
+ if (l > 0 && !r.channels) {
165
+ let e = (await n.store.pendingDigest(r.to))[0]?.flushAt ?? new Date(s.getTime() + l).toISOString();
166
+ return await n.store.enqueueHeld({
167
+ id: t(),
168
+ kind: "digest",
169
+ subjectId: r.to,
170
+ send: r,
171
+ flushAt: e
172
+ }), {
173
+ delivered: [],
174
+ failed: [],
175
+ skipped: [],
176
+ held: "digest"
177
+ };
178
+ }
179
+ return p(r);
180
+ };
181
+ return {
182
+ send: m,
183
+ inbox: (e, t) => n.store.listInbox(e, t),
184
+ unreadCount: (e) => n.store.unreadCount(e),
185
+ markRead: (e, t) => n.store.markRead(e, t),
186
+ markUnread: (e, t) => n.store.markUnread(e, t),
187
+ markAllRead: (e) => n.store.markAllRead(e),
188
+ setArchived: (e, t, r) => n.store.setArchived(e, t, r),
189
+ getPreferences: (e) => n.store.getPreferences(e),
190
+ setPreference: (e) => n.store.setPreference(e),
191
+ subscribe: (e, t, r) => n.store.subscribeTopic({
192
+ topic: e,
193
+ subjectId: t,
194
+ tenantId: r ?? null
195
+ }),
196
+ unsubscribe: (e, t) => n.store.unsubscribeTopic(e, t),
197
+ broadcast: async (e, t) => {
198
+ let r = await n.store.listTopicSubscribers(e), i = [];
199
+ for (let e of r) i.push(await m({
200
+ ...t,
201
+ to: e.subjectId,
202
+ tenantId: t.tenantId ?? e.tenantId ?? null
203
+ }));
204
+ return {
205
+ recipients: r.length,
206
+ results: i
207
+ };
208
+ },
209
+ setQuietHours: (e) => n.store.setQuietHours(e),
210
+ clearQuietHours: (e) => n.store.clearQuietHours(e),
211
+ getQuietHours: (e) => n.store.getQuietHours(e),
212
+ flushDue: async (e = new Date(c())) => {
213
+ let t = await n.store.dueHeld(e.toISOString());
214
+ if (t.length === 0) return 0;
215
+ for (let e of t.filter((e) => e.kind === "quiet")) await p(e.send);
216
+ let r = /* @__PURE__ */ new Map();
217
+ for (let e of t.filter((e) => e.kind === "digest")) {
218
+ if (e.send.deliveryGuard !== void 0) {
219
+ await p(e.send);
220
+ continue;
221
+ }
222
+ let t = JSON.stringify([e.send.tenantId ?? null, e.subjectId]), n = r.get(t) ?? [];
223
+ n.push(e), r.set(t, n);
224
+ }
225
+ for (let e of r.values()) await p(s(e[0].subjectId, e));
226
+ return await n.store.clearHeld(t.map((e) => e.id)), t.length;
227
+ }
228
+ };
229
+ }, s = (e, t) => {
230
+ if (t.length === 1) return t[0].send;
231
+ let n = t.map((e) => ({
232
+ category: e.send.category,
233
+ title: e.send.title,
234
+ body: e.send.body
235
+ }));
236
+ return {
237
+ to: e,
238
+ category: "digest",
239
+ title: `${t.length} new notifications`,
240
+ body: n.map((e) => `• ${e.title}`).join("\n"),
241
+ data: { items: n },
242
+ tenantId: t[0].send.tenantId ?? null
243
+ };
244
+ };
245
+ //#endregion
246
+ export { o as buildNotificationService, n as inAppChannel, i as inQuietHours, r as minuteOfDayInZone, a as quietHoursEnd };