@12-apps/notifications 4.1.0 → 4.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/dist/chunk-4PSUZ7X5.js +1320 -0
- package/dist/chunk-4PSUZ7X5.js.map +1 -0
- package/dist/chunk-4TTYQVPK.js +29 -0
- package/dist/chunk-4TTYQVPK.js.map +1 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-AHNRSA6U.js +134 -0
- package/dist/chunk-AHNRSA6U.js.map +1 -0
- package/dist/generators-FATT537X.d.ts +26 -0
- package/dist/hono/index.d.ts +44 -0
- package/dist/hono/index.js +60 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +394 -0
- package/dist/react/index.js +1029 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/index.d.ts +905 -0
- package/dist/server/index.js +46 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-yq_o4N01.d.ts +155 -0
- package/dist/web-push/index.d.ts +52 -0
- package/dist/web-push/index.js +25 -0
- package/dist/web-push/index.js.map +1 -0
- package/dist/web-push-KLY6UMRT.d.ts +128 -0
- package/dist/wire-SDUtscGu.d.ts +197 -0
- package/package.json +27 -9
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +1 -1
- package/src/react/transport.ts +9 -3
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_CHANNEL_ROW,
|
|
3
|
+
UnknownNotificationRecipientError,
|
|
4
|
+
createGeneratorRegistry,
|
|
5
|
+
enabledChannelsOf,
|
|
6
|
+
inboxWire,
|
|
7
|
+
mergeChoices,
|
|
8
|
+
mergeStoredRow,
|
|
9
|
+
normalizePhoneE164
|
|
10
|
+
} from "./chunk-AHNRSA6U.js";
|
|
11
|
+
import {
|
|
12
|
+
NOTIFICATION_CHANNELS,
|
|
13
|
+
messagesOf,
|
|
14
|
+
taxonomyOf
|
|
15
|
+
} from "./chunk-4TTYQVPK.js";
|
|
16
|
+
import {
|
|
17
|
+
__name
|
|
18
|
+
} from "./chunk-7QVYU63E.js";
|
|
19
|
+
|
|
20
|
+
// src/server/context.ts
|
|
21
|
+
var NotificationsApiError = class _NotificationsApiError extends Error {
|
|
22
|
+
static {
|
|
23
|
+
__name(this, "NotificationsApiError");
|
|
24
|
+
}
|
|
25
|
+
status;
|
|
26
|
+
constructor(status, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "NotificationsApiError";
|
|
29
|
+
this.status = status;
|
|
30
|
+
Object.setPrototypeOf(this, _NotificationsApiError.prototype);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var ok = /* @__PURE__ */ __name((data, status = 200) => ({
|
|
34
|
+
status,
|
|
35
|
+
body: { data }
|
|
36
|
+
}), "ok");
|
|
37
|
+
var fail = /* @__PURE__ */ __name((status, error) => ({
|
|
38
|
+
status,
|
|
39
|
+
body: { error }
|
|
40
|
+
}), "fail");
|
|
41
|
+
function foldApiError(error) {
|
|
42
|
+
if (error instanceof NotificationsApiError) return fail(error.status, error.message);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
__name(foldApiError, "foldApiError");
|
|
46
|
+
function guarded(handle) {
|
|
47
|
+
return async (request) => {
|
|
48
|
+
try {
|
|
49
|
+
return await handle(request);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return foldApiError(error);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
__name(guarded, "guarded");
|
|
56
|
+
function asRecord(body, messages) {
|
|
57
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
58
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
59
|
+
}
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
62
|
+
__name(asRecord, "asRecord");
|
|
63
|
+
function parseIds(value, messages) {
|
|
64
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
|
65
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
66
|
+
}
|
|
67
|
+
return value.map((id) => {
|
|
68
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
69
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
__name(parseIds, "parseIds");
|
|
75
|
+
function parseLimit(raw, messages) {
|
|
76
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
77
|
+
const limit = Number(raw);
|
|
78
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
79
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
80
|
+
}
|
|
81
|
+
return limit;
|
|
82
|
+
}
|
|
83
|
+
__name(parseLimit, "parseLimit");
|
|
84
|
+
function parseFilter(raw, messages) {
|
|
85
|
+
if (raw === void 0) return void 0;
|
|
86
|
+
if (raw !== "all" && raw !== "unread") {
|
|
87
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
88
|
+
}
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
__name(parseFilter, "parseFilter");
|
|
92
|
+
function parseListQuery(query, messages) {
|
|
93
|
+
const filter = parseFilter(query.filter, messages);
|
|
94
|
+
const limit = parseLimit(query.limit, messages);
|
|
95
|
+
return {
|
|
96
|
+
...filter !== void 0 ? { filter } : {},
|
|
97
|
+
...query.cursor ? { cursor: query.cursor } : {},
|
|
98
|
+
...limit !== void 0 ? { limit } : {}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
__name(parseListQuery, "parseListQuery");
|
|
102
|
+
function parseMarkReadBody(body, messages) {
|
|
103
|
+
const record = asRecord(body, messages);
|
|
104
|
+
const wantsAll = record.all === true;
|
|
105
|
+
const hasIds = record.ids !== void 0;
|
|
106
|
+
if (wantsAll === hasIds) {
|
|
107
|
+
throw new NotificationsApiError(400, messages.markReadTargetRequired);
|
|
108
|
+
}
|
|
109
|
+
return wantsAll ? { all: true } : { ids: parseIds(record.ids, messages) };
|
|
110
|
+
}
|
|
111
|
+
__name(parseMarkReadBody, "parseMarkReadBody");
|
|
112
|
+
function parseDeleteBody(body, messages) {
|
|
113
|
+
return parseIds(asRecord(body, messages).ids, messages);
|
|
114
|
+
}
|
|
115
|
+
__name(parseDeleteBody, "parseDeleteBody");
|
|
116
|
+
function parsePreferencesBody(body, messages) {
|
|
117
|
+
const record = asRecord(body, messages);
|
|
118
|
+
const parsed = {};
|
|
119
|
+
for (const [category, value] of Object.entries(record)) {
|
|
120
|
+
parsed[category] = parseToggles(value, messages);
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
__name(parsePreferencesBody, "parsePreferencesBody");
|
|
125
|
+
function parseToggles(value, messages) {
|
|
126
|
+
const toggles = asRecord(value, messages);
|
|
127
|
+
const row = {};
|
|
128
|
+
for (const channel of NOTIFICATION_CHANNELS) {
|
|
129
|
+
const flag = toggles[channel];
|
|
130
|
+
if (flag === void 0) continue;
|
|
131
|
+
if (typeof flag !== "boolean") throw new NotificationsApiError(400, messages.invalidBody);
|
|
132
|
+
row[channel] = flag;
|
|
133
|
+
}
|
|
134
|
+
return row;
|
|
135
|
+
}
|
|
136
|
+
__name(parseToggles, "parseToggles");
|
|
137
|
+
var MAX_ENDPOINT_CHARS = 2e3;
|
|
138
|
+
var MAX_KEY_CHARS = 500;
|
|
139
|
+
function parseEndpoint(value, messages) {
|
|
140
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_ENDPOINT_CHARS) {
|
|
141
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
142
|
+
}
|
|
143
|
+
let url;
|
|
144
|
+
try {
|
|
145
|
+
url = new URL(value);
|
|
146
|
+
} catch {
|
|
147
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
148
|
+
}
|
|
149
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
150
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
__name(parseEndpoint, "parseEndpoint");
|
|
155
|
+
function parsePushSubscriptionBody(body, messages) {
|
|
156
|
+
const record = asRecord(body, messages);
|
|
157
|
+
const keys = asRecord(record.keys, messages);
|
|
158
|
+
const key = /* @__PURE__ */ __name((value) => {
|
|
159
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_KEY_CHARS) {
|
|
160
|
+
throw new NotificationsApiError(400, messages.invalidBody);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}, "key");
|
|
164
|
+
return {
|
|
165
|
+
endpoint: parseEndpoint(record.endpoint, messages),
|
|
166
|
+
keys: { p256dh: key(keys.p256dh), auth: key(keys.auth) }
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
__name(parsePushSubscriptionBody, "parsePushSubscriptionBody");
|
|
170
|
+
function parsePushEndpointBody(body, messages) {
|
|
171
|
+
return parseEndpoint(asRecord(body, messages).endpoint, messages);
|
|
172
|
+
}
|
|
173
|
+
__name(parsePushEndpointBody, "parsePushEndpointBody");
|
|
174
|
+
function parsePushEndpointQuery(query, messages) {
|
|
175
|
+
if (query.endpoint === void 0 || query.endpoint === "") return void 0;
|
|
176
|
+
return parseEndpoint(query.endpoint, messages);
|
|
177
|
+
}
|
|
178
|
+
__name(parsePushEndpointQuery, "parsePushEndpointQuery");
|
|
179
|
+
|
|
180
|
+
// src/server/transports/drivers.ts
|
|
181
|
+
var NotificationProviderError = class _NotificationProviderError extends Error {
|
|
182
|
+
static {
|
|
183
|
+
__name(this, "NotificationProviderError");
|
|
184
|
+
}
|
|
185
|
+
status;
|
|
186
|
+
constructor(vendor, status, detail) {
|
|
187
|
+
super(`${vendor} rejected the message (${status} ${detail}).`);
|
|
188
|
+
this.name = "NotificationProviderError";
|
|
189
|
+
this.status = status;
|
|
190
|
+
Object.setPrototypeOf(this, _NotificationProviderError.prototype);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
async function postOrThrow(vendor, fetchImpl, url, init) {
|
|
194
|
+
const call = fetchImpl ?? globalThis.fetch;
|
|
195
|
+
const response = await call(url, { method: "POST", ...init });
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new NotificationProviderError(vendor, response.status, await response.text());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
__name(postOrThrow, "postOrThrow");
|
|
201
|
+
function resolveDriver(channel, declaration, table) {
|
|
202
|
+
const factory = table[declaration.driver];
|
|
203
|
+
if (!factory) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`@12-apps/notifications: unknown ${channel} driver "${declaration.driver}". Known drivers: ${Object.keys(table).sort().join(", ")}.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return factory(declaration);
|
|
209
|
+
}
|
|
210
|
+
__name(resolveDriver, "resolveDriver");
|
|
211
|
+
function absoluteLink(link, appUrl) {
|
|
212
|
+
if (!link || !appUrl) return null;
|
|
213
|
+
try {
|
|
214
|
+
return new URL(link, appUrl).toString();
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
__name(absoluteLink, "absoluteLink");
|
|
220
|
+
function phoneChannel(channel, options) {
|
|
221
|
+
const toE164 = /* @__PURE__ */ __name((recipient) => normalizePhoneE164(recipient.phone, {
|
|
222
|
+
defaultCountryCode: options.defaultCountryCode
|
|
223
|
+
}), "toE164");
|
|
224
|
+
return {
|
|
225
|
+
channel,
|
|
226
|
+
supports: /* @__PURE__ */ __name((recipient) => toE164(recipient) !== null, "supports"),
|
|
227
|
+
format: options.format,
|
|
228
|
+
async send(message, recipient) {
|
|
229
|
+
const to = toE164(recipient);
|
|
230
|
+
if (!to) throw new Error("Recipient has no usable phone number.");
|
|
231
|
+
await options.send(to, message);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
__name(phoneChannel, "phoneChannel");
|
|
236
|
+
|
|
237
|
+
// src/server/transports/email.ts
|
|
238
|
+
function escapeHtml(value) {
|
|
239
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
240
|
+
}
|
|
241
|
+
__name(escapeHtml, "escapeHtml");
|
|
242
|
+
var resendDriver = /* @__PURE__ */ __name((declaration) => ({
|
|
243
|
+
async send(to, message) {
|
|
244
|
+
if (!declaration.apiKey || !declaration.from) {
|
|
245
|
+
throw new Error("The resend email driver needs both `apiKey` and `from`.");
|
|
246
|
+
}
|
|
247
|
+
await postOrThrow("Resend", declaration.fetchImpl, "https://api.resend.com/emails", {
|
|
248
|
+
headers: {
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
Authorization: `Bearer ${declaration.apiKey}`
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
from: declaration.from,
|
|
254
|
+
to: [to],
|
|
255
|
+
subject: message.subject,
|
|
256
|
+
text: message.text,
|
|
257
|
+
html: message.html
|
|
258
|
+
})
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}), "resendDriver");
|
|
262
|
+
var logEmailDriver = /* @__PURE__ */ __name((declaration) => ({
|
|
263
|
+
// Deliberately logs NO destination address — a recipient e-mail is PII and
|
|
264
|
+
// must never reach logs; the subject alone is enough for local debugging.
|
|
265
|
+
send(_to, message) {
|
|
266
|
+
declaration.logger?.info(
|
|
267
|
+
`[notifications:email] log driver suppressed a real send (subject="${message.subject}")`
|
|
268
|
+
);
|
|
269
|
+
return Promise.resolve();
|
|
270
|
+
}
|
|
271
|
+
}), "logEmailDriver");
|
|
272
|
+
var EMAIL_DRIVERS = {
|
|
273
|
+
resend: resendDriver,
|
|
274
|
+
log: logEmailDriver
|
|
275
|
+
};
|
|
276
|
+
function formatEmail(content, declaration) {
|
|
277
|
+
const href = absoluteLink(content.link, declaration.appUrl);
|
|
278
|
+
const label = declaration.linkLabel ?? "Ver detalhes";
|
|
279
|
+
return {
|
|
280
|
+
subject: content.title,
|
|
281
|
+
text: href ? `${content.body}
|
|
282
|
+
|
|
283
|
+
${href}` : content.body,
|
|
284
|
+
html: [
|
|
285
|
+
`<p><strong>${escapeHtml(content.title)}</strong></p>`,
|
|
286
|
+
`<p>${escapeHtml(content.body)}</p>`,
|
|
287
|
+
...href ? [`<p><a href="${escapeHtml(href)}">${escapeHtml(label)}</a></p>`] : []
|
|
288
|
+
].join("\n")
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
__name(formatEmail, "formatEmail");
|
|
292
|
+
function emailTransport(declaration, extraDrivers = {}) {
|
|
293
|
+
const driver = resolveDriver("EMAIL", declaration, { ...EMAIL_DRIVERS, ...extraDrivers });
|
|
294
|
+
return {
|
|
295
|
+
channel: "EMAIL",
|
|
296
|
+
// Truthiness, not `!== null`: an EMPTY STRING is a very ordinary DB value
|
|
297
|
+
// for a nullable column, and it used to pass this gate, earn a delivery row
|
|
298
|
+
// and then fail forever against `send`'s own `!recipient.email` check.
|
|
299
|
+
supports: /* @__PURE__ */ __name((recipient) => Boolean(recipient.email), "supports"),
|
|
300
|
+
format: /* @__PURE__ */ __name((content) => formatEmail(content, declaration), "format"),
|
|
301
|
+
async send(message, recipient) {
|
|
302
|
+
if (!recipient.email) throw new Error("Recipient has no email address.");
|
|
303
|
+
await driver.send(recipient.email, message);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
__name(emailTransport, "emailTransport");
|
|
308
|
+
|
|
309
|
+
// src/server/transports/sms.ts
|
|
310
|
+
var MAX_SMS_CHARS = 480;
|
|
311
|
+
var twilioDriver = /* @__PURE__ */ __name((declaration) => ({
|
|
312
|
+
async send(toE164, message) {
|
|
313
|
+
const sid = declaration.accountSid;
|
|
314
|
+
if (!sid || !declaration.authToken || !declaration.from) {
|
|
315
|
+
throw new Error("The twilio sms driver needs `accountSid`, `authToken` and `from`.");
|
|
316
|
+
}
|
|
317
|
+
const auth = Buffer.from(`${sid}:${declaration.authToken}`).toString("base64");
|
|
318
|
+
await postOrThrow(
|
|
319
|
+
"Twilio",
|
|
320
|
+
declaration.fetchImpl,
|
|
321
|
+
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(sid)}/Messages.json`,
|
|
322
|
+
{
|
|
323
|
+
headers: {
|
|
324
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
325
|
+
Authorization: `Basic ${auth}`
|
|
326
|
+
},
|
|
327
|
+
body: new URLSearchParams({
|
|
328
|
+
To: toE164,
|
|
329
|
+
From: declaration.from,
|
|
330
|
+
Body: message.body
|
|
331
|
+
}).toString()
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}), "twilioDriver");
|
|
336
|
+
var logSmsDriver = /* @__PURE__ */ __name((declaration) => ({
|
|
337
|
+
// Deliberately logs NO destination — a phone number is PII and must never
|
|
338
|
+
// reach logs; the message body alone is enough for local debugging.
|
|
339
|
+
send(_toE164, message) {
|
|
340
|
+
declaration.logger?.info(
|
|
341
|
+
`[notifications:sms] log driver suppressed a real send (body="${message.body}")`
|
|
342
|
+
);
|
|
343
|
+
return Promise.resolve();
|
|
344
|
+
}
|
|
345
|
+
}), "logSmsDriver");
|
|
346
|
+
var SMS_DRIVERS = {
|
|
347
|
+
twilio: twilioDriver,
|
|
348
|
+
log: logSmsDriver
|
|
349
|
+
};
|
|
350
|
+
function formatSms(content, declaration) {
|
|
351
|
+
const parts = [`${content.title}: ${content.body}`];
|
|
352
|
+
const href = absoluteLink(content.link, declaration.appUrl);
|
|
353
|
+
if (href) parts.push(href);
|
|
354
|
+
const body = parts.join(" ");
|
|
355
|
+
return { body: body.length > MAX_SMS_CHARS ? `${body.slice(0, MAX_SMS_CHARS - 1)}\u2026` : body };
|
|
356
|
+
}
|
|
357
|
+
__name(formatSms, "formatSms");
|
|
358
|
+
function smsTransport(declaration, extraDrivers = {}) {
|
|
359
|
+
const driver = resolveDriver("SMS", declaration, { ...SMS_DRIVERS, ...extraDrivers });
|
|
360
|
+
return phoneChannel("SMS", {
|
|
361
|
+
defaultCountryCode: declaration.defaultCountryCode,
|
|
362
|
+
format: /* @__PURE__ */ __name((content) => formatSms(content, declaration), "format"),
|
|
363
|
+
send: /* @__PURE__ */ __name((toE164, message) => driver.send(toE164, message), "send")
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
__name(smsTransport, "smsTransport");
|
|
367
|
+
|
|
368
|
+
// src/server/transports/web-push.ts
|
|
369
|
+
var vapidDriver = /* @__PURE__ */ __name((declaration) => {
|
|
370
|
+
const sender = declaration.sender;
|
|
371
|
+
if (!sender) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
"The vapid web-push driver needs a `sender` \u2014 import `vapidPushSender` from @12-apps/notifications/web-push."
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return sender;
|
|
377
|
+
}, "vapidDriver");
|
|
378
|
+
var logWebPushDriver = /* @__PURE__ */ __name((declaration) => (_subscription, payload) => {
|
|
379
|
+
declaration.logger?.info(
|
|
380
|
+
`[notifications:web-push] log driver suppressed a real send (${payload.length} bytes)`
|
|
381
|
+
);
|
|
382
|
+
return Promise.resolve();
|
|
383
|
+
}, "logWebPushDriver");
|
|
384
|
+
var WEB_PUSH_DRIVERS = {
|
|
385
|
+
vapid: vapidDriver,
|
|
386
|
+
log: logWebPushDriver
|
|
387
|
+
};
|
|
388
|
+
var GONE_STATUSES = /* @__PURE__ */ new Set([404, 410]);
|
|
389
|
+
function statusCodeOf(error) {
|
|
390
|
+
if (error && typeof error === "object" && "statusCode" in error) {
|
|
391
|
+
const code = error.statusCode;
|
|
392
|
+
return typeof code === "number" ? code : null;
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
__name(statusCodeOf, "statusCodeOf");
|
|
397
|
+
function formatWebPush(content) {
|
|
398
|
+
return {
|
|
399
|
+
title: content.title,
|
|
400
|
+
body: content.body,
|
|
401
|
+
link: content.link ?? null,
|
|
402
|
+
data: content.data ?? {}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
__name(formatWebPush, "formatWebPush");
|
|
406
|
+
function webPushTransport(declaration, subscriptions, extraDrivers = {}) {
|
|
407
|
+
const send = resolveDriver("WEB_PUSH", declaration, {
|
|
408
|
+
...WEB_PUSH_DRIVERS,
|
|
409
|
+
...extraDrivers
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
channel: "WEB_PUSH",
|
|
413
|
+
supports: /* @__PURE__ */ __name((recipient) => recipient.pushSubscriptionCount > 0, "supports"),
|
|
414
|
+
format: formatWebPush,
|
|
415
|
+
async send(message, recipient) {
|
|
416
|
+
const rows = await subscriptions.list(recipient.userId);
|
|
417
|
+
if (rows.length === 0) throw new Error("Recipient no longer has push subscriptions.");
|
|
418
|
+
const payload = JSON.stringify(message);
|
|
419
|
+
let delivered = 0;
|
|
420
|
+
let lastError = null;
|
|
421
|
+
for (const row of rows) {
|
|
422
|
+
try {
|
|
423
|
+
await send({ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } }, payload);
|
|
424
|
+
delivered += 1;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
const status = statusCodeOf(error);
|
|
427
|
+
if (status !== null && GONE_STATUSES.has(status)) {
|
|
428
|
+
await subscriptions.prune(row.id).catch(() => {
|
|
429
|
+
});
|
|
430
|
+
} else {
|
|
431
|
+
lastError = error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (delivered === 0) {
|
|
436
|
+
throw lastError instanceof Error ? lastError : new Error("No push subscription accepted the payload.");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
__name(webPushTransport, "webPushTransport");
|
|
442
|
+
|
|
443
|
+
// src/server/transports/whatsapp.ts
|
|
444
|
+
var DEFAULT_GRAPH_API_BASE = "https://graph.facebook.com/v20.0";
|
|
445
|
+
function templatePayload(toDigits, message, declaration) {
|
|
446
|
+
return {
|
|
447
|
+
messaging_product: "whatsapp",
|
|
448
|
+
to: toDigits,
|
|
449
|
+
type: "template",
|
|
450
|
+
template: {
|
|
451
|
+
name: declaration.templateName,
|
|
452
|
+
language: { code: declaration.templateLanguage },
|
|
453
|
+
components: [
|
|
454
|
+
{
|
|
455
|
+
type: "body",
|
|
456
|
+
parameters: message.templateParameters.map((text) => ({ type: "text", text }))
|
|
457
|
+
}
|
|
458
|
+
]
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
__name(templatePayload, "templatePayload");
|
|
463
|
+
function textPayload(toDigits, message) {
|
|
464
|
+
return {
|
|
465
|
+
messaging_product: "whatsapp",
|
|
466
|
+
to: toDigits,
|
|
467
|
+
type: "text",
|
|
468
|
+
text: { body: message.text }
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
__name(textPayload, "textPayload");
|
|
472
|
+
var metaDriver = /* @__PURE__ */ __name((declaration) => ({
|
|
473
|
+
async send(toE164, message) {
|
|
474
|
+
if (!declaration.accessToken || !declaration.phoneNumberId) {
|
|
475
|
+
throw new Error("The meta whatsapp driver needs `accessToken` and `phoneNumberId`.");
|
|
476
|
+
}
|
|
477
|
+
const toDigits = toE164.replace("+", "");
|
|
478
|
+
const payload = declaration.templateName ? templatePayload(toDigits, message, declaration) : textPayload(toDigits, message);
|
|
479
|
+
const base = declaration.graphApiBase ?? DEFAULT_GRAPH_API_BASE;
|
|
480
|
+
await postOrThrow(
|
|
481
|
+
"WhatsApp Cloud API",
|
|
482
|
+
declaration.fetchImpl,
|
|
483
|
+
`${base}/${encodeURIComponent(declaration.phoneNumberId)}/messages`,
|
|
484
|
+
{
|
|
485
|
+
headers: {
|
|
486
|
+
"Content-Type": "application/json",
|
|
487
|
+
Authorization: `Bearer ${declaration.accessToken}`
|
|
488
|
+
},
|
|
489
|
+
body: JSON.stringify(payload)
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}), "metaDriver");
|
|
494
|
+
var logWhatsAppDriver = /* @__PURE__ */ __name((declaration) => ({
|
|
495
|
+
// Deliberately logs NO destination — a phone number is PII and must never
|
|
496
|
+
// reach logs; the message text alone is enough for local debugging.
|
|
497
|
+
send(_toE164, message) {
|
|
498
|
+
declaration.logger?.info(
|
|
499
|
+
`[notifications:whatsapp] log driver suppressed a real send (text="${message.text}")`
|
|
500
|
+
);
|
|
501
|
+
return Promise.resolve();
|
|
502
|
+
}
|
|
503
|
+
}), "logWhatsAppDriver");
|
|
504
|
+
var WHATSAPP_DRIVERS = {
|
|
505
|
+
meta: metaDriver,
|
|
506
|
+
log: logWhatsAppDriver
|
|
507
|
+
};
|
|
508
|
+
function formatWhatsApp(content, declaration) {
|
|
509
|
+
const lines = [`*${content.title}*`, "", content.body];
|
|
510
|
+
const href = absoluteLink(content.link, declaration.appUrl);
|
|
511
|
+
if (href) lines.push("", href);
|
|
512
|
+
return { text: lines.join("\n"), templateParameters: [content.title, content.body] };
|
|
513
|
+
}
|
|
514
|
+
__name(formatWhatsApp, "formatWhatsApp");
|
|
515
|
+
function whatsAppTransport(declaration, extraDrivers = {}, logger) {
|
|
516
|
+
const driver = resolveDriver("WHATSAPP", declaration, {
|
|
517
|
+
...WHATSAPP_DRIVERS,
|
|
518
|
+
...extraDrivers
|
|
519
|
+
});
|
|
520
|
+
if (!declaration.templateName) {
|
|
521
|
+
logger?.error(
|
|
522
|
+
"[notifications] WHATSAPP is declared with no `templateName`: only free-form replies inside the 24h customer-service window will be accepted, and every business-initiated send will be rejected by the Graph API."
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
return phoneChannel("WHATSAPP", {
|
|
526
|
+
defaultCountryCode: declaration.defaultCountryCode,
|
|
527
|
+
format: /* @__PURE__ */ __name((content) => formatWhatsApp(content, declaration), "format"),
|
|
528
|
+
send: /* @__PURE__ */ __name((toE164, message) => driver.send(toE164, message), "send")
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
__name(whatsAppTransport, "whatsAppTransport");
|
|
532
|
+
|
|
533
|
+
// src/server/transports/registry.ts
|
|
534
|
+
function build(declaration, subscriptions, extra, logger) {
|
|
535
|
+
switch (declaration.channel) {
|
|
536
|
+
case "EMAIL":
|
|
537
|
+
return emailTransport(declaration, extra.email ?? {});
|
|
538
|
+
case "SMS":
|
|
539
|
+
return smsTransport(declaration, extra.sms ?? {});
|
|
540
|
+
case "WHATSAPP":
|
|
541
|
+
return whatsAppTransport(
|
|
542
|
+
declaration,
|
|
543
|
+
extra.whatsapp ?? {},
|
|
544
|
+
logger
|
|
545
|
+
);
|
|
546
|
+
case "WEB_PUSH":
|
|
547
|
+
return webPushTransport(
|
|
548
|
+
declaration,
|
|
549
|
+
subscriptions,
|
|
550
|
+
extra.webPush ?? {}
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
__name(build, "build");
|
|
555
|
+
function createTransportRegistry(declarations, subscriptions, extra = {}, logger) {
|
|
556
|
+
const transports = /* @__PURE__ */ new Map();
|
|
557
|
+
let publicKey = null;
|
|
558
|
+
for (const declaration of declarations) {
|
|
559
|
+
if (transports.has(declaration.channel)) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
`@12-apps/notifications: the ${declaration.channel} channel is declared twice.`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (declaration.channel === "WEB_PUSH") publicKey = declaration.publicKey ?? null;
|
|
565
|
+
transports.set(declaration.channel, build(declaration, subscriptions, extra, logger));
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
get: /* @__PURE__ */ __name((channel) => transports.get(channel) ?? null, "get"),
|
|
569
|
+
list: /* @__PURE__ */ __name(() => [...transports.values()], "list"),
|
|
570
|
+
register(transport) {
|
|
571
|
+
transports.set(transport.channel, transport);
|
|
572
|
+
},
|
|
573
|
+
webPushPublicKey: /* @__PURE__ */ __name(() => publicKey, "webPushPublicKey")
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
__name(createTransportRegistry, "createTransportRegistry");
|
|
577
|
+
|
|
578
|
+
// src/server/by-permission.ts
|
|
579
|
+
function reportOutcome(logger, report) {
|
|
580
|
+
const { type, clientId, permissions, candidateCount, result } = report;
|
|
581
|
+
const failed = result.skipped.filter((skip) => skip.reason !== "missing-permission").length;
|
|
582
|
+
if (failed > 0) {
|
|
583
|
+
logger.error(
|
|
584
|
+
`[notifications] ${type}: ${failed} of ${failed + result.notified.length} matching recipient(s) at client ${clientId} could not be reached; ${result.notified.length} delivered`
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (result.notified.length > 0) return;
|
|
589
|
+
logger.info(
|
|
590
|
+
`[notifications] ${type}: no recipient at client ${clientId} holds [${permissions.join(", ")}] (${candidateCount} candidate(s) evaluated)`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
__name(reportOutcome, "reportOutcome");
|
|
594
|
+
async function dispatchTo(deps, clientId, userId, event) {
|
|
595
|
+
try {
|
|
596
|
+
await deps.router.notify({ ...event, recipient: { userId, clientId } });
|
|
597
|
+
return true;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
deps.logger.error(
|
|
600
|
+
`[notifications] ${event.type} dispatch failed for user ${userId} at client ${clientId}:`,
|
|
601
|
+
error
|
|
602
|
+
);
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
__name(dispatchTo, "dispatchTo");
|
|
607
|
+
async function heldBy(deps, clientId, userId) {
|
|
608
|
+
try {
|
|
609
|
+
const granted = await deps.directory.getPermissions(userId, clientId);
|
|
610
|
+
return granted instanceof Set ? granted : new Set(granted);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
deps.logger.error(
|
|
613
|
+
`[notifications] audience lookup failed for user ${userId} at client ${clientId}:`,
|
|
614
|
+
error
|
|
615
|
+
);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
__name(heldBy, "heldBy");
|
|
620
|
+
function createNotifyByPermission(deps) {
|
|
621
|
+
return /* @__PURE__ */ __name(async function notifyByPermission(clientId, permissions, event) {
|
|
622
|
+
if (permissions.length === 0) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
"notifyByPermission(): `permissions` must be non-empty \u2014 an empty list matches every user of the tenant."
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const candidates = [...new Set(await deps.directory.listCandidates(clientId))];
|
|
628
|
+
const result = { notified: [], skipped: [] };
|
|
629
|
+
for (const userId of candidates) {
|
|
630
|
+
const held = await heldBy(deps, clientId, userId);
|
|
631
|
+
if (!held) {
|
|
632
|
+
result.skipped.push({ userId, reason: "audience-error" });
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (!permissions.every((permission) => held.has(permission))) {
|
|
636
|
+
result.skipped.push({ userId, reason: "missing-permission" });
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (await dispatchTo(deps, clientId, userId, event)) result.notified.push(userId);
|
|
640
|
+
else result.skipped.push({ userId, reason: "dispatch-failed" });
|
|
641
|
+
}
|
|
642
|
+
reportOutcome(deps.logger, {
|
|
643
|
+
type: event.type,
|
|
644
|
+
clientId,
|
|
645
|
+
permissions,
|
|
646
|
+
candidateCount: candidates.length,
|
|
647
|
+
result
|
|
648
|
+
});
|
|
649
|
+
return result;
|
|
650
|
+
}, "notifyByPermission");
|
|
651
|
+
}
|
|
652
|
+
__name(createNotifyByPermission, "createNotifyByPermission");
|
|
653
|
+
|
|
654
|
+
// src/server/dispatch.ts
|
|
655
|
+
var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
|
|
656
|
+
var DEFAULT_SWEEP_TAKE = 200;
|
|
657
|
+
var DEFAULT_SWEEP_CUTOFF_MS = 5 * 6e4;
|
|
658
|
+
var RETRYABLE = ["FAILED", "QUEUED", "SENDING"];
|
|
659
|
+
async function loadRecipient(deps, userId) {
|
|
660
|
+
const contact = await deps.contacts.getContact(userId);
|
|
661
|
+
if (!contact) return null;
|
|
662
|
+
return {
|
|
663
|
+
userId,
|
|
664
|
+
email: contact.email,
|
|
665
|
+
phone: contact.phone,
|
|
666
|
+
pushSubscriptionCount: await deps.pushSubscriptions.count(userId)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
__name(loadRecipient, "loadRecipient");
|
|
670
|
+
function contentOf(notification) {
|
|
671
|
+
return {
|
|
672
|
+
title: notification.title,
|
|
673
|
+
body: notification.body,
|
|
674
|
+
...notification.link !== null ? { link: notification.link } : {},
|
|
675
|
+
data: notification.data ?? {}
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
__name(contentOf, "contentOf");
|
|
679
|
+
var messageOf = /* @__PURE__ */ __name((error) => error instanceof Error ? error.message : String(error), "messageOf");
|
|
680
|
+
async function settle(deps, client, delivery, error) {
|
|
681
|
+
const spent = delivery.attempts + 1;
|
|
682
|
+
const terminal = spent >= deps.maxAttempts;
|
|
683
|
+
await client.notificationDelivery.update({
|
|
684
|
+
where: { id: delivery.id },
|
|
685
|
+
data: {
|
|
686
|
+
status: terminal ? "DEAD" : "FAILED",
|
|
687
|
+
error: terminal ? `${messageOf(error)} (gave up after ${spent} attempts)` : messageOf(error)
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
__name(settle, "settle");
|
|
692
|
+
async function sendClaimed(deps, client, delivery, content, recipient) {
|
|
693
|
+
const claimed = await client.notificationDelivery.updateMany({
|
|
694
|
+
where: { id: delivery.id, status: "QUEUED" },
|
|
695
|
+
data: { status: "SENDING", attempts: { increment: 1 } }
|
|
696
|
+
});
|
|
697
|
+
if (claimed.count !== 1) return false;
|
|
698
|
+
const transport = deps.transports.get(delivery.channel);
|
|
699
|
+
try {
|
|
700
|
+
if (!transport) throw new Error(`No transport declared for ${delivery.channel}.`);
|
|
701
|
+
await transport.send(transport.format(content), recipient);
|
|
702
|
+
await client.notificationDelivery.update({
|
|
703
|
+
where: { id: delivery.id },
|
|
704
|
+
data: { status: "SENT", sentAt: /* @__PURE__ */ new Date(), error: null }
|
|
705
|
+
});
|
|
706
|
+
} catch (error) {
|
|
707
|
+
await settle(deps, client, delivery, error);
|
|
708
|
+
}
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
__name(sendClaimed, "sendClaimed");
|
|
712
|
+
async function abandonUnreachable(deps, client, queued, userId) {
|
|
713
|
+
deps.logger.error(
|
|
714
|
+
`[notifications] no contact for user ${userId}: ${queued.length} delivery row(s) marked DEAD`
|
|
715
|
+
);
|
|
716
|
+
for (const delivery of queued) {
|
|
717
|
+
await client.notificationDelivery.update({
|
|
718
|
+
where: { id: delivery.id },
|
|
719
|
+
data: { status: "DEAD", error: "The contact directory no longer knows this recipient." }
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
__name(abandonUnreachable, "abandonUnreachable");
|
|
724
|
+
async function dispatchOne(deps, notificationId) {
|
|
725
|
+
const client = await deps.db();
|
|
726
|
+
const notification = await client.notification.findUnique({ where: { id: notificationId } });
|
|
727
|
+
if (!notification) return 0;
|
|
728
|
+
const queued = await client.notificationDelivery.findMany({
|
|
729
|
+
where: { notificationId, status: "QUEUED" }
|
|
730
|
+
});
|
|
731
|
+
if (queued.length === 0) return 0;
|
|
732
|
+
const recipient = await loadRecipient(deps, notification.userId);
|
|
733
|
+
if (!recipient) {
|
|
734
|
+
await abandonUnreachable(deps, client, queued, notification.userId);
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
const content = contentOf(notification);
|
|
738
|
+
let sent = 0;
|
|
739
|
+
for (const delivery of queued) {
|
|
740
|
+
if (await sendClaimed(deps, client, delivery, content, recipient)) sent += 1;
|
|
741
|
+
}
|
|
742
|
+
return sent;
|
|
743
|
+
}
|
|
744
|
+
__name(dispatchOne, "dispatchOne");
|
|
745
|
+
async function requeue(client, row, cutoff) {
|
|
746
|
+
const moved = await client.notificationDelivery.updateMany({
|
|
747
|
+
where: { id: row.id, status: row.status, updatedAt: { lt: cutoff } },
|
|
748
|
+
data: { status: "QUEUED" }
|
|
749
|
+
});
|
|
750
|
+
return moved.count === 1;
|
|
751
|
+
}
|
|
752
|
+
__name(requeue, "requeue");
|
|
753
|
+
async function drainPending(deps, olderThanMs, take) {
|
|
754
|
+
const client = await deps.db();
|
|
755
|
+
const cutoff = new Date(Date.now() - olderThanMs);
|
|
756
|
+
const stale = await client.notificationDelivery.findMany({
|
|
757
|
+
where: { status: { in: RETRYABLE }, updatedAt: { lt: cutoff } },
|
|
758
|
+
orderBy: { updatedAt: "asc" },
|
|
759
|
+
take
|
|
760
|
+
});
|
|
761
|
+
const notificationIds = /* @__PURE__ */ new Set();
|
|
762
|
+
for (const row of stale) {
|
|
763
|
+
if (await requeue(client, row, cutoff)) notificationIds.add(row.notificationId);
|
|
764
|
+
}
|
|
765
|
+
let dispatched = 0;
|
|
766
|
+
for (const id of notificationIds) {
|
|
767
|
+
try {
|
|
768
|
+
dispatched += await dispatchOne(deps, id);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
deps.logger.error(`[notifications] sweep failed to dispatch ${id}:`, error);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return { dispatched };
|
|
774
|
+
}
|
|
775
|
+
__name(drainPending, "drainPending");
|
|
776
|
+
|
|
777
|
+
// src/server/inbox.ts
|
|
778
|
+
var DEFAULT_PAGE = 20;
|
|
779
|
+
var MAX_PAGE = 100;
|
|
780
|
+
async function resolveAnchor(notifications, userId, cursor) {
|
|
781
|
+
const anchor = await notifications.findUnique({ where: { id: cursor } });
|
|
782
|
+
if (!anchor || anchor.userId !== userId) return void 0;
|
|
783
|
+
return { createdAt: anchor.createdAt, id: anchor.id };
|
|
784
|
+
}
|
|
785
|
+
__name(resolveAnchor, "resolveAnchor");
|
|
786
|
+
function pageWhere(userId, filter, anchor) {
|
|
787
|
+
return {
|
|
788
|
+
userId,
|
|
789
|
+
deletedAt: null,
|
|
790
|
+
...filter === "unread" ? { readAt: null } : {},
|
|
791
|
+
// `(createdAt, id) < (anchor.createdAt, anchor.id)`, as a portable `where`.
|
|
792
|
+
...anchor ? {
|
|
793
|
+
OR: [
|
|
794
|
+
{ createdAt: { lt: anchor.createdAt } },
|
|
795
|
+
{ createdAt: anchor.createdAt, id: { lt: anchor.id } }
|
|
796
|
+
]
|
|
797
|
+
} : {}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
__name(pageWhere, "pageWhere");
|
|
801
|
+
function createInboxStore(db) {
|
|
802
|
+
return {
|
|
803
|
+
/** The owner's inbox, newest first, keyset-paginated, deleted excluded. */
|
|
804
|
+
async list(userId, input = {}) {
|
|
805
|
+
const client = await db();
|
|
806
|
+
const limit = Math.min(Math.max(input.limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
|
|
807
|
+
const anchor = input.cursor ? await resolveAnchor(client.notification, userId, input.cursor) : void 0;
|
|
808
|
+
if (input.cursor && !anchor) return { items: [], nextCursor: null };
|
|
809
|
+
const rows = await client.notification.findMany({
|
|
810
|
+
where: pageWhere(userId, input.filter, anchor),
|
|
811
|
+
// `id` tie-breaks equal timestamps so pages never skip/repeat.
|
|
812
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
813
|
+
take: limit + 1
|
|
814
|
+
});
|
|
815
|
+
const page = rows.slice(0, limit);
|
|
816
|
+
return {
|
|
817
|
+
items: page.map(inboxWire),
|
|
818
|
+
nextCursor: rows.length > limit ? page[page.length - 1]?.id ?? null : null
|
|
819
|
+
};
|
|
820
|
+
},
|
|
821
|
+
/** Unread badge count (non-deleted, unread). */
|
|
822
|
+
async unreadCount(userId) {
|
|
823
|
+
const client = await db();
|
|
824
|
+
return client.notification.count({ where: { userId, deletedAt: null, readAt: null } });
|
|
825
|
+
},
|
|
826
|
+
/**
|
|
827
|
+
* Mark specific notifications read. Only the owner's own, still-unread,
|
|
828
|
+
* non-deleted rows are touched — foreign or already-read ids are silently
|
|
829
|
+
* ignored (idempotent). Returns how many rows flipped.
|
|
830
|
+
*/
|
|
831
|
+
async markRead(userId, ids) {
|
|
832
|
+
if (ids.length === 0) return 0;
|
|
833
|
+
const client = await db();
|
|
834
|
+
const result = await client.notification.updateMany({
|
|
835
|
+
where: { id: { in: [...ids] }, userId, deletedAt: null, readAt: null },
|
|
836
|
+
data: { readAt: /* @__PURE__ */ new Date() }
|
|
837
|
+
});
|
|
838
|
+
return result.count;
|
|
839
|
+
},
|
|
840
|
+
/** Mark every unread notification of the owner read ("mark all"). */
|
|
841
|
+
async markAllRead(userId) {
|
|
842
|
+
const client = await db();
|
|
843
|
+
const result = await client.notification.updateMany({
|
|
844
|
+
where: { userId, deletedAt: null, readAt: null },
|
|
845
|
+
data: { readAt: /* @__PURE__ */ new Date() }
|
|
846
|
+
});
|
|
847
|
+
return result.count;
|
|
848
|
+
},
|
|
849
|
+
/**
|
|
850
|
+
* Soft-delete notifications (single or bulk): stamps `deletedAt` so the
|
|
851
|
+
* rows drop out of every list/count forever, while the delivery audit
|
|
852
|
+
* trail under them survives. Owner-scoped and idempotent like mark-read.
|
|
853
|
+
*/
|
|
854
|
+
async softDelete(userId, ids) {
|
|
855
|
+
if (ids.length === 0) return 0;
|
|
856
|
+
const client = await db();
|
|
857
|
+
const result = await client.notification.updateMany({
|
|
858
|
+
where: { id: { in: [...ids] }, userId, deletedAt: null },
|
|
859
|
+
data: { deletedAt: /* @__PURE__ */ new Date() }
|
|
860
|
+
});
|
|
861
|
+
return result.count;
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
__name(createInboxStore, "createInboxStore");
|
|
866
|
+
|
|
867
|
+
// src/server/preferences.ts
|
|
868
|
+
function createPreferenceStore(db, taxonomy, channelDefaults = {}) {
|
|
869
|
+
const defaultRow = { ...DEFAULT_CHANNEL_ROW, ...channelDefaults };
|
|
870
|
+
const known = new Set(taxonomy.categories);
|
|
871
|
+
return {
|
|
872
|
+
async get(userId) {
|
|
873
|
+
const client = await db();
|
|
874
|
+
const rows = await client.notificationPreference.findMany({ where: { userId } });
|
|
875
|
+
const stored = new Map(rows.map((row) => [row.category, row.channels]));
|
|
876
|
+
return Object.fromEntries(
|
|
877
|
+
taxonomy.categories.map((category) => [
|
|
878
|
+
category,
|
|
879
|
+
// A category with no row, or a row missing a channel key, falls back
|
|
880
|
+
// to the defaults — so a NEW channel ships without a data migration.
|
|
881
|
+
mergeStoredRow(stored.get(category), defaultRow)
|
|
882
|
+
])
|
|
883
|
+
);
|
|
884
|
+
},
|
|
885
|
+
/**
|
|
886
|
+
* Only the categories present on `input` are written; within a category,
|
|
887
|
+
* the toggles are merged over the user's CURRENT effective row (their
|
|
888
|
+
* stored choices, or the defaults when none) — so a single-toggle save
|
|
889
|
+
* (how the settings UI writes) never resets the category's other channels
|
|
890
|
+
* back to their defaults.
|
|
891
|
+
*
|
|
892
|
+
* A category outside the taxonomy is IGNORED rather than stored: the DB
|
|
893
|
+
* CHECK would reject it anyway, and a 500 from a stale client's extra key
|
|
894
|
+
* would fail the whole save including the toggle the user did flip.
|
|
895
|
+
*/
|
|
896
|
+
async save(userId, input) {
|
|
897
|
+
const client = await db();
|
|
898
|
+
for (const [category, choices] of Object.entries(input)) {
|
|
899
|
+
if (!choices || !known.has(category)) continue;
|
|
900
|
+
const existing = await client.notificationPreference.findUnique({
|
|
901
|
+
where: { userId_category: { userId, category } }
|
|
902
|
+
});
|
|
903
|
+
const current = existing ? mergeStoredRow(existing.channels, defaultRow) : defaultRow;
|
|
904
|
+
const channels = mergeChoices(current, choices);
|
|
905
|
+
await client.notificationPreference.upsert({
|
|
906
|
+
where: { userId_category: { userId, category } },
|
|
907
|
+
create: { userId, category, channels },
|
|
908
|
+
update: { channels }
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
async enabledChannels(userId, category) {
|
|
913
|
+
const client = await db();
|
|
914
|
+
const row = await client.notificationPreference.findUnique({
|
|
915
|
+
where: { userId_category: { userId, category } }
|
|
916
|
+
});
|
|
917
|
+
return enabledChannelsOf(
|
|
918
|
+
row ? mergeStoredRow(row.channels, defaultRow) : defaultRow
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
__name(createPreferenceStore, "createPreferenceStore");
|
|
924
|
+
|
|
925
|
+
// src/server/push-subscriptions.ts
|
|
926
|
+
function createPushSubscriptionStore(db, logger) {
|
|
927
|
+
return {
|
|
928
|
+
async save(userId, input) {
|
|
929
|
+
const client = await db();
|
|
930
|
+
const existing = await client.pushSubscription.findUnique({
|
|
931
|
+
where: { endpoint: input.endpoint }
|
|
932
|
+
});
|
|
933
|
+
if (existing && existing.userId !== userId) {
|
|
934
|
+
logger?.error(
|
|
935
|
+
`[notifications] push endpoint re-owned: user ${existing.userId} lost this browser's subscription to user ${userId}`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
await client.pushSubscription.upsert({
|
|
939
|
+
where: { endpoint: input.endpoint },
|
|
940
|
+
create: {
|
|
941
|
+
userId,
|
|
942
|
+
endpoint: input.endpoint,
|
|
943
|
+
p256dh: input.keys.p256dh,
|
|
944
|
+
auth: input.keys.auth,
|
|
945
|
+
userAgent: input.userAgent ?? null
|
|
946
|
+
},
|
|
947
|
+
update: {
|
|
948
|
+
userId,
|
|
949
|
+
p256dh: input.keys.p256dh,
|
|
950
|
+
auth: input.keys.auth,
|
|
951
|
+
userAgent: input.userAgent ?? null
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
},
|
|
955
|
+
async remove(userId, endpoint) {
|
|
956
|
+
const client = await db();
|
|
957
|
+
await client.pushSubscription.deleteMany({ where: { userId, endpoint } });
|
|
958
|
+
},
|
|
959
|
+
async count(userId) {
|
|
960
|
+
const client = await db();
|
|
961
|
+
return client.pushSubscription.count({ where: { userId } });
|
|
962
|
+
},
|
|
963
|
+
async isRegisteredTo(userId, endpoint) {
|
|
964
|
+
const client = await db();
|
|
965
|
+
const row = await client.pushSubscription.findUnique({ where: { endpoint } });
|
|
966
|
+
return row?.userId === userId;
|
|
967
|
+
},
|
|
968
|
+
async list(userId) {
|
|
969
|
+
const client = await db();
|
|
970
|
+
const rows = await client.pushSubscription.findMany({ where: { userId } });
|
|
971
|
+
return rows.map((row) => ({
|
|
972
|
+
id: row.id,
|
|
973
|
+
endpoint: row.endpoint,
|
|
974
|
+
p256dh: row.p256dh,
|
|
975
|
+
auth: row.auth
|
|
976
|
+
}));
|
|
977
|
+
},
|
|
978
|
+
async prune(id) {
|
|
979
|
+
const client = await db();
|
|
980
|
+
await client.pushSubscription.delete({ where: { id } });
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
__name(createPushSubscriptionStore, "createPushSubscriptionStore");
|
|
985
|
+
|
|
986
|
+
// src/server/routes.ts
|
|
987
|
+
async function channelAvailability(deps, userId) {
|
|
988
|
+
const contact = await deps.contacts.getContact(userId);
|
|
989
|
+
const recipient = {
|
|
990
|
+
userId,
|
|
991
|
+
email: contact?.email ?? null,
|
|
992
|
+
phone: contact?.phone ?? null,
|
|
993
|
+
pushSubscriptionCount: 1
|
|
994
|
+
};
|
|
995
|
+
return Object.fromEntries(
|
|
996
|
+
NOTIFICATION_CHANNELS.map((channel) => [
|
|
997
|
+
channel,
|
|
998
|
+
deps.transports.get(channel)?.supports(recipient) ?? false
|
|
999
|
+
])
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
__name(channelAvailability, "channelAvailability");
|
|
1003
|
+
async function preferencesPayload(deps, userId) {
|
|
1004
|
+
const [preferences, availability] = await Promise.all([
|
|
1005
|
+
deps.preferences.get(userId),
|
|
1006
|
+
channelAvailability(deps, userId)
|
|
1007
|
+
]);
|
|
1008
|
+
return { preferences, availability, categories: [...deps.categories] };
|
|
1009
|
+
}
|
|
1010
|
+
__name(preferencesPayload, "preferencesPayload");
|
|
1011
|
+
function inboxRoutes(deps) {
|
|
1012
|
+
return [
|
|
1013
|
+
{
|
|
1014
|
+
method: "GET",
|
|
1015
|
+
path: "/notifications",
|
|
1016
|
+
handle: guarded(
|
|
1017
|
+
async ({ actor, query }) => ok(await deps.inbox.list(actor.userId, parseListQuery(query, deps.messages)))
|
|
1018
|
+
)
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
method: "GET",
|
|
1022
|
+
path: "/notifications/unread-count",
|
|
1023
|
+
handle: guarded(
|
|
1024
|
+
async ({ actor }) => (
|
|
1025
|
+
// Polled by the SPAs, so it stays a single indexed COUNT.
|
|
1026
|
+
ok({ count: await deps.inbox.unreadCount(actor.userId) })
|
|
1027
|
+
)
|
|
1028
|
+
)
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
method: "POST",
|
|
1032
|
+
path: "/notifications/mark-read",
|
|
1033
|
+
handle: guarded(async ({ actor, body }) => {
|
|
1034
|
+
const target = parseMarkReadBody(body, deps.messages);
|
|
1035
|
+
const updated = "all" in target ? await deps.inbox.markAllRead(actor.userId) : await deps.inbox.markRead(actor.userId, target.ids);
|
|
1036
|
+
if (updated > 0) deps.onInboxChanged?.(actor.userId);
|
|
1037
|
+
return ok({ updated });
|
|
1038
|
+
})
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
method: "POST",
|
|
1042
|
+
// POST, not DELETE, because the ids travel in a JSON body.
|
|
1043
|
+
path: "/notifications/delete",
|
|
1044
|
+
handle: guarded(async ({ actor, body }) => {
|
|
1045
|
+
const deleted = await deps.inbox.softDelete(
|
|
1046
|
+
actor.userId,
|
|
1047
|
+
parseDeleteBody(body, deps.messages)
|
|
1048
|
+
);
|
|
1049
|
+
if (deleted > 0) deps.onInboxChanged?.(actor.userId);
|
|
1050
|
+
return ok({ deleted });
|
|
1051
|
+
})
|
|
1052
|
+
}
|
|
1053
|
+
];
|
|
1054
|
+
}
|
|
1055
|
+
__name(inboxRoutes, "inboxRoutes");
|
|
1056
|
+
function preferenceRoutes(deps) {
|
|
1057
|
+
return [
|
|
1058
|
+
{
|
|
1059
|
+
method: "GET",
|
|
1060
|
+
path: "/notification-preferences",
|
|
1061
|
+
handle: guarded(async ({ actor }) => ok(await preferencesPayload(deps, actor.userId)))
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
method: "PUT",
|
|
1065
|
+
path: "/notification-preferences",
|
|
1066
|
+
handle: guarded(async ({ actor, body }) => {
|
|
1067
|
+
await deps.preferences.save(actor.userId, parsePreferencesBody(body, deps.messages));
|
|
1068
|
+
return ok(await preferencesPayload(deps, actor.userId));
|
|
1069
|
+
})
|
|
1070
|
+
}
|
|
1071
|
+
];
|
|
1072
|
+
}
|
|
1073
|
+
__name(preferenceRoutes, "preferenceRoutes");
|
|
1074
|
+
function pushRoutes(deps) {
|
|
1075
|
+
return [
|
|
1076
|
+
{
|
|
1077
|
+
method: "GET",
|
|
1078
|
+
path: "/push-subscriptions",
|
|
1079
|
+
handle: guarded(async ({ actor, query }) => {
|
|
1080
|
+
const endpoint = parsePushEndpointQuery(query, deps.messages);
|
|
1081
|
+
return ok({
|
|
1082
|
+
// null = web push is not configured on this deployment.
|
|
1083
|
+
vapidPublicKey: deps.transports.webPushPublicKey(),
|
|
1084
|
+
count: await deps.pushSubscriptions.count(actor.userId),
|
|
1085
|
+
// Only when asked. `registered` is what lets the settings screen stop
|
|
1086
|
+
// trusting the browser alone: a re-owned or pruned row answers false,
|
|
1087
|
+
// so the screen offers *Ativar* again instead of claiming all is well.
|
|
1088
|
+
...endpoint !== void 0 ? { registered: await deps.pushSubscriptions.isRegisteredTo(actor.userId, endpoint) } : {}
|
|
1089
|
+
});
|
|
1090
|
+
})
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
method: "POST",
|
|
1094
|
+
path: "/push-subscriptions",
|
|
1095
|
+
handle: guarded(async ({ actor, body, headers }) => {
|
|
1096
|
+
const input = parsePushSubscriptionBody(body, deps.messages);
|
|
1097
|
+
const userAgent = headers?.["user-agent"];
|
|
1098
|
+
await deps.pushSubscriptions.save(actor.userId, {
|
|
1099
|
+
...input,
|
|
1100
|
+
...userAgent ? { userAgent } : {}
|
|
1101
|
+
});
|
|
1102
|
+
return ok({ count: await deps.pushSubscriptions.count(actor.userId) });
|
|
1103
|
+
})
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
method: "DELETE",
|
|
1107
|
+
// The endpoint is a long opaque URL, unusable as a path param.
|
|
1108
|
+
path: "/push-subscriptions",
|
|
1109
|
+
handle: guarded(async ({ actor, body }) => {
|
|
1110
|
+
await deps.pushSubscriptions.remove(
|
|
1111
|
+
actor.userId,
|
|
1112
|
+
parsePushEndpointBody(body, deps.messages)
|
|
1113
|
+
);
|
|
1114
|
+
return ok({ count: await deps.pushSubscriptions.count(actor.userId) });
|
|
1115
|
+
})
|
|
1116
|
+
}
|
|
1117
|
+
];
|
|
1118
|
+
}
|
|
1119
|
+
__name(pushRoutes, "pushRoutes");
|
|
1120
|
+
function notificationRoutes(deps) {
|
|
1121
|
+
return [...inboxRoutes(deps), ...preferenceRoutes(deps), ...pushRoutes(deps)];
|
|
1122
|
+
}
|
|
1123
|
+
__name(notificationRoutes, "notificationRoutes");
|
|
1124
|
+
|
|
1125
|
+
// src/server/router.ts
|
|
1126
|
+
function policyFallback(channels) {
|
|
1127
|
+
const free = new Set(enabledChannelsOf(DEFAULT_CHANNEL_ROW));
|
|
1128
|
+
return channels.filter((channel) => free.has(channel));
|
|
1129
|
+
}
|
|
1130
|
+
__name(policyFallback, "policyFallback");
|
|
1131
|
+
async function applyPolicy(deps, clientId, channels) {
|
|
1132
|
+
if (!deps.channelPolicy || clientId === null || clientId === void 0) return channels;
|
|
1133
|
+
try {
|
|
1134
|
+
return await deps.channelPolicy(clientId, channels);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
deps.logger.error(
|
|
1137
|
+
`[notifications] channelPolicy failed for client ${clientId}; degrading to the free channels:`,
|
|
1138
|
+
error
|
|
1139
|
+
);
|
|
1140
|
+
return policyFallback(channels);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
__name(applyPolicy, "applyPolicy");
|
|
1144
|
+
function announce(deps, notification) {
|
|
1145
|
+
if (!deps.onCommitted) return;
|
|
1146
|
+
try {
|
|
1147
|
+
deps.onCommitted(notification);
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
deps.logger.error(
|
|
1150
|
+
`[notifications] commit listener failed for ${notification.notificationId}:`,
|
|
1151
|
+
error
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
__name(announce, "announce");
|
|
1156
|
+
async function resolveChannels(deps, event, category, recipient) {
|
|
1157
|
+
const enabled = await deps.preferences.enabledChannels(event.recipient.userId, category);
|
|
1158
|
+
const supported = enabled.filter((channel) => {
|
|
1159
|
+
const transport = deps.transports.get(channel);
|
|
1160
|
+
return transport !== null && transport.supports(recipient);
|
|
1161
|
+
});
|
|
1162
|
+
return applyPolicy(deps, event.recipient.clientId, supported);
|
|
1163
|
+
}
|
|
1164
|
+
__name(resolveChannels, "resolveChannels");
|
|
1165
|
+
async function commit(deps, event, category, content, channels) {
|
|
1166
|
+
const client = await deps.db();
|
|
1167
|
+
return client.$transaction(async (tx) => {
|
|
1168
|
+
const created = await tx.notification.create({
|
|
1169
|
+
data: {
|
|
1170
|
+
userId: event.recipient.userId,
|
|
1171
|
+
clientId: event.recipient.clientId ?? null,
|
|
1172
|
+
type: event.type,
|
|
1173
|
+
category,
|
|
1174
|
+
title: content.title,
|
|
1175
|
+
body: content.body,
|
|
1176
|
+
link: content.link ?? null,
|
|
1177
|
+
data: content.data ?? {}
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
if (channels.length > 0) {
|
|
1181
|
+
await tx.notificationDelivery.createMany({
|
|
1182
|
+
data: channels.map((channel) => ({ notificationId: created.id, channel })),
|
|
1183
|
+
// Idempotence backstop: the unique (notification, channel) key.
|
|
1184
|
+
skipDuplicates: true
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return created;
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
__name(commit, "commit");
|
|
1191
|
+
function createNotificationRouter(deps) {
|
|
1192
|
+
return {
|
|
1193
|
+
dispatchDeliveries: /* @__PURE__ */ __name(async (notificationId) => {
|
|
1194
|
+
await dispatchOne(deps, notificationId);
|
|
1195
|
+
}, "dispatchDeliveries"),
|
|
1196
|
+
drainPending: /* @__PURE__ */ __name((olderThanMs = DEFAULT_SWEEP_CUTOFF_MS, take = DEFAULT_SWEEP_TAKE) => drainPending(deps, olderThanMs, take), "drainPending"),
|
|
1197
|
+
async notify(event, options = {}) {
|
|
1198
|
+
const generator = deps.generators.resolve(event.type);
|
|
1199
|
+
const content = generator.generate(event.payload);
|
|
1200
|
+
const recipient = await loadRecipient(deps, event.recipient.userId);
|
|
1201
|
+
if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
|
|
1202
|
+
const channels = await resolveChannels(deps, event, generator.category, recipient);
|
|
1203
|
+
const notification = await commit(deps, event, generator.category, content, channels);
|
|
1204
|
+
announce(deps, {
|
|
1205
|
+
notificationId: notification.id,
|
|
1206
|
+
userId: notification.userId,
|
|
1207
|
+
clientId: notification.clientId
|
|
1208
|
+
});
|
|
1209
|
+
if (options.sync) {
|
|
1210
|
+
await dispatchOne(deps, notification.id);
|
|
1211
|
+
return { notificationId: notification.id, channels };
|
|
1212
|
+
}
|
|
1213
|
+
void (deps.scheduleDispatch ? deps.scheduleDispatch(notification.id) : dispatchOne(deps, notification.id)).catch((error) => {
|
|
1214
|
+
deps.logger.error(`[notifications] dispatch failed for ${notification.id}:`, error);
|
|
1215
|
+
});
|
|
1216
|
+
return { notificationId: notification.id, channels };
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
__name(createNotificationRouter, "createNotificationRouter");
|
|
1221
|
+
|
|
1222
|
+
// src/server/create-api-notifications.ts
|
|
1223
|
+
function present(entries) {
|
|
1224
|
+
return Object.fromEntries(
|
|
1225
|
+
Object.entries(entries).filter(([, value]) => value !== void 0)
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
__name(present, "present");
|
|
1229
|
+
var consoleLogger = {
|
|
1230
|
+
info: /* @__PURE__ */ __name((message, ...meta) => console.info(message, ...meta), "info"),
|
|
1231
|
+
error: /* @__PURE__ */ __name((message, ...meta) => console.error(message, ...meta), "error")
|
|
1232
|
+
};
|
|
1233
|
+
function createApiNotifications(config) {
|
|
1234
|
+
const messages = messagesOf(config);
|
|
1235
|
+
const taxonomy = taxonomyOf(config);
|
|
1236
|
+
const logger = config.logger ?? consoleLogger;
|
|
1237
|
+
const generators = createGeneratorRegistry(config.generators ?? []);
|
|
1238
|
+
const inbox = createInboxStore(config.db);
|
|
1239
|
+
const preferences = createPreferenceStore(
|
|
1240
|
+
config.db,
|
|
1241
|
+
taxonomy,
|
|
1242
|
+
config.channelDefaults ?? {}
|
|
1243
|
+
);
|
|
1244
|
+
const pushSubscriptions = createPushSubscriptionStore(config.db, logger);
|
|
1245
|
+
const transports = createTransportRegistry(
|
|
1246
|
+
config.transports ?? [],
|
|
1247
|
+
pushSubscriptions,
|
|
1248
|
+
config.drivers ?? {},
|
|
1249
|
+
logger
|
|
1250
|
+
);
|
|
1251
|
+
const router = createNotificationRouter({
|
|
1252
|
+
db: config.db,
|
|
1253
|
+
generators,
|
|
1254
|
+
transports,
|
|
1255
|
+
preferences,
|
|
1256
|
+
pushSubscriptions,
|
|
1257
|
+
contacts: config.contacts,
|
|
1258
|
+
logger,
|
|
1259
|
+
maxAttempts: config.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS,
|
|
1260
|
+
// `exactOptionalPropertyTypes` is on, so an absent host seam must be an
|
|
1261
|
+
// ABSENT key rather than an explicit `undefined`.
|
|
1262
|
+
...present({
|
|
1263
|
+
channelPolicy: config.channelPolicy,
|
|
1264
|
+
scheduleDispatch: config.scheduleDispatch,
|
|
1265
|
+
onCommitted: config.onCommitted
|
|
1266
|
+
})
|
|
1267
|
+
});
|
|
1268
|
+
const audience = config.audience;
|
|
1269
|
+
const notifyByPermission = audience ? createNotifyByPermission({ router, directory: audience, logger }) : () => Promise.reject(
|
|
1270
|
+
new Error(
|
|
1271
|
+
"notifyByPermission() needs an `audience` directory \u2014 pass the host authorization engine to createApiNotifications({ audience })."
|
|
1272
|
+
)
|
|
1273
|
+
);
|
|
1274
|
+
return {
|
|
1275
|
+
routes: notificationRoutes({
|
|
1276
|
+
inbox,
|
|
1277
|
+
preferences,
|
|
1278
|
+
pushSubscriptions,
|
|
1279
|
+
transports,
|
|
1280
|
+
contacts: config.contacts,
|
|
1281
|
+
categories: taxonomy.categories,
|
|
1282
|
+
messages,
|
|
1283
|
+
...present({ onInboxChanged: config.onInboxChanged })
|
|
1284
|
+
}),
|
|
1285
|
+
notify: router.notify,
|
|
1286
|
+
dispatchDeliveries: router.dispatchDeliveries,
|
|
1287
|
+
drainPending: router.drainPending,
|
|
1288
|
+
notifyByPermission,
|
|
1289
|
+
inbox,
|
|
1290
|
+
preferences,
|
|
1291
|
+
pushSubscriptions,
|
|
1292
|
+
registerGenerator: generators.register,
|
|
1293
|
+
transports,
|
|
1294
|
+
messages
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
__name(createApiNotifications, "createApiNotifications");
|
|
1298
|
+
|
|
1299
|
+
export {
|
|
1300
|
+
NotificationsApiError,
|
|
1301
|
+
ok,
|
|
1302
|
+
foldApiError,
|
|
1303
|
+
NotificationProviderError,
|
|
1304
|
+
absoluteLink,
|
|
1305
|
+
EMAIL_DRIVERS,
|
|
1306
|
+
formatEmail,
|
|
1307
|
+
emailTransport,
|
|
1308
|
+
SMS_DRIVERS,
|
|
1309
|
+
formatSms,
|
|
1310
|
+
smsTransport,
|
|
1311
|
+
WEB_PUSH_DRIVERS,
|
|
1312
|
+
formatWebPush,
|
|
1313
|
+
webPushTransport,
|
|
1314
|
+
WHATSAPP_DRIVERS,
|
|
1315
|
+
formatWhatsApp,
|
|
1316
|
+
whatsAppTransport,
|
|
1317
|
+
createTransportRegistry,
|
|
1318
|
+
createApiNotifications
|
|
1319
|
+
};
|
|
1320
|
+
//# sourceMappingURL=chunk-4PSUZ7X5.js.map
|