@marktiderman/genesis-notifications 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- package/src/web/index.tsx +428 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Tiderman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,252 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @marktiderman/genesis-notifications
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One notifier for every part of a Genesis app. A domain package (chat, forms,
|
|
4
|
+
sites, support) decides **who** is told and **what**: the candidates, its own
|
|
5
|
+
mutes and mentions, who may see what. The notifier decides **how and when**:
|
|
6
|
+
|
|
7
|
+
- the channel: the in-app feed, Expo push, email;
|
|
8
|
+
- quiet hours, read in the person's time zone and right across daylight saving;
|
|
9
|
+
- preferences by category and channel, with tenant defaults, and `critical` types always on;
|
|
10
|
+
- delay-then-skip-if-read for email;
|
|
11
|
+
- dedupe, retries, delivery receipts, and pruning dead push tokens.
|
|
12
|
+
|
|
13
|
+
It never reads a domain's tables. The contract, the tables and a worked example
|
|
14
|
+
for a domain are in [docs/NOTIFICATIONS.md](../../docs/NOTIFICATIONS.md).
|
|
15
|
+
|
|
16
|
+
## Entry points
|
|
17
|
+
|
|
18
|
+
| Import | What |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `@marktiderman/genesis-notifications` (`/core`) | `defineNotifications`, the shared types, `MESSAGE_ACTIONS`, `GENESIS_TEST_TYPE`. No dependencies. |
|
|
21
|
+
| `/runner` | `createRunner`: the one outbox runner (claim, send in groups, settle, `maxDeferrals`, jitter, `disable` on 410). genesis-forms' destinations run on it too. No dependencies. |
|
|
22
|
+
| `/server` | `notify()`, `syncNotificationTypes`, `createNotificationRunner`, `createNotificationsHandler`, `expoPushProvider`, `resendProvider`, `resolveSecretLink`, `checkSenderDomain`, unsubscribe tokens, webhook verification. Service role only. |
|
|
23
|
+
| `/email` | React Email: `BrandLayout`, `EmailText`, `EmailButton`, `reactEmailTemplate`, `brandedDefaultTemplate`. |
|
|
24
|
+
| `/supabase` | `NOTIFICATIONS_MIGRATIONS` and `notificationsClient(supabase)`, the browser and phone side under row level security. |
|
|
25
|
+
| `/react` | `NotificationsProvider`, `useNotifications`, `useUnreadCount`, `usePreferences`. |
|
|
26
|
+
| `/web` | `NotificationBell`, `NotificationFeed`, `PreferencesMatrix`, and for admins `DeliveryLog`, `SuppressionList`, `SendTestNotification`, all on genesis-ui. |
|
|
27
|
+
| `/native` | `NotificationsFeed` (on genesis-ui-native's `NotificationsPage`), `ChannelPreferences`, `NotificationPreferences`, `notificationsPushBackend`, `pushOptionsFrom`, `notificationAction`. |
|
|
28
|
+
| `/blocks` | `notificationsBlock`: routes, nav, a Settings row, the migration and the privacy notes. |
|
|
29
|
+
|
|
30
|
+
The package ships `migrations/0001_genesis_notifications.sql` with its pgTAP
|
|
31
|
+
test, and the `functions/genesis-notifications` Edge Function template.
|
|
32
|
+
|
|
33
|
+
## Set up
|
|
34
|
+
|
|
35
|
+
1. Copy the migration into `supabase/migrations/` under a new timestamped name and its test into
|
|
36
|
+
`supabase/tests/database/`. It needs Vault (every Supabase project has it) for the key that
|
|
37
|
+
encrypts one-time secrets.
|
|
38
|
+
2. Say who is an admin, in a migration of your own after it:
|
|
39
|
+
|
|
40
|
+
```sql
|
|
41
|
+
create or replace function private.genesis_notifications_admin(p_user uuid, p_tenant uuid)
|
|
42
|
+
returns boolean language sql stable security definer set search_path = '' as $$
|
|
43
|
+
-- Your own rule: here, a tenant's owners and admins see that tenant (p_tenant null, the
|
|
44
|
+
-- whole app and the suppression list, is nobody's).
|
|
45
|
+
select p_tenant is not null and exists (
|
|
46
|
+
select 1 from public.memberships m
|
|
47
|
+
where m.user_id = p_user and m.tenant_id = p_tenant and m.role in ('owner', 'admin'))
|
|
48
|
+
$$;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Row level security calls it as the signed-in person, so it stays executable by
|
|
52
|
+
`authenticated`; never grant it to `anon` or `public`. Until you replace it nobody is an admin.
|
|
53
|
+
3. Define your types once and import the list everywhere (app, server, function):
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { defineNotifications, MESSAGE_ACTIONS, GENESIS_TEST_CATEGORY, GENESIS_TEST_TYPE } from "@marktiderman/genesis-notifications";
|
|
57
|
+
|
|
58
|
+
export const notifications = defineNotifications({
|
|
59
|
+
categories: { messages: { label: "Messages", android: { importance: "high" } }, ...GENESIS_TEST_CATEGORY },
|
|
60
|
+
actions: { message: MESSAGE_ACTIONS },
|
|
61
|
+
types: {
|
|
62
|
+
"chat.message": {
|
|
63
|
+
category: "messages",
|
|
64
|
+
channels: { in_app: true, push: true, email: true },
|
|
65
|
+
emailDelaySeconds: 600,
|
|
66
|
+
actions: "message",
|
|
67
|
+
render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),
|
|
68
|
+
},
|
|
69
|
+
...GENESIS_TEST_TYPE,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
4. Mirror it on deploy: `await syncNotificationTypes(admin, notifications)` (types left out are
|
|
75
|
+
retired, never deleted).
|
|
76
|
+
5. Deploy `functions/genesis-notifications` (`--no-verify-jwt`), and schedule `POST …/run`
|
|
77
|
+
every minute and `POST …/receipts` every 15 minutes with
|
|
78
|
+
`Authorization: Bearer <GENESIS_NOTIFICATIONS_CRON_SECRET>`, and
|
|
79
|
+
`select public.genesis_notifications_prune()` daily. Point Resend's webhook (bounced,
|
|
80
|
+
complained, delivered) at `…/webhooks/resend`.
|
|
81
|
+
6. In the app, wrap the signed-in screens:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<NotificationsProvider client={supabase} registry={notifications} userId={session?.user.id ?? null}>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
On native, give the push block this package's backend and the registry's categories:
|
|
88
|
+
`createPush({ backend: notificationsPushBackend(supabase), ...pushOptionsFrom(notifications), randomUUID: Crypto.randomUUID, … })`.
|
|
89
|
+
|
|
90
|
+
## Sending
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { notify } from "@marktiderman/genesis-notifications/server";
|
|
94
|
+
|
|
95
|
+
await notify(admin, {
|
|
96
|
+
type: "chat.message",
|
|
97
|
+
recipients: memberIds, // user ids, or { email, name, ref } for people with no login
|
|
98
|
+
data: { conversation_id, sender_name, preview },
|
|
99
|
+
idempotencyKey: `push:${messageId}`, // per recipient, type and tenant: the same key again sends nothing more
|
|
100
|
+
threadKey: conversationId, // groups them on the lock screen
|
|
101
|
+
collapseKey: `conversation:${conversationId}`, // a newer one replaces an unsent older one
|
|
102
|
+
badge: unreadCount, // the caller counts
|
|
103
|
+
tenantId,
|
|
104
|
+
}, { registry: notifications });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`genesis_notify` (the SQL function under it, service role only) can be called with the same
|
|
108
|
+
arguments from a domain's own SQL.
|
|
109
|
+
|
|
110
|
+
To take back what hasn't gone out (a release rolled back, an event undone):
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
await cancelNotification(admin, { type: "release.published", idempotencyKey: `release:${releaseId}`, tenantId, archive: true });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Every pending send job made with that type, tenant and key (for everyone, or only `recipients`)
|
|
117
|
+
becomes `cancelled` and is never claimed; one being sent at that moment ends cancelled unless
|
|
118
|
+
that attempt goes out (it is never retried); a job already sent is left alone; `archive: true`
|
|
119
|
+
archives the feed rows too (`genesis_notifications_cancel` in SQL). Another tenant's
|
|
120
|
+
notifications with the same key are never touched.
|
|
121
|
+
|
|
122
|
+
### Rules the notifier keeps
|
|
123
|
+
|
|
124
|
+
- **Secrets never go in `data`, or in a push.** `genesis_notify` (and `notify()` before it)
|
|
125
|
+
refuses any key ending in `token`, `code`, `secret`, `key`, `otp` or `password` (and
|
|
126
|
+
`signature`, `sig`, `invite_link` and the like), and any link, absolute or relative, with a
|
|
127
|
+
query or fragment parameter named that way (`/accept?inviteCode=`, `#access_token=`). This is
|
|
128
|
+
a backstop, not a guarantee: it catches the usual names, so keep secrets out by design, and
|
|
129
|
+
rename a display value it trips on (`country_code` → `country`). Store a reference
|
|
130
|
+
(`{ response_id }`) and mark the type `secretLink: true`; the runner calls your
|
|
131
|
+
`secretLinks[type]` at send time (`resolveSecretLink`). A token that exists only hashed in your
|
|
132
|
+
tables is passed as `notify({ secret })`, to exactly one recipient: encrypted onto the email's
|
|
133
|
+
send job with the Vault key, decrypted only when the runner claims it, cleared once the job
|
|
134
|
+
settles. Keys are matched as words, so `inviteToken` is refused as well as `invite_token`.
|
|
135
|
+
- **Retries are byte-identical.** An email is rendered once, at its first claim, and stored with
|
|
136
|
+
its hash; every retry sends the stored copy with the same idempotency key. A secret link is
|
|
137
|
+
built afresh on each attempt, so **a `secretLinks` builder must be deterministic**: the same
|
|
138
|
+
link for the same notification every time. Resend's 409 `invalid_idempotent_request` (the key
|
|
139
|
+
with another body) fails the job loudly (`idempotency_body_changed`), never counting it as
|
|
140
|
+
sent; `concurrent_idempotent_requests` retries in 5 s. `resendProvider` paces its requests
|
|
141
|
+
(`perSecond`, default 5, under Resend's default 10 a second per team); two runners at once each
|
|
142
|
+
keep to their own pace.
|
|
143
|
+
- **Links are the app's own.** A type's `render().url` must be a path (`/messages/42`): the push
|
|
144
|
+
carries it as is, the email resolves it against `appUrl`'s origin; anything else (another host,
|
|
145
|
+
`//host`, `javascript:`) is left out.
|
|
146
|
+
- **Push stays within its lease.** Jobs are sent in Expo batches of 100 (a job's devices never
|
|
147
|
+
split), each batch's tickets recorded and its jobs settled on its own; no batch starts once the
|
|
148
|
+
lease is about to run out (those jobs come straight back). With `runBudgetMs` (your function's
|
|
149
|
+
wall-clock limit) a run claims again while claims come back full, and stops once the time left
|
|
150
|
+
is under the lease.
|
|
151
|
+
- **One unreadable secret fails alone** (`secret_unreadable`); the rest of the claim goes out. No
|
|
152
|
+
Vault key at all raises, since that is the app's to fix.
|
|
153
|
+
- **Transactional types** (`transactional: true`) have no unsubscribe link, and their email is
|
|
154
|
+
sent whatever the person chose for the category (an unsubscribe never stops a receipt or a
|
|
155
|
+
reset); they are never held by quiet hours or the email delay, and still reach someone who
|
|
156
|
+
complained (not someone who hard bounced). Rate caps stay in the domain. A critical type's
|
|
157
|
+
email has no unsubscribe link either (preferences can't turn it off).
|
|
158
|
+
- **A job is never stuck.** One whose lease ran out `maxAttempts` times (the runner died with it
|
|
159
|
+
each time) fails as `lease_expired`.
|
|
160
|
+
- **Erasure.** `genesis_notifications_erase(user, email, ref)` deletes a person's
|
|
161
|
+
notifications (with every send job, and for a login also those addressed to its email),
|
|
162
|
+
devices, receipts, preferences and device moves, logins or not. The suppression list keeps only a hash, so a bounced address stays suppressed.
|
|
163
|
+
`genesis_notifications_prune(keep, keep_rendered, keep_feed)` keeps the delivery log short
|
|
164
|
+
(30 days, rendered bodies a day, by default); notifications that were never in a feed
|
|
165
|
+
(email-only recipients) go with their send jobs.
|
|
166
|
+
|
|
167
|
+
## Email
|
|
168
|
+
|
|
169
|
+
One Resend key per app (`RESEND_API_KEY`). Each tenant has a sender identity in
|
|
170
|
+
`notification_senders`: without its own domain, mail goes from the app's shared domain with the
|
|
171
|
+
tenant's name as the from name and its reply-to; once its own domain is verified, from that
|
|
172
|
+
domain. Verifying proves the domain is the tenant's: `genesis_notifications_set_sender` gives
|
|
173
|
+
the sender a `domain_token`, the tenant adds the TXT record `senderOwnershipRecord(domain,
|
|
174
|
+
token)` (`_genesis-notifications.<domain>` = `genesis-notifications-verify=<token>`) beside
|
|
175
|
+
Resend's records, and `checkSenderDomain(domain, { token, reservedDomains })` reads it with SPF,
|
|
176
|
+
DKIM and DMARC over DNS over HTTPS; `genesis_notifications_set_sender_dns(tenant, domain,
|
|
177
|
+
token, status, dns)` records the result for the domain and token checked. A domain is verified for one tenant at
|
|
178
|
+
most, and never the app's own shared domains or one under them (`reservedDomains`; the runner
|
|
179
|
+
refuses one too). Transactional and other mail
|
|
180
|
+
go from separate shared domains (`domains: { transactional, other }`), tagged `stream`.
|
|
181
|
+
|
|
182
|
+
Everything that isn't transactional carries `List-Unsubscribe` and
|
|
183
|
+
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) with a signed token; the endpoint
|
|
184
|
+
never unsubscribes on a GET (link scanners follow links), only on the POST. The token never
|
|
185
|
+
carries an address: someone with no login is named by its SHA-256 (`emailHash`). The unsubscribe
|
|
186
|
+
is for the tenant the email came from: a person with a login has that tenant's email of the
|
|
187
|
+
category turned off (a choice in Settings turns it back on everywhere); someone with none is
|
|
188
|
+
suppressed for the category and tenant. Only a bounce Resend calls `Permanent` suppresses an
|
|
189
|
+
address for good. The provider's id is kept the moment a send returns, so a webhook that comes
|
|
190
|
+
before the job settles still finds it.
|
|
191
|
+
|
|
192
|
+
The provider is chosen per job (`email.providerFor`), so a tenant-owned Resend key kept in
|
|
193
|
+
Vault can be added later without changing the runner. It isn't built.
|
|
194
|
+
|
|
195
|
+
## Push
|
|
196
|
+
|
|
197
|
+
The device side stays in genesis-native's push block: registration, the permission, routing, the
|
|
198
|
+
badge, and hiding a push for the screen already open (`useSuppressNotifications`, client-side).
|
|
199
|
+
The runner sends every due job's devices in Expo batches of 100 with `threadId`, `categoryId`
|
|
200
|
+
(the device category with Reply and Mark as read), `badge`, and a `collapseId` (the collapse key,
|
|
201
|
+
else the notification id, so a resend after a timeout replaces rather than repeats). The payload's
|
|
202
|
+
`data` is `{ url, notification_id, type, thread_key }`, never the notification's own `data`. A
|
|
203
|
+
person who hides previews, or a type with `pushPrivacy: "generic"`, gets neutral text. Receipts
|
|
204
|
+
are checked about every 15 minutes; `DeviceNotRegistered` forgets the device.
|
|
205
|
+
|
|
206
|
+
Android shows a push only on a channel the app made: the runner sends the category id as
|
|
207
|
+
`channelId`, so build the push block with `pushOptionsFrom(registry)` (one channel per
|
|
208
|
+
category, `genesis` included when you spread `GENESIS_TEST_CATEGORY`).
|
|
209
|
+
|
|
210
|
+
`notificationAction(response)` reads an action button: its id, the typed text, and the
|
|
211
|
+
notification id, which an app uses as the reply's client id so a retried reply stays one reply.
|
|
212
|
+
|
|
213
|
+
## Admins
|
|
214
|
+
|
|
215
|
+
`DeliveryLog`, `SuppressionList` and `SendTestNotification` (web) read the log columns of
|
|
216
|
+
`notification_deliveries` (status, attempts, an error code, a masked address; never content,
|
|
217
|
+
the rendered email or a secret) and the suppression list, through
|
|
218
|
+
`private.genesis_notifications_admin`.
|
|
219
|
+
|
|
220
|
+
## Coming from genesis-supabase's push migration (0005)
|
|
221
|
+
|
|
222
|
+
The migration adopts `push_devices`, `notification_preferences` and `push_receipts` as they are
|
|
223
|
+
and only adds to them (`notification_preferences.channels`, `push_receipts.delivery_id`,
|
|
224
|
+
`push_devices.installation_id`); no row changes. Then:
|
|
225
|
+
|
|
226
|
+
- stop calling the `genesis-push-send` function and 0005's sender functions; the runner here
|
|
227
|
+
replaces them;
|
|
228
|
+
- remove 0005's pgTAP test from `supabase/tests/database/`: this migration redefines
|
|
229
|
+
`genesis_push_register` so a token another login holds moves only with the same install id
|
|
230
|
+
(0005's test asserts the old move), and `genesis_push_forget` answers the same whether or not
|
|
231
|
+
there was a token (0005's test asserts `false` for an unknown one);
|
|
232
|
+
- switch the push block to `notificationsPushBackend(supabase)`, so it registers with the install id.
|
|
233
|
+
|
|
234
|
+
A category someone switched off under 0005 (`categories`) still turns its push and email off,
|
|
235
|
+
and every screen here shows it so; the first time they change one of its channels, the old
|
|
236
|
+
switch becomes explicit channel choices (`genesis_notifications_set_preference`).
|
|
237
|
+
|
|
238
|
+
## Not in v1
|
|
239
|
+
|
|
240
|
+
- **Digests** (next, v1.1).
|
|
241
|
+
- SMS.
|
|
242
|
+
- Web push.
|
|
243
|
+
- The Notification Service Extension and Communication Notifications (rich pushes, sender avatars).
|
|
244
|
+
- Live Activities.
|
|
245
|
+
- Slack as a channel.
|
|
246
|
+
- Routing auth emails (sign-in links, password resets) through Supabase's send-email hook.
|
|
247
|
+
- Per-tenant Resend keys (the provider seam is there; reading a key from Vault isn't).
|
|
248
|
+
|
|
249
|
+
Also not done yet: nothing has been sent through Expo or Resend with real credentials; the
|
|
250
|
+
providers are tested against faked services.
|
|
251
|
+
|
|
252
|
+
Part of the [Genesis framework](https://github.com/marktiderman/genesis).
|
package/dist/blocks.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as _marktiderman_genesis_features_blocks from '@marktiderman/genesis-features/blocks';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Notifications: the feed, push and email for every domain, on one notifier.
|
|
5
|
+
*
|
|
6
|
+
* @stability Beta
|
|
7
|
+
*/
|
|
8
|
+
declare const notificationsBlock: _marktiderman_genesis_features_blocks.BlockDef<"notifications", never>;
|
|
9
|
+
|
|
10
|
+
export { notificationsBlock };
|
package/dist/blocks.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineBlock } from '@marktiderman/genesis-features/blocks';
|
|
2
|
+
|
|
3
|
+
// src/blocks.ts
|
|
4
|
+
var PACKAGE = "@marktiderman/genesis-notifications";
|
|
5
|
+
var notificationsBlock = defineBlock({
|
|
6
|
+
id: "notifications",
|
|
7
|
+
platforms: ["web", "native", "server"],
|
|
8
|
+
contributes: {
|
|
9
|
+
nav: [{ id: "notifications", label: "Notifications", path: "/notifications", icon: "bell", order: 50 }],
|
|
10
|
+
settings: [{ id: "notification-preferences", section: "notifications", title: "Notifications", href: "/settings/notifications", testID: "genesis-notifications-settings" }],
|
|
11
|
+
routes: [
|
|
12
|
+
{ path: "/notifications", description: "The notification feed", signedIn: true },
|
|
13
|
+
{ path: "/settings/notifications", description: "Notification preferences: categories by channel, quiet hours, previews", signedIn: true },
|
|
14
|
+
{ path: "/admin/notifications", description: "Admin: the delivery log, suppressed addresses, send a test", signedIn: true }
|
|
15
|
+
],
|
|
16
|
+
migrations: [{ package: PACKAGE, file: "migrations/0001_genesis_notifications.sql", test: "migrations/tests/0001_genesis_notifications.test.sql" }],
|
|
17
|
+
privacy: {
|
|
18
|
+
collects: [
|
|
19
|
+
{ type: "device ID", purposes: ["app functionality"], linked: true, tracking: false },
|
|
20
|
+
{ type: "email address", purposes: ["app functionality"], linked: true, tracking: false }
|
|
21
|
+
],
|
|
22
|
+
notes: [
|
|
23
|
+
"Keeps each person's notifications (what the app told them), read and archived, until they are pruned or the person is erased.",
|
|
24
|
+
"Keeps this device's push token against the signed-in person; forgotten on sign-out and account deletion. A token moves to another login only on the same phone, and each move is recorded.",
|
|
25
|
+
"Keeps notification preferences per person: each category by channel, quiet hours with a time zone, and previews.",
|
|
26
|
+
"Keeps a short delivery log (status and an error code, no content) and, for addresses that bounced, complained or unsubscribed with no login, a hash of the address.",
|
|
27
|
+
"Erasing a person deletes their notifications, send jobs, devices and preferences, whether or not they have a login."
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export { notificationsBlock };
|
|
34
|
+
//# sourceMappingURL=blocks.js.map
|
|
35
|
+
//# sourceMappingURL=blocks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/blocks.ts"],"names":[],"mappings":";;;AAQA,IAAM,OAAA,GAAU,qCAAA;AAOT,IAAM,qBAAqB,WAAA,CAAY;AAAA,EAC5C,EAAA,EAAI,eAAA;AAAA,EACJ,SAAA,EAAW,CAAC,KAAA,EAAO,QAAA,EAAU,QAAQ,CAAA;AAAA,EACrC,WAAA,EAAa;AAAA,IACX,GAAA,EAAK,CAAC,EAAE,EAAA,EAAI,eAAA,EAAiB,KAAA,EAAO,eAAA,EAAiB,IAAA,EAAM,gBAAA,EAAkB,IAAA,EAAM,MAAA,EAAQ,KAAA,EAAO,IAAI,CAAA;AAAA,IACtG,QAAA,EAAU,CAAC,EAAE,EAAA,EAAI,0BAAA,EAA4B,OAAA,EAAS,eAAA,EAAiB,KAAA,EAAO,eAAA,EAAiB,IAAA,EAAM,yBAAA,EAA2B,MAAA,EAAQ,kCAAkC,CAAA;AAAA,IAC1K,MAAA,EAAQ;AAAA,MACN,EAAE,IAAA,EAAM,gBAAA,EAAkB,WAAA,EAAa,uBAAA,EAAyB,UAAU,IAAA,EAAK;AAAA,MAC/E,EAAE,IAAA,EAAM,yBAAA,EAA2B,WAAA,EAAa,wEAAA,EAA0E,UAAU,IAAA,EAAK;AAAA,MACzI,EAAE,IAAA,EAAM,sBAAA,EAAwB,WAAA,EAAa,4DAAA,EAA8D,UAAU,IAAA;AAAK,KAC5H;AAAA,IACA,UAAA,EAAY,CAAC,EAAE,OAAA,EAAS,SAAS,IAAA,EAAM,2CAAA,EAA6C,IAAA,EAAM,sDAAA,EAAwD,CAAA;AAAA,IAClJ,OAAA,EAAS;AAAA,MACP,QAAA,EAAU;AAAA,QACR,EAAE,IAAA,EAAM,WAAA,EAAa,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA,EAAM;AAAA,QACpF,EAAE,IAAA,EAAM,eAAA,EAAiB,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA;AAAM,OAC1F;AAAA,MACA,KAAA,EAAO;AAAA,QACL,+HAAA;AAAA,QACA,4LAAA;AAAA,QACA,kHAAA;AAAA,QACA,qKAAA;AAAA,QACA;AAAA;AACF;AACF;AAEJ,CAAC","file":"blocks.js","sourcesContent":["/**\n * The notifications block, as a `defineBlock` declaration: what `genesis add\n * notifications` installs (the migration and its pgTAP test), the screens it\n * needs routed, its nav and Settings rows, and what it means for the stores'\n * privacy forms. Node-safe: no React, no Supabase client.\n */\nimport { defineBlock } from \"@marktiderman/genesis-features/blocks\";\n\nconst PACKAGE = \"@marktiderman/genesis-notifications\";\n\n/**\n * Notifications: the feed, push and email for every domain, on one notifier.\n *\n * @stability Beta\n */\nexport const notificationsBlock = defineBlock({\n id: \"notifications\",\n platforms: [\"web\", \"native\", \"server\"],\n contributes: {\n nav: [{ id: \"notifications\", label: \"Notifications\", path: \"/notifications\", icon: \"bell\", order: 50 }],\n settings: [{ id: \"notification-preferences\", section: \"notifications\", title: \"Notifications\", href: \"/settings/notifications\", testID: \"genesis-notifications-settings\" }],\n routes: [\n { path: \"/notifications\", description: \"The notification feed\", signedIn: true },\n { path: \"/settings/notifications\", description: \"Notification preferences: categories by channel, quiet hours, previews\", signedIn: true },\n { path: \"/admin/notifications\", description: \"Admin: the delivery log, suppressed addresses, send a test\", signedIn: true },\n ],\n migrations: [{ package: PACKAGE, file: \"migrations/0001_genesis_notifications.sql\", test: \"migrations/tests/0001_genesis_notifications.test.sql\" }],\n privacy: {\n collects: [\n { type: \"device ID\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"email address\", purposes: [\"app functionality\"], linked: true, tracking: false },\n ],\n notes: [\n \"Keeps each person's notifications (what the app told them), read and archived, until they are pruned or the person is erased.\",\n \"Keeps this device's push token against the signed-in person; forgotten on sign-out and account deletion. A token moves to another login only on the same phone, and each move is recorded.\",\n \"Keeps notification preferences per person: each category by channel, quiet hours with a time zone, and previews.\",\n \"Keeps a short delivery log (status and an error code, no content) and, for addresses that bounced, complained or unsubscribed with no login, a hash of the address.\",\n \"Erasing a person deletes their notifications, send jobs, devices and preferences, whether or not they have a login.\",\n ],\n },\n },\n});\n"]}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { notificationsClient } from './chunk-EWZB35V6.js';
|
|
2
|
+
import { createContext, useMemo, useEffect, useContext } from 'react';
|
|
3
|
+
import { useQueryClient, useQuery, useMutation } from '@tanstack/react-query';
|
|
4
|
+
import { jsx } from 'react/jsx-runtime';
|
|
5
|
+
|
|
6
|
+
var NotificationsContext = createContext(null);
|
|
7
|
+
var isClient = (c) => typeof c.feed === "function";
|
|
8
|
+
var notificationKeys = {
|
|
9
|
+
all: (userId) => ["genesis-notifications", userId],
|
|
10
|
+
feed: (userId, filter, limit) => ["genesis-notifications", userId, "feed", filter, limit],
|
|
11
|
+
unread: (userId) => ["genesis-notifications", userId, "unread"],
|
|
12
|
+
choices: (userId) => ["genesis-notifications", userId, "choices"]
|
|
13
|
+
};
|
|
14
|
+
function NotificationsProvider({ client, registry, userId, realtime = true, children }) {
|
|
15
|
+
const resolved = useMemo(() => isClient(client) ? client : notificationsClient(client), [client]);
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!realtime || !userId) return;
|
|
19
|
+
try {
|
|
20
|
+
return resolved.subscribe(userId, () => {
|
|
21
|
+
void queryClient.invalidateQueries({ queryKey: notificationKeys.all(userId) });
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.warn(`[genesis-notifications] couldn't listen for feed changes: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
}, [resolved, userId, realtime, queryClient]);
|
|
28
|
+
const value = useMemo(() => ({ client: resolved, registry, userId }), [resolved, registry, userId]);
|
|
29
|
+
return /* @__PURE__ */ jsx(NotificationsContext.Provider, { value, children });
|
|
30
|
+
}
|
|
31
|
+
function useNotificationsContext() {
|
|
32
|
+
const ctx = useContext(NotificationsContext);
|
|
33
|
+
if (!ctx) throw new Error("useNotifications: wrap the screen in <NotificationsProvider>.");
|
|
34
|
+
return ctx;
|
|
35
|
+
}
|
|
36
|
+
function useNotifications(options = {}) {
|
|
37
|
+
const { client, registry, userId } = useNotificationsContext();
|
|
38
|
+
const filter = options.filter ?? "all";
|
|
39
|
+
const limit = options.limit ?? 50;
|
|
40
|
+
const queryClient = useQueryClient();
|
|
41
|
+
const query = useQuery({
|
|
42
|
+
queryKey: notificationKeys.feed(userId, filter, limit),
|
|
43
|
+
queryFn: () => client.feed({ filter, limit }),
|
|
44
|
+
enabled: userId !== null
|
|
45
|
+
});
|
|
46
|
+
const items = useMemo(
|
|
47
|
+
() => (query.data ?? []).map((row) => {
|
|
48
|
+
const text = registry.render(row.type, row.data ?? {});
|
|
49
|
+
return { ...row, ...text, unread: row.read_at === null };
|
|
50
|
+
}),
|
|
51
|
+
[query.data, registry]
|
|
52
|
+
);
|
|
53
|
+
const changed = () => queryClient.invalidateQueries({ queryKey: notificationKeys.all(userId) });
|
|
54
|
+
const mark = useMutation({
|
|
55
|
+
mutationFn: ({ ids, state }) => client.mark(ids, state),
|
|
56
|
+
onSettled: changed
|
|
57
|
+
});
|
|
58
|
+
const all = useMutation({ mutationFn: (state) => client.markAll(state), onSettled: changed });
|
|
59
|
+
const list = (ids) => typeof ids === "string" ? [ids] : ids;
|
|
60
|
+
return {
|
|
61
|
+
items,
|
|
62
|
+
isLoading: query.isLoading,
|
|
63
|
+
isRefetching: query.isRefetching,
|
|
64
|
+
error: query.error ?? null,
|
|
65
|
+
refetch: () => query.refetch(),
|
|
66
|
+
markRead: async (ids) => void await mark.mutateAsync({ ids: list(ids), state: "read" }),
|
|
67
|
+
markUnread: async (ids) => void await mark.mutateAsync({ ids: list(ids), state: "unread" }),
|
|
68
|
+
archive: async (ids) => void await mark.mutateAsync({ ids: list(ids), state: "archived" }),
|
|
69
|
+
markAllRead: async () => void await all.mutateAsync("read"),
|
|
70
|
+
markAllSeen: async () => void await all.mutateAsync("seen")
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function useUnreadCount() {
|
|
74
|
+
const { client, userId } = useNotificationsContext();
|
|
75
|
+
const query = useQuery({ queryKey: notificationKeys.unread(userId), queryFn: () => client.unreadCount(), enabled: userId !== null });
|
|
76
|
+
return query.data ?? 0;
|
|
77
|
+
}
|
|
78
|
+
function preferenceRows(registry, choices) {
|
|
79
|
+
return registry.matrix().map((row) => {
|
|
80
|
+
const chosen = choices[row.id] ?? {};
|
|
81
|
+
const values = {};
|
|
82
|
+
for (const ch of row.channels) values[ch] = row.critical ? Boolean(row.defaults[ch]) : chosen[ch] ?? Boolean(row.defaults[ch]);
|
|
83
|
+
return { ...row, values, chosen: row.critical ? {} : chosen };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function usePreferences() {
|
|
87
|
+
const { client, registry, userId } = useNotificationsContext();
|
|
88
|
+
const queryClient = useQueryClient();
|
|
89
|
+
const key = notificationKeys.choices(userId);
|
|
90
|
+
const query = useQuery({ queryKey: key, queryFn: () => client.choices(userId), enabled: userId !== null });
|
|
91
|
+
const save = useMutation({
|
|
92
|
+
mutationFn: ({ category, channel, on }) => client.setChoice(category, channel, on),
|
|
93
|
+
onMutate: async ({ category, channel, on }) => {
|
|
94
|
+
await queryClient.cancelQueries({ queryKey: key });
|
|
95
|
+
const before = queryClient.getQueryData(key);
|
|
96
|
+
const next = { ...before ?? {} };
|
|
97
|
+
const row = { ...next[category] ?? {} };
|
|
98
|
+
if (on === null) delete row[channel];
|
|
99
|
+
else row[channel] = on;
|
|
100
|
+
next[category] = row;
|
|
101
|
+
queryClient.setQueryData(key, next);
|
|
102
|
+
return { before };
|
|
103
|
+
},
|
|
104
|
+
onError: (_e, _v, context) => queryClient.setQueryData(key, context?.before),
|
|
105
|
+
// Read back through `choices()` (which folds in 0005's category switches), never the raw row the save returns.
|
|
106
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key })
|
|
107
|
+
});
|
|
108
|
+
const rows = useMemo(() => preferenceRows(registry, query.data ?? {}), [registry, query.data]);
|
|
109
|
+
return {
|
|
110
|
+
rows,
|
|
111
|
+
status: query.isError ? "error" : query.isSuccess ? "ready" : "loading",
|
|
112
|
+
error: query.error ?? save.error ?? null,
|
|
113
|
+
saving: save.isPending,
|
|
114
|
+
set: async (category, channel, on) => {
|
|
115
|
+
const row = rows.find((r) => r.id === category);
|
|
116
|
+
if (!row || !row.channels.includes(channel) || row.critical && on === false) return;
|
|
117
|
+
await save.mutateAsync({ category, channel, on });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { NotificationsProvider, notificationKeys, preferenceRows, useNotifications, useNotificationsContext, usePreferences, useUnreadCount };
|
|
123
|
+
//# sourceMappingURL=chunk-2BG336II.js.map
|
|
124
|
+
//# sourceMappingURL=chunk-2BG336II.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/index.tsx"],"names":[],"mappings":"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","file":"chunk-2BG336II.js","sourcesContent":["/**\n * `@marktiderman/genesis-notifications/react`: the hooks every screen uses,\n * on web and native alike. `NotificationsProvider` holds the client, the\n * registry and the signed-in user, and listens on the person's private\n * realtime channel: any change refetches (the broadcast carries ids only).\n *\n * useNotifications() the feed, each row rendered by the registry, plus mark read / archive\n * useUnreadCount() the unread count, for a bell or a tab badge\n * usePreferences() the category × channel matrix and its switches\n *\n * Built on TanStack Query (a peer): the app's QueryClientProvider must be above.\n *\n * @packageDocumentation\n */\nimport { createContext, useContext, useEffect, useMemo, type ReactNode } from \"react\";\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport type { Channel, ChannelChoices, MatrixRow, NotificationRegistry, NotificationRow } from \"../core/index\";\nimport { notificationsClient, type FeedFilter, type NotificationsClient, type NotificationsSupabaseLike } from \"../supabase/index\";\n\ninterface Context {\n client: NotificationsClient;\n registry: NotificationRegistry;\n userId: string | null;\n}\n\nconst NotificationsContext = createContext<Context | null>(null);\n\nexport interface NotificationsProviderProps {\n /** A Supabase client signed in as the person (or a `notificationsClient`). */\n client: NotificationsSupabaseLike | NotificationsClient;\n registry: NotificationRegistry;\n /** The signed-in user; null while signed out (nothing is read). */\n userId: string | null;\n /** Listen on `user:<id>` and refetch on a change. Default true. */\n realtime?: boolean;\n children?: ReactNode;\n}\n\nconst isClient = (c: NotificationsSupabaseLike | NotificationsClient): c is NotificationsClient => typeof (c as NotificationsClient).feed === \"function\";\n\n/** The query keys, for an app that invalidates them itself. */\nexport const notificationKeys = {\n all: (userId: string | null) => [\"genesis-notifications\", userId] as const,\n feed: (userId: string | null, filter: FeedFilter, limit: number) => [\"genesis-notifications\", userId, \"feed\", filter, limit] as const,\n unread: (userId: string | null) => [\"genesis-notifications\", userId, \"unread\"] as const,\n choices: (userId: string | null) => [\"genesis-notifications\", userId, \"choices\"] as const,\n};\n\n/**\n * Provides the notifier to the hooks.\n *\n * @stability Beta\n */\nexport function NotificationsProvider({ client, registry, userId, realtime = true, children }: NotificationsProviderProps) {\n const resolved = useMemo(() => (isClient(client) ? client : notificationsClient(client)), [client]);\n const queryClient = useQueryClient();\n useEffect(() => {\n if (!realtime || !userId) return;\n try {\n return resolved.subscribe(userId, () => {\n void queryClient.invalidateQueries({ queryKey: notificationKeys.all(userId) });\n });\n } catch (error) {\n console.warn(`[genesis-notifications] couldn't listen for feed changes: ${error instanceof Error ? error.message : \"unknown error\"}`);\n return undefined;\n }\n }, [resolved, userId, realtime, queryClient]);\n const value = useMemo(() => ({ client: resolved, registry, userId }), [resolved, registry, userId]);\n return <NotificationsContext.Provider value={value}>{children}</NotificationsContext.Provider>;\n}\n\n/** The provider's client, registry and user. Throws outside one. */\nexport function useNotificationsContext(): Context {\n const ctx = useContext(NotificationsContext);\n if (!ctx) throw new Error(\"useNotifications: wrap the screen in <NotificationsProvider>.\");\n return ctx;\n}\n\n/** A feed row with what the registry says about it. */\nexport interface FeedItem extends NotificationRow {\n title: string;\n body?: string;\n url?: string;\n unread: boolean;\n}\n\nexport interface UseNotificationsResult {\n items: FeedItem[];\n isLoading: boolean;\n isRefetching: boolean;\n error: Error | null;\n refetch: () => Promise<unknown>;\n markRead: (ids: string | readonly string[]) => Promise<void>;\n markUnread: (ids: string | readonly string[]) => Promise<void>;\n markAllRead: () => Promise<void>;\n archive: (ids: string | readonly string[]) => Promise<void>;\n /** Mark everything shown as seen (opening the feed); the unread count is about read, not seen. */\n markAllSeen: () => Promise<void>;\n}\n\n/**\n * The person's feed.\n *\n * @stability Beta\n */\nexport function useNotifications(options: { filter?: FeedFilter; limit?: number } = {}): UseNotificationsResult {\n const { client, registry, userId } = useNotificationsContext();\n const filter = options.filter ?? \"all\";\n const limit = options.limit ?? 50;\n const queryClient = useQueryClient();\n const query = useQuery({\n queryKey: notificationKeys.feed(userId, filter, limit),\n queryFn: () => client.feed({ filter, limit }),\n enabled: userId !== null,\n });\n const items = useMemo<FeedItem[]>(\n () =>\n (query.data ?? []).map((row) => {\n const text = registry.render(row.type, row.data ?? {});\n return { ...row, ...text, unread: row.read_at === null };\n }),\n [query.data, registry],\n );\n const changed = () => queryClient.invalidateQueries({ queryKey: notificationKeys.all(userId) });\n const mark = useMutation({\n mutationFn: ({ ids, state }: { ids: readonly string[]; state: \"seen\" | \"read\" | \"unread\" | \"archived\" }) => client.mark(ids, state),\n onSettled: changed,\n });\n const all = useMutation({ mutationFn: (state: \"seen\" | \"read\") => client.markAll(state), onSettled: changed });\n const list = (ids: string | readonly string[]) => (typeof ids === \"string\" ? [ids] : ids);\n return {\n items,\n isLoading: query.isLoading,\n isRefetching: query.isRefetching,\n error: (query.error as Error | null) ?? null,\n refetch: () => query.refetch(),\n markRead: async (ids) => void (await mark.mutateAsync({ ids: list(ids), state: \"read\" })),\n markUnread: async (ids) => void (await mark.mutateAsync({ ids: list(ids), state: \"unread\" })),\n archive: async (ids) => void (await mark.mutateAsync({ ids: list(ids), state: \"archived\" })),\n markAllRead: async () => void (await all.mutateAsync(\"read\")),\n markAllSeen: async () => void (await all.mutateAsync(\"seen\")),\n };\n}\n\n/**\n * The unread count (0 while signed out).\n *\n * @stability Beta\n */\nexport function useUnreadCount(): number {\n const { client, userId } = useNotificationsContext();\n const query = useQuery({ queryKey: notificationKeys.unread(userId), queryFn: () => client.unreadCount(), enabled: userId !== null });\n return query.data ?? 0;\n}\n\n/** One category's row in the matrix. */\nexport interface PreferenceRow extends MatrixRow {\n /** What each offered channel is now: the person's choice, else the default. */\n values: Partial<Record<Channel, boolean>>;\n /** Which values the person chose (the rest are defaults). */\n chosen: Partial<Record<Channel, boolean>>;\n}\n\nexport interface UsePreferencesResult {\n rows: PreferenceRow[];\n status: \"loading\" | \"ready\" | \"error\";\n error: Error | null;\n saving: boolean;\n /** Turn one category's channel on or off; `null` goes back to the default. Critical rows can't be turned off. */\n set: (category: string, channel: Channel, on: boolean | null) => Promise<void>;\n}\n\n/** The matrix with the person's choices applied. Exported for tests and custom screens. */\nexport function preferenceRows(registry: NotificationRegistry, choices: ChannelChoices): PreferenceRow[] {\n return registry.matrix().map((row) => {\n const chosen = choices[row.id] ?? {};\n const values: Partial<Record<Channel, boolean>> = {};\n for (const ch of row.channels) values[ch] = row.critical ? Boolean(row.defaults[ch]) : (chosen[ch] ?? Boolean(row.defaults[ch]));\n return { ...row, values, chosen: row.critical ? {} : chosen };\n });\n}\n\n/**\n * The person's preferences by category and channel. A tenant's default\n * isn't shown here (the server applies it); what shows for a channel the\n * person never chose is the type's default.\n *\n * @stability Beta\n */\nexport function usePreferences(): UsePreferencesResult {\n const { client, registry, userId } = useNotificationsContext();\n const queryClient = useQueryClient();\n const key = notificationKeys.choices(userId);\n const query = useQuery({ queryKey: key, queryFn: () => client.choices(userId!), enabled: userId !== null });\n const save = useMutation({\n mutationFn: ({ category, channel, on }: { category: string; channel: Channel; on: boolean | null }) => client.setChoice(category, channel, on),\n onMutate: async ({ category, channel, on }) => {\n await queryClient.cancelQueries({ queryKey: key });\n const before = queryClient.getQueryData<ChannelChoices>(key);\n const next: ChannelChoices = { ...(before ?? {}) };\n const row = { ...(next[category] ?? {}) };\n if (on === null) delete row[channel];\n else row[channel] = on;\n next[category] = row;\n queryClient.setQueryData(key, next);\n return { before };\n },\n onError: (_e, _v, context) => queryClient.setQueryData(key, context?.before),\n // Read back through `choices()` (which folds in 0005's category switches), never the raw row the save returns.\n onSettled: () => queryClient.invalidateQueries({ queryKey: key }),\n });\n const rows = useMemo(() => preferenceRows(registry, query.data ?? {}), [registry, query.data]);\n return {\n rows,\n status: query.isError ? \"error\" : query.isSuccess ? \"ready\" : \"loading\",\n error: ((query.error ?? save.error) as Error | null) ?? null,\n saving: save.isPending,\n set: async (category, channel, on) => {\n const row = rows.find((r) => r.id === category);\n if (!row || !row.channels.includes(channel) || (row.critical && on === false)) return;\n await save.mutateAsync({ category, channel, on });\n },\n };\n}\n\nexport { notificationsClient, type NotificationsClient } from \"../supabase/index\";\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/core/validate.ts
|
|
2
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
var EMAIL = /^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/;
|
|
4
|
+
var SECRET_KEY = /((token|code|secret|key|otp|password)|(^|[._-])(signature|sig|magic_?link|invite_?link|reset_?link))$/;
|
|
5
|
+
function keyWords(key) {
|
|
6
|
+
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
var SECRET_URL = /^([a-z][a-z0-9+.-]*:|\/|\.\.?\/|(?=[?#]))\S*[?&#;][^=&#;\s]*(token|code|secret|key|otp|password|signature|sig)=/i;
|
|
9
|
+
var SecretInDataError = class extends Error {
|
|
10
|
+
constructor(path) {
|
|
11
|
+
super(`notify: data.${path} looks like a secret. Store a reference and build the link at send time with resolveSecretLink.`);
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.name = "SecretInDataError";
|
|
14
|
+
}
|
|
15
|
+
path;
|
|
16
|
+
};
|
|
17
|
+
function assertNoSecrets(data, path = "") {
|
|
18
|
+
if (!data || typeof data !== "object") return;
|
|
19
|
+
for (const [key, value] of Object.entries(data)) {
|
|
20
|
+
const here = path ? `${path}.${key}` : key;
|
|
21
|
+
if (SECRET_KEY.test(keyWords(key))) throw new SecretInDataError(here);
|
|
22
|
+
if (typeof value === "string" && SECRET_URL.test(value)) throw new SecretInDataError(here);
|
|
23
|
+
if (value && typeof value === "object") assertNoSecrets(value, here);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function recipientsToJson(recipients) {
|
|
27
|
+
if (!Array.isArray(recipients) || recipients.length === 0) throw new Error("notify: no recipients");
|
|
28
|
+
if (recipients.length > 1e3) throw new Error("notify: at most 1000 recipients a call");
|
|
29
|
+
return recipients.map((r, i) => {
|
|
30
|
+
if (typeof r === "string") {
|
|
31
|
+
if (!UUID.test(r)) throw new Error(`notify: recipient ${i} is not a user id`);
|
|
32
|
+
return { user_id: r };
|
|
33
|
+
}
|
|
34
|
+
if ("userId" in r) {
|
|
35
|
+
if (!UUID.test(r.userId)) throw new Error(`notify: recipient ${i} is not a user id`);
|
|
36
|
+
if (r.badge === void 0) return { user_id: r.userId };
|
|
37
|
+
if (!Number.isInteger(r.badge) || r.badge < 0 || r.badge > 99999) throw new Error(`notify: recipient ${i}'s badge is a whole number from 0`);
|
|
38
|
+
return { user_id: r.userId, badge: r.badge };
|
|
39
|
+
}
|
|
40
|
+
if ("email" in r) {
|
|
41
|
+
const email = r.email.trim();
|
|
42
|
+
if (!EMAIL.test(email) || email.length > 254) throw new Error(`notify: recipient ${i} has no valid email address`);
|
|
43
|
+
return { email, ...r.name ? { name: r.name.slice(0, 200) } : {}, ...r.ref ? { ref: r.ref.slice(0, 200) } : {} };
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`notify: recipient ${i} is neither a user id nor an email`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { SecretInDataError, assertNoSecrets, recipientsToJson };
|
|
50
|
+
//# sourceMappingURL=chunk-7PHA3WVG.js.map
|
|
51
|
+
//# sourceMappingURL=chunk-7PHA3WVG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/validate.ts"],"names":[],"mappings":"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","file":"chunk-7PHA3WVG.js","sourcesContent":["/**\n * Checks `notify()` runs before anything reaches the database (which checks\n * the same again): recipients are well formed, and `data` holds no secret.\n */\nimport type { NotificationData } from \"./registry\";\nimport type { Recipient } from \"./types\";\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\nconst EMAIL = /^[^\\s@<>,;:\"()[\\]\\\\]+@[^\\s@<>,;:\"()[\\]\\\\]+\\.[^\\s@<>,;:\"()[\\]\\\\]+$/;\n\n/**\n * Keys that hold a secret by their name, matched on the key split into words\n * (`inviteToken` is `invite_token`): any key ending in token, code, secret,\n * key, otp or password, and a few more by word. The database refuses the same\n * list. A backstop, not a guarantee: it catches the usual names, so a display\n * value named that way (`country_code`) is refused too; name it otherwise.\n */\nexport const SECRET_KEY = /((token|code|secret|key|otp|password)|(^|[._-])(signature|sig|magic_?link|invite_?link|reset_?link))$/;\n\n/** A key as words, lower case: `inviteToken` → `invite_token`. */\nexport function keyWords(key: string): string {\n return key.replace(/([a-z0-9])([A-Z])/g, \"$1_$2\").toLowerCase();\n}\n/**\n * A link, absolute (`https://…`, `mailto:…`) or relative (`/…`, `./…`, `?…`,\n * `#…`), with a query or fragment parameter whose name ends in token, code,\n * secret, key, otp, password, sig or signature (`?token=`, `#access_token=`,\n * `&inviteCode=`).\n */\nexport const SECRET_URL = /^([a-z][a-z0-9+.-]*:|\\/|\\.\\.?\\/|(?=[?#]))\\S*[?&#;][^=&#;\\s]*(token|code|secret|key|otp|password|signature|sig)=/i;\n\n/** `data` held something that looks like a secret: build such links at send time instead. */\nexport class SecretInDataError extends Error {\n constructor(readonly path: string) {\n super(`notify: data.${path} looks like a secret. Store a reference and build the link at send time with resolveSecretLink.`);\n this.name = \"SecretInDataError\";\n }\n}\n\n/** Throws a `SecretInDataError` naming the first key (or link) that looks like a secret. */\nexport function assertNoSecrets(data: NotificationData | undefined, path = \"\"): void {\n if (!data || typeof data !== \"object\") return;\n for (const [key, value] of Object.entries(data)) {\n const here = path ? `${path}.${key}` : key;\n if (SECRET_KEY.test(keyWords(key))) throw new SecretInDataError(here);\n if (typeof value === \"string\" && SECRET_URL.test(value)) throw new SecretInDataError(here);\n if (value && typeof value === \"object\") assertNoSecrets(value as NotificationData, here);\n }\n}\n\n/** The recipients as `genesis_notify` takes them; throws on a malformed one. */\nexport function recipientsToJson(recipients: readonly Recipient[]): ({ user_id: string; badge?: number } | { email: string; name?: string; ref?: string })[] {\n if (!Array.isArray(recipients) || recipients.length === 0) throw new Error(\"notify: no recipients\");\n if (recipients.length > 1000) throw new Error(\"notify: at most 1000 recipients a call\");\n return recipients.map((r, i) => {\n if (typeof r === \"string\") {\n if (!UUID.test(r)) throw new Error(`notify: recipient ${i} is not a user id`);\n return { user_id: r };\n }\n if (\"userId\" in r) {\n if (!UUID.test(r.userId)) throw new Error(`notify: recipient ${i} is not a user id`);\n if (r.badge === undefined) return { user_id: r.userId };\n if (!Number.isInteger(r.badge) || r.badge < 0 || r.badge > 99999) throw new Error(`notify: recipient ${i}'s badge is a whole number from 0`);\n return { user_id: r.userId, badge: r.badge };\n }\n if (\"email\" in r) {\n const email = r.email.trim();\n if (!EMAIL.test(email) || email.length > 254) throw new Error(`notify: recipient ${i} has no valid email address`);\n return { email, ...(r.name ? { name: r.name.slice(0, 200) } : {}), ...(r.ref ? { ref: r.ref.slice(0, 200) } : {}) };\n }\n throw new Error(`notify: recipient ${i} is neither a user id nor an email`);\n });\n}\n"]}
|