@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
package/ADOPTING.md ADDED
@@ -0,0 +1,316 @@
1
+ # Adopting @12-apps/notifications
2
+
3
+ This package is a **plug-and-play notification plugin** (12-15): one library,
4
+ reusable across repositories, exposing standardized surfaces. A host repo only
5
+ *points* at these surfaces — when the library updates, every host updates with
6
+ **no app changes**. The contract is the one `@12-apps/report-builder`,
7
+ `@12-apps/rbac`, `@12-apps/entity-lifecycle` and `@12-apps/payments-*`
8
+ established.
9
+
10
+ What it gives a host: an **always-on in-app inbox** (bell, badge, slide-over,
11
+ mark-read, soft delete, cursor pager), **per-user × per-category channel
12
+ preferences** with an availability probe, and **email / SMS / WhatsApp /
13
+ web-push transports** with delivery records, failure isolation and a retry
14
+ sweep — endpoints and screens included.
15
+
16
+ ## The standardized plugin surfaces
17
+
18
+ | Surface | Export | What the host does |
19
+ |---|---|---|
20
+ | **Core** | `@12-apps/notifications` | Nothing to wire — the framework-free, storage-free vocabulary both halves share: types, the generator registry, the preference policy, the phone rules, the copy table, the inbox wire shape. Safe in a browser. |
21
+ | **Server** | `@12-apps/notifications/server` | Call `createApiNotifications({ db, contacts, transports })` and mount the `routes` it returns — the nine inbox / preferences / push-subscription endpoints, with parsing, statuses and the `{ data }` envelope inside. It also returns `notify` (the emit front door), `notifyByPermission`, `dispatchDeliveries`, `drainPending` and the three stores. |
22
+ | **Hono** | `@12-apps/notifications/hono` | `const notifications = notificationsRouter({ ...serverConfig, resolveActor }); app.route('/api/account', notifications.router)`. A one-call mount; `hono` is an OPTIONAL peer, so importing the root, `/server` or `/react` never resolves it. |
23
+ | **React** | `@12-apps/notifications/react` | Call `createWebNotifications({ apiBase })`. `BellWithPanel` is the whole feature as one element; `BellButton` + `Panel` are the pair for a host with its own chrome; `page` is the preferences screen you route to. pt-BR product copy and the future-pay test ids ship inside. |
24
+ | **Web Push** | `@12-apps/notifications/web-push` | `sender: vapidPushSender({ subject, publicKey, privateKey })` on the `WEB_PUSH` declaration. Its own subpath because it is the only piece that needs `web-push` — an OPTIONAL peer a host that never enables the channel never installs. |
25
+ | **Prisma** | `prisma/notifications.prisma` + `prisma/migrations/*` | Run `pnpm --filter @12-apps/notifications prisma:sync -- <host schema dir>`: the partial is **COPIED** into the host's multi-file schema folder — never symlinked (a symlinked migration is silently skipped by Prisma; a symlinked partial dangles under `turbo prune`). Migrations are discovered structurally from the installed package's `prisma/migrations` by the host's plugin-migration sync. |
26
+
27
+ ## Host wiring rules (the ones that bite)
28
+
29
+ 1. **The host resolves WHO; the package resolves the rest.** `resolveActor`
30
+ answers `{ userId }` or `null` (→ 401 before any handler runs). There is no
31
+ tenant and no permission list on that actor, and both absences are the
32
+ design: every endpoint here is SELF-scoped — a user reads and writes their
33
+ own inbox — so the authorization is applied by scoping every query to
34
+ `userId` rather than by a guard that could be forgotten. An admin view of
35
+ someone else's inbox would be a different surface with a different actor.
36
+
37
+ 2. **`contacts` is not optional, and it is not the `users` table.** A transport
38
+ needs an address: `getContact(userId) → { email, phone } | null`. future-pay's
39
+ router read `users.email` / `users.phone` directly, which is the one thing in
40
+ the pipeline that was always the host's — a package cannot know the shape of
41
+ an identity table, and a host with phone VERIFICATION wants to answer the
42
+ question differently:
43
+
44
+ ```ts
45
+ contacts: {
46
+ async getContact(userId) {
47
+ const user = await prisma.user.findUnique({
48
+ where: { id: userId },
49
+ select: { email: true, phone: true, phoneVerifiedAt: true },
50
+ });
51
+ if (!user) return null;
52
+ // Only a VERIFIED phone is a destination — the whole reason this is a seam.
53
+ return { email: user.email, phone: user.phoneVerifiedAt ? user.phone : null };
54
+ },
55
+ }
56
+ ```
57
+
58
+ Returning `null` means "no such recipient", and `notify` throws
59
+ `UnknownNotificationRecipientError` on it. That is deliberate: a
60
+ notification addressed to nobody is a caller bug, never a silent drop.
61
+
62
+ The same answer at DISPATCH time — the account was deleted between the emit
63
+ and the send — marks that notification's delivery rows `DEAD` rather than
64
+ leaving them QUEUED, because a QUEUED row nobody can be reached at is a row
65
+ every sweep picks up forever. So `getContact` must return `null` only for
66
+ "there is no such person", never for a transient failure: **throw** on a
67
+ database error and the sweep will retry.
68
+
69
+ 3. **A channel is ON because it is DECLARED, not because an env var is set.**
70
+ One entry per channel, and a channel with no entry reports
71
+ `supports() === false` — no delivery row, nothing fake-sent:
72
+
73
+ ```ts
74
+ transports: [
75
+ { channel: 'EMAIL', driver: 'resend', apiKey: env.RESEND_API_KEY, from: env.MAIL_FROM, appUrl },
76
+ { channel: 'SMS', driver: 'twilio', accountSid, authToken, from, defaultCountryCode: '55' },
77
+ { channel: 'WHATSAPP', driver: 'meta', accessToken, phoneNumberId, templateName,
78
+ defaultCountryCode: '55' },
79
+ { channel: 'WEB_PUSH', driver: 'vapid', publicKey, sender: vapidPushSender({ … }) },
80
+ ]
81
+ ```
82
+
83
+ **A second vendor is one more entry**, either a built-in driver key or one of
84
+ the host's own through `drivers` — and **zero** host code either way. An
85
+ unknown driver key throws at MOUNT rather than at the first send: a typo'd
86
+ vendor that silently disabled a channel is the failure this seam removes.
87
+
88
+ Two things the phone channels REQUIRE, and one they warn about:
89
+
90
+ - **`defaultCountryCode` is mandatory** on `SMS` and `WHATSAPP`. It used to
91
+ default to `55` (the first host's market), which for a US adopter turned
92
+ `4155552671` into `+554155552671` — a plausible Brazilian mobile — and sent a
93
+ stranger the customer's order details. There is no country a published
94
+ package can assume, so it assumes none and the omission is a compile error.
95
+ - A number that will not normalize to E.164 makes the channel unavailable for
96
+ that recipient (no delivery row, nothing sent) rather than being mangled.
97
+ - Declaring `WHATSAPP` with **no `templateName`** logs a warning at mount:
98
+ free-form text is only accepted inside Meta's 24-hour customer-service
99
+ window, which cannot be tracked from here, so a host that emits
100
+ business-initiated notifications that way has every send rejected.
101
+
102
+ 4. **Duck-typed DB, never a generated client.** `db` is a lazy provider of the
103
+ structural `NotificationsDb` seam over the four owned tables — a Prisma
104
+ client satisfies it directly (`$transaction` included); the harness satisfies
105
+ it with hand-written SQL. The argument shapes are CLOSED (documented in
106
+ `src/server/db.ts`), so a non-Prisma host has a finite surface to fill.
107
+
108
+ 5. **`categories` is product vocabulary.** The default is future-pay's four
109
+ (`orders` / `payments` / `stock` / `system`) and the preferences screen
110
+ renders whatever the api half was given — the taxonomy travels on the
111
+ `GET /notification-preferences` payload, so the two halves cannot disagree
112
+ about it. The packaged migration deliberately puts **no CHECK** on
113
+ `notifications.category` for this reason; a host that wants one adds it.
114
+
115
+ 6. **`generators` are registered from the OUTSIDE, and stay the host's.** A
116
+ generator maps a domain event to `{ title, body, link, data }`, and the
117
+ events are exactly what does not port: `order.paid`, `stock.low` and
118
+ `short-payment` are Future Pay's, not any host's. Pass them in `generators`,
119
+ or call `registerGenerator` for a module imported later. The generator's
120
+ `category` is what the router gates fan-out on.
121
+
122
+ 7. **Billing stays outside, and its gate is `channelPolicy`.** The money logic
123
+ is the host's — this package never learns what a plan is. What it owns is
124
+ *where the answer is asked for*: `(clientId, channels) => channels`, awaited
125
+ per emit. A tenant-scoped emit keeps only the channels the plan covers, so a
126
+ revoked transport **degrades** to the remaining ones rather than dropping the
127
+ notification. A platform emit (no `clientId`) is never filtered.
128
+
129
+ A policy that THROWS degrades to the **free** channels — the intersection of
130
+ what the other gates allowed with e-mail + web push — and not to everything.
131
+ The half of that which is "an extra notification beats a silent one" is why it
132
+ does not fail closed: the dunning e-mail this system carries is how payment
133
+ gets collected. But that argument only ever covered the free channels. Failing
134
+ fully open billed the host for SMS and WhatsApp on a transient entitlements
135
+ error, which are the two channels that cost money per message and the exact
136
+ two its own gate was about to refuse.
137
+
138
+ 8. **Dispatch is detached by default; hand it a queue in production.**
139
+ `scheduleDispatch(notificationId)` puts the send on the host's worker
140
+ (`@12-apps/jobs`, BullMQ, anything). The delivery rows are the durable
141
+ record, so a queue that is unavailable costs latency, never a notification —
142
+ and `drainPending()` on a cron re-dispatches FAILED rows and QUEUED/SENDING
143
+ strays. `notify(event, { sync: true })` always sends in-process, which is what
144
+ a test or a worker about to exit needs.
145
+
146
+ **`drainPending` needs no lease, and you may run it on every instance.** Every
147
+ send is CLAIMED first — one conditional `UPDATE` moves the row `QUEUED →
148
+ SENDING` and the sender proceeds only if it moved exactly one row — so two
149
+ sweeps, two cron runners, or the two app containers a zero-downtime rollout
150
+ briefly runs cannot both send the same delivery. Three consequences worth
151
+ knowing:
152
+
153
+ - `drainPending(olderThanMs, take)` is BOUNDED (default 200 rows). A run
154
+ cannot outlive its own interval and pile up behind itself.
155
+ - Staleness is judged on `updated_at`, so a row the sweep just re-queued is
156
+ not stale again on the next tick.
157
+ - Nothing is retried forever: each claim spends one of `maxDeliveryAttempts`
158
+ (default 5) and the last failure writes `DEAD`, which no sweep selects
159
+ again. Without a ceiling a permanently invalid destination is a billed
160
+ provider call on every sweep for the life of the row.
161
+
162
+ `notify` opens **its own transaction** and a host cannot enlist in it (a
163
+ Prisma `TransactionClient` has no `$transaction` to nest). So **call `notify`
164
+ after your own transaction commits** — called from inside one, it commits an
165
+ inbox row and dispatches an e-mail for a payment that then rolls back.
166
+
167
+ 9. **`onCommitted` is where a realtime bus hangs.** The inbox row is written
168
+ inside this package, and the bus that should announce it is a dependency this
169
+ package must not gain. The listener fires AFTER the transaction — never inside
170
+ it, or a subscriber would re-read and not find the row it was told about — and
171
+ a listener that throws is logged, never propagated: the row is committed, and
172
+ an observer must not turn a delivered notification into a 500. Pair it with
173
+ `subscribe` on the web config, and the badge is pushed instead of polled.
174
+
175
+ 10. **`notifyByPermission` needs `audience`, and it is your engine.** "Tell
176
+ whoever can act on this" is resolved against the host's real authorization
177
+ engine through two queries — `listCandidates(tenantId)` and
178
+ `getPermissions(userId, tenantId)`. The package owns the fold: the **AND**
179
+ (not OR — pair the permission that HANDLES the thing with the one that gates
180
+ the SURFACE it links to, and every addressee is someone that surface's own
181
+ guard will serve), the dedup, the refusal of an empty permission list, the
182
+ per-recipient isolation — of BOTH host queries, so a candidate whose
183
+ `getPermissions` throws is skipped as `audience-error` and the rest are still
184
+ notified — and the log line that tells "nobody holds it" apart from "every
185
+ dispatch failed". Two rules for your implementation:
186
+
187
+ - `listCandidates` must be **bounded to people who hold a role**. future-pay's
188
+ requires a role grant, which keeps a store's storefront BUYERS out of a loop
189
+ that resolves permissions one user at a time.
190
+ - `getPermissions` must be **scoped to the tenant**. Unioning a user's grants
191
+ across tenants notifies someone about a store whose money they have no
192
+ authority over, and no upstream `where` can save it — that user is already
193
+ a candidate.
194
+
195
+ Configure no `audience` and the method rejects loudly rather than resolving
196
+ to "nobody", because the alternative is a money alert nobody gets.
197
+
198
+ 11. **Mount order.** Nothing in this surface is shaped `/:id`, so it captures no
199
+ host route. The reverse is not free: a host route shaped
200
+ `/notifications/:id` under the same prefix, registered first, captures
201
+ `GET /notifications/unread-count`. Mount `notifications.router` before any
202
+ such route.
203
+
204
+ 12. **The service worker is the host's.** `enableWebPush` registers `/sw.js` by
205
+ default (`webPush.swPath` overrides it) and the file itself lives in the
206
+ host's public directory — path-routed SPAs each control their own scope. On
207
+ iOS there is no browser-level Web Push at all, so pass
208
+ `webPush.needsInstallFirst` + `webPush.installHint` and the screen shows the
209
+ one instruction that leads somewhere instead of an "Ativar" button that asks
210
+ no permission and creates no subscription.
211
+
212
+ One property of the packaged screen worth knowing, because it explains a
213
+ request you will see: a push **endpoint is per browser PROFILE, not per
214
+ user**, so on a shared machine the second person to click *Ativar* re-owns
215
+ the row and the first person's subscription is gone. Re-owning is the safer
216
+ of the two designs — one row per `(userId, endpoint)` would push A's
217
+ notifications to a browser now used by B, encrypted to B's keys, which
218
+ decrypt — so the screen instead stops trusting the browser alone: it reads
219
+ `GET /push-subscriptions?endpoint=…` and shows *Ativar* again whenever the
220
+ server no longer has that endpoint under the caller. That also recovers the
221
+ 404/410 prune case. A re-own is logged (both user ids, never the endpoint).
222
+
223
+ 13. **These endpoints are cookie-authenticated WRITES. CSRF is yours.** Eight of
224
+ the nine change state, and `resolveActor` typically reads a session cookie —
225
+ so the host, not this package, owns the cross-site question. In future-pay it
226
+ is fully mitigated by the Auth.js cookie's `SameSite=Lax`; a host
227
+ authenticating with `SameSite=None`, or one whose `resolveActor` trusts a
228
+ header a proxy sets, inherits an unguarded write surface. The one that matters
229
+ most is `POST /push-subscriptions`: a cross-site request riding the victim's
230
+ cookie can register the ATTACKER's endpoint and keys under the victim's
231
+ account, after which the attacker's browser receives the victim's
232
+ notifications and can decrypt them. So: **enforce `SameSite` on the session
233
+ cookie, or check a CSRF token in `resolveActor`.**
234
+
235
+ What the package does do is refuse to parse a body whose `Content-Type` is
236
+ not JSON. `text/plain`, `multipart/form-data` and
237
+ `application/x-www-form-urlencoded` are the three types a cross-site `fetch`
238
+ or a plain `<form>` can send with no preflight; refusing them means such a
239
+ request must earn a preflight the browser will then deny. That is a speed
240
+ bump, not the defence — it does not help a host whose cookie is
241
+ `SameSite=None` and whose attacker can send `application/json`.
242
+
243
+ ## The minimum host
244
+
245
+ ```ts
246
+ // server
247
+ import { notificationsRouter } from '@12-apps/notifications/hono';
248
+ import { vapidPushSender } from '@12-apps/notifications/web-push';
249
+
250
+ const notifications = notificationsRouter({
251
+ db: getPrismaClient,
252
+ contacts: { getContact: (id) => loadContact(id) },
253
+ transports: [{ channel: 'EMAIL', driver: 'resend', apiKey, from, appUrl }],
254
+ generators: [orderPaidGenerator],
255
+ resolveActor: async (c) => {
256
+ const session = await auth(c);
257
+ return session ? { userId: session.user.id } : null;
258
+ },
259
+ });
260
+ app.route('/api/account', notifications.router);
261
+
262
+ // frontend
263
+ import { createWebNotifications } from '@12-apps/notifications/react';
264
+
265
+ const notifications = createWebNotifications({ apiBase: '/api/account' });
266
+ // header: <notifications.BellWithPanel onNavigate={navigate} />
267
+ // route: <Route path="/account/notifications" element={<notifications.page />} />
268
+ ```
269
+
270
+ ## Adopting the schema
271
+
272
+ ```bash
273
+ # 1. the partial, COPIED into the host's schema folder
274
+ pnpm --filter @12-apps/notifications prisma:sync -- packages/prisma/prisma/schema
275
+
276
+ # 2. the migrations, discovered structurally by the host's plugin sync
277
+ pnpm --filter @12-apps/prisma prisma:sync-plugins
278
+
279
+ # 3. drift is a red CI step, not a surprise at deploy time
280
+ pnpm --filter @12-apps/notifications prisma:sync:check
281
+ ```
282
+
283
+ The migration is **replay-safe per column**, because the first adopters already
284
+ have these tables: `CREATE TABLE IF NOT EXISTS` is followed by one
285
+ `ADD COLUMN IF NOT EXISTS` per column, and the CHECKs are guarded by a
286
+ `pg_constraint` lookup. `CREATE TABLE IF NOT EXISTS` alone is the trap it
287
+ avoids — it skips the whole table, so a host whose table predates a column
288
+ silently never gets that column, and the failure surfaces later as a
289
+ missing-column error in production.
290
+
291
+ Two statements are deliberately NOT existence-guarded, because they must
292
+ **converge** rather than be skipped:
293
+
294
+ - the delivery `status` CHECK is `DROP CONSTRAINT IF EXISTS` + `ADD`. The value
295
+ set widened (`SENDING`, `DEAD`), so an adopter holding a constraint under that
296
+ name holds the old three — and a guard would keep it, rejecting the claim's own
297
+ `SENDING` write at runtime with a CHECK older than the claim;
298
+ - the sweep's index moved from `(status, created_at)` to `(status, updated_at)`,
299
+ so the old one is dropped and the new one created.
300
+
301
+ Both are idempotent, which is the property replay safety actually needs.
302
+
303
+ The `attempts` column is new too; an adopting host gets it at `0`, which is the
304
+ correct starting point ("no claim spent yet").
305
+
306
+ There are **no foreign keys into host tables**: `user_id` and `client_id` are
307
+ by-value scalars. Add your own in a host migration — `ON DELETE CASCADE` on
308
+ both is the recommendation, so a deleted account takes its inbox with it.
309
+
310
+ ## What does NOT come with it
311
+
312
+ - **The events.** Generators are host code (see rule 6).
313
+ - **A queue.** `scheduleDispatch` is a seam, not an implementation.
314
+ - **A plan model.** `channelPolicy` answers; it does not decide.
315
+ - **An authorization engine.** `audience` answers; it does not decide.
316
+ - **A service worker.** The file is the host's (rule 12).
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # @12-apps/notifications
2
+
3
+ The channel-agnostic notification system: an always-on in-app inbox, per-user ×
4
+ per-category channel preferences, and email / SMS / WhatsApp / web-push
5
+ transports behind vendor drivers — both halves, one factory each.
6
+
7
+ ```ts
8
+ const notifications = createApiNotifications({ db, contacts, transports }); // backend
9
+ const notifications = createWebNotifications({ apiBase }); // frontend
10
+ ```
11
+
12
+ Adoption contract, wiring rules and the seams: **[ADOPTING.md](./ADOPTING.md)**.
13
+
14
+ ## Three layers, each open without touching the others
15
+
16
+ ```
17
+ GENERATORS domain event → agnostic content { title, body, link, data }
18
+ ↓
19
+ CHANNEL ROUTER always writes the inbox record, then fans out one QUEUED
20
+ delivery per channel that is (a) enabled by the recipient's
21
+ preferences for the generator's category and (b) supported by
22
+ its transport for this recipient
23
+ ↓
24
+ TRANSPORTS format the agnostic content for one channel and send it,
25
+ through a vendor DRIVER
26
+ ```
27
+
28
+ Adding an event type is a generator. Adding a channel is a transport. Adding a
29
+ **vendor** is a config entry. None of the three touches the other two.
30
+
31
+ ## What one emit does
32
+
33
+ ```ts
34
+ await notifications.notify({
35
+ type: 'order.paid',
36
+ recipient: { userId, clientId },
37
+ payload: { code: 'A-1024' },
38
+ });
39
+ ```
40
+
41
+ 1. Resolves the registered generator for `type` → agnostic content.
42
+ 2. **Always** writes the inbox record — the always-on channel — atomically with…
43
+ 3. …one QUEUED delivery per channel that survives the preference gate, the
44
+ transport's `supports()` gate and the host's plan policy.
45
+ 4. Hands the deliveries to the transports asynchronously, so an emit site never
46
+ blocks on provider I/O.
47
+
48
+ `notify` opens its **own** transaction and a host cannot join it, so call it
49
+ **after** your own transaction commits — from inside one it commits an inbox row
50
+ and dispatches an e-mail for a payment that then rolls back.
51
+
52
+ Failure is isolated per channel: one provider failing marks only its row FAILED
53
+ (with the provider's error) and never blocks the inbox record or the other
54
+ channels.
55
+
56
+ ## The delivery lifecycle
57
+
58
+ ```
59
+ QUEUED ──claim──▶ SENDING ──▶ SENT
60
+ │
61
+ ├──▶ FAILED ──sweep──▶ QUEUED (attempts < max)
62
+ └──▶ DEAD (attempts = max, terminal)
63
+ ```
64
+
65
+ Every send is **claimed** first: one conditional `UPDATE` moves the row out of
66
+ `QUEUED`, and the sender proceeds only if it moved exactly one row. So two
67
+ dispatchers racing one delivery — two cron runners, a sweep overlapping itself,
68
+ the two app containers a zero-downtime rollout briefly runs — produce exactly one
69
+ provider call. Delivery is still at-least-once, but the remaining window is the
70
+ unavoidable one: a crash between the provider call and the `SENT` flip. Transports
71
+ must tolerate that.
72
+
73
+ `drainPending(olderThanMs, take)` is the retry sweep a host puts on a cron. It
74
+ selects on `updated_at` (so a row it just re-queued is not stale again on the next
75
+ tick), takes at most `take` rows (default 200), and re-queues each with the same
76
+ conditional shape — so it can never drag a committed `SENT` row back. Each claim
77
+ spends one of `maxDeliveryAttempts` (default 5); the last failure writes `DEAD`,
78
+ which no sweep selects again.
79
+
80
+ ## The endpoints
81
+
82
+ Mounted under whatever prefix the host chooses (future-pay: `/api/account`):
83
+
84
+ | | |
85
+ |---|---|
86
+ | `GET /notifications` | the owner's inbox — newest first, cursor-paginated, `filter=unread` |
87
+ | `GET /notifications/unread-count` | the badge number, a single indexed COUNT |
88
+ | `POST /notifications/mark-read` | `{ ids }` or `{ all: true }`; idempotent, reports what moved |
89
+ | `POST /notifications/delete` | soft delete, 1..100 ids; the delivery trail survives |
90
+ | `GET` / `PUT /notification-preferences` | the category × channel matrix, plus per-channel AVAILABILITY (destination on file + channel declared) so dead toggles render disabled with a hint |
91
+ | `GET` / `POST` / `DELETE /push-subscriptions` | the VAPID public key + device count; register / drop this browser. `GET ?endpoint=…` also answers whether the server still has THAT endpoint under the caller |
92
+
93
+ Success is `{ data }`; a denial is `{ error }`, unwrapped. Every endpoint takes its
94
+ subject from the actor and never from the request — and eight of the nine are
95
+ cookie-authenticated writes, so the host owes them a CSRF story (ADOPTING rule 13).
96
+
97
+ ## The channels
98
+
99
+ | Channel | Built-in drivers | Destination |
100
+ |---|---|---|
101
+ | `EMAIL` | `resend`, `log` | the contact directory's `email` |
102
+ | `SMS` | `twilio`, `log` | `phone`, normalized to E.164 (`defaultCountryCode` required) |
103
+ | `WHATSAPP` | `meta`, `log` | `phone`, free-form or a pre-approved template |
104
+ | `WEB_PUSH` | `vapid`, `log` | the user's `push_subscriptions` rows, all of them |
105
+
106
+ `log` is the dev/e2e driver: it logs instead of sending, and deliberately logs
107
+ **no destination** — an address and a phone number are PII, and a push endpoint
108
+ is a bearer capability for that browser.
109
+
110
+ `defaultCountryCode` is required on both phone channels rather than defaulting to
111
+ Brazil: a US adopter that forgot it turned `4155552671` into `+554155552671` — a
112
+ plausible Brazilian mobile — and texted a stranger the customer's order.
113
+
114
+ Web Push prunes a subscription the push service reports GONE (404/410) and
115
+ succeeds when at least one browser accepted the payload, so the table self-heals
116
+ as browsers expire subscriptions. A transient failure (503) prunes nothing:
117
+ pruning there would destroy a live destination. A push endpoint belongs to a
118
+ browser PROFILE, not to a user, so registering the same browser as a second user
119
+ re-owns the row (the safe choice — the alternative leaks A's notifications to B in
120
+ a form B's keys decrypt) and the preferences screen derives "this browser is
121
+ receiving alerts" from the SERVER's answer, not from the browser's own
122
+ subscription object.
123
+
124
+ ## The models
125
+
126
+ `prisma/notifications.prisma` — `Notification`, `NotificationDelivery`,
127
+ `NotificationPreference`, `PushSubscription` — plus this package's own
128
+ migrations, COPIED into a host's schema folder (never symlinked). See ADOPTING.
129
+
130
+ `notification_preferences.channels` is a JSON map rather than a boolean column
131
+ per channel: the channel set is open-ended, and a stored row missing a key falls
132
+ back to that channel's default, which is what makes the ARRIVAL of a channel a
133
+ no-op for every existing row.
134
+
135
+ ## Testing
136
+
137
+ ```bash
138
+ pnpm --filter @12-apps/notifications test # 159 unit tests
139
+ ```
140
+
141
+ The unit suites pin the logic against an in-memory db seam. The same contracts
142
+ are then re-run against a **real Postgres**, out of the **published tarball**, in
143
+ `harness/backend` — and the frontend surface is driven by Playwright against that
144
+ same server in `harness/frontend` (page `notifications-center`). An in-memory
145
+ double can agree with a wrong SQL translation; the harness is what makes the port
146
+ real.
147
+
148
+ The **concurrency** contracts are the clearest case for that, and they are pinned
149
+ in both places on purpose. A single-threaded fake agrees quite happily with a
150
+ read-validate-write dispatcher — the send resolves before any second caller can
151
+ reach the row — so "two dispatchers, one delivery" is asserted over real SQL with
152
+ the sends held open, where the predicate is evaluated by Postgres against the row
153
+ version the other dispatcher committed.
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@12-apps/notifications",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./server": "./src/server/index.ts",
9
+ "./hono": "./src/hono/index.ts",
10
+ "./react": "./src/react/index.ts",
11
+ "./web-push": "./src/web-push/index.ts",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "scripts": {
15
+ "clean": "rm -rf node_modules coverage",
16
+ "test": "node ../../scripts/vitest-with-teardown.mjs run",
17
+ "test:watch": "vitest watch",
18
+ "lint": "eslint src --max-warnings 0",
19
+ "check-types": "tsc --noEmit",
20
+ "typecheck": "tsc --noEmit",
21
+ "prisma:sync": "node scripts/sync-notifications-schema.mjs",
22
+ "prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
23
+ },
24
+ "dependencies": {
25
+ "@12-apps/ui": "^4.0.1"
26
+ },
27
+ "peerDependencies": {
28
+ "hono": ">=4.0.0",
29
+ "react": ">=19.0.0",
30
+ "react-dom": ">=19.0.0",
31
+ "web-push": ">=3.6.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "hono": {
35
+ "optional": true
36
+ },
37
+ "react": {
38
+ "optional": true
39
+ },
40
+ "react-dom": {
41
+ "optional": true
42
+ },
43
+ "web-push": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@12-apps/eslint-config": "^1.20.0",
49
+ "@12-apps/typescript-config": "^1.20.0",
50
+ "@testing-library/react": "^16.1.0",
51
+ "@types/react": "19.2.2",
52
+ "@types/react-dom": "19.2.2",
53
+ "@types/web-push": "^3.6.4",
54
+ "eslint": "^9.39.1",
55
+ "eslint-plugin-test-flakiness": "^1.4.0",
56
+ "hono": "^4.6.0",
57
+ "jsdom": "^25.0.1",
58
+ "react": "^19.2.0",
59
+ "react-dom": "^19.2.0",
60
+ "typescript": "^5.9.2",
61
+ "vitest": "^3.2.4",
62
+ "web-push": "^3.6.7"
63
+ },
64
+ "engines": {
65
+ "node": ">=22.0.0"
66
+ },
67
+ "license": "MIT",
68
+ "publishConfig": {
69
+ "registry": "https://registry.npmjs.org",
70
+ "access": "public"
71
+ },
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "git+https://github.com/12-apps/shared-packages.git",
75
+ "directory": "packages/notifications"
76
+ },
77
+ "files": [
78
+ "src",
79
+ "dist",
80
+ "prisma",
81
+ "scripts",
82
+ "*.js",
83
+ "*.mjs",
84
+ "*.md",
85
+ "!eslint.config.js",
86
+ "!**/__tests__/**",
87
+ "!**/tests/**",
88
+ "!**/*.test.*",
89
+ "!**/*.spec.*",
90
+ "!**/*.stories.*",
91
+ "!**/*.test-story.*",
92
+ "!**/test-helpers.*"
93
+ ]
94
+ }