@12-apps/notifications 4.4.0 → 4.6.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-WU6QJLSZ.js +94 -0
- package/dist/chunk-WU6QJLSZ.js.map +1 -0
- package/dist/chunk-YE24MDS6.js +1022 -0
- package/dist/chunk-YE24MDS6.js.map +1 -0
- package/dist/create-api-notifications-CgBdjfyF.d.ts +909 -0
- package/dist/create-web-notifications-Du3hTs7P.d.ts +354 -0
- package/dist/{generators-FATT537X.d.ts → generators-B9xt3sRh.d.ts} +1 -1
- package/dist/hono/index.d.ts +5 -5
- package/dist/index.d.ts +3 -3
- package/dist/jobs-DhDjrAX5.d.ts +74 -0
- package/dist/manifest/index.d.ts +20 -6
- package/dist/manifest/index.js +2 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +22 -30
- package/dist/manifest/server.js +11 -2
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +51 -0
- package/dist/manifest/web.js +15 -0
- package/dist/manifest/web.js.map +1 -0
- package/dist/react/index.d.ts +6 -353
- package/dist/react/index.js +20 -1008
- package/dist/react/index.js.map +1 -1
- package/dist/server/index.d.ts +79 -902
- package/dist/server/index.js +13 -2
- package/dist/{types-yq_o4N01.d.ts → types-CXLAG3UU.d.ts} +1 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-KLY6UMRT.d.ts → web-push-Cs14Wp9u.d.ts} +2 -2
- package/dist/{wire-SDUtscGu.d.ts → wire-5IRin4zH.d.ts} +1 -1
- package/package.json +13 -5
- package/src/manifest/index.ts +20 -6
- package/src/manifest/server.ts +8 -0
- package/src/manifest/web.ts +46 -0
- package/src/server/index.ts +20 -0
- package/src/server/jobs.ts +118 -0
- package/src/server/wire-notify-port.ts +125 -0
- package/dist/chunk-F5ANWJCY.js +0 -1
- package/dist/chunk-F5ANWJCY.js.map +0 -1
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import { N as NotificationGeneratorRegistry } from './generators-B9xt3sRh.js';
|
|
2
|
+
import { b as NotificationRow, C as ChannelMatrix, a as ChannelRow, L as ListNotificationsResult, c as NotificationWireMessages } from './wire-5IRin4zH.js';
|
|
3
|
+
import { b as NotificationChannel, D as DeliveryStatus, f as NotificationLogger, i as NotificationTransport, c as NotificationContent, a as NotificationCategory, d as NotificationEvent, e as NotificationGenerator } from './types-CXLAG3UU.js';
|
|
4
|
+
import { e as WebPushSubscriptionSource, D as DriverDeclarationBase, a as WebPushDriverDeclaration, c as WebPushSender } from './web-push-Cs14Wp9u.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The database seam (12-15) — the exact, narrow slice of a Prisma-shaped
|
|
8
|
+
* client this surface reads and writes on the four models the package owns
|
|
9
|
+
* (`prisma/notifications.prisma`). Structural on purpose, never generated: a
|
|
10
|
+
* real host passes its Prisma client; the harness passes hand-written SQL, and
|
|
11
|
+
* the stores cannot tell.
|
|
12
|
+
*
|
|
13
|
+
* Every argument type below is CLOSED — the union of the shapes this package's
|
|
14
|
+
* own stores actually pass — so a non-Prisma implementation has a finite,
|
|
15
|
+
* documented surface to satisfy instead of "all of Prisma".
|
|
16
|
+
*
|
|
17
|
+
* What is NOT here: the `users` table. the origin's router read
|
|
18
|
+
* `users.email/phone` directly to answer "can this channel reach them", which
|
|
19
|
+
* is the one thing in the pipeline that belonged to the host all along — a
|
|
20
|
+
* package cannot know the shape of a host's identity table, and a host with
|
|
21
|
+
* phone VERIFICATION wants to answer the question differently. It crosses as
|
|
22
|
+
* {@link NotificationContactDirectory} instead.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface NotificationCreateData {
|
|
26
|
+
userId: string;
|
|
27
|
+
clientId: string | null;
|
|
28
|
+
type: string;
|
|
29
|
+
category: string;
|
|
30
|
+
title: string;
|
|
31
|
+
body: string;
|
|
32
|
+
link: string | null;
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One page boundary, as an explicit KEYSET rather than Prisma's positional
|
|
37
|
+
* cursor.
|
|
38
|
+
*
|
|
39
|
+
* `cursor` + `skip: 1` was the obvious translation and it is wrong in exactly
|
|
40
|
+
* one case, which the inbox reaches routinely: `skip` is an OFFSET applied
|
|
41
|
+
* AFTER the `where`, so once the anchor row stops matching — the user deleted
|
|
42
|
+
* the bottom visible row, which is the one carrying the delete button — the
|
|
43
|
+
* offset consumes the first SURVIVING row instead of the anchor and that row
|
|
44
|
+
* never appears in the list. Stated as a keyset comparison on the same order
|
|
45
|
+
* key, the anchor's own membership is irrelevant, so nothing can be skipped or
|
|
46
|
+
* repeated. It also gives the two non-Prisma implementations of this seam
|
|
47
|
+
* something they can satisfy exactly instead of approximately.
|
|
48
|
+
*/
|
|
49
|
+
interface NotificationPageAfter {
|
|
50
|
+
createdAt: Date;
|
|
51
|
+
id: string;
|
|
52
|
+
}
|
|
53
|
+
/** The inbox read filter. `deletedAt: null` is on every read, always. */
|
|
54
|
+
interface NotificationWhere {
|
|
55
|
+
userId?: string;
|
|
56
|
+
id?: string | {
|
|
57
|
+
in: string[];
|
|
58
|
+
};
|
|
59
|
+
deletedAt: null;
|
|
60
|
+
readAt?: null;
|
|
61
|
+
/** The keyset half of `(createdAt, id) < (anchor.createdAt, anchor.id)`. */
|
|
62
|
+
OR?: [{
|
|
63
|
+
createdAt: {
|
|
64
|
+
lt: Date;
|
|
65
|
+
};
|
|
66
|
+
}, {
|
|
67
|
+
createdAt: Date;
|
|
68
|
+
id: {
|
|
69
|
+
lt: string;
|
|
70
|
+
};
|
|
71
|
+
}];
|
|
72
|
+
}
|
|
73
|
+
interface NotificationDelegate {
|
|
74
|
+
create(args: {
|
|
75
|
+
data: NotificationCreateData;
|
|
76
|
+
}): Promise<NotificationRow>;
|
|
77
|
+
/** Deliberately NOT `deletedAt`-filtered: the pager anchors on a row the
|
|
78
|
+
* user may have just soft-deleted, and its position is still valid. */
|
|
79
|
+
findUnique(args: {
|
|
80
|
+
where: {
|
|
81
|
+
id: string;
|
|
82
|
+
};
|
|
83
|
+
}): Promise<NotificationRow | null>;
|
|
84
|
+
findMany(args: {
|
|
85
|
+
where: NotificationWhere;
|
|
86
|
+
orderBy: [{
|
|
87
|
+
createdAt: 'desc';
|
|
88
|
+
}, {
|
|
89
|
+
id: 'desc';
|
|
90
|
+
}];
|
|
91
|
+
take: number;
|
|
92
|
+
}): Promise<NotificationRow[]>;
|
|
93
|
+
count(args: {
|
|
94
|
+
where: NotificationWhere;
|
|
95
|
+
}): Promise<number>;
|
|
96
|
+
updateMany(args: {
|
|
97
|
+
where: NotificationWhere;
|
|
98
|
+
data: {
|
|
99
|
+
readAt: Date;
|
|
100
|
+
} | {
|
|
101
|
+
deletedAt: Date;
|
|
102
|
+
};
|
|
103
|
+
}): Promise<{
|
|
104
|
+
count: number;
|
|
105
|
+
}>;
|
|
106
|
+
}
|
|
107
|
+
interface NotificationDeliveryRow {
|
|
108
|
+
id: string;
|
|
109
|
+
notificationId: string;
|
|
110
|
+
channel: string;
|
|
111
|
+
status: string;
|
|
112
|
+
error: string | null;
|
|
113
|
+
sentAt: Date | null;
|
|
114
|
+
/** How many times a dispatcher has CLAIMED this row (the retry ceiling). */
|
|
115
|
+
attempts: number;
|
|
116
|
+
createdAt: Date;
|
|
117
|
+
updatedAt: Date;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The delivery filter, and it is deliberately small: four shapes, all of which
|
|
121
|
+
* are either a claim's precondition or the sweep's selection.
|
|
122
|
+
*
|
|
123
|
+
* `updatedAt` and never `createdAt`. The sweep's job is "this row has not moved
|
|
124
|
+
* in a while", and `created_at` cannot express that — a row the sweep re-queued
|
|
125
|
+
* one second ago still carries a `created_at` from days back, so it reads as
|
|
126
|
+
* stale again immediately and the sweep re-dispatches its own work on every
|
|
127
|
+
* tick. Every write here advances `updatedAt`, which is what makes the cutoff
|
|
128
|
+
* mean what it says.
|
|
129
|
+
*/
|
|
130
|
+
interface NotificationDeliveryWhere {
|
|
131
|
+
id?: string;
|
|
132
|
+
notificationId?: string;
|
|
133
|
+
status?: DeliveryStatus | {
|
|
134
|
+
in: DeliveryStatus[];
|
|
135
|
+
};
|
|
136
|
+
updatedAt?: {
|
|
137
|
+
lt: Date;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
interface NotificationDeliveryDelegate {
|
|
141
|
+
createMany(args: {
|
|
142
|
+
data: {
|
|
143
|
+
notificationId: string;
|
|
144
|
+
channel: NotificationChannel;
|
|
145
|
+
}[];
|
|
146
|
+
skipDuplicates: true;
|
|
147
|
+
}): Promise<{
|
|
148
|
+
count: number;
|
|
149
|
+
}>;
|
|
150
|
+
findMany(args: {
|
|
151
|
+
where: NotificationDeliveryWhere;
|
|
152
|
+
/** Oldest-stalest first, so a bounded sweep drains a backlog in order. */
|
|
153
|
+
orderBy?: {
|
|
154
|
+
updatedAt: 'asc';
|
|
155
|
+
};
|
|
156
|
+
take?: number;
|
|
157
|
+
}): Promise<NotificationDeliveryRow[]>;
|
|
158
|
+
update(args: {
|
|
159
|
+
where: {
|
|
160
|
+
id: string;
|
|
161
|
+
};
|
|
162
|
+
data: {
|
|
163
|
+
status: DeliveryStatus;
|
|
164
|
+
sentAt?: Date | null;
|
|
165
|
+
error?: string | null;
|
|
166
|
+
};
|
|
167
|
+
}): Promise<NotificationDeliveryRow>;
|
|
168
|
+
/**
|
|
169
|
+
* THE CLAIM, and the only reason this is `updateMany` rather than `update`:
|
|
170
|
+
* `where` carries the precondition ("this row is still QUEUED"), so the
|
|
171
|
+
* returned `count` answers "did I win it" — one statement, atomic in the
|
|
172
|
+
* database, and never a read the caller then validates in application code.
|
|
173
|
+
*/
|
|
174
|
+
updateMany(args: {
|
|
175
|
+
where: NotificationDeliveryWhere;
|
|
176
|
+
data: {
|
|
177
|
+
status: DeliveryStatus;
|
|
178
|
+
attempts?: {
|
|
179
|
+
increment: number;
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
}): Promise<{
|
|
183
|
+
count: number;
|
|
184
|
+
}>;
|
|
185
|
+
}
|
|
186
|
+
interface NotificationPreferenceRow {
|
|
187
|
+
id: string;
|
|
188
|
+
userId: string;
|
|
189
|
+
category: string;
|
|
190
|
+
channels: unknown;
|
|
191
|
+
}
|
|
192
|
+
interface NotificationPreferenceDelegate {
|
|
193
|
+
findMany(args: {
|
|
194
|
+
where: {
|
|
195
|
+
userId: string;
|
|
196
|
+
};
|
|
197
|
+
}): Promise<NotificationPreferenceRow[]>;
|
|
198
|
+
findUnique(args: {
|
|
199
|
+
where: {
|
|
200
|
+
userId_category: {
|
|
201
|
+
userId: string;
|
|
202
|
+
category: string;
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
}): Promise<NotificationPreferenceRow | null>;
|
|
206
|
+
upsert(args: {
|
|
207
|
+
where: {
|
|
208
|
+
userId_category: {
|
|
209
|
+
userId: string;
|
|
210
|
+
category: string;
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
create: {
|
|
214
|
+
userId: string;
|
|
215
|
+
category: string;
|
|
216
|
+
channels: Record<string, boolean>;
|
|
217
|
+
};
|
|
218
|
+
update: {
|
|
219
|
+
channels: Record<string, boolean>;
|
|
220
|
+
};
|
|
221
|
+
}): Promise<NotificationPreferenceRow>;
|
|
222
|
+
}
|
|
223
|
+
interface PushSubscriptionRow {
|
|
224
|
+
id: string;
|
|
225
|
+
userId: string;
|
|
226
|
+
endpoint: string;
|
|
227
|
+
p256dh: string;
|
|
228
|
+
auth: string;
|
|
229
|
+
userAgent: string | null;
|
|
230
|
+
}
|
|
231
|
+
interface PushSubscriptionDelegate {
|
|
232
|
+
count(args: {
|
|
233
|
+
where: {
|
|
234
|
+
userId: string;
|
|
235
|
+
};
|
|
236
|
+
}): Promise<number>;
|
|
237
|
+
/**
|
|
238
|
+
* The row holding one endpoint, whoever owns it. Read BEFORE an upsert so a
|
|
239
|
+
* re-own (the same browser profile, a different signed-in user) is a logged
|
|
240
|
+
* event rather than a silent transfer, and so the settings screen can be told
|
|
241
|
+
* whether THIS browser's subscription is still the caller's.
|
|
242
|
+
*/
|
|
243
|
+
findUnique(args: {
|
|
244
|
+
where: {
|
|
245
|
+
endpoint: string;
|
|
246
|
+
};
|
|
247
|
+
}): Promise<PushSubscriptionRow | null>;
|
|
248
|
+
findMany(args: {
|
|
249
|
+
where: {
|
|
250
|
+
userId: string;
|
|
251
|
+
};
|
|
252
|
+
}): Promise<PushSubscriptionRow[]>;
|
|
253
|
+
upsert(args: {
|
|
254
|
+
where: {
|
|
255
|
+
endpoint: string;
|
|
256
|
+
};
|
|
257
|
+
create: {
|
|
258
|
+
userId: string;
|
|
259
|
+
endpoint: string;
|
|
260
|
+
p256dh: string;
|
|
261
|
+
auth: string;
|
|
262
|
+
userAgent: string | null;
|
|
263
|
+
};
|
|
264
|
+
update: {
|
|
265
|
+
userId: string;
|
|
266
|
+
p256dh: string;
|
|
267
|
+
auth: string;
|
|
268
|
+
userAgent: string | null;
|
|
269
|
+
};
|
|
270
|
+
}): Promise<PushSubscriptionRow>;
|
|
271
|
+
delete(args: {
|
|
272
|
+
where: {
|
|
273
|
+
id: string;
|
|
274
|
+
};
|
|
275
|
+
}): Promise<PushSubscriptionRow>;
|
|
276
|
+
deleteMany(args: {
|
|
277
|
+
where: {
|
|
278
|
+
userId: string;
|
|
279
|
+
endpoint: string;
|
|
280
|
+
};
|
|
281
|
+
}): Promise<{
|
|
282
|
+
count: number;
|
|
283
|
+
}>;
|
|
284
|
+
}
|
|
285
|
+
/** The model delegates — what both a live client and a transaction expose. */
|
|
286
|
+
interface NotificationsDbClient {
|
|
287
|
+
notification: NotificationDelegate;
|
|
288
|
+
notificationDelivery: NotificationDeliveryDelegate;
|
|
289
|
+
notificationPreference: NotificationPreferenceDelegate;
|
|
290
|
+
pushSubscription: PushSubscriptionDelegate;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The full seam: delegates plus interactive transactions. The inbox record and
|
|
294
|
+
* its delivery rows commit together, so a crash can never leave a notification
|
|
295
|
+
* a user can see with no record of what was meant to carry it. Prisma's own
|
|
296
|
+
* `$transaction(fn)` satisfies this structurally.
|
|
297
|
+
*/
|
|
298
|
+
interface NotificationsDb extends NotificationsDbClient {
|
|
299
|
+
$transaction<T>(fn: (tx: NotificationsDbClient) => Promise<T>): Promise<T>;
|
|
300
|
+
}
|
|
301
|
+
/** Deferred so hosts with an async client bootstrap can pass it directly. */
|
|
302
|
+
type NotificationsDbProvider = () => Promise<NotificationsDb>;
|
|
303
|
+
/**
|
|
304
|
+
* How a transport reaches a person: the destinations the HOST owns.
|
|
305
|
+
*
|
|
306
|
+
* Returning `null` for a user id means "no such recipient", which `notify`
|
|
307
|
+
* treats as a caller bug and throws on — a notification addressed to nobody is
|
|
308
|
+
* never silently dropped.
|
|
309
|
+
*/
|
|
310
|
+
interface NotificationContactDirectory {
|
|
311
|
+
getContact(userId: string): Promise<{
|
|
312
|
+
email: string | null;
|
|
313
|
+
phone: string | null;
|
|
314
|
+
} | null>;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Browser push subscription registry — the write side of the Web Push
|
|
319
|
+
* destination. The client obtains a `PushSubscription` from
|
|
320
|
+
* `PushManager.subscribe()` (using the VAPID public key) and posts it here;
|
|
321
|
+
* unsubscribe removes it by endpoint. All owner-scoped.
|
|
322
|
+
*/
|
|
323
|
+
/** What `PushSubscription.toJSON()` yields in the browser. */
|
|
324
|
+
interface PushSubscriptionInput {
|
|
325
|
+
endpoint: string;
|
|
326
|
+
keys: {
|
|
327
|
+
p256dh: string;
|
|
328
|
+
auth: string;
|
|
329
|
+
};
|
|
330
|
+
/** Optional browser/device hint for a device list. */
|
|
331
|
+
userAgent?: string;
|
|
332
|
+
}
|
|
333
|
+
interface PushSubscriptionStore extends WebPushSubscriptionSource {
|
|
334
|
+
/**
|
|
335
|
+
* Register (or refresh) one browser's subscription. Upserts on the globally
|
|
336
|
+
* unique endpoint, so re-subscribing the same browser never duplicates — and
|
|
337
|
+
* an endpoint recycled to a different signed-in user is re-owned by them.
|
|
338
|
+
*
|
|
339
|
+
* Re-owning is the right call and the alternative is worse: `PushManager`
|
|
340
|
+
* returns the SAME endpoint for the same browser profile, so one row per
|
|
341
|
+
* `(userId, endpoint)` would push user A's notifications to a browser now used
|
|
342
|
+
* by B with B's own keys — which decrypt. Re-owning costs A their channel;
|
|
343
|
+
* keeping both rows costs A their privacy. What re-owning must NOT do is
|
|
344
|
+
* happen unrecorded, hence the warning.
|
|
345
|
+
*/
|
|
346
|
+
save(userId: string, input: PushSubscriptionInput): Promise<void>;
|
|
347
|
+
/** Remove one browser's subscription (owner-scoped; unknown = no-op). */
|
|
348
|
+
remove(userId: string, endpoint: string): Promise<void>;
|
|
349
|
+
/** How many devices the user has registered (settings UI hint). */
|
|
350
|
+
count(userId: string): Promise<number>;
|
|
351
|
+
/**
|
|
352
|
+
* Whether THIS endpoint is currently registered to THIS user.
|
|
353
|
+
*
|
|
354
|
+
* The settings screen needs it because a browser's own subscription object is
|
|
355
|
+
* not evidence that the server still has the row: a re-own or a 404/410 prune
|
|
356
|
+
* removes the row while the browser keeps the subscription, and a screen that
|
|
357
|
+
* reads only the browser then tells a user they are receiving alerts they will
|
|
358
|
+
* never get again. `false` covers both "no such row" and "somebody else's
|
|
359
|
+
* row", so an endpoint the caller does not own reveals nothing about who does.
|
|
360
|
+
*/
|
|
361
|
+
isRegisteredTo(userId: string, endpoint: string): Promise<boolean>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* EMAIL transport: formatter + sender behind the driver port.
|
|
366
|
+
*
|
|
367
|
+
* - `driver: 'resend'` — Resend's HTTP API (plain JSON POST, no SDK):
|
|
368
|
+
* `apiKey` + `from`.
|
|
369
|
+
* - `driver: 'log'` — dev/e2e driver: logs the message instead of sending
|
|
370
|
+
* (explicit opt-in, never a silent default).
|
|
371
|
+
* - no EMAIL declaration at all — `supports() === false`, router skips it.
|
|
372
|
+
*
|
|
373
|
+
* A different vendor (SES, an SMTP relay…) is one more entry in
|
|
374
|
+
* {@link EMAIL_DRIVERS} — this transport, the router and the registry stay
|
|
375
|
+
* untouched.
|
|
376
|
+
*/
|
|
377
|
+
/** The channel message an email formatter produces. */
|
|
378
|
+
interface EmailMessage {
|
|
379
|
+
subject: string;
|
|
380
|
+
text: string;
|
|
381
|
+
html: string;
|
|
382
|
+
}
|
|
383
|
+
/** The vendor seam: deliver one already-formatted email. Throws on failure. */
|
|
384
|
+
interface EmailDriver {
|
|
385
|
+
send(to: string, message: EmailMessage): Promise<void>;
|
|
386
|
+
}
|
|
387
|
+
interface EmailDriverDeclaration extends DriverDeclarationBase {
|
|
388
|
+
channel: 'EMAIL';
|
|
389
|
+
/** Resend: the API key. */
|
|
390
|
+
apiKey?: string;
|
|
391
|
+
/** Resend: the verified `From` address. */
|
|
392
|
+
from?: string;
|
|
393
|
+
/** Where the CTA link points; without it a link is dropped. */
|
|
394
|
+
appUrl?: string;
|
|
395
|
+
/**
|
|
396
|
+
* The CTA link's label. REQUIRED — this used to default to `'Ver detalhes'`,
|
|
397
|
+
* so a host that declared EMAIL and nothing else mailed this product's
|
|
398
|
+
* Portuguese to its own users, in their inbox, signed with the host's own
|
|
399
|
+
* `from` address.
|
|
400
|
+
*/
|
|
401
|
+
linkLabel: string;
|
|
402
|
+
logger?: NotificationLogger;
|
|
403
|
+
}
|
|
404
|
+
/** The built-in email vendors. A host adds one by extending this table. */
|
|
405
|
+
declare const EMAIL_DRIVERS: Record<string, (declaration: EmailDriverDeclaration) => EmailDriver>;
|
|
406
|
+
/**
|
|
407
|
+
* Agnostic content → subject/text/html. The link becomes a trailing CTA, only
|
|
408
|
+
* when an app base URL is configured.
|
|
409
|
+
*/
|
|
410
|
+
declare function formatEmail(content: NotificationContent, declaration: EmailDriverDeclaration): EmailMessage;
|
|
411
|
+
declare function emailTransport(declaration: EmailDriverDeclaration, extraDrivers?: Record<string, (d: EmailDriverDeclaration) => EmailDriver>): NotificationTransport<EmailMessage>;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* SMS transport — Twilio behind the same driver port as email.
|
|
415
|
+
*
|
|
416
|
+
* - `driver: 'twilio'` — the Messages API (form-encoded POST with basic
|
|
417
|
+
* auth, no SDK): `accountSid`, `authToken`, `from` (an E.164 sender or a
|
|
418
|
+
* Messaging Service SID).
|
|
419
|
+
* - `driver: 'log'` — dev driver: logs instead of sending.
|
|
420
|
+
* - no SMS declaration — channel unavailable, router skips it.
|
|
421
|
+
*
|
|
422
|
+
* A recipient without a normalizable phone is unavailable on this channel
|
|
423
|
+
* regardless of the driver (see `../../phone.ts` for the verification caveat).
|
|
424
|
+
*/
|
|
425
|
+
/** The channel message the SMS formatter produces: one plain text body. */
|
|
426
|
+
interface SmsMessage {
|
|
427
|
+
body: string;
|
|
428
|
+
}
|
|
429
|
+
interface SmsDriver {
|
|
430
|
+
send(toE164: string, message: SmsMessage): Promise<void>;
|
|
431
|
+
}
|
|
432
|
+
interface SmsDriverDeclaration extends DriverDeclarationBase {
|
|
433
|
+
channel: 'SMS';
|
|
434
|
+
accountSid?: string;
|
|
435
|
+
authToken?: string;
|
|
436
|
+
from?: string;
|
|
437
|
+
appUrl?: string;
|
|
438
|
+
/**
|
|
439
|
+
* Country calling code for a bare local number, digits only (`'55'`, `'1'`).
|
|
440
|
+
* REQUIRED: this package assumes no country, because the one it used to
|
|
441
|
+
* assume turned a US number into a plausible Brazilian mobile and texted a
|
|
442
|
+
* stranger the customer's order (see `../../phone.ts`).
|
|
443
|
+
*/
|
|
444
|
+
defaultCountryCode: string;
|
|
445
|
+
logger?: NotificationLogger;
|
|
446
|
+
}
|
|
447
|
+
declare const SMS_DRIVERS: Record<string, (declaration: SmsDriverDeclaration) => SmsDriver>;
|
|
448
|
+
/** Agnostic content → one plain SMS: "title: body (link)", length-capped. */
|
|
449
|
+
declare function formatSms(content: NotificationContent, declaration: SmsDriverDeclaration): SmsMessage;
|
|
450
|
+
declare function smsTransport(declaration: SmsDriverDeclaration, extraDrivers?: Record<string, (d: SmsDriverDeclaration) => SmsDriver>): NotificationTransport<SmsMessage>;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* WHATSAPP transport — Meta's WhatsApp Cloud API behind the driver port.
|
|
454
|
+
*
|
|
455
|
+
* - `driver: 'meta'` — the Cloud API (JSON POST with a bearer token, no
|
|
456
|
+
* SDK): `accessToken` + `phoneNumberId`.
|
|
457
|
+
* - `driver: 'log'` — dev driver: logs instead of sending.
|
|
458
|
+
* - no WHATSAPP declaration — channel unavailable, router skips it.
|
|
459
|
+
*
|
|
460
|
+
* Template/session-window rule: WhatsApp only accepts FREE-FORM text inside a
|
|
461
|
+
* 24h customer-service window; business-initiated messages outside it require
|
|
462
|
+
* a pre-approved TEMPLATE. With `templateName` set the transport sends that
|
|
463
|
+
* template with two body parameters — {{1}} = title, {{2}} = body (language
|
|
464
|
+
* `templateLanguage`, default `pt_BR`). Without it the transport sends
|
|
465
|
+
* free-form text, and a send outside the session window FAILS with the
|
|
466
|
+
* provider's error recorded on the delivery row — the documented fallback
|
|
467
|
+
* behaviour, visible instead of silent.
|
|
468
|
+
*
|
|
469
|
+
* The window cannot be TRACKED from here (only Meta knows when the customer
|
|
470
|
+
* last wrote), so a host that declares WHATSAPP with no `templateName` and then
|
|
471
|
+
* emits business-initiated notifications has every send rejected. That is
|
|
472
|
+
* visible on the delivery rows, but only once they exist — so the mount warns
|
|
473
|
+
* about the combination the moment the declaration is read, which is the one
|
|
474
|
+
* moment a misconfiguration is cheap to notice.
|
|
475
|
+
*/
|
|
476
|
+
/** The channel message the WhatsApp formatter produces. */
|
|
477
|
+
interface WhatsAppMessage {
|
|
478
|
+
/** Free-form text used inside the session window / without a template. */
|
|
479
|
+
text: string;
|
|
480
|
+
/** Template body parameters ({{1}} title, {{2}} body) when templated. */
|
|
481
|
+
templateParameters: [title: string, body: string];
|
|
482
|
+
}
|
|
483
|
+
interface WhatsAppDriver {
|
|
484
|
+
send(toE164: string, message: WhatsAppMessage): Promise<void>;
|
|
485
|
+
}
|
|
486
|
+
interface WhatsAppDriverDeclaration extends DriverDeclarationBase {
|
|
487
|
+
channel: 'WHATSAPP';
|
|
488
|
+
accessToken?: string;
|
|
489
|
+
phoneNumberId?: string;
|
|
490
|
+
templateName?: string;
|
|
491
|
+
/**
|
|
492
|
+
* The WhatsApp template's language code, e.g. `pt_BR`, `en_US`.
|
|
493
|
+
*
|
|
494
|
+
* REQUIRED: a template is registered with Meta under one language, and
|
|
495
|
+
* sending it with the wrong code is rejected by the Graph API. This defaulted
|
|
496
|
+
* to `pt_BR` — one market's answer — so a host that forgot it did not get a
|
|
497
|
+
* sensible fallback, it got somebody else's template language and a delivery
|
|
498
|
+
* failure it had no reason to expect.
|
|
499
|
+
*/
|
|
500
|
+
templateLanguage: string;
|
|
501
|
+
/** Graph API base, so a host can pin a version. */
|
|
502
|
+
graphApiBase?: string;
|
|
503
|
+
appUrl?: string;
|
|
504
|
+
/**
|
|
505
|
+
* Country calling code for a bare local number, digits only (`'55'`, `'1'`).
|
|
506
|
+
* REQUIRED for the same reason SMS requires it — see `../../phone.ts`.
|
|
507
|
+
*/
|
|
508
|
+
defaultCountryCode: string;
|
|
509
|
+
logger?: NotificationLogger;
|
|
510
|
+
}
|
|
511
|
+
declare const WHATSAPP_DRIVERS: Record<string, (declaration: WhatsAppDriverDeclaration) => WhatsAppDriver>;
|
|
512
|
+
/** Agnostic content → WhatsApp text + template parameters. */
|
|
513
|
+
declare function formatWhatsApp(content: NotificationContent, declaration: WhatsAppDriverDeclaration): WhatsAppMessage;
|
|
514
|
+
declare function whatsAppTransport(declaration: WhatsAppDriverDeclaration, extraDrivers?: Record<string, (d: WhatsAppDriverDeclaration) => WhatsAppDriver>, logger?: NotificationLogger): NotificationTransport<WhatsAppMessage>;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The transport registry: the router dispatches through this, so adding a
|
|
518
|
+
* channel = registering one adapter and the router, generators and existing
|
|
519
|
+
* transports are untouched (open/closed).
|
|
520
|
+
*
|
|
521
|
+
* A mount declares its channels and gets a registry; nothing is process-wide.
|
|
522
|
+
* The origin registered its four transports as an IMPORT SIDE EFFECT of the
|
|
523
|
+
* package's root entry, which made "which channels are on" a property of the
|
|
524
|
+
* module graph rather than of any configuration — importing the inbox helpers
|
|
525
|
+
* in a unit test silently armed four transports.
|
|
526
|
+
*/
|
|
527
|
+
/** One channel's declaration. The union is closed; the drivers are not. */
|
|
528
|
+
type TransportDeclaration = EmailDriverDeclaration | SmsDriverDeclaration | WhatsAppDriverDeclaration | WebPushDriverDeclaration;
|
|
529
|
+
/** A host's own vendors, added per channel without touching the package. */
|
|
530
|
+
interface ExtraDrivers {
|
|
531
|
+
email?: Record<string, (declaration: EmailDriverDeclaration) => EmailDriver>;
|
|
532
|
+
sms?: Record<string, (declaration: SmsDriverDeclaration) => SmsDriver>;
|
|
533
|
+
whatsapp?: Record<string, (declaration: WhatsAppDriverDeclaration) => WhatsAppDriver>;
|
|
534
|
+
webPush?: Record<string, (declaration: WebPushDriverDeclaration) => WebPushSender>;
|
|
535
|
+
}
|
|
536
|
+
interface TransportRegistry {
|
|
537
|
+
/** The adapter for `channel`, or null when the host declared none. */
|
|
538
|
+
get(channel: NotificationChannel): NotificationTransport<never> | null;
|
|
539
|
+
/** Every declared adapter, in declaration order. */
|
|
540
|
+
list(): NotificationTransport<never>[];
|
|
541
|
+
/** Register (or replace, last-wins) an adapter built by the host itself. */
|
|
542
|
+
register<TMessage>(transport: NotificationTransport<TMessage>): void;
|
|
543
|
+
/** The VAPID public key, when the WEB_PUSH channel declared one. */
|
|
544
|
+
webPushPublicKey(): string | null;
|
|
545
|
+
}
|
|
546
|
+
declare function createTransportRegistry(declarations: readonly TransportDeclaration[], subscriptions: WebPushSubscriptionSource, extra?: ExtraDrivers,
|
|
547
|
+
/** The mount's logger, for a declaration that is legal but probably wrong. */
|
|
548
|
+
logger?: NotificationLogger): TransportRegistry;
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Per-user channel preferences: which transport channels may carry each
|
|
552
|
+
* notification category to a user. The inbox is NOT gated here — it is always
|
|
553
|
+
* on.
|
|
554
|
+
*
|
|
555
|
+
* The POLICY (defaults, coercion, merge) lives in `../preferences-core.ts`;
|
|
556
|
+
* this is the storage over it, and the split is what lets the react half render
|
|
557
|
+
* the same defaults before the first read lands.
|
|
558
|
+
*/
|
|
559
|
+
interface NotificationPreferenceStore {
|
|
560
|
+
/** The user's full matrix, defaults merged in. */
|
|
561
|
+
get(userId: string): Promise<ChannelMatrix>;
|
|
562
|
+
/** Persist explicit choices for any subset of categories/toggles. */
|
|
563
|
+
save(userId: string, input: Partial<Record<NotificationCategory, Partial<ChannelRow>>>): Promise<void>;
|
|
564
|
+
/** The channels enabled for one (user, category) — the router's gate. */
|
|
565
|
+
enabledChannels(userId: string, category: NotificationCategory): Promise<NotificationChannel[]>;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The channel router + the `notify` emit API — the single front door into the
|
|
570
|
+
* pipeline. Any server-side caller (route handler, background worker, agent
|
|
571
|
+
* tool) emits with one typed call and zero knowledge of channels, formatting,
|
|
572
|
+
* or preferences:
|
|
573
|
+
*
|
|
574
|
+
* await notifications.notify({ type: 'order.paid', recipient: { userId }, payload });
|
|
575
|
+
*
|
|
576
|
+
* What one emit does:
|
|
577
|
+
* 1. Resolves the registered generator for `type` → agnostic content.
|
|
578
|
+
* 2. ALWAYS writes the inbox record (the always-on channel), atomically
|
|
579
|
+
* with…
|
|
580
|
+
* 3. …one QUEUED delivery per channel that is (a) enabled by the recipient's
|
|
581
|
+
* preferences for the generator's category and (b) supported by its
|
|
582
|
+
* transport for this recipient.
|
|
583
|
+
* 4. Hands the deliveries to the transports ASYNCHRONOUSLY (fire-and-forget
|
|
584
|
+
* by default) so emit sites never block on provider I/O.
|
|
585
|
+
*
|
|
586
|
+
* The TRANSACTION IS THIS PACKAGE'S OWN, and a host cannot enlist in it: step 2
|
|
587
|
+
* opens `client.$transaction` itself, and a Prisma `TransactionClient` has no
|
|
588
|
+
* `$transaction` to nest. So `notify` must be called AFTER the caller's own
|
|
589
|
+
* transaction commits — called from inside one, it commits an inbox row and
|
|
590
|
+
* dispatches an e-mail for a payment that then rolls back.
|
|
591
|
+
*
|
|
592
|
+
* Failure isolation: each delivery is sent in its own try/catch — one channel
|
|
593
|
+
* failing marks only its row FAILED (error recorded) and never blocks the
|
|
594
|
+
* inbox record or the other channels. Delivery is at-least-once: the unique
|
|
595
|
+
* (notification, channel) row makes fan-out idempotent, and every send is
|
|
596
|
+
* CLAIMED before it happens (`./dispatch.ts`), so the remaining re-send window
|
|
597
|
+
* is the unavoidable one — a crash between the provider call and the SENT flip.
|
|
598
|
+
* Transports are required to tolerate that.
|
|
599
|
+
*
|
|
600
|
+
* Queueing: in-process async dispatch by default, or a real queue when the
|
|
601
|
+
* host passes `scheduleDispatch`. The QUEUED status + the drain sweep are what
|
|
602
|
+
* make either safe — the delivery rows are the durable record, so a queue that
|
|
603
|
+
* is unavailable (or absent) costs latency, never a notification.
|
|
604
|
+
*/
|
|
605
|
+
/**
|
|
606
|
+
* Which channels a TENANT may use, decided per emit — the host's plan gate.
|
|
607
|
+
*
|
|
608
|
+
* A `null`/absent clientId is a PLATFORM notification (password resets,
|
|
609
|
+
* operator alerts) and is never policy-filtered. With no policy installed
|
|
610
|
+
* every channel passes.
|
|
611
|
+
*/
|
|
612
|
+
type NotificationChannelPolicy = (clientId: string, channels: readonly NotificationChannel[]) => Promise<NotificationChannel[]> | NotificationChannel[];
|
|
613
|
+
/** How the host defers dispatch of one already-committed notification. */
|
|
614
|
+
type NotificationDispatchScheduler = (notificationId: string) => Promise<void>;
|
|
615
|
+
/** One committed inbox record, as the commit observer sees it. */
|
|
616
|
+
interface CommittedNotification {
|
|
617
|
+
notificationId: string;
|
|
618
|
+
/** The owner — the only field a user-scoped fan-out needs. */
|
|
619
|
+
userId: string;
|
|
620
|
+
/** The tenant the row was stamped with, or null for a platform emit. */
|
|
621
|
+
clientId: string | null;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Told about each inbox record the moment it commits. Synchronous and
|
|
625
|
+
* `void`-returning by contract: an observer may not make an emit site wait,
|
|
626
|
+
* and may not fail one.
|
|
627
|
+
*
|
|
628
|
+
* It exists because the inbox record is written HERE, in the package, while the
|
|
629
|
+
* thing that usually wants to know — a realtime bus — is a dependency this
|
|
630
|
+
* package does not have and should not gain. Placing it at the funnel rather
|
|
631
|
+
* than at the emit sites is the point: `notify` is the single front door, so
|
|
632
|
+
* every sender is covered by construction, including ones written later.
|
|
633
|
+
*/
|
|
634
|
+
type NotificationCommittedListener = (notification: CommittedNotification) => void;
|
|
635
|
+
/** Options for `notify`. */
|
|
636
|
+
interface NotifyOptions {
|
|
637
|
+
/**
|
|
638
|
+
* Await transport dispatch instead of fire-and-forget. For tests and
|
|
639
|
+
* worker/cron contexts where the process may exit right after emitting.
|
|
640
|
+
*/
|
|
641
|
+
sync?: boolean;
|
|
642
|
+
}
|
|
643
|
+
/** What `notify` resolves with (dispatch may still be in flight). */
|
|
644
|
+
interface NotifyResult {
|
|
645
|
+
notificationId: string;
|
|
646
|
+
/** Channels a delivery row was enqueued for (preference ∩ transport gate). */
|
|
647
|
+
channels: NotificationChannel[];
|
|
648
|
+
}
|
|
649
|
+
interface NotificationRouter {
|
|
650
|
+
notify<TPayload>(event: NotificationEvent<TPayload>, options?: NotifyOptions): Promise<NotifyResult>;
|
|
651
|
+
dispatchDeliveries(notificationId: string): Promise<void>;
|
|
652
|
+
drainPending(olderThanMs?: number, take?: number): Promise<{
|
|
653
|
+
dispatched: number;
|
|
654
|
+
}>;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Permission-addressed notifications: "tell whoever can act on this", resolved
|
|
659
|
+
* against the host's REAL authorization engine.
|
|
660
|
+
*
|
|
661
|
+
* Naming an audience by ROLE reads a coarse mirror column, so a tenant who
|
|
662
|
+
* moved a capability onto a custom role — or granted it additively — gets a
|
|
663
|
+
* notification list that disagrees with what the app actually authorizes. The
|
|
664
|
+
* two answers drift silently, and the direction they drift in is "the person
|
|
665
|
+
* who can fix it never hears about it".
|
|
666
|
+
*
|
|
667
|
+
* So this addresses by CAPABILITY: name the permissions the recipient must
|
|
668
|
+
* hold, and the audience is derived from the same evaluation the guards use.
|
|
669
|
+
*
|
|
670
|
+
* ## What the package owns, and what the host answers
|
|
671
|
+
*
|
|
672
|
+
* The FOLD is the package's: the AND, the deduplication, the refusal of an
|
|
673
|
+
* empty permission list, the per-recipient isolation, and the log line that
|
|
674
|
+
* distinguishes "nobody holds it" from "everybody's dispatch failed". Those are
|
|
675
|
+
* the parts that are the same in every host and that are easy to get subtly
|
|
676
|
+
* wrong.
|
|
677
|
+
*
|
|
678
|
+
* The two QUERIES are the host's, through {@link NotificationAudienceDirectory}
|
|
679
|
+
* — because an authorization engine is host machinery. In the extraction origin this
|
|
680
|
+
* module could not live in a package at all: it needed `notify()` AND the RBAC
|
|
681
|
+
* engine, and neither package could see the other. Inverting the dependency
|
|
682
|
+
* (the host answers, the package asks) is what makes it portable.
|
|
683
|
+
*/
|
|
684
|
+
/**
|
|
685
|
+
* The host's authorization engine, as this fan-out needs it.
|
|
686
|
+
*
|
|
687
|
+
* `listCandidates` must be BOUNDED to people who actually hold a role at the
|
|
688
|
+
* tenant. that host's implementation requires a role grant, which is what
|
|
689
|
+
* keeps a store's storefront BUYERS — who all carry a default membership — out
|
|
690
|
+
* of a loop that resolves permissions one user at a time.
|
|
691
|
+
*
|
|
692
|
+
* `getPermissions` must be scoped to `tenantId`. Unioning a user's grants
|
|
693
|
+
* across tenants — the obvious way to "simplify" it — notifies someone about a
|
|
694
|
+
* store whose money they have no authority over, and no `where` clause upstream
|
|
695
|
+
* can save it because that user is already a candidate.
|
|
696
|
+
*/
|
|
697
|
+
interface NotificationAudienceDirectory {
|
|
698
|
+
listCandidates(tenantId: string): Promise<readonly string[]>;
|
|
699
|
+
getPermissions(userId: string, tenantId: string): Promise<ReadonlySet<string> | readonly string[]>;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* One candidate that did not receive it, and why.
|
|
703
|
+
*
|
|
704
|
+
* `audience-error` is deliberately its own reason rather than folded into
|
|
705
|
+
* `missing-permission`: "this user does not hold the pair" is a configuration
|
|
706
|
+
* fact, while "we could not find out whether they hold it" is an outage, and
|
|
707
|
+
* they need opposite responses. Collapsing them would report a database timeout
|
|
708
|
+
* as a tenant that simply has nobody to tell.
|
|
709
|
+
*/
|
|
710
|
+
interface PermissionNotificationSkip {
|
|
711
|
+
userId: string;
|
|
712
|
+
reason: 'missing-permission' | 'dispatch-failed' | 'audience-error';
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* What one fan-out actually did. Returned rather than logged-and-forgotten so a
|
|
716
|
+
* caller (and a test) can assert on the OUTCOME — who was reached and who was
|
|
717
|
+
* not — without reaching into transport mocks to infer it.
|
|
718
|
+
*/
|
|
719
|
+
interface PermissionNotificationResult {
|
|
720
|
+
/** User ids whose notification committed, in candidate order. */
|
|
721
|
+
notified: string[];
|
|
722
|
+
/** Candidates that did not receive it, with the reason. */
|
|
723
|
+
skipped: PermissionNotificationSkip[];
|
|
724
|
+
}
|
|
725
|
+
type NotifyByPermission = <TPayload>(clientId: string, permissions: readonly string[], event: Omit<NotificationEvent<TPayload>, 'recipient'>) => Promise<PermissionNotificationResult>;
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* What every route in this surface shares (12-15): the actor, the request, the
|
|
729
|
+
* response envelope and the body parsing. Mirrors the entity-lifecycle /
|
|
730
|
+
* report-builder shape — framework-neutral descriptors a forty-line adapter
|
|
731
|
+
* mounts.
|
|
732
|
+
*/
|
|
733
|
+
/**
|
|
734
|
+
* What a host must resolve before a request reaches these handlers: WHO is
|
|
735
|
+
* calling. That is the whole seam.
|
|
736
|
+
*
|
|
737
|
+
* There is no tenant here and no permission list, and both absences are the
|
|
738
|
+
* design. Every endpoint in this surface is SELF-scoped — a user reads and
|
|
739
|
+
* writes their own inbox and their own preferences — so the only authorization
|
|
740
|
+
* question is "who is signed in", and the answer is applied by scoping every
|
|
741
|
+
* query to `userId` rather than by a guard that could be forgotten. A
|
|
742
|
+
* permission-gated ADMIN view of someone else's inbox would be a different
|
|
743
|
+
* surface, and would need a different actor.
|
|
744
|
+
*/
|
|
745
|
+
interface NotificationsActor {
|
|
746
|
+
userId: string;
|
|
747
|
+
}
|
|
748
|
+
/** One request, already authenticated and routed by the host. */
|
|
749
|
+
interface NotificationsRequest {
|
|
750
|
+
actor: NotificationsActor;
|
|
751
|
+
params: Record<string, string | undefined>;
|
|
752
|
+
query: Record<string, string | undefined>;
|
|
753
|
+
body?: unknown;
|
|
754
|
+
/** Headers the surface reads (`user-agent`, for the device hint). */
|
|
755
|
+
headers?: Record<string, string | undefined>;
|
|
756
|
+
}
|
|
757
|
+
/** What a handler answers with; the adapter maps it onto its response type. */
|
|
758
|
+
interface NotificationsResponse {
|
|
759
|
+
status: number;
|
|
760
|
+
/** `undefined` means NO body at all (204) — not the same as `null`. */
|
|
761
|
+
body: unknown;
|
|
762
|
+
}
|
|
763
|
+
interface NotificationsRoute {
|
|
764
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
765
|
+
/**
|
|
766
|
+
* Path relative to the host's account mount, in `:param` form. The SHAPE is
|
|
767
|
+
* fixed because the packaged client builds these URLs.
|
|
768
|
+
*/
|
|
769
|
+
path: string;
|
|
770
|
+
handle(request: NotificationsRequest): Promise<NotificationsResponse>;
|
|
771
|
+
}
|
|
772
|
+
/** A user-safe API error carrying the HTTP status the wire promises. */
|
|
773
|
+
declare class NotificationsApiError extends Error {
|
|
774
|
+
readonly status: number;
|
|
775
|
+
constructor(status: number, message: string);
|
|
776
|
+
}
|
|
777
|
+
/** Success is `{ data }`; a denial is `{ error }`, unwrapped. */
|
|
778
|
+
declare const ok: (data: unknown, status?: number) => NotificationsResponse;
|
|
779
|
+
/** Fold a thrown {@link NotificationsApiError} into a response; rethrow the rest. */
|
|
780
|
+
declare function foldApiError(error: unknown): NotificationsResponse;
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Notification-centre inbox reads/writes. Every function is scoped to the
|
|
784
|
+
* OWNER's `userId` — a caller can only ever see or touch their own rows (the
|
|
785
|
+
* route layer supplies the authenticated user's id, never a client value).
|
|
786
|
+
* Soft-deleted rows (`deletedAt` set) are excluded from every read and can
|
|
787
|
+
* never be resurrected by mark-read.
|
|
788
|
+
*/
|
|
789
|
+
interface ListNotificationsInput {
|
|
790
|
+
/** `unread` narrows to unread rows; default lists all non-deleted. */
|
|
791
|
+
filter?: 'all' | 'unread';
|
|
792
|
+
/**
|
|
793
|
+
* Cursor = the `id` of the last item of the previous page. Resolved to a
|
|
794
|
+
* KEYSET position, so a row the user soft-deleted between the two requests —
|
|
795
|
+
* routinely the bottom one, since that is the row with the delete button —
|
|
796
|
+
* still anchors the next page instead of costing it a row. Owner-checked: an
|
|
797
|
+
* id that is not the caller's names no position and answers an empty page.
|
|
798
|
+
*/
|
|
799
|
+
cursor?: string;
|
|
800
|
+
/** Page size (server-clamped 1..100, default 20). */
|
|
801
|
+
limit?: number;
|
|
802
|
+
}
|
|
803
|
+
interface NotificationInboxStore {
|
|
804
|
+
list(userId: string, input?: ListNotificationsInput): Promise<ListNotificationsResult>;
|
|
805
|
+
unreadCount(userId: string): Promise<number>;
|
|
806
|
+
markRead(userId: string, ids: readonly string[]): Promise<number>;
|
|
807
|
+
markAllRead(userId: string): Promise<number>;
|
|
808
|
+
softDelete(userId: string, ids: readonly string[]): Promise<number>;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The one thing this package exposes to a BACKEND host (12-15).
|
|
813
|
+
*
|
|
814
|
+
* The pipeline used to be a private workspace package plus six hand-written
|
|
815
|
+
* route files: each one resolving the session, calling a loose helper, and
|
|
816
|
+
* shaping a response, with the transports reading their own credentials out of
|
|
817
|
+
* `process.env` and registering themselves as an import side effect. Only "who
|
|
818
|
+
* is calling, where the rows live, how a channel reaches a person" was ever the
|
|
819
|
+
* host's business; the rest — the routing, the delivery rows, the retries, the
|
|
820
|
+
* request contract, the envelope, the pt-BR copy — is this surface's.
|
|
821
|
+
*
|
|
822
|
+
* Routes are FRAMEWORK-NEUTRAL descriptors, not a Hono/Express router (the
|
|
823
|
+
* report-builder doctrine). `@12-apps/notifications/hono` adapts them.
|
|
824
|
+
*
|
|
825
|
+
* What stays the HOST's, and is passed in rather than guessed at:
|
|
826
|
+
*
|
|
827
|
+
* - **Authentication** — the adapter's `resolveActor` hands over a user id.
|
|
828
|
+
* Every endpoint is self-scoped, so that is the entire authorization seam.
|
|
829
|
+
* - **Where the four owned tables live** — the structural `db` seam.
|
|
830
|
+
* - **How to reach a person** — `contacts`, because a package cannot know the
|
|
831
|
+
* shape of a host's identity table (nor whether its phones are verified).
|
|
832
|
+
* - **Which vendors carry which channel** — `transports`, one declaration per
|
|
833
|
+
* channel. An undeclared channel is off; a second vendor is a config entry.
|
|
834
|
+
* - **Billing** — `channelPolicy`, the plan gate answered per emit.
|
|
835
|
+
* - **Its authorization engine** — `audience`, for the permission fan-out.
|
|
836
|
+
* - **Its domain events** — `generators`, registered from the outside.
|
|
837
|
+
*/
|
|
838
|
+
interface NotificationsServerConfig {
|
|
839
|
+
/** Prisma-shaped client for the four owned models, through the seam. */
|
|
840
|
+
db: NotificationsDbProvider;
|
|
841
|
+
/** How a transport reaches a person (the host's identity table). */
|
|
842
|
+
contacts: NotificationContactDirectory;
|
|
843
|
+
/** One declaration per channel the host wants on. Default: none, all off. */
|
|
844
|
+
transports?: readonly TransportDeclaration[];
|
|
845
|
+
/** The host's own vendor drivers, per channel. */
|
|
846
|
+
drivers?: ExtraDrivers;
|
|
847
|
+
/** The domain events this mount can emit. */
|
|
848
|
+
generators?: readonly NotificationGenerator<never>[];
|
|
849
|
+
/**
|
|
850
|
+
* Preference categories — the granularity at which a user chooses channels.
|
|
851
|
+
*
|
|
852
|
+
* REQUIRED. This defaulted to one product's four (`orders`, `payments`,
|
|
853
|
+
* `stock`, `system`), which is the host's vocabulary and not this library's:
|
|
854
|
+
* a host that omitted it rendered four rows it never chose, with its own
|
|
855
|
+
* categories absent, and nothing failed — `category` is a free string by
|
|
856
|
+
* design, so there was no layer left to notice.
|
|
857
|
+
*/
|
|
858
|
+
categories: readonly NotificationCategory[];
|
|
859
|
+
/** Override which channels a never-touched category defaults to. */
|
|
860
|
+
channelDefaults?: Partial<ChannelRow>;
|
|
861
|
+
/** The tenant plan gate, answered per emit. */
|
|
862
|
+
channelPolicy?: NotificationChannelPolicy;
|
|
863
|
+
/** Hand dispatch to a real queue instead of the in-process detached send. */
|
|
864
|
+
scheduleDispatch?: NotificationDispatchScheduler;
|
|
865
|
+
/**
|
|
866
|
+
* Claims one delivery gets before the sweep gives up on it and writes DEAD.
|
|
867
|
+
* Default 5. There is no "unlimited": a permanently invalid destination would
|
|
868
|
+
* be a billed provider call on every sweep for the life of the row.
|
|
869
|
+
*/
|
|
870
|
+
maxDeliveryAttempts?: number;
|
|
871
|
+
/** Told the moment an inbox record commits (a realtime bus, typically). */
|
|
872
|
+
onCommitted?: NotificationCommittedListener;
|
|
873
|
+
/** Told when a mark-read/delete actually changed something. */
|
|
874
|
+
onInboxChanged?: (userId: string) => void;
|
|
875
|
+
/** The host's authorization engine, for `notifyByPermission`. */
|
|
876
|
+
audience?: NotificationAudienceDirectory;
|
|
877
|
+
/** User-facing copy overrides (pt-BR product copy by default). */
|
|
878
|
+
messages: NotificationWireMessages;
|
|
879
|
+
/** The host's logger. Defaults to the console. */
|
|
880
|
+
logger?: NotificationLogger;
|
|
881
|
+
}
|
|
882
|
+
interface ApiNotifications {
|
|
883
|
+
/** The whole generated surface, in mount order. */
|
|
884
|
+
routes: NotificationsRoute[];
|
|
885
|
+
/** The emit front door. */
|
|
886
|
+
notify: NotificationRouter['notify'];
|
|
887
|
+
/** Send every still-QUEUED delivery of one notification. */
|
|
888
|
+
dispatchDeliveries: NotificationRouter['dispatchDeliveries'];
|
|
889
|
+
/** The retry sweep, for a cron/admin trigger. */
|
|
890
|
+
drainPending: NotificationRouter['drainPending'];
|
|
891
|
+
/**
|
|
892
|
+
* "Tell whoever can act on this." Rejects when the host configured no
|
|
893
|
+
* `audience` — loudly, because the alternative is a money alert nobody gets.
|
|
894
|
+
*/
|
|
895
|
+
notifyByPermission: NotifyByPermission;
|
|
896
|
+
/** The stores, for host surfaces that read the same tables. */
|
|
897
|
+
inbox: NotificationInboxStore;
|
|
898
|
+
preferences: NotificationPreferenceStore;
|
|
899
|
+
pushSubscriptions: PushSubscriptionStore;
|
|
900
|
+
/** Register a generator after the mount (a lazily-imported domain module). */
|
|
901
|
+
registerGenerator: NotificationGeneratorRegistry['register'];
|
|
902
|
+
/** The declared transports, for diagnostics and availability probes. */
|
|
903
|
+
transports: TransportRegistry;
|
|
904
|
+
/** The copy in force, so a host's own screens can reuse a sentence. */
|
|
905
|
+
messages: NotificationWireMessages;
|
|
906
|
+
}
|
|
907
|
+
declare function createApiNotifications(config: NotificationsServerConfig): ApiNotifications;
|
|
908
|
+
|
|
909
|
+
export { createApiNotifications as $, type ApiNotifications as A, type NotificationsDbProvider as B, type CommittedNotification as C, type NotificationsRequest as D, EMAIL_DRIVERS as E, type NotificationsResponse as F, type NotifyByPermission as G, type NotifyOptions as H, type NotifyResult as I, type PermissionNotificationSkip as J, type PushSubscriptionDelegate as K, type ListNotificationsInput as L, type PushSubscriptionInput as M, type NotificationsServerConfig as N, type PushSubscriptionRow as O, type PermissionNotificationResult as P, type PushSubscriptionStore as Q, type SmsDriver as R, SMS_DRIVERS as S, type SmsDriverDeclaration as T, type SmsMessage as U, type TransportDeclaration as V, type TransportRegistry as W, WHATSAPP_DRIVERS as X, type WhatsAppDriver as Y, type WhatsAppDriverDeclaration as Z, type WhatsAppMessage as _, type NotificationsActor as a, createTransportRegistry as a0, emailTransport as a1, foldApiError as a2, formatEmail as a3, formatSms as a4, formatWhatsApp as a5, ok as a6, smsTransport as a7, whatsAppTransport as a8, type NotificationsRoute as b, type EmailDriver as c, type EmailDriverDeclaration as d, type EmailMessage as e, type ExtraDrivers as f, type NotificationAudienceDirectory as g, type NotificationChannelPolicy as h, type NotificationCommittedListener as i, type NotificationContactDirectory as j, type NotificationCreateData as k, type NotificationDelegate as l, type NotificationDeliveryDelegate as m, type NotificationDeliveryRow as n, type NotificationDeliveryWhere as o, type NotificationDispatchScheduler as p, type NotificationInboxStore as q, type NotificationPageAfter as r, type NotificationPreferenceDelegate as s, type NotificationPreferenceRow as t, type NotificationPreferenceStore as u, type NotificationRouter as v, type NotificationWhere as w, NotificationsApiError as x, type NotificationsDb as y, type NotificationsDbClient as z };
|