@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,215 @@
|
|
|
1
|
+
import { createGeneratorRegistry, type NotificationGeneratorRegistry } from '../generators';
|
|
2
|
+
import { messagesOf, type NotificationMessages } from '../messages';
|
|
3
|
+
import type { ChannelRow } from '../preferences-core';
|
|
4
|
+
import {
|
|
5
|
+
taxonomyOf,
|
|
6
|
+
type NotificationCategory,
|
|
7
|
+
type NotificationGenerator,
|
|
8
|
+
type NotificationLogger,
|
|
9
|
+
} from '../types';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createNotifyByPermission,
|
|
13
|
+
type NotificationAudienceDirectory,
|
|
14
|
+
type NotifyByPermission,
|
|
15
|
+
} from './by-permission';
|
|
16
|
+
import type { NotificationsRoute } from './context';
|
|
17
|
+
import type { NotificationContactDirectory, NotificationsDbProvider } from './db';
|
|
18
|
+
import { DEFAULT_MAX_DELIVERY_ATTEMPTS } from './dispatch';
|
|
19
|
+
import { createInboxStore, type NotificationInboxStore } from './inbox';
|
|
20
|
+
import { createPreferenceStore, type NotificationPreferenceStore } from './preferences';
|
|
21
|
+
import {
|
|
22
|
+
createPushSubscriptionStore,
|
|
23
|
+
type PushSubscriptionStore,
|
|
24
|
+
} from './push-subscriptions';
|
|
25
|
+
import { notificationRoutes } from './routes';
|
|
26
|
+
import {
|
|
27
|
+
createNotificationRouter,
|
|
28
|
+
type NotificationChannelPolicy,
|
|
29
|
+
type NotificationCommittedListener,
|
|
30
|
+
type NotificationDispatchScheduler,
|
|
31
|
+
type NotificationRouter,
|
|
32
|
+
} from './router';
|
|
33
|
+
import {
|
|
34
|
+
createTransportRegistry,
|
|
35
|
+
type ExtraDrivers,
|
|
36
|
+
type TransportDeclaration,
|
|
37
|
+
type TransportRegistry,
|
|
38
|
+
} from './transports/registry';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The one thing this package exposes to a BACKEND host (12-15).
|
|
42
|
+
*
|
|
43
|
+
* The pipeline used to be a private workspace package plus six hand-written
|
|
44
|
+
* route files: each one resolving the session, calling a loose helper, and
|
|
45
|
+
* shaping a response, with the transports reading their own credentials out of
|
|
46
|
+
* `process.env` and registering themselves as an import side effect. Only "who
|
|
47
|
+
* is calling, where the rows live, how a channel reaches a person" was ever the
|
|
48
|
+
* host's business; the rest — the routing, the delivery rows, the retries, the
|
|
49
|
+
* request contract, the envelope, the pt-BR copy — is this surface's.
|
|
50
|
+
*
|
|
51
|
+
* Routes are FRAMEWORK-NEUTRAL descriptors, not a Hono/Express router (the
|
|
52
|
+
* report-builder doctrine). `@12-apps/notifications/hono` adapts them.
|
|
53
|
+
*
|
|
54
|
+
* What stays the HOST's, and is passed in rather than guessed at:
|
|
55
|
+
*
|
|
56
|
+
* - **Authentication** — the adapter's `resolveActor` hands over a user id.
|
|
57
|
+
* Every endpoint is self-scoped, so that is the entire authorization seam.
|
|
58
|
+
* - **Where the four owned tables live** — the structural `db` seam.
|
|
59
|
+
* - **How to reach a person** — `contacts`, because a package cannot know the
|
|
60
|
+
* shape of a host's identity table (nor whether its phones are verified).
|
|
61
|
+
* - **Which vendors carry which channel** — `transports`, one declaration per
|
|
62
|
+
* channel. An undeclared channel is off; a second vendor is a config entry.
|
|
63
|
+
* - **Billing** — `channelPolicy`, the plan gate answered per emit.
|
|
64
|
+
* - **Its authorization engine** — `audience`, for the permission fan-out.
|
|
65
|
+
* - **Its domain events** — `generators`, registered from the outside.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
export interface NotificationsServerConfig {
|
|
69
|
+
/** Prisma-shaped client for the four owned models, through the seam. */
|
|
70
|
+
db: NotificationsDbProvider;
|
|
71
|
+
/** How a transport reaches a person (the host's identity table). */
|
|
72
|
+
contacts: NotificationContactDirectory;
|
|
73
|
+
/** One declaration per channel the host wants on. Default: none, all off. */
|
|
74
|
+
transports?: readonly TransportDeclaration[];
|
|
75
|
+
/** The host's own vendor drivers, per channel. */
|
|
76
|
+
drivers?: ExtraDrivers;
|
|
77
|
+
/** The domain events this mount can emit. */
|
|
78
|
+
generators?: readonly NotificationGenerator<never>[];
|
|
79
|
+
/** Preference categories. Default: orders / payments / stock / system. */
|
|
80
|
+
categories?: readonly NotificationCategory[];
|
|
81
|
+
/** Override which channels a never-touched category defaults to. */
|
|
82
|
+
channelDefaults?: Partial<ChannelRow>;
|
|
83
|
+
/** The tenant plan gate, answered per emit. */
|
|
84
|
+
channelPolicy?: NotificationChannelPolicy;
|
|
85
|
+
/** Hand dispatch to a real queue instead of the in-process detached send. */
|
|
86
|
+
scheduleDispatch?: NotificationDispatchScheduler;
|
|
87
|
+
/**
|
|
88
|
+
* Claims one delivery gets before the sweep gives up on it and writes DEAD.
|
|
89
|
+
* Default 5. There is no "unlimited": a permanently invalid destination would
|
|
90
|
+
* be a billed provider call on every sweep for the life of the row.
|
|
91
|
+
*/
|
|
92
|
+
maxDeliveryAttempts?: number;
|
|
93
|
+
/** Told the moment an inbox record commits (a realtime bus, typically). */
|
|
94
|
+
onCommitted?: NotificationCommittedListener;
|
|
95
|
+
/** Told when a mark-read/delete actually changed something. */
|
|
96
|
+
onInboxChanged?: (userId: string) => void;
|
|
97
|
+
/** The host's authorization engine, for `notifyByPermission`. */
|
|
98
|
+
audience?: NotificationAudienceDirectory;
|
|
99
|
+
/** User-facing copy overrides (pt-BR product copy by default). */
|
|
100
|
+
messages?: Partial<NotificationMessages>;
|
|
101
|
+
/** The host's logger. Defaults to the console. */
|
|
102
|
+
logger?: NotificationLogger;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ApiNotifications {
|
|
106
|
+
/** The whole generated surface, in mount order. */
|
|
107
|
+
routes: NotificationsRoute[];
|
|
108
|
+
/** The emit front door. */
|
|
109
|
+
notify: NotificationRouter['notify'];
|
|
110
|
+
/** Send every still-QUEUED delivery of one notification. */
|
|
111
|
+
dispatchDeliveries: NotificationRouter['dispatchDeliveries'];
|
|
112
|
+
/** The retry sweep, for a cron/admin trigger. */
|
|
113
|
+
drainPending: NotificationRouter['drainPending'];
|
|
114
|
+
/**
|
|
115
|
+
* "Tell whoever can act on this." Rejects when the host configured no
|
|
116
|
+
* `audience` — loudly, because the alternative is a money alert nobody gets.
|
|
117
|
+
*/
|
|
118
|
+
notifyByPermission: NotifyByPermission;
|
|
119
|
+
/** The stores, for host surfaces that read the same tables. */
|
|
120
|
+
inbox: NotificationInboxStore;
|
|
121
|
+
preferences: NotificationPreferenceStore;
|
|
122
|
+
pushSubscriptions: PushSubscriptionStore;
|
|
123
|
+
/** Register a generator after the mount (a lazily-imported domain module). */
|
|
124
|
+
registerGenerator: NotificationGeneratorRegistry['register'];
|
|
125
|
+
/** The declared transports, for diagnostics and availability probes. */
|
|
126
|
+
transports: TransportRegistry;
|
|
127
|
+
/** The copy in force, so a host's own screens can reuse a sentence. */
|
|
128
|
+
messages: NotificationMessages;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Drop the keys the host left unset, so an absent seam stays absent. */
|
|
132
|
+
function present<T extends object>(entries: T): Partial<T> {
|
|
133
|
+
return Object.fromEntries(
|
|
134
|
+
Object.entries(entries).filter(([, value]) => value !== undefined),
|
|
135
|
+
) as Partial<T>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Fallback logger — used until the host passes its own. */
|
|
139
|
+
const consoleLogger: NotificationLogger = {
|
|
140
|
+
info: (message, ...meta) => console.info(message, ...meta),
|
|
141
|
+
error: (message, ...meta) => console.error(message, ...meta),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export function createApiNotifications(config: NotificationsServerConfig): ApiNotifications {
|
|
145
|
+
const messages = messagesOf(config);
|
|
146
|
+
const taxonomy = taxonomyOf(config);
|
|
147
|
+
const logger = config.logger ?? consoleLogger;
|
|
148
|
+
|
|
149
|
+
const generators = createGeneratorRegistry(config.generators ?? []);
|
|
150
|
+
const inbox = createInboxStore(config.db);
|
|
151
|
+
const preferences = createPreferenceStore(
|
|
152
|
+
config.db,
|
|
153
|
+
taxonomy,
|
|
154
|
+
config.channelDefaults ?? {},
|
|
155
|
+
);
|
|
156
|
+
const pushSubscriptions = createPushSubscriptionStore(config.db, logger);
|
|
157
|
+
const transports = createTransportRegistry(
|
|
158
|
+
config.transports ?? [],
|
|
159
|
+
pushSubscriptions,
|
|
160
|
+
config.drivers ?? {},
|
|
161
|
+
logger,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const router = createNotificationRouter({
|
|
165
|
+
db: config.db,
|
|
166
|
+
generators,
|
|
167
|
+
transports,
|
|
168
|
+
preferences,
|
|
169
|
+
pushSubscriptions,
|
|
170
|
+
contacts: config.contacts,
|
|
171
|
+
logger,
|
|
172
|
+
maxAttempts: config.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS,
|
|
173
|
+
// `exactOptionalPropertyTypes` is on, so an absent host seam must be an
|
|
174
|
+
// ABSENT key rather than an explicit `undefined`.
|
|
175
|
+
...present({
|
|
176
|
+
channelPolicy: config.channelPolicy,
|
|
177
|
+
scheduleDispatch: config.scheduleDispatch,
|
|
178
|
+
onCommitted: config.onCommitted,
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const audience = config.audience;
|
|
183
|
+
const notifyByPermission: NotifyByPermission = audience
|
|
184
|
+
? createNotifyByPermission({ router, directory: audience, logger })
|
|
185
|
+
: () =>
|
|
186
|
+
Promise.reject(
|
|
187
|
+
new Error(
|
|
188
|
+
'notifyByPermission() needs an `audience` directory — pass the host authorization ' +
|
|
189
|
+
'engine to createApiNotifications({ audience }).',
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
routes: notificationRoutes({
|
|
195
|
+
inbox,
|
|
196
|
+
preferences,
|
|
197
|
+
pushSubscriptions,
|
|
198
|
+
transports,
|
|
199
|
+
contacts: config.contacts,
|
|
200
|
+
categories: taxonomy.categories,
|
|
201
|
+
messages,
|
|
202
|
+
...present({ onInboxChanged: config.onInboxChanged }),
|
|
203
|
+
}),
|
|
204
|
+
notify: router.notify,
|
|
205
|
+
dispatchDeliveries: router.dispatchDeliveries,
|
|
206
|
+
drainPending: router.drainPending,
|
|
207
|
+
notifyByPermission,
|
|
208
|
+
inbox,
|
|
209
|
+
preferences,
|
|
210
|
+
pushSubscriptions,
|
|
211
|
+
registerGenerator: generators.register,
|
|
212
|
+
transports,
|
|
213
|
+
messages,
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/server/db.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The database seam (12-15) — the exact, narrow slice of a Prisma-shaped
|
|
3
|
+
* client this surface reads and writes on the four models the package owns
|
|
4
|
+
* (`prisma/notifications.prisma`). Structural on purpose, never generated: a
|
|
5
|
+
* real host passes its Prisma client; the harness passes hand-written SQL, and
|
|
6
|
+
* the stores cannot tell.
|
|
7
|
+
*
|
|
8
|
+
* Every argument type below is CLOSED — the union of the shapes this package's
|
|
9
|
+
* own stores actually pass — so a non-Prisma implementation has a finite,
|
|
10
|
+
* documented surface to satisfy instead of "all of Prisma".
|
|
11
|
+
*
|
|
12
|
+
* What is NOT here: the `users` table. future-pay's router read
|
|
13
|
+
* `users.email/phone` directly to answer "can this channel reach them", which
|
|
14
|
+
* is the one thing in the pipeline that belonged to the host all along — a
|
|
15
|
+
* package cannot know the shape of a host's identity table, and a host with
|
|
16
|
+
* phone VERIFICATION wants to answer the question differently. It crosses as
|
|
17
|
+
* {@link NotificationContactDirectory} instead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DeliveryStatus, NotificationChannel } from '../types';
|
|
21
|
+
import type { NotificationRow } from '../wire';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// notifications
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface NotificationCreateData {
|
|
28
|
+
userId: string;
|
|
29
|
+
clientId: string | null;
|
|
30
|
+
type: string;
|
|
31
|
+
category: string;
|
|
32
|
+
title: string;
|
|
33
|
+
body: string;
|
|
34
|
+
link: string | null;
|
|
35
|
+
data: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One page boundary, as an explicit KEYSET rather than Prisma's positional
|
|
40
|
+
* cursor.
|
|
41
|
+
*
|
|
42
|
+
* `cursor` + `skip: 1` was the obvious translation and it is wrong in exactly
|
|
43
|
+
* one case, which the inbox reaches routinely: `skip` is an OFFSET applied
|
|
44
|
+
* AFTER the `where`, so once the anchor row stops matching — the user deleted
|
|
45
|
+
* the bottom visible row, which is the one carrying the delete button — the
|
|
46
|
+
* offset consumes the first SURVIVING row instead of the anchor and that row
|
|
47
|
+
* never appears in the list. Stated as a keyset comparison on the same order
|
|
48
|
+
* key, the anchor's own membership is irrelevant, so nothing can be skipped or
|
|
49
|
+
* repeated. It also gives the two non-Prisma implementations of this seam
|
|
50
|
+
* something they can satisfy exactly instead of approximately.
|
|
51
|
+
*/
|
|
52
|
+
export interface NotificationPageAfter {
|
|
53
|
+
createdAt: Date;
|
|
54
|
+
id: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The inbox read filter. `deletedAt: null` is on every read, always. */
|
|
58
|
+
export interface NotificationWhere {
|
|
59
|
+
userId?: string;
|
|
60
|
+
id?: string | { in: string[] };
|
|
61
|
+
deletedAt: null;
|
|
62
|
+
readAt?: null;
|
|
63
|
+
/** The keyset half of `(createdAt, id) < (anchor.createdAt, anchor.id)`. */
|
|
64
|
+
OR?: [{ createdAt: { lt: Date } }, { createdAt: Date; id: { lt: string } }];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface NotificationDelegate {
|
|
68
|
+
create(args: { data: NotificationCreateData }): Promise<NotificationRow>;
|
|
69
|
+
/** Deliberately NOT `deletedAt`-filtered: the pager anchors on a row the
|
|
70
|
+
* user may have just soft-deleted, and its position is still valid. */
|
|
71
|
+
findUnique(args: { where: { id: string } }): Promise<NotificationRow | null>;
|
|
72
|
+
findMany(args: {
|
|
73
|
+
where: NotificationWhere;
|
|
74
|
+
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }];
|
|
75
|
+
take: number;
|
|
76
|
+
}): Promise<NotificationRow[]>;
|
|
77
|
+
count(args: { where: NotificationWhere }): Promise<number>;
|
|
78
|
+
updateMany(args: {
|
|
79
|
+
where: NotificationWhere;
|
|
80
|
+
data: { readAt: Date } | { deletedAt: Date };
|
|
81
|
+
}): Promise<{ count: number }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// notification_deliveries
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface NotificationDeliveryRow {
|
|
89
|
+
id: string;
|
|
90
|
+
notificationId: string;
|
|
91
|
+
channel: string;
|
|
92
|
+
status: string;
|
|
93
|
+
error: string | null;
|
|
94
|
+
sentAt: Date | null;
|
|
95
|
+
/** How many times a dispatcher has CLAIMED this row (the retry ceiling). */
|
|
96
|
+
attempts: number;
|
|
97
|
+
createdAt: Date;
|
|
98
|
+
updatedAt: Date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The delivery filter, and it is deliberately small: four shapes, all of which
|
|
103
|
+
* are either a claim's precondition or the sweep's selection.
|
|
104
|
+
*
|
|
105
|
+
* `updatedAt` and never `createdAt`. The sweep's job is "this row has not moved
|
|
106
|
+
* in a while", and `created_at` cannot express that — a row the sweep re-queued
|
|
107
|
+
* one second ago still carries a `created_at` from days back, so it reads as
|
|
108
|
+
* stale again immediately and the sweep re-dispatches its own work on every
|
|
109
|
+
* tick. Every write here advances `updatedAt`, which is what makes the cutoff
|
|
110
|
+
* mean what it says.
|
|
111
|
+
*/
|
|
112
|
+
export interface NotificationDeliveryWhere {
|
|
113
|
+
id?: string;
|
|
114
|
+
notificationId?: string;
|
|
115
|
+
status?: DeliveryStatus | { in: DeliveryStatus[] };
|
|
116
|
+
updatedAt?: { lt: Date };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface NotificationDeliveryDelegate {
|
|
120
|
+
createMany(args: {
|
|
121
|
+
data: { notificationId: string; channel: NotificationChannel }[];
|
|
122
|
+
skipDuplicates: true;
|
|
123
|
+
}): Promise<{ count: number }>;
|
|
124
|
+
findMany(args: {
|
|
125
|
+
where: NotificationDeliveryWhere;
|
|
126
|
+
/** Oldest-stalest first, so a bounded sweep drains a backlog in order. */
|
|
127
|
+
orderBy?: { updatedAt: 'asc' };
|
|
128
|
+
take?: number;
|
|
129
|
+
}): Promise<NotificationDeliveryRow[]>;
|
|
130
|
+
update(args: {
|
|
131
|
+
where: { id: string };
|
|
132
|
+
data: {
|
|
133
|
+
status: DeliveryStatus;
|
|
134
|
+
sentAt?: Date | null;
|
|
135
|
+
error?: string | null;
|
|
136
|
+
};
|
|
137
|
+
}): Promise<NotificationDeliveryRow>;
|
|
138
|
+
/**
|
|
139
|
+
* THE CLAIM, and the only reason this is `updateMany` rather than `update`:
|
|
140
|
+
* `where` carries the precondition ("this row is still QUEUED"), so the
|
|
141
|
+
* returned `count` answers "did I win it" — one statement, atomic in the
|
|
142
|
+
* database, and never a read the caller then validates in application code.
|
|
143
|
+
*/
|
|
144
|
+
updateMany(args: {
|
|
145
|
+
where: NotificationDeliveryWhere;
|
|
146
|
+
data: { status: DeliveryStatus; attempts?: { increment: number } };
|
|
147
|
+
}): Promise<{ count: number }>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// notification_preferences
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export interface NotificationPreferenceRow {
|
|
155
|
+
id: string;
|
|
156
|
+
userId: string;
|
|
157
|
+
category: string;
|
|
158
|
+
channels: unknown;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface NotificationPreferenceDelegate {
|
|
162
|
+
findMany(args: { where: { userId: string } }): Promise<NotificationPreferenceRow[]>;
|
|
163
|
+
findUnique(args: {
|
|
164
|
+
where: { userId_category: { userId: string; category: string } };
|
|
165
|
+
}): Promise<NotificationPreferenceRow | null>;
|
|
166
|
+
upsert(args: {
|
|
167
|
+
where: { userId_category: { userId: string; category: string } };
|
|
168
|
+
create: { userId: string; category: string; channels: Record<string, boolean> };
|
|
169
|
+
update: { channels: Record<string, boolean> };
|
|
170
|
+
}): Promise<NotificationPreferenceRow>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// push_subscriptions
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export interface PushSubscriptionRow {
|
|
178
|
+
id: string;
|
|
179
|
+
userId: string;
|
|
180
|
+
endpoint: string;
|
|
181
|
+
p256dh: string;
|
|
182
|
+
auth: string;
|
|
183
|
+
userAgent: string | null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PushSubscriptionDelegate {
|
|
187
|
+
count(args: { where: { userId: string } }): Promise<number>;
|
|
188
|
+
/**
|
|
189
|
+
* The row holding one endpoint, whoever owns it. Read BEFORE an upsert so a
|
|
190
|
+
* re-own (the same browser profile, a different signed-in user) is a logged
|
|
191
|
+
* event rather than a silent transfer, and so the settings screen can be told
|
|
192
|
+
* whether THIS browser's subscription is still the caller's.
|
|
193
|
+
*/
|
|
194
|
+
findUnique(args: { where: { endpoint: string } }): Promise<PushSubscriptionRow | null>;
|
|
195
|
+
findMany(args: { where: { userId: string } }): Promise<PushSubscriptionRow[]>;
|
|
196
|
+
upsert(args: {
|
|
197
|
+
where: { endpoint: string };
|
|
198
|
+
create: {
|
|
199
|
+
userId: string;
|
|
200
|
+
endpoint: string;
|
|
201
|
+
p256dh: string;
|
|
202
|
+
auth: string;
|
|
203
|
+
userAgent: string | null;
|
|
204
|
+
};
|
|
205
|
+
update: {
|
|
206
|
+
userId: string;
|
|
207
|
+
p256dh: string;
|
|
208
|
+
auth: string;
|
|
209
|
+
userAgent: string | null;
|
|
210
|
+
};
|
|
211
|
+
}): Promise<PushSubscriptionRow>;
|
|
212
|
+
delete(args: { where: { id: string } }): Promise<PushSubscriptionRow>;
|
|
213
|
+
deleteMany(args: {
|
|
214
|
+
where: { userId: string; endpoint: string };
|
|
215
|
+
}): Promise<{ count: number }>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// The client
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/** The model delegates — what both a live client and a transaction expose. */
|
|
223
|
+
export interface NotificationsDbClient {
|
|
224
|
+
notification: NotificationDelegate;
|
|
225
|
+
notificationDelivery: NotificationDeliveryDelegate;
|
|
226
|
+
notificationPreference: NotificationPreferenceDelegate;
|
|
227
|
+
pushSubscription: PushSubscriptionDelegate;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The full seam: delegates plus interactive transactions. The inbox record and
|
|
232
|
+
* its delivery rows commit together, so a crash can never leave a notification
|
|
233
|
+
* a user can see with no record of what was meant to carry it. Prisma's own
|
|
234
|
+
* `$transaction(fn)` satisfies this structurally.
|
|
235
|
+
*/
|
|
236
|
+
export interface NotificationsDb extends NotificationsDbClient {
|
|
237
|
+
$transaction<T>(fn: (tx: NotificationsDbClient) => Promise<T>): Promise<T>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Deferred so hosts with an async client bootstrap can pass it directly. */
|
|
241
|
+
export type NotificationsDbProvider = () => Promise<NotificationsDb>;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* How a transport reaches a person: the destinations the HOST owns.
|
|
245
|
+
*
|
|
246
|
+
* Returning `null` for a user id means "no such recipient", which `notify`
|
|
247
|
+
* treats as a caller bug and throws on — a notification addressed to nobody is
|
|
248
|
+
* never silently dropped.
|
|
249
|
+
*/
|
|
250
|
+
export interface NotificationContactDirectory {
|
|
251
|
+
getContact(userId: string): Promise<{ email: string | null; phone: string | null } | null>;
|
|
252
|
+
}
|