@12-apps/notifications 4.1.0 → 4.1.1

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