@12-apps/notifications 1.0.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 (47) hide show
  1. package/ADOPTING.md +316 -0
  2. package/README.md +153 -0
  3. package/package.json +94 -0
  4. package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
  5. package/prisma/notifications.prisma +141 -0
  6. package/scripts/sync-notifications-schema.mjs +60 -0
  7. package/src/errors.ts +21 -0
  8. package/src/generators.ts +44 -0
  9. package/src/hono/index.ts +121 -0
  10. package/src/index.ts +73 -0
  11. package/src/messages.ts +156 -0
  12. package/src/phone.ts +57 -0
  13. package/src/preferences-core.ts +89 -0
  14. package/src/react/api.ts +111 -0
  15. package/src/react/bell-button.tsx +78 -0
  16. package/src/react/bell-icon.tsx +33 -0
  17. package/src/react/create-web-notifications.tsx +127 -0
  18. package/src/react/hooks.ts +74 -0
  19. package/src/react/inbox-state.ts +216 -0
  20. package/src/react/index.ts +61 -0
  21. package/src/react/panel.tsx +181 -0
  22. package/src/react/preferences-screen.tsx +242 -0
  23. package/src/react/relative-time.ts +18 -0
  24. package/src/react/row.tsx +98 -0
  25. package/src/react/transport.ts +72 -0
  26. package/src/react/web-push-client.ts +113 -0
  27. package/src/react/web-push-setup.tsx +167 -0
  28. package/src/server/by-permission.ts +255 -0
  29. package/src/server/context.ts +269 -0
  30. package/src/server/create-api-notifications.ts +215 -0
  31. package/src/server/db.ts +252 -0
  32. package/src/server/dispatch.ts +298 -0
  33. package/src/server/inbox.ts +155 -0
  34. package/src/server/index.ts +115 -0
  35. package/src/server/preferences.ts +103 -0
  36. package/src/server/push-subscriptions.ts +121 -0
  37. package/src/server/router.ts +275 -0
  38. package/src/server/routes.ts +218 -0
  39. package/src/server/transports/drivers.ts +148 -0
  40. package/src/server/transports/email.ts +141 -0
  41. package/src/server/transports/registry.ts +106 -0
  42. package/src/server/transports/sms.ts +120 -0
  43. package/src/server/transports/web-push.ts +168 -0
  44. package/src/server/transports/whatsapp.ts +183 -0
  45. package/src/types.ts +158 -0
  46. package/src/web-push/index.ts +70 -0
  47. package/src/wire.ts +62 -0
