@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,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
+ }
@@ -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
+ }