@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,121 @@
1
+ import type { NotificationLogger } from '../types';
2
+
3
+ import type { NotificationsDbProvider } from './db';
4
+ import type { WebPushSubscriptionSource } from './transports/web-push';
5
+
6
+ /**
7
+ * Browser push subscription registry — the write side of the Web Push
8
+ * destination. The client obtains a `PushSubscription` from
9
+ * `PushManager.subscribe()` (using the VAPID public key) and posts it here;
10
+ * unsubscribe removes it by endpoint. All owner-scoped.
11
+ */
12
+
13
+ /** What `PushSubscription.toJSON()` yields in the browser. */
14
+ export interface PushSubscriptionInput {
15
+ endpoint: string;
16
+ keys: { p256dh: string; auth: string };
17
+ /** Optional browser/device hint for a device list. */
18
+ userAgent?: string;
19
+ }
20
+
21
+ export interface PushSubscriptionStore extends WebPushSubscriptionSource {
22
+ /**
23
+ * Register (or refresh) one browser's subscription. Upserts on the globally
24
+ * unique endpoint, so re-subscribing the same browser never duplicates — and
25
+ * an endpoint recycled to a different signed-in user is re-owned by them.
26
+ *
27
+ * Re-owning is the right call and the alternative is worse: `PushManager`
28
+ * returns the SAME endpoint for the same browser profile, so one row per
29
+ * `(userId, endpoint)` would push user A's notifications to a browser now used
30
+ * by B with B's own keys — which decrypt. Re-owning costs A their channel;
31
+ * keeping both rows costs A their privacy. What re-owning must NOT do is
32
+ * happen unrecorded, hence the warning.
33
+ */
34
+ save(userId: string, input: PushSubscriptionInput): Promise<void>;
35
+ /** Remove one browser's subscription (owner-scoped; unknown = no-op). */
36
+ remove(userId: string, endpoint: string): Promise<void>;
37
+ /** How many devices the user has registered (settings UI hint). */
38
+ count(userId: string): Promise<number>;
39
+ /**
40
+ * Whether THIS endpoint is currently registered to THIS user.
41
+ *
42
+ * The settings screen needs it because a browser's own subscription object is
43
+ * not evidence that the server still has the row: a re-own or a 404/410 prune
44
+ * removes the row while the browser keeps the subscription, and a screen that
45
+ * reads only the browser then tells a user they are receiving alerts they will
46
+ * never get again. `false` covers both "no such row" and "somebody else's
47
+ * row", so an endpoint the caller does not own reveals nothing about who does.
48
+ */
49
+ isRegisteredTo(userId: string, endpoint: string): Promise<boolean>;
50
+ }
51
+
52
+ export function createPushSubscriptionStore(
53
+ db: NotificationsDbProvider,
54
+ logger?: NotificationLogger,
55
+ ): PushSubscriptionStore {
56
+ return {
57
+ async save(userId, input) {
58
+ const client = await db();
59
+ const existing = await client.pushSubscription.findUnique({
60
+ where: { endpoint: input.endpoint },
61
+ });
62
+ if (existing && existing.userId !== userId) {
63
+ // No endpoint in the message: a push endpoint is a bearer capability for
64
+ // that browser and must not reach logs. The two user ids are what makes
65
+ // "user X stopped getting web push on a shared machine" answerable.
66
+ logger?.error(
67
+ `[notifications] push endpoint re-owned: user ${existing.userId} lost this ` +
68
+ `browser's subscription to user ${userId}`,
69
+ );
70
+ }
71
+ await client.pushSubscription.upsert({
72
+ where: { endpoint: input.endpoint },
73
+ create: {
74
+ userId,
75
+ endpoint: input.endpoint,
76
+ p256dh: input.keys.p256dh,
77
+ auth: input.keys.auth,
78
+ userAgent: input.userAgent ?? null,
79
+ },
80
+ update: {
81
+ userId,
82
+ p256dh: input.keys.p256dh,
83
+ auth: input.keys.auth,
84
+ userAgent: input.userAgent ?? null,
85
+ },
86
+ });
87
+ },
88
+
89
+ async remove(userId, endpoint) {
90
+ const client = await db();
91
+ await client.pushSubscription.deleteMany({ where: { userId, endpoint } });
92
+ },
93
+
94
+ async count(userId) {
95
+ const client = await db();
96
+ return client.pushSubscription.count({ where: { userId } });
97
+ },
98
+
99
+ async isRegisteredTo(userId, endpoint) {
100
+ const client = await db();
101
+ const row = await client.pushSubscription.findUnique({ where: { endpoint } });
102
+ return row?.userId === userId;
103
+ },
104
+
105
+ async list(userId) {
106
+ const client = await db();
107
+ const rows = await client.pushSubscription.findMany({ where: { userId } });
108
+ return rows.map((row) => ({
109
+ id: row.id,
110
+ endpoint: row.endpoint,
111
+ p256dh: row.p256dh,
112
+ auth: row.auth,
113
+ }));
114
+ },
115
+
116
+ async prune(id) {
117
+ const client = await db();
118
+ await client.pushSubscription.delete({ where: { id } });
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,275 @@
1
+ import { UnknownNotificationRecipientError } from '../errors';
2
+ import type { NotificationGeneratorRegistry } from '../generators';
3
+ import { DEFAULT_CHANNEL_ROW, enabledChannelsOf } from '../preferences-core';
4
+ import type { NotificationChannel, NotificationEvent, TransportRecipient } from '../types';
5
+
6
+ import {
7
+ dispatchOne,
8
+ drainPending,
9
+ loadRecipient,
10
+ DEFAULT_SWEEP_CUTOFF_MS,
11
+ DEFAULT_SWEEP_TAKE,
12
+ type NotificationDispatchDeps,
13
+ } from './dispatch';
14
+ import type { NotificationPreferenceStore } from './preferences';
15
+
16
+ /**
17
+ * The channel router + the `notify` emit API — the single front door into the
18
+ * pipeline. Any server-side caller (route handler, background worker, agent
19
+ * tool) emits with one typed call and zero knowledge of channels, formatting,
20
+ * or preferences:
21
+ *
22
+ * await notifications.notify({ type: 'order.paid', recipient: { userId }, payload });
23
+ *
24
+ * What one emit does:
25
+ * 1. Resolves the registered generator for `type` → agnostic content.
26
+ * 2. ALWAYS writes the inbox record (the always-on channel), atomically
27
+ * with…
28
+ * 3. …one QUEUED delivery per channel that is (a) enabled by the recipient's
29
+ * preferences for the generator's category and (b) supported by its
30
+ * transport for this recipient.
31
+ * 4. Hands the deliveries to the transports ASYNCHRONOUSLY (fire-and-forget
32
+ * by default) so emit sites never block on provider I/O.
33
+ *
34
+ * The TRANSACTION IS THIS PACKAGE'S OWN, and a host cannot enlist in it: step 2
35
+ * opens `client.$transaction` itself, and a Prisma `TransactionClient` has no
36
+ * `$transaction` to nest. So `notify` must be called AFTER the caller's own
37
+ * transaction commits — called from inside one, it commits an inbox row and
38
+ * dispatches an e-mail for a payment that then rolls back.
39
+ *
40
+ * Failure isolation: each delivery is sent in its own try/catch — one channel
41
+ * failing marks only its row FAILED (error recorded) and never blocks the
42
+ * inbox record or the other channels. Delivery is at-least-once: the unique
43
+ * (notification, channel) row makes fan-out idempotent, and every send is
44
+ * CLAIMED before it happens (`./dispatch.ts`), so the remaining re-send window
45
+ * is the unavoidable one — a crash between the provider call and the SENT flip.
46
+ * Transports are required to tolerate that.
47
+ *
48
+ * Queueing: in-process async dispatch by default, or a real queue when the
49
+ * host passes `scheduleDispatch`. The QUEUED status + the drain sweep are what
50
+ * make either safe — the delivery rows are the durable record, so a queue that
51
+ * is unavailable (or absent) costs latency, never a notification.
52
+ */
53
+
54
+ /**
55
+ * Which channels a TENANT may use, decided per emit — the host's plan gate.
56
+ *
57
+ * A `null`/absent clientId is a PLATFORM notification (password resets,
58
+ * operator alerts) and is never policy-filtered. With no policy installed
59
+ * every channel passes.
60
+ */
61
+ export type NotificationChannelPolicy = (
62
+ clientId: string,
63
+ channels: readonly NotificationChannel[],
64
+ ) => Promise<NotificationChannel[]> | NotificationChannel[];
65
+
66
+ /** How the host defers dispatch of one already-committed notification. */
67
+ export type NotificationDispatchScheduler = (notificationId: string) => Promise<void>;
68
+
69
+ /** One committed inbox record, as the commit observer sees it. */
70
+ export interface CommittedNotification {
71
+ notificationId: string;
72
+ /** The owner — the only field a user-scoped fan-out needs. */
73
+ userId: string;
74
+ /** The tenant the row was stamped with, or null for a platform emit. */
75
+ clientId: string | null;
76
+ }
77
+
78
+ /**
79
+ * Told about each inbox record the moment it commits. Synchronous and
80
+ * `void`-returning by contract: an observer may not make an emit site wait,
81
+ * and may not fail one.
82
+ *
83
+ * It exists because the inbox record is written HERE, in the package, while the
84
+ * thing that usually wants to know — a realtime bus — is a dependency this
85
+ * package does not have and should not gain. Placing it at the funnel rather
86
+ * than at the emit sites is the point: `notify` is the single front door, so
87
+ * every sender is covered by construction, including ones written later.
88
+ */
89
+ export type NotificationCommittedListener = (notification: CommittedNotification) => void;
90
+
91
+ /** Options for `notify`. */
92
+ export interface NotifyOptions {
93
+ /**
94
+ * Await transport dispatch instead of fire-and-forget. For tests and
95
+ * worker/cron contexts where the process may exit right after emitting.
96
+ */
97
+ sync?: boolean;
98
+ }
99
+
100
+ /** What `notify` resolves with (dispatch may still be in flight). */
101
+ export interface NotifyResult {
102
+ notificationId: string;
103
+ /** Channels a delivery row was enqueued for (preference ∩ transport gate). */
104
+ channels: NotificationChannel[];
105
+ }
106
+
107
+ interface NotificationRouterDeps extends NotificationDispatchDeps {
108
+ generators: NotificationGeneratorRegistry;
109
+ preferences: NotificationPreferenceStore;
110
+ channelPolicy?: NotificationChannelPolicy;
111
+ scheduleDispatch?: NotificationDispatchScheduler;
112
+ onCommitted?: NotificationCommittedListener;
113
+ }
114
+
115
+ export interface NotificationRouter {
116
+ notify<TPayload>(
117
+ event: NotificationEvent<TPayload>,
118
+ options?: NotifyOptions,
119
+ ): Promise<NotifyResult>;
120
+ dispatchDeliveries(notificationId: string): Promise<void>;
121
+ drainPending(olderThanMs?: number, take?: number): Promise<{ dispatched: number }>;
122
+ }
123
+
124
+ /**
125
+ * The channels that survive a plan gate this package could not consult.
126
+ *
127
+ * A policy error degrades to the FREE defaults (`DEFAULT_CHANNEL_ROW`, i.e.
128
+ * e-mail + web push) intersected with what the other gates allowed — not to
129
+ * everything. Failing fully open was the original reading, and the rationale
130
+ * given for it only ever argued for the free channels: the dunning e-mail this
131
+ * system carries is how payment gets collected, so a transient entitlements
132
+ * error must not silence it. That argument says nothing about SMS and WhatsApp,
133
+ * which are billed per message and are exactly what the host's own gate was
134
+ * about to refuse. So the free channels stay (an extra notification beats none)
135
+ * and the paid ones do not (the host is not billed for a channel it did not
136
+ * authorize).
137
+ */
138
+ function policyFallback(channels: NotificationChannel[]): NotificationChannel[] {
139
+ const free = new Set(enabledChannelsOf(DEFAULT_CHANNEL_ROW));
140
+ return channels.filter((channel) => free.has(channel));
141
+ }
142
+
143
+ /** Apply the host's policy, degrading to the free channels on an error. */
144
+ async function applyPolicy(
145
+ deps: NotificationRouterDeps,
146
+ clientId: string | null | undefined,
147
+ channels: NotificationChannel[],
148
+ ): Promise<NotificationChannel[]> {
149
+ if (!deps.channelPolicy || clientId === null || clientId === undefined) return channels;
150
+ try {
151
+ return await deps.channelPolicy(clientId, channels);
152
+ } catch (error) {
153
+ deps.logger.error(
154
+ `[notifications] channelPolicy failed for client ${clientId}; ` +
155
+ 'degrading to the free channels:',
156
+ error,
157
+ );
158
+ return policyFallback(channels);
159
+ }
160
+ }
161
+
162
+ /** Run the commit observer without ever letting it reach the caller. */
163
+ function announce(deps: NotificationRouterDeps, notification: CommittedNotification): void {
164
+ if (!deps.onCommitted) return;
165
+ try {
166
+ deps.onCommitted(notification);
167
+ } catch (error) {
168
+ // An observer is an accelerator, never a step. The row is committed; a
169
+ // listener that throws must not turn a delivered notification into a 500.
170
+ deps.logger.error(
171
+ `[notifications] commit listener failed for ${notification.notificationId}:`,
172
+ error,
173
+ );
174
+ }
175
+ }
176
+
177
+ /** The channels one emit will actually enqueue: preference ∩ transport ∩ plan. */
178
+ async function resolveChannels(
179
+ deps: NotificationRouterDeps,
180
+ event: NotificationEvent<unknown>,
181
+ category: string,
182
+ recipient: TransportRecipient,
183
+ ): Promise<NotificationChannel[]> {
184
+ const enabled = await deps.preferences.enabledChannels(event.recipient.userId, category);
185
+ const supported = enabled.filter((channel) => {
186
+ const transport = deps.transports.get(channel);
187
+ return transport !== null && transport.supports(recipient);
188
+ });
189
+ // The host's plan gate: a tenant-scoped emit keeps only the channels the
190
+ // tenant's plan covers, so a revoked transport DEGRADES to the remaining ones
191
+ // rather than dropping the notification silently.
192
+ return applyPolicy(deps, event.recipient.clientId, supported);
193
+ }
194
+
195
+ /** Commit the inbox record and its delivery rows together, in one transaction. */
196
+ async function commit(
197
+ deps: NotificationRouterDeps,
198
+ event: NotificationEvent<unknown>,
199
+ category: string,
200
+ content: { title: string; body: string; link?: string; data?: Record<string, unknown> },
201
+ channels: NotificationChannel[],
202
+ ): Promise<{ id: string; userId: string; clientId: string | null }> {
203
+ const client = await deps.db();
204
+ return client.$transaction(async (tx) => {
205
+ const created = await tx.notification.create({
206
+ data: {
207
+ userId: event.recipient.userId,
208
+ clientId: event.recipient.clientId ?? null,
209
+ type: event.type,
210
+ category,
211
+ title: content.title,
212
+ body: content.body,
213
+ link: content.link ?? null,
214
+ data: content.data ?? {},
215
+ },
216
+ });
217
+ if (channels.length > 0) {
218
+ await tx.notificationDelivery.createMany({
219
+ data: channels.map((channel) => ({ notificationId: created.id, channel })),
220
+ // Idempotence backstop: the unique (notification, channel) key.
221
+ skipDuplicates: true,
222
+ });
223
+ }
224
+ return created;
225
+ });
226
+ }
227
+
228
+ export function createNotificationRouter(deps: NotificationRouterDeps): NotificationRouter {
229
+ return {
230
+ dispatchDeliveries: async (notificationId) => {
231
+ await dispatchOne(deps, notificationId);
232
+ },
233
+ drainPending: (olderThanMs = DEFAULT_SWEEP_CUTOFF_MS, take = DEFAULT_SWEEP_TAKE) =>
234
+ drainPending(deps, olderThanMs, take),
235
+
236
+ async notify(event, options = {}) {
237
+ const generator = deps.generators.resolve(event.type);
238
+ const content = generator.generate(event.payload as never);
239
+
240
+ const recipient = await loadRecipient(deps, event.recipient.userId);
241
+ if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
242
+
243
+ const channels = await resolveChannels(deps, event, generator.category, recipient);
244
+ const notification = await commit(deps, event, generator.category, content, channels);
245
+
246
+ // AFTER the transaction, never inside it: a subscriber woken by an event
247
+ // published mid-transaction would re-read and not find the row it was told
248
+ // about — the classic read-your-own-hint race.
249
+ announce(deps, {
250
+ notificationId: notification.id,
251
+ userId: notification.userId,
252
+ clientId: notification.clientId,
253
+ });
254
+
255
+ // `sync` always means "send it here, now" — a test or a worker about to
256
+ // exit must not have its send handed to a queue it will never drain.
257
+ if (options.sync) {
258
+ await dispatchOne(deps, notification.id);
259
+ return { notificationId: notification.id, channels };
260
+ }
261
+
262
+ void (
263
+ deps.scheduleDispatch
264
+ ? deps.scheduleDispatch(notification.id)
265
+ : dispatchOne(deps, notification.id)
266
+ ).catch((error: unknown) => {
267
+ // Detached-path backstop only: per-delivery failures are already
268
+ // recorded on their rows; this catches infrastructure errors.
269
+ deps.logger.error(`[notifications] dispatch failed for ${notification.id}:`, error);
270
+ });
271
+
272
+ return { notificationId: notification.id, channels };
273
+ },
274
+ };
275
+ }
@@ -0,0 +1,218 @@
1
+ import type { NotificationMessages } from '../messages';
2
+ import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
3
+
4
+ import {
5
+ guarded,
6
+ ok,
7
+ parseDeleteBody,
8
+ parseListQuery,
9
+ parseMarkReadBody,
10
+ parsePreferencesBody,
11
+ parsePushEndpointBody,
12
+ parsePushEndpointQuery,
13
+ parsePushSubscriptionBody,
14
+ type NotificationsRoute,
15
+ } from './context';
16
+ import type { NotificationContactDirectory } from './db';
17
+ import type { NotificationInboxStore } from './inbox';
18
+ import type { NotificationPreferenceStore } from './preferences';
19
+ import type { PushSubscriptionStore } from './push-subscriptions';
20
+ import type { TransportRegistry } from './transports/registry';
21
+
22
+ /**
23
+ * The endpoints, as framework-neutral descriptors (12-15).
24
+ *
25
+ * Nine routes, and the paths are the PACKAGE's: the shipped react client
26
+ * builds these URLs, so a host that renamed one would be a host whose own bell
27
+ * stopped working. The host names only where the whole block is mounted
28
+ * (future-pay: `/api/account`).
29
+ *
30
+ * Route ORDER is preserved by every adapter. Nothing here is shaped `/:id`, so
31
+ * no sibling can capture a literal — but the order is still the contract,
32
+ * because that is what a host mounting an `/:id` route of its own under the
33
+ * same prefix has to reason about.
34
+ */
35
+
36
+ interface NotificationRoutesDeps {
37
+ inbox: NotificationInboxStore;
38
+ preferences: NotificationPreferenceStore;
39
+ pushSubscriptions: PushSubscriptionStore;
40
+ transports: TransportRegistry;
41
+ contacts: NotificationContactDirectory;
42
+ categories: readonly string[];
43
+ messages: NotificationMessages;
44
+ /** Told when a write actually changed something (for a realtime hint). */
45
+ onInboxChanged?: (userId: string) => void;
46
+ }
47
+
48
+ /**
49
+ * Whether each channel CAN reach this user right now — destination on file and
50
+ * the channel declared — so the settings UI can disable dead toggles with a
51
+ * hint (no phone → SMS/WhatsApp off).
52
+ *
53
+ * Web push is probed with a HYPOTHETICAL subscription: for the SETTINGS screen
54
+ * the channel is "available" when the platform can send at all, because the
55
+ * browser subscribe step happens right from that toggle. Requiring an existing
56
+ * subscription here would deadlock the UX.
57
+ */
58
+ async function channelAvailability(
59
+ deps: NotificationRoutesDeps,
60
+ userId: string,
61
+ ): Promise<Record<NotificationChannel, boolean>> {
62
+ const contact = await deps.contacts.getContact(userId);
63
+ const recipient = {
64
+ userId,
65
+ email: contact?.email ?? null,
66
+ phone: contact?.phone ?? null,
67
+ pushSubscriptionCount: 1,
68
+ };
69
+ return Object.fromEntries(
70
+ NOTIFICATION_CHANNELS.map((channel) => [
71
+ channel,
72
+ deps.transports.get(channel)?.supports(recipient) ?? false,
73
+ ]),
74
+ ) as Record<NotificationChannel, boolean>;
75
+ }
76
+
77
+ /** The `{ preferences, availability, categories }` payload both prefs routes answer. */
78
+ async function preferencesPayload(
79
+ deps: NotificationRoutesDeps,
80
+ userId: string,
81
+ ): Promise<{
82
+ preferences: Record<string, Record<string, boolean>>;
83
+ availability: Record<NotificationChannel, boolean>;
84
+ categories: string[];
85
+ }> {
86
+ const [preferences, availability] = await Promise.all([
87
+ deps.preferences.get(userId),
88
+ channelAvailability(deps, userId),
89
+ ]);
90
+ // `categories` travels with the matrix so the settings screen renders the
91
+ // HOST's taxonomy without being told it twice (once in the api config, once
92
+ // in the web config) — the two could then disagree.
93
+ return { preferences, availability, categories: [...deps.categories] };
94
+ }
95
+
96
+ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
97
+ return [
98
+ {
99
+ method: 'GET',
100
+ path: '/notifications',
101
+ handle: guarded(async ({ actor, query }) =>
102
+ ok(await deps.inbox.list(actor.userId, parseListQuery(query, deps.messages))),
103
+ ),
104
+ },
105
+ {
106
+ method: 'GET',
107
+ path: '/notifications/unread-count',
108
+ handle: guarded(async ({ actor }) =>
109
+ // Polled by the SPAs, so it stays a single indexed COUNT.
110
+ ok({ count: await deps.inbox.unreadCount(actor.userId) }),
111
+ ),
112
+ },
113
+ {
114
+ method: 'POST',
115
+ path: '/notifications/mark-read',
116
+ handle: guarded(async ({ actor, body }) => {
117
+ const target = parseMarkReadBody(body, deps.messages);
118
+ const updated =
119
+ 'all' in target
120
+ ? await deps.inbox.markAllRead(actor.userId)
121
+ : await deps.inbox.markRead(actor.userId, target.ids);
122
+ // Only when something actually flipped. This endpoint is idempotent, so
123
+ // a re-send of an already-read id reports `updated: 0` and has changed
124
+ // nothing — hinting on that would wake every one of this user's devices
125
+ // to re-read a badge that did not move.
126
+ if (updated > 0) deps.onInboxChanged?.(actor.userId);
127
+ return ok({ updated });
128
+ }),
129
+ },
130
+ {
131
+ method: 'POST',
132
+ // POST, not DELETE, because the ids travel in a JSON body.
133
+ path: '/notifications/delete',
134
+ handle: guarded(async ({ actor, body }) => {
135
+ const deleted = await deps.inbox.softDelete(
136
+ actor.userId,
137
+ parseDeleteBody(body, deps.messages),
138
+ );
139
+ // Same rule as mark-read. A delete can move the badge too — an UNREAD
140
+ // row that is removed takes its place in the count with it.
141
+ if (deleted > 0) deps.onInboxChanged?.(actor.userId);
142
+ return ok({ deleted });
143
+ }),
144
+ },
145
+ ];
146
+ }
147
+
148
+ function preferenceRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
149
+ return [
150
+ {
151
+ method: 'GET',
152
+ path: '/notification-preferences',
153
+ handle: guarded(async ({ actor }) => ok(await preferencesPayload(deps, actor.userId))),
154
+ },
155
+ {
156
+ method: 'PUT',
157
+ path: '/notification-preferences',
158
+ handle: guarded(async ({ actor, body }) => {
159
+ // The dispatch pipeline reads these on every emit, so a save takes
160
+ // effect immediately — no cache to invalidate.
161
+ await deps.preferences.save(actor.userId, parsePreferencesBody(body, deps.messages));
162
+ return ok(await preferencesPayload(deps, actor.userId));
163
+ }),
164
+ },
165
+ ];
166
+ }
167
+
168
+ function pushRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
169
+ return [
170
+ {
171
+ method: 'GET',
172
+ path: '/push-subscriptions',
173
+ handle: guarded(async ({ actor, query }) => {
174
+ const endpoint = parsePushEndpointQuery(query, deps.messages);
175
+ return ok({
176
+ // null = web push is not configured on this deployment.
177
+ vapidPublicKey: deps.transports.webPushPublicKey(),
178
+ count: await deps.pushSubscriptions.count(actor.userId),
179
+ // Only when asked. `registered` is what lets the settings screen stop
180
+ // trusting the browser alone: a re-owned or pruned row answers false,
181
+ // so the screen offers *Ativar* again instead of claiming all is well.
182
+ ...(endpoint !== undefined
183
+ ? { registered: await deps.pushSubscriptions.isRegisteredTo(actor.userId, endpoint) }
184
+ : {}),
185
+ });
186
+ }),
187
+ },
188
+ {
189
+ method: 'POST',
190
+ path: '/push-subscriptions',
191
+ handle: guarded(async ({ actor, body, headers }) => {
192
+ const input = parsePushSubscriptionBody(body, deps.messages);
193
+ const userAgent = headers?.['user-agent'];
194
+ await deps.pushSubscriptions.save(actor.userId, {
195
+ ...input,
196
+ ...(userAgent ? { userAgent } : {}),
197
+ });
198
+ return ok({ count: await deps.pushSubscriptions.count(actor.userId) });
199
+ }),
200
+ },
201
+ {
202
+ method: 'DELETE',
203
+ // The endpoint is a long opaque URL, unusable as a path param.
204
+ path: '/push-subscriptions',
205
+ handle: guarded(async ({ actor, body }) => {
206
+ await deps.pushSubscriptions.remove(
207
+ actor.userId,
208
+ parsePushEndpointBody(body, deps.messages),
209
+ );
210
+ return ok({ count: await deps.pushSubscriptions.count(actor.userId) });
211
+ }),
212
+ },
213
+ ];
214
+ }
215
+
216
+ export function notificationRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
217
+ return [...inboxRoutes(deps), ...preferenceRoutes(deps), ...pushRoutes(deps)];
218
+ }