@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.
- package/ADOPTING.md +316 -0
- package/README.md +153 -0
- package/package.json +94 -0
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
- package/prisma/notifications.prisma +141 -0
- package/scripts/sync-notifications-schema.mjs +60 -0
- package/src/errors.ts +21 -0
- package/src/generators.ts +44 -0
- package/src/hono/index.ts +121 -0
- package/src/index.ts +73 -0
- package/src/messages.ts +156 -0
- package/src/phone.ts +57 -0
- package/src/preferences-core.ts +89 -0
- package/src/react/api.ts +111 -0
- package/src/react/bell-button.tsx +78 -0
- package/src/react/bell-icon.tsx +33 -0
- package/src/react/create-web-notifications.tsx +127 -0
- package/src/react/hooks.ts +74 -0
- package/src/react/inbox-state.ts +216 -0
- package/src/react/index.ts +61 -0
- package/src/react/panel.tsx +181 -0
- package/src/react/preferences-screen.tsx +242 -0
- package/src/react/relative-time.ts +18 -0
- package/src/react/row.tsx +98 -0
- package/src/react/transport.ts +72 -0
- package/src/react/web-push-client.ts +113 -0
- package/src/react/web-push-setup.tsx +167 -0
- package/src/server/by-permission.ts +255 -0
- package/src/server/context.ts +269 -0
- package/src/server/create-api-notifications.ts +215 -0
- package/src/server/db.ts +252 -0
- package/src/server/dispatch.ts +298 -0
- package/src/server/inbox.ts +155 -0
- package/src/server/index.ts +115 -0
- package/src/server/preferences.ts +103 -0
- package/src/server/push-subscriptions.ts +121 -0
- package/src/server/router.ts +275 -0
- package/src/server/routes.ts +218 -0
- package/src/server/transports/drivers.ts +148 -0
- package/src/server/transports/email.ts +141 -0
- package/src/server/transports/registry.ts +106 -0
- package/src/server/transports/sms.ts +120 -0
- package/src/server/transports/web-push.ts +168 -0
- package/src/server/transports/whatsapp.ts +183 -0
- package/src/types.ts +158 -0
- package/src/web-push/index.ts +70 -0
- 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
|
+
}
|