@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.
Files changed (37) hide show
  1. package/dist/chunk-WU6QJLSZ.js +94 -0
  2. package/dist/chunk-WU6QJLSZ.js.map +1 -0
  3. package/dist/chunk-YE24MDS6.js +1022 -0
  4. package/dist/chunk-YE24MDS6.js.map +1 -0
  5. package/dist/create-api-notifications-CgBdjfyF.d.ts +909 -0
  6. package/dist/create-web-notifications-Du3hTs7P.d.ts +354 -0
  7. package/dist/{generators-FATT537X.d.ts → generators-B9xt3sRh.d.ts} +1 -1
  8. package/dist/hono/index.d.ts +5 -5
  9. package/dist/index.d.ts +3 -3
  10. package/dist/jobs-DhDjrAX5.d.ts +74 -0
  11. package/dist/manifest/index.d.ts +20 -6
  12. package/dist/manifest/index.js +2 -1
  13. package/dist/manifest/index.js.map +1 -1
  14. package/dist/manifest/server.d.ts +22 -30
  15. package/dist/manifest/server.js +11 -2
  16. package/dist/manifest/server.js.map +1 -1
  17. package/dist/manifest/web.d.ts +51 -0
  18. package/dist/manifest/web.js +15 -0
  19. package/dist/manifest/web.js.map +1 -0
  20. package/dist/react/index.d.ts +6 -353
  21. package/dist/react/index.js +20 -1008
  22. package/dist/react/index.js.map +1 -1
  23. package/dist/server/index.d.ts +79 -902
  24. package/dist/server/index.js +13 -2
  25. package/dist/{types-yq_o4N01.d.ts → types-CXLAG3UU.d.ts} +1 -1
  26. package/dist/web-push/index.d.ts +2 -2
  27. package/dist/{web-push-KLY6UMRT.d.ts → web-push-Cs14Wp9u.d.ts} +2 -2
  28. package/dist/{wire-SDUtscGu.d.ts → wire-5IRin4zH.d.ts} +1 -1
  29. package/package.json +13 -5
  30. package/src/manifest/index.ts +20 -6
  31. package/src/manifest/server.ts +8 -0
  32. package/src/manifest/web.ts +46 -0
  33. package/src/server/index.ts +20 -0
  34. package/src/server/jobs.ts +118 -0
  35. package/src/server/wire-notify-port.ts +125 -0
  36. package/dist/chunk-F5ANWJCY.js +0 -1
  37. 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 };