@@ -0,0 +1,298 @@
1
+ import type {
2
+ DeliveryStatus,
3
+ NotificationChannel,
4
+ NotificationContent,
5
+ NotificationLogger,
6
+ TransportRecipient,
7
+ } from '../types';
8
+
9
+ import type {
10
+ NotificationContactDirectory,
11
+ NotificationDeliveryRow,
12
+ NotificationsDb,
13
+ NotificationsDbProvider,
14
+ } from './db';
15
+ import type { PushSubscriptionStore } from './push-subscriptions';
16
+ import type { TransportRegistry } from './transports/registry';
17
+
18
+ /**
19
+ * Handing a delivery to its transport, and the retry sweep that re-hands the
20
+ * ones that did not make it (12-15).
21
+ *
22
+ * ## Every send is CLAIMED first
23
+ *
24
+ * A dispatcher never sends a row it merely READ. It moves the row `QUEUED →
25
+ * SENDING` with one conditional `updateMany` whose `where` carries the
26
+ * precondition, and sends only when that statement reports `count === 1`:
27
+ *
28
+ * updateMany({ where: { id, status: 'QUEUED' }, data: { status: 'SENDING' } })
29
+ *
30
+ * The database decides the winner, in one statement, so two dispatchers racing
31
+ * the same delivery produce exactly one provider call. The version this replaced
32
+ * read the QUEUED rows, called `transport.send`, and only then wrote `SENT` —
33
+ * with nothing marking the row taken in between, which is a read-validate-write
34
+ * with a network round trip in the window. It needed no crash to double-send: a
35
+ * sweep whose backlog outlived its own cron interval overlapped itself, and
36
+ * every row the first run had not yet reached was sent twice. On SMS and
37
+ * WhatsApp that is a second billed message to a real phone.
38
+ *
39
+ * A single-threaded fake cannot tell the two implementations apart, which is why
40
+ * the concurrency contracts are pinned against real SQL in
41
+ * `harness/backend/tests/notifications-pipeline.test.ts` as well as against the
42
+ * in-memory seam.
43
+ *
44
+ * ## Nothing is retried forever
45
+ *
46
+ * Each claim increments `attempts` — at CLAIM time, so a dispatcher that dies
47
+ * mid-send still spends one — and the `maxAttempts`-th failure writes `DEAD`
48
+ * instead of `FAILED`. A `DEAD` row is terminal: no sweep selects it again.
49
+ * Without a ceiling a permanently invalid destination is a billed provider call
50
+ * on every sweep for the life of the row, and the sweep's working set only ever
51
+ * grows.
52
+ *
53
+ * ## The sweep is bounded and cannot un-send
54
+ *
55
+ * It selects on `updatedAt` (never `createdAt`), takes at most `take` rows, and
56
+ * re-queues each one with the same conditional-update shape — status pinned to
57
+ * what was read, plus the staleness predicate. A row another dispatcher has
58
+ * already moved fails that predicate, so a committed `SENT` can never be dragged
59
+ * back to `QUEUED`.
60
+ */
61
+
62
+ /** Claims a delivery gets before it is DEAD. Overridable per mount. */
63
+ export const DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
64
+
65
+ /** Rows one sweep may take. Bounds a run to well inside a cron interval. */
66
+ export const DEFAULT_SWEEP_TAKE = 200;
67
+
68
+ /** Default staleness cutoff: a row that has not moved in five minutes. */
69
+ export const DEFAULT_SWEEP_CUTOFF_MS = 5 * 60_000;
70
+
71
+ /**
72
+ * The statuses a sweep may return to QUEUED, once stale.
73
+ *
74
+ * `SENDING` is in the list because that is what a dispatcher that died mid-send
75
+ * leaves behind, and it is safe precisely because of the cutoff: a SENDING row
76
+ * younger than the cutoff belongs to a dispatcher that is still working.
77
+ * `SENT` and `DEAD` are absent, and that is the whole point of them.
78
+ */
79
+ const RETRYABLE: DeliveryStatus[] = ['FAILED', 'QUEUED', 'SENDING'];
80
+
81
+ /** What dispatch needs from the mount. */
82
+ export interface NotificationDispatchDeps {
83
+ db: NotificationsDbProvider;
84
+ transports: TransportRegistry;
85
+ pushSubscriptions: PushSubscriptionStore;
86
+ contacts: NotificationContactDirectory;
87
+ logger: NotificationLogger;
88
+ maxAttempts: number;
89
+ }
90
+
91
+ /** Load the recipient's destinations once, for every transport's gate. */
92
+ export async function loadRecipient(
93
+ deps: NotificationDispatchDeps,
94
+ userId: string,
95
+ ): Promise<TransportRecipient | null> {
96
+ const contact = await deps.contacts.getContact(userId);
97
+ if (!contact) return null;
98
+ return {
99
+ userId,
100
+ email: contact.email,
101
+ phone: contact.phone,
102
+ pushSubscriptionCount: await deps.pushSubscriptions.count(userId),
103
+ };
104
+ }
105
+
106
+ /** The stored inbox row, back as the agnostic content a formatter takes. */
107
+ function contentOf(notification: {
108
+ title: string;
109
+ body: string;
110
+ link: string | null;
111
+ data: unknown;
112
+ }): NotificationContent {
113
+ return {
114
+ title: notification.title,
115
+ body: notification.body,
116
+ ...(notification.link !== null ? { link: notification.link } : {}),
117
+ data: (notification.data ?? {}) as Record<string, unknown>,
118
+ };
119
+ }
120
+
121
+ const messageOf = (error: unknown): string =>
122
+ error instanceof Error ? error.message : String(error);
123
+
124
+ /** Record the outcome of a claimed send — terminal once the ceiling is hit. */
125
+ async function settle(
126
+ deps: NotificationDispatchDeps,
127
+ client: NotificationsDb,
128
+ delivery: NotificationDeliveryRow,
129
+ error: unknown,
130
+ ): Promise<void> {
131
+ // `attempts` was read BEFORE this claim incremented it, so `+ 1` is the
132
+ // attempt that just failed.
133
+ const spent = delivery.attempts + 1;
134
+ const terminal = spent >= deps.maxAttempts;
135
+ await client.notificationDelivery.update({
136
+ where: { id: delivery.id },
137
+ data: {
138
+ status: terminal ? 'DEAD' : 'FAILED',
139
+ error: terminal ? `${messageOf(error)} (gave up after ${spent} attempts)` : messageOf(error),
140
+ },
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Claim one delivery and, if we won it, send it.
146
+ *
147
+ * Resolves whether a provider call was made, which is what the sweep counts —
148
+ * "dispatched" must mean sends attempted, not rows looked at.
149
+ */
150
+ async function sendClaimed(
151
+ deps: NotificationDispatchDeps,
152
+ client: NotificationsDb,
153
+ delivery: NotificationDeliveryRow,
154
+ content: NotificationContent,
155
+ recipient: TransportRecipient,
156
+ ): Promise<boolean> {
157
+ const claimed = await client.notificationDelivery.updateMany({
158
+ where: { id: delivery.id, status: 'QUEUED' },
159
+ data: { status: 'SENDING', attempts: { increment: 1 } },
160
+ });
161
+ // Someone else moved it between our read and here. They own the send now.
162
+ if (claimed.count !== 1) return false;
163
+
164
+ const transport = deps.transports.get(delivery.channel as NotificationChannel);
165
+ try {
166
+ if (!transport) throw new Error(`No transport declared for ${delivery.channel}.`);
167
+ await transport.send(transport.format(content) as never, recipient);
168
+ await client.notificationDelivery.update({
169
+ where: { id: delivery.id },
170
+ data: { status: 'SENT', sentAt: new Date(), error: null },
171
+ });
172
+ } catch (error) {
173
+ await settle(deps, client, delivery, error);
174
+ }
175
+ return true;
176
+ }
177
+
178
+ /**
179
+ * The recipient no longer exists, so no channel will ever reach them.
180
+ *
181
+ * The rows are marked DEAD rather than left QUEUED: `getContact` returning null
182
+ * is the host saying "no such person" (the same answer `notify` throws on), and
183
+ * a QUEUED row for a deleted account is a row every sweep, forever, picks up and
184
+ * cannot deliver.
185
+ */
186
+ async function abandonUnreachable(
187
+ deps: NotificationDispatchDeps,
188
+ client: NotificationsDb,
189
+ queued: readonly NotificationDeliveryRow[],
190
+ userId: string,
191
+ ): Promise<void> {
192
+ deps.logger.error(
193
+ `[notifications] no contact for user ${userId}: ${queued.length} delivery row(s) marked DEAD`,
194
+ );
195
+ for (const delivery of queued) {
196
+ await client.notificationDelivery.update({
197
+ where: { id: delivery.id },
198
+ data: { status: 'DEAD', error: 'The contact directory no longer knows this recipient.' },
199
+ });
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Send every still-QUEUED delivery of one notification, claiming each one first.
205
+ *
206
+ * Safe to call repeatedly and safe to call concurrently: SENT, SENDING and DEAD
207
+ * rows are not selected, and of two callers that both read the same QUEUED row
208
+ * only one wins the claim.
209
+ */
210
+ export async function dispatchOne(
211
+ deps: NotificationDispatchDeps,
212
+ notificationId: string,
213
+ ): Promise<number> {
214
+ const client = await deps.db();
215
+ const notification = await client.notification.findUnique({ where: { id: notificationId } });
216
+ if (!notification) return 0;
217
+ const queued = await client.notificationDelivery.findMany({
218
+ where: { notificationId, status: 'QUEUED' },
219
+ });
220
+ if (queued.length === 0) return 0;
221
+
222
+ const recipient = await loadRecipient(deps, notification.userId);
223
+ if (!recipient) {
224
+ await abandonUnreachable(deps, client, queued, notification.userId);
225
+ return 0;
226
+ }
227
+
228
+ const content = contentOf(notification);
229
+ // Sequential on purpose: one recipient's channels (2–4 sends) gain little
230
+ // from parallelism, and providers rate-limit per sender anyway.
231
+ let sent = 0;
232
+ for (const delivery of queued) {
233
+ if (await sendClaimed(deps, client, delivery, content, recipient)) sent += 1;
234
+ }
235
+ return sent;
236
+ }
237
+
238
+ /**
239
+ * Return one stale row to QUEUED, or report that somebody else got there first.
240
+ *
241
+ * The `where` is the guard, and every clause of it is load-bearing: `status`
242
+ * pinned to the value we READ means a row that has since been SENT is not
243
+ * dragged back (the unguarded version matched on `id` alone and reverted
244
+ * committed sends, leaving a row claiming QUEUED with a `sent_at`), and
245
+ * `updatedAt < cutoff` means a row a concurrent sweep already re-queued — whose
246
+ * `updated_at` is now — is not re-queued a second time.
247
+ */
248
+ async function requeue(
249
+ client: NotificationsDb,
250
+ row: NotificationDeliveryRow,
251
+ cutoff: Date,
252
+ ): Promise<boolean> {
253
+ const moved = await client.notificationDelivery.updateMany({
254
+ where: { id: row.id, status: row.status as DeliveryStatus, updatedAt: { lt: cutoff } },
255
+ data: { status: 'QUEUED' },
256
+ });
257
+ return moved.count === 1;
258
+ }
259
+
260
+ /**
261
+ * Retry sweep for a cron/admin trigger: re-dispatch deliveries that have not
262
+ * moved in `olderThanMs`.
263
+ *
264
+ * BOUNDED by `take`, deliberately. Unbounded, a 2 000-row outage backlog is a
265
+ * single run that takes minutes of sequential provider round trips — outliving
266
+ * its own cron interval, so the next tick starts while it is still working. The
267
+ * claim makes that overlap harmless; the bound makes it rare.
268
+ */
269
+ export async function drainPending(
270
+ deps: NotificationDispatchDeps,
271
+ olderThanMs: number,
272
+ take: number,
273
+ ): Promise<{ dispatched: number }> {
274
+ const client = await deps.db();
275
+ const cutoff = new Date(Date.now() - olderThanMs);
276
+ const stale = await client.notificationDelivery.findMany({
277
+ where: { status: { in: RETRYABLE }, updatedAt: { lt: cutoff } },
278
+ orderBy: { updatedAt: 'asc' },
279
+ take,
280
+ });
281
+
282
+ const notificationIds = new Set<string>();
283
+ for (const row of stale) {
284
+ if (await requeue(client, row, cutoff)) notificationIds.add(row.notificationId);
285
+ }
286
+
287
+ let dispatched = 0;
288
+ for (const id of notificationIds) {
289
+ // Isolated per notification: an unreachable host seam on one of them must
290
+ // not cost every later row in the batch its retry.
291
+ try {
292
+ dispatched += await dispatchOne(deps, id);
293
+ } catch (error) {
294
+ deps.logger.error(`[notifications] sweep failed to dispatch ${id}:`, error);
295
+ }
296
+ }
297
+ return { dispatched };
298
+ }
@@ -0,0 +1,155 @@
1
+ import { inboxWire, type ListNotificationsResult } from '../wire';
2
+
3
+ import type {
4
+ NotificationDelegate,
5
+ NotificationPageAfter,
6
+ NotificationsDbProvider,
7
+ NotificationWhere,
8
+ } from './db';
9
+
10
+ /**
11
+ * Notification-centre inbox reads/writes. Every function is scoped to the
12
+ * OWNER's `userId` — a caller can only ever see or touch their own rows (the
13
+ * route layer supplies the authenticated user's id, never a client value).
14
+ * Soft-deleted rows (`deletedAt` set) are excluded from every read and can
15
+ * never be resurrected by mark-read.
16
+ */
17
+
18
+ export interface ListNotificationsInput {
19
+ /** `unread` narrows to unread rows; default lists all non-deleted. */
20
+ filter?: 'all' | 'unread';
21
+ /**
22
+ * Cursor = the `id` of the last item of the previous page. Resolved to a
23
+ * KEYSET position, so a row the user soft-deleted between the two requests —
24
+ * routinely the bottom one, since that is the row with the delete button —
25
+ * still anchors the next page instead of costing it a row. Owner-checked: an
26
+ * id that is not the caller's names no position and answers an empty page.
27
+ */
28
+ cursor?: string;
29
+ /** Page size (server-clamped 1..100, default 20). */
30
+ limit?: number;
31
+ }
32
+
33
+ const DEFAULT_PAGE = 20;
34
+ const MAX_PAGE = 100;
35
+
36
+ export interface NotificationInboxStore {
37
+ list(userId: string, input?: ListNotificationsInput): Promise<ListNotificationsResult>;
38
+ unreadCount(userId: string): Promise<number>;
39
+ markRead(userId: string, ids: readonly string[]): Promise<number>;
40
+ markAllRead(userId: string): Promise<number>;
41
+ softDelete(userId: string, ids: readonly string[]): Promise<number>;
42
+ }
43
+
44
+ /**
45
+ * Resolve a cursor into a keyset anchor, or refuse it.
46
+ *
47
+ * OWNERSHIP-CHECKED, which the positional cursor never was: `cursor` is a raw
48
+ * client value, and while the `where` kept the ROWS the caller's own, the
49
+ * anchor's position leaked the `created_at` of whatever row the id named.
50
+ * `undefined` here means "this cursor names no position in your list" and the
51
+ * caller answers an empty page — the anchor is not `deletedAt`-filtered, so the
52
+ * only way to reach that is a foreign or invented id.
53
+ */
54
+ async function resolveAnchor(
55
+ notifications: NotificationDelegate,
56
+ userId: string,
57
+ cursor: string,
58
+ ): Promise<NotificationPageAfter | undefined> {
59
+ const anchor = await notifications.findUnique({ where: { id: cursor } });
60
+ if (!anchor || anchor.userId !== userId) return undefined;
61
+ return { createdAt: anchor.createdAt, id: anchor.id };
62
+ }
63
+
64
+ /** The whole read filter for one page: owner, live, filter, page boundary. */
65
+ function pageWhere(
66
+ userId: string,
67
+ filter: ListNotificationsInput['filter'],
68
+ anchor: NotificationPageAfter | undefined,
69
+ ): NotificationWhere {
70
+ return {
71
+ userId,
72
+ deletedAt: null,
73
+ ...(filter === 'unread' ? { readAt: null } : {}),
74
+ // `(createdAt, id) < (anchor.createdAt, anchor.id)`, as a portable `where`.
75
+ ...(anchor
76
+ ? {
77
+ OR: [
78
+ { createdAt: { lt: anchor.createdAt } },
79
+ { createdAt: anchor.createdAt, id: { lt: anchor.id } },
80
+ ] as NonNullable<NotificationWhere['OR']>,
81
+ }
82
+ : {}),
83
+ };
84
+ }
85
+
86
+ export function createInboxStore(db: NotificationsDbProvider): NotificationInboxStore {
87
+ return {
88
+ /** The owner's inbox, newest first, keyset-paginated, deleted excluded. */
89
+ async list(userId, input = {}) {
90
+ const client = await db();
91
+ const limit = Math.min(Math.max(input.limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
92
+ const anchor = input.cursor
93
+ ? await resolveAnchor(client.notification, userId, input.cursor)
94
+ : undefined;
95
+ if (input.cursor && !anchor) return { items: [], nextCursor: null };
96
+ const rows = await client.notification.findMany({
97
+ where: pageWhere(userId, input.filter, anchor),
98
+ // `id` tie-breaks equal timestamps so pages never skip/repeat.
99
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
100
+ take: limit + 1,
101
+ });
102
+ const page = rows.slice(0, limit);
103
+ return {
104
+ items: page.map(inboxWire),
105
+ nextCursor: rows.length > limit ? (page[page.length - 1]?.id ?? null) : null,
106
+ };
107
+ },
108
+
109
+ /** Unread badge count (non-deleted, unread). */
110
+ async unreadCount(userId) {
111
+ const client = await db();
112
+ return client.notification.count({ where: { userId, deletedAt: null, readAt: null } });
113
+ },
114
+
115
+ /**
116
+ * Mark specific notifications read. Only the owner's own, still-unread,
117
+ * non-deleted rows are touched — foreign or already-read ids are silently
118
+ * ignored (idempotent). Returns how many rows flipped.
119
+ */
120
+ async markRead(userId, ids) {
121
+ if (ids.length === 0) return 0;
122
+ const client = await db();
123
+ const result = await client.notification.updateMany({
124
+ where: { id: { in: [...ids] }, userId, deletedAt: null, readAt: null },
125
+ data: { readAt: new Date() },
126
+ });
127
+ return result.count;
128
+ },
129
+
130
+ /** Mark every unread notification of the owner read ("mark all"). */
131
+ async markAllRead(userId) {
132
+ const client = await db();
133
+ const result = await client.notification.updateMany({
134
+ where: { userId, deletedAt: null, readAt: null },
135
+ data: { readAt: new Date() },
136
+ });
137
+ return result.count;
138
+ },
139
+
140
+ /**
141
+ * Soft-delete notifications (single or bulk): stamps `deletedAt` so the
142
+ * rows drop out of every list/count forever, while the delivery audit
143
+ * trail under them survives. Owner-scoped and idempotent like mark-read.
144
+ */
145
+ async softDelete(userId, ids) {
146
+ if (ids.length === 0) return 0;
147
+ const client = await db();
148
+ const result = await client.notification.updateMany({
149
+ where: { id: { in: [...ids] }, userId, deletedAt: null },
150
+ data: { deletedAt: new Date() },
151
+ });
152
+ return result.count;
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `@12-apps/notifications/server` — the host-mounted backend half (12-15).
3
+ *
4
+ * Server-only: the transports reach for `Buffer` and the network. Everything a
5
+ * browser also needs (types, the preference policy, the wire shape, the copy)
6
+ * lives in the root entry and is re-exported by neither half, so the two can
7
+ * never drift into two vocabularies.
8
+ */
9
+
10
+ export {
11
+ createApiNotifications,
12
+ type ApiNotifications,
13
+ type NotificationsServerConfig,
14
+ } from './create-api-notifications';
15
+
16
+ export {
17
+ NotificationsApiError,
18
+ foldApiError,
19
+ ok,
20
+ type NotificationsActor,
21
+ type NotificationsRequest,
22
+ type NotificationsResponse,
23
+ type NotificationsRoute,
24
+ } from './context';
25
+
26
+ export type {
27
+ NotificationContactDirectory,
28
+ NotificationCreateData,
29
+ NotificationDelegate,
30
+ NotificationDeliveryDelegate,
31
+ NotificationDeliveryRow,
32
+ NotificationDeliveryWhere,
33
+ NotificationPageAfter,
34
+ NotificationPreferenceDelegate,
35
+ NotificationPreferenceRow,
36
+ NotificationWhere,
37
+ NotificationsDb,
38
+ NotificationsDbClient,
39
+ NotificationsDbProvider,
40
+ PushSubscriptionDelegate,
41
+ PushSubscriptionRow,
42
+ } from './db';
43
+
44
+ export type { ListNotificationsInput, NotificationInboxStore } from './inbox';
45
+ export type { NotificationPreferenceStore } from './preferences';
46
+ export type { PushSubscriptionInput, PushSubscriptionStore } from './push-subscriptions';
47
+
48
+ export type {
49
+ CommittedNotification,
50
+ NotificationChannelPolicy,
51
+ NotificationCommittedListener,
52
+ NotificationDispatchScheduler,
53
+ NotificationRouter,
54
+ NotifyOptions,
55
+ NotifyResult,
56
+ } from './router';
57
+
58
+ export type {
59
+ NotificationAudienceDirectory,
60
+ NotifyByPermission,
61
+ PermissionNotificationResult,
62
+ PermissionNotificationSkip,
63
+ } from './by-permission';
64
+
65
+ export {
66
+ NotificationProviderError,
67
+ absoluteLink,
68
+ type DriverDeclarationBase,
69
+ type FetchImpl,
70
+ } from './transports/drivers';
71
+
72
+ export {
73
+ createTransportRegistry,
74
+ type ExtraDrivers,
75
+ type TransportDeclaration,
76
+ type TransportRegistry,
77
+ } from './transports/registry';
78
+
79
+ export {
80
+ EMAIL_DRIVERS,
81
+ emailTransport,
82
+ formatEmail,
83
+ type EmailDriver,
84
+ type EmailDriverDeclaration,
85
+ type EmailMessage,
86
+ } from './transports/email';
87
+
88
+ export {
89
+ SMS_DRIVERS,
90
+ formatSms,
91
+ smsTransport,
92
+ type SmsDriver,
93
+ type SmsDriverDeclaration,
94
+ type SmsMessage,
95
+ } from './transports/sms';
96
+
97
+ export {
98
+ WHATSAPP_DRIVERS,
99
+ formatWhatsApp,
100
+ whatsAppTransport,
101
+ type WhatsAppDriver,
102
+ type WhatsAppDriverDeclaration,
103
+ type WhatsAppMessage,
104
+ } from './transports/whatsapp';
105
+
106
+ export {
107
+ WEB_PUSH_DRIVERS,
108
+ formatWebPush,
109
+ webPushTransport,
110
+ type WebPushDriverDeclaration,
111
+ type WebPushMessage,
112
+ type WebPushSender,
113
+ type WebPushSubscription,
114
+ type WebPushSubscriptionSource,
115
+ } from './transports/web-push';
@@ -0,0 +1,103 @@
1
+ import {
2
+ DEFAULT_CHANNEL_ROW,
3
+ enabledChannelsOf,
4
+ mergeChoices,
5
+ mergeStoredRow,
6
+ type ChannelMatrix,
7
+ type ChannelRow,
8
+ } from '../preferences-core';
9
+ import type {
10
+ NotificationCategory,
11
+ NotificationChannel,
12
+ NotificationTaxonomy,
13
+ } from '../types';
14
+
15
+ import type { NotificationsDbProvider } from './db';
16
+
17
+ /**
18
+ * Per-user channel preferences: which transport channels may carry each
19
+ * notification category to a user. The inbox is NOT gated here — it is always
20
+ * on.
21
+ *
22
+ * The POLICY (defaults, coercion, merge) lives in `../preferences-core.ts`;
23
+ * this is the storage over it, and the split is what lets the react half render
24
+ * the same defaults before the first read lands.
25
+ */
26
+
27
+ export interface NotificationPreferenceStore {
28
+ /** The user's full matrix, defaults merged in. */
29
+ get(userId: string): Promise<ChannelMatrix>;
30
+ /** Persist explicit choices for any subset of categories/toggles. */
31
+ save(
32
+ userId: string,
33
+ input: Partial<Record<NotificationCategory, Partial<ChannelRow>>>,
34
+ ): Promise<void>;
35
+ /** The channels enabled for one (user, category) — the router's gate. */
36
+ enabledChannels(
37
+ userId: string,
38
+ category: NotificationCategory,
39
+ ): Promise<NotificationChannel[]>;
40
+ }
41
+
42
+ export function createPreferenceStore(
43
+ db: NotificationsDbProvider,
44
+ taxonomy: NotificationTaxonomy,
45
+ channelDefaults: Partial<ChannelRow> = {},
46
+ ): NotificationPreferenceStore {
47
+ const defaultRow: ChannelRow = { ...DEFAULT_CHANNEL_ROW, ...channelDefaults };
48
+ const known = new Set(taxonomy.categories);
49
+
50
+ return {
51
+ async get(userId) {
52
+ const client = await db();
53
+ const rows = await client.notificationPreference.findMany({ where: { userId } });
54
+ const stored = new Map(rows.map((row) => [row.category, row.channels]));
55
+ return Object.fromEntries(
56
+ taxonomy.categories.map((category) => [
57
+ category,
58
+ // A category with no row, or a row missing a channel key, falls back
59
+ // to the defaults — so a NEW channel ships without a data migration.
60
+ mergeStoredRow(stored.get(category), defaultRow),
61
+ ]),
62
+ ) as ChannelMatrix;
63
+ },
64
+
65
+ /**
66
+ * Only the categories present on `input` are written; within a category,
67
+ * the toggles are merged over the user's CURRENT effective row (their
68
+ * stored choices, or the defaults when none) — so a single-toggle save
69
+ * (how the settings UI writes) never resets the category's other channels
70
+ * back to their defaults.
71
+ *
72
+ * A category outside the taxonomy is IGNORED rather than stored: the DB
73
+ * CHECK would reject it anyway, and a 500 from a stale client's extra key
74
+ * would fail the whole save including the toggle the user did flip.
75
+ */
76
+ async save(userId, input) {
77
+ const client = await db();
78
+ for (const [category, choices] of Object.entries(input)) {
79
+ if (!choices || !known.has(category)) continue;
80
+ const existing = await client.notificationPreference.findUnique({
81
+ where: { userId_category: { userId, category } },
82
+ });
83
+ const current = existing ? mergeStoredRow(existing.channels, defaultRow) : defaultRow;
84
+ const channels = mergeChoices(current, choices);
85
+ await client.notificationPreference.upsert({
86
+ where: { userId_category: { userId, category } },
87
+ create: { userId, category, channels },
88
+ update: { channels },
89
+ });
90
+ }
91
+ },
92
+
93
+ async enabledChannels(userId, category) {
94
+ const client = await db();
95
+ const row = await client.notificationPreference.findUnique({
96
+ where: { userId_category: { userId, category } },
97
+ });
98
+ return enabledChannelsOf(
99
+ row ? mergeStoredRow(row.channels, defaultRow) : defaultRow,
100
+ );
101
+ },
102
+ };
103
+ }