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