@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/package.json
CHANGED
|
@@ -1,6 +1,194 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marktiderman/genesis-notifications",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"description": "One notifier for Genesis apps: a domain (chat, forms, sites, support) says who and what; this decides how and when. defineNotifications registers types (category, channels, critical, transactional, device action categories, Android channels); notify() queues them through a security-definer SQL function; the in-app feed is also the outbox; one runner (claim with SKIP LOCKED, retries with jittered backoff, deferrals) sends Expo push (batches of 100, receipts, dead tokens pruned, private previews) and Resend email (rendered once for byte-identical retries, tenant sender identities, RFC 8058 one-click unsubscribe, bounce and complaint suppression); preferences by category and channel with tenant defaults and quiet hours across daylight saving; secret links built at send time; erasure. Hooks for React and React Native, a web bell, feed, preferences matrix and admin views on genesis-ui, a native feed on genesis-ui-native. Part of the Genesis framework.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/marktiderman/genesis.git",
|
|
11
|
+
"directory": "packages/notifications"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"provenance": false
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"source": "./src/index.ts",
|
|
22
|
+
"react-native": "./src/index.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"react-native": "./src/index.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./core": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"react-native": "./src/index.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./runner": {
|
|
36
|
+
"types": "./dist/runner.d.ts",
|
|
37
|
+
"react-native": "./src/runner/index.ts",
|
|
38
|
+
"import": "./dist/runner.js"
|
|
39
|
+
},
|
|
40
|
+
"./server": {
|
|
41
|
+
"types": "./dist/server.d.ts",
|
|
42
|
+
"import": "./dist/server.js"
|
|
43
|
+
},
|
|
44
|
+
"./email": {
|
|
45
|
+
"types": "./dist/email.d.ts",
|
|
46
|
+
"import": "./dist/email.js"
|
|
47
|
+
},
|
|
48
|
+
"./supabase": {
|
|
49
|
+
"types": "./dist/supabase.d.ts",
|
|
50
|
+
"react-native": "./src/supabase/index.ts",
|
|
51
|
+
"import": "./dist/supabase.js"
|
|
52
|
+
},
|
|
53
|
+
"./react": {
|
|
54
|
+
"types": "./dist/react.d.ts",
|
|
55
|
+
"react-native": "./src/react/index.tsx",
|
|
56
|
+
"import": "./dist/react.js"
|
|
57
|
+
},
|
|
58
|
+
"./web": {
|
|
59
|
+
"types": "./dist/web.d.ts",
|
|
60
|
+
"import": "./dist/web.js"
|
|
61
|
+
},
|
|
62
|
+
"./native": {
|
|
63
|
+
"types": "./dist/native.d.ts",
|
|
64
|
+
"react-native": "./src/native/index.tsx",
|
|
65
|
+
"import": "./dist/native.js"
|
|
66
|
+
},
|
|
67
|
+
"./blocks": {
|
|
68
|
+
"types": "./dist/blocks.d.ts",
|
|
69
|
+
"import": "./dist/blocks.js"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"files": [
|
|
73
|
+
"dist",
|
|
74
|
+
"src",
|
|
75
|
+
"!src/**/__tests__",
|
|
76
|
+
"migrations",
|
|
77
|
+
"functions",
|
|
78
|
+
"README.md"
|
|
79
|
+
],
|
|
80
|
+
"dependencies": {
|
|
81
|
+
"@marktiderman/genesis-features": "^0.3.0"
|
|
82
|
+
},
|
|
83
|
+
"peerDependencies": {
|
|
84
|
+
"@marktiderman/genesis-native": ">=0.2.0 <1.0.0",
|
|
85
|
+
"@marktiderman/genesis-ui": "^2.13.0",
|
|
86
|
+
"@marktiderman/genesis-ui-native": "^2.4.0",
|
|
87
|
+
"@react-email/components": "^1.0.0",
|
|
88
|
+
"@tanstack/react-query": "^5.0.0",
|
|
89
|
+
"lucide-react": "^1.0.0",
|
|
90
|
+
"react": "^19.0.0"
|
|
91
|
+
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"@marktiderman/genesis-native": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"@marktiderman/genesis-ui": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"@marktiderman/genesis-ui-native": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"@react-email/components": {
|
|
103
|
+
"optional": true
|
|
104
|
+
},
|
|
105
|
+
"@tanstack/react-query": {
|
|
106
|
+
"optional": true
|
|
107
|
+
},
|
|
108
|
+
"lucide-react": {
|
|
109
|
+
"optional": true
|
|
110
|
+
},
|
|
111
|
+
"react": {
|
|
112
|
+
"optional": true
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"devDependencies": {
|
|
116
|
+
"@marktiderman/genesis-native": "^0.4.0",
|
|
117
|
+
"@marktiderman/genesis-ui": "^2.13.0",
|
|
118
|
+
"@marktiderman/genesis-ui-native": "^2.4.0",
|
|
119
|
+
"@react-email/components": "1.0.12",
|
|
120
|
+
"@tanstack/react-query": "^5.75.0",
|
|
121
|
+
"@testing-library/react": "^16.3.2",
|
|
122
|
+
"@types/node": "^22",
|
|
123
|
+
"@types/react": "^19.2.14",
|
|
124
|
+
"@types/react-dom": "^19.2.3",
|
|
125
|
+
"happy-dom": "^20.9.0",
|
|
126
|
+
"lucide-react": "^1.0.0",
|
|
127
|
+
"react": "^19.2.5",
|
|
128
|
+
"react-dom": "^19.2.5",
|
|
129
|
+
"tsup": "^8.3.0",
|
|
130
|
+
"typescript": "~6.0.3",
|
|
131
|
+
"vitest": "^4.1.12"
|
|
132
|
+
},
|
|
133
|
+
"genesis": {
|
|
134
|
+
"blocks": {
|
|
135
|
+
"notifications": {
|
|
136
|
+
"feature": {
|
|
137
|
+
"name": "Notifications",
|
|
138
|
+
"summary": "One notifier for every domain: the in-app feed, Expo push and Resend email, with preferences by category and channel, quiet hours, retries, receipts and suppression.",
|
|
139
|
+
"keywords": [
|
|
140
|
+
"notifications",
|
|
141
|
+
"push",
|
|
142
|
+
"email",
|
|
143
|
+
"inbox",
|
|
144
|
+
"preferences",
|
|
145
|
+
"unsubscribe"
|
|
146
|
+
],
|
|
147
|
+
"audience": [
|
|
148
|
+
"person",
|
|
149
|
+
"admin"
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
"platforms": [
|
|
153
|
+
"web",
|
|
154
|
+
"native",
|
|
155
|
+
"server"
|
|
156
|
+
],
|
|
157
|
+
"migrations": [
|
|
158
|
+
{
|
|
159
|
+
"file": "migrations/0001_genesis_notifications.sql",
|
|
160
|
+
"test": "migrations/tests/0001_genesis_notifications.test.sql"
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
"functions": [
|
|
164
|
+
{
|
|
165
|
+
"dir": "functions/genesis-notifications",
|
|
166
|
+
"verifyJwt": false,
|
|
167
|
+
"secrets": [
|
|
168
|
+
"GENESIS_NOTIFICATIONS_CRON_SECRET",
|
|
169
|
+
"GENESIS_NOTIFICATIONS_UNSUBSCRIBE_SECRET",
|
|
170
|
+
"RESEND_API_KEY",
|
|
171
|
+
"RESEND_WEBHOOK_SECRET",
|
|
172
|
+
"EXPO_ACCESS_TOKEN"
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
"install": [
|
|
177
|
+
"@tanstack/react-query@^5.0.0"
|
|
178
|
+
],
|
|
179
|
+
"steps": [
|
|
180
|
+
"Mirror your registry on deploy: syncNotificationTypes(adminClient, notifications) from @marktiderman/genesis-notifications/server.",
|
|
181
|
+
"Say who administers notifications: replace private.genesis_notifications_admin(uuid, uuid) in a migration of your own, after this one (genesis-notifications README, Admins).",
|
|
182
|
+
"Deploy functions/genesis-notifications and schedule POST …/run every minute and POST …/receipts every 15 minutes, with GENESIS_NOTIFICATIONS_CRON_SECRET as the bearer token; point Resend's webhook at …/webhooks/resend.",
|
|
183
|
+
"Coming from genesis-supabase's push migration (0005): stop calling genesis-push-send, and remove its pgTAP test (tests/database/*genesis_push.test.sql): two of its assertions describe the token takeover this migration closes."
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"scripts": {
|
|
189
|
+
"build": "tsup && node ../../scripts/check-exports-resolve.mjs",
|
|
190
|
+
"clean": "rm -rf dist",
|
|
191
|
+
"test": "vitest run",
|
|
192
|
+
"typecheck": "tsc --noEmit"
|
|
193
|
+
}
|
|
6
194
|
}
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notifications block, as a `defineBlock` declaration: what `genesis add
|
|
3
|
+
* notifications` installs (the migration and its pgTAP test), the screens it
|
|
4
|
+
* needs routed, its nav and Settings rows, and what it means for the stores'
|
|
5
|
+
* privacy forms. Node-safe: no React, no Supabase client.
|
|
6
|
+
*/
|
|
7
|
+
import { defineBlock } from "@marktiderman/genesis-features/blocks";
|
|
8
|
+
|
|
9
|
+
const PACKAGE = "@marktiderman/genesis-notifications";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Notifications: the feed, push and email for every domain, on one notifier.
|
|
13
|
+
*
|
|
14
|
+
* @stability Beta
|
|
15
|
+
*/
|
|
16
|
+
export const notificationsBlock = defineBlock({
|
|
17
|
+
id: "notifications",
|
|
18
|
+
platforms: ["web", "native", "server"],
|
|
19
|
+
contributes: {
|
|
20
|
+
nav: [{ id: "notifications", label: "Notifications", path: "/notifications", icon: "bell", order: 50 }],
|
|
21
|
+
settings: [{ id: "notification-preferences", section: "notifications", title: "Notifications", href: "/settings/notifications", testID: "genesis-notifications-settings" }],
|
|
22
|
+
routes: [
|
|
23
|
+
{ path: "/notifications", description: "The notification feed", signedIn: true },
|
|
24
|
+
{ path: "/settings/notifications", description: "Notification preferences: categories by channel, quiet hours, previews", signedIn: true },
|
|
25
|
+
{ path: "/admin/notifications", description: "Admin: the delivery log, suppressed addresses, send a test", signedIn: true },
|
|
26
|
+
],
|
|
27
|
+
migrations: [{ package: PACKAGE, file: "migrations/0001_genesis_notifications.sql", test: "migrations/tests/0001_genesis_notifications.test.sql" }],
|
|
28
|
+
privacy: {
|
|
29
|
+
collects: [
|
|
30
|
+
{ type: "device ID", purposes: ["app functionality"], linked: true, tracking: false },
|
|
31
|
+
{ type: "email address", purposes: ["app functionality"], linked: true, tracking: false },
|
|
32
|
+
],
|
|
33
|
+
notes: [
|
|
34
|
+
"Keeps each person's notifications (what the app told them), read and archived, until they are pruned or the person is erased.",
|
|
35
|
+
"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.",
|
|
36
|
+
"Keeps notification preferences per person: each category by channel, quiet hours with a time zone, and previews.",
|
|
37
|
+
"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.",
|
|
38
|
+
"Erasing a person deletes their notifications, send jobs, devices and preferences, whether or not they have a login.",
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-notifications/core` (also the package root): the
|
|
3
|
+
* registry (`defineNotifications`) and the types every part shares. No
|
|
4
|
+
* dependencies; runs in the browser, React Native, Node and edge runtimes.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
CHANNELS,
|
|
10
|
+
IOS_ID_PATTERN,
|
|
11
|
+
KEY_PATTERN,
|
|
12
|
+
MAX_EMAIL_DELAY_SECONDS,
|
|
13
|
+
MESSAGE_ACTIONS,
|
|
14
|
+
GENESIS_TEST_CATEGORY,
|
|
15
|
+
GENESIS_TEST_TYPE,
|
|
16
|
+
NotificationRegistryError,
|
|
17
|
+
defineNotifications,
|
|
18
|
+
type Channel,
|
|
19
|
+
type ChannelImportance,
|
|
20
|
+
type MatrixRow,
|
|
21
|
+
type NotificationActionDef,
|
|
22
|
+
type NotificationActionSetDef,
|
|
23
|
+
type NotificationCategoryDef,
|
|
24
|
+
type NotificationData,
|
|
25
|
+
type NotificationRegistry,
|
|
26
|
+
type NotificationTypeDef,
|
|
27
|
+
type NotificationTypeRow,
|
|
28
|
+
type NotificationsInput,
|
|
29
|
+
type RenderedText,
|
|
30
|
+
} from "./registry";
|
|
31
|
+
export {
|
|
32
|
+
DELIVERY_STATUSES,
|
|
33
|
+
type ChannelChoices,
|
|
34
|
+
type DeliveryStatus,
|
|
35
|
+
type EmailRecipient,
|
|
36
|
+
type NotificationBrand,
|
|
37
|
+
type NotificationRow,
|
|
38
|
+
type NotifyInput,
|
|
39
|
+
type NotifyResult,
|
|
40
|
+
type Recipient,
|
|
41
|
+
type UserRecipient,
|
|
42
|
+
} from "./types";
|
|
43
|
+
export { recipientsToJson, assertNoSecrets, SecretInDataError } from "./validate";
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineNotifications`: the one list of what an app notifies people about,
|
|
3
|
+
* as `defineFeatures` is the list of what it does. Each type names its
|
|
4
|
+
* category, the channels it may use (and which are on by default), and two
|
|
5
|
+
* flags: `critical` (preferences can't turn it off) and `transactional` (no
|
|
6
|
+
* unsubscribe link, no quiet hours, no digest). The registry also defines the
|
|
7
|
+
* device notification categories (iOS action buttons) and the Android
|
|
8
|
+
* channels the app makes on the phone.
|
|
9
|
+
*
|
|
10
|
+
* It is plain data plus small render functions, so the browser, the phone
|
|
11
|
+
* and the server import the same list. `toTypeRows()` is what is mirrored
|
|
12
|
+
* into the database's `notification_types`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Where a notification can go. `in_app` is the feed. */
|
|
16
|
+
export const CHANNELS = ["in_app", "push", "email"] as const;
|
|
17
|
+
export type Channel = (typeof CHANNELS)[number];
|
|
18
|
+
|
|
19
|
+
/** Lower case letters, digits, ".", "_" or "-", up to 64 characters: type keys, category ids. */
|
|
20
|
+
export const KEY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
21
|
+
/** Device category and action ids: Expo asks for no ":" or "-". */
|
|
22
|
+
export const IOS_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
|
|
23
|
+
|
|
24
|
+
/** Android channel importance (Expo's `AndroidImportance`). */
|
|
25
|
+
export type ChannelImportance = "min" | "low" | "default" | "high" | "max";
|
|
26
|
+
|
|
27
|
+
export interface NotificationCategoryDef {
|
|
28
|
+
/** What a person sees in their preferences ("Messages"). */
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
/** Preferences can't turn this category off (account security, say). */
|
|
32
|
+
critical?: boolean;
|
|
33
|
+
/** The Android channel made for it (its id is the category id). Default importance `"default"`. */
|
|
34
|
+
android?: { importance?: ChannelImportance; name?: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An action button on a notification. */
|
|
38
|
+
export interface NotificationActionDef {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
/** A reply field instead of a plain button. */
|
|
42
|
+
textInput?: { placeholder?: string; submitTitle?: string };
|
|
43
|
+
/** Opens the app (default false: the action is handled in the background). */
|
|
44
|
+
opensApp?: boolean;
|
|
45
|
+
destructive?: boolean;
|
|
46
|
+
/** Needs the phone unlocked. */
|
|
47
|
+
requiresAuth?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A notification category on the device (iOS's word): the actions a notification shows. */
|
|
51
|
+
export interface NotificationActionSetDef {
|
|
52
|
+
actions: readonly NotificationActionDef[];
|
|
53
|
+
/** Shown when the person has hidden previews for the app. */
|
|
54
|
+
previewPlaceholder?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a notification says, made from its `data`. */
|
|
58
|
+
export interface RenderedText {
|
|
59
|
+
title: string;
|
|
60
|
+
body?: string;
|
|
61
|
+
/** The app path a tap opens (`/messages/42`). */
|
|
62
|
+
url?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Push-only extras, for a type whose app reads more than the link: `ids` always travel
|
|
65
|
+
* (a Reply action needs the conversation and message), `display` only when previews are
|
|
66
|
+
* shown (a sender's name and photo for iOS rich push), and `mutableContent` lets the app's
|
|
67
|
+
* Notification Service Extension run (dropped with hidden previews). Flat values only; the
|
|
68
|
+
* notifier's own keys (`notification_id`, `type`, `url`, `thread_key`) can't be replaced.
|
|
69
|
+
*/
|
|
70
|
+
push?: {
|
|
71
|
+
ids?: Record<string, string | number>;
|
|
72
|
+
display?: Record<string, string | number>;
|
|
73
|
+
mutableContent?: boolean;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A notification's `data`: references and display values, never a secret. */
|
|
78
|
+
export type NotificationData = Record<string, unknown>;
|
|
79
|
+
|
|
80
|
+
export interface NotificationTypeDef<C extends string = string, A extends string = string> {
|
|
81
|
+
category: C;
|
|
82
|
+
/**
|
|
83
|
+
* The channels this type may use, and whether each is on for someone who
|
|
84
|
+
* never chose: `{ in_app: true, push: true, email: false }` offers email
|
|
85
|
+
* as an opt-in. A channel left out is never used.
|
|
86
|
+
*/
|
|
87
|
+
channels: Partial<Record<Channel, boolean>>;
|
|
88
|
+
/** Preferences can't turn it off: each channel it offers stays as defaulted. */
|
|
89
|
+
critical?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Mail the person needs for something they did (a receipt, a reset): no
|
|
92
|
+
* unsubscribe link, never held by quiet hours or folded into a digest.
|
|
93
|
+
* Rate caps are the domain's.
|
|
94
|
+
*/
|
|
95
|
+
transactional?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Wait this long before emailing, and skip the email if the person read
|
|
98
|
+
* the notification in the app meanwhile. Default 0 (send at once).
|
|
99
|
+
*/
|
|
100
|
+
emailDelaySeconds?: number;
|
|
101
|
+
/** The device category (a key of `actions`) its pushes carry. */
|
|
102
|
+
actions?: A;
|
|
103
|
+
/** `"generic"` always sends neutral push text, whatever the person chose. */
|
|
104
|
+
pushPrivacy?: "default" | "generic";
|
|
105
|
+
/**
|
|
106
|
+
* The email holds a secret link (a one-time token). It is never stored in
|
|
107
|
+
* `data` or pushed: the server builds it at send time with
|
|
108
|
+
* `resolveSecretLink(type, ref)`.
|
|
109
|
+
*/
|
|
110
|
+
secretLink?: boolean;
|
|
111
|
+
/** What the feed and the push say. */
|
|
112
|
+
render: (data: NotificationData) => RenderedText;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface NotificationsInput<
|
|
116
|
+
C extends Record<string, NotificationCategoryDef>,
|
|
117
|
+
A extends Record<string, NotificationActionSetDef>,
|
|
118
|
+
T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>,
|
|
119
|
+
> {
|
|
120
|
+
categories: C;
|
|
121
|
+
actions?: A;
|
|
122
|
+
types: T;
|
|
123
|
+
/** Neutral text for hidden previews. Default "New notification". */
|
|
124
|
+
hiddenPreview?: { title: string; body?: string };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One row of `notification_types`, as `genesis_notifications_sync_types` takes it. */
|
|
128
|
+
export interface NotificationTypeRow {
|
|
129
|
+
key: string;
|
|
130
|
+
category: string;
|
|
131
|
+
channels: Partial<Record<Channel, boolean>>;
|
|
132
|
+
critical: boolean;
|
|
133
|
+
transactional: boolean;
|
|
134
|
+
email_delay_seconds: number;
|
|
135
|
+
secret_link: boolean;
|
|
136
|
+
push_privacy: "default" | "generic";
|
|
137
|
+
ios_category: string | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface MatrixRow {
|
|
141
|
+
id: string;
|
|
142
|
+
label: string;
|
|
143
|
+
description?: string;
|
|
144
|
+
critical: boolean;
|
|
145
|
+
/** The channels some type in the category offers, in `CHANNELS` order. */
|
|
146
|
+
channels: Channel[];
|
|
147
|
+
/** Each offered channel's default: on when any type in the category has it on. */
|
|
148
|
+
defaults: Partial<Record<Channel, boolean>>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface NotificationRegistry<
|
|
152
|
+
C extends Record<string, NotificationCategoryDef> = Record<string, NotificationCategoryDef>,
|
|
153
|
+
A extends Record<string, NotificationActionSetDef> = Record<string, NotificationActionSetDef>,
|
|
154
|
+
T extends Record<string, NotificationTypeDef> = Record<string, NotificationTypeDef>,
|
|
155
|
+
> {
|
|
156
|
+
readonly categories: C;
|
|
157
|
+
readonly actions: A;
|
|
158
|
+
readonly types: T;
|
|
159
|
+
readonly hiddenPreview: { title: string; body?: string };
|
|
160
|
+
/** The type, or throws: an unknown key is a bug, never dropped quietly. */
|
|
161
|
+
type(key: string): NotificationTypeDef;
|
|
162
|
+
has(key: string): boolean;
|
|
163
|
+
/** Preferences can't turn it off: a critical type, or a type in a critical category. */
|
|
164
|
+
isCritical(key: string): boolean;
|
|
165
|
+
/** What the feed and the push say. A render that throws gives the type's key as the title. */
|
|
166
|
+
render(key: string, data: NotificationData): RenderedText;
|
|
167
|
+
/** The rows mirrored into `notification_types`. */
|
|
168
|
+
toTypeRows(): NotificationTypeRow[];
|
|
169
|
+
/** For a preferences matrix: each category with the channels its types offer. */
|
|
170
|
+
matrix(): MatrixRow[];
|
|
171
|
+
/** The device categories to register (`setNotificationCategoryAsync`). */
|
|
172
|
+
deviceCategories(): { identifier: string; actions: NotificationActionDef[]; previewPlaceholder?: string }[];
|
|
173
|
+
/** The Android channels, one per category. */
|
|
174
|
+
androidChannels(): { id: string; name: string; description?: string; importance: ChannelImportance }[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Thrown by `defineNotifications`; lists every problem at once. */
|
|
178
|
+
export class NotificationRegistryError extends Error {
|
|
179
|
+
constructor(readonly problems: string[]) {
|
|
180
|
+
super(`Invalid notifications:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
181
|
+
this.name = "NotificationRegistryError";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The longest email delay: a week. */
|
|
186
|
+
export const MAX_EMAIL_DELAY_SECONDS = 7 * 24 * 3600;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The notification registry.
|
|
190
|
+
*
|
|
191
|
+
* ```ts
|
|
192
|
+
* export const notifications = defineNotifications({
|
|
193
|
+
* categories: { messages: { label: "Messages", android: { importance: "high" } } },
|
|
194
|
+
* actions: { message: { actions: [{ id: "reply", title: "Reply", textInput: {} }, { id: "mark_read", title: "Mark as read" }] } },
|
|
195
|
+
* types: {
|
|
196
|
+
* "chat.message": {
|
|
197
|
+
* category: "messages",
|
|
198
|
+
* channels: { in_app: true, push: true, email: true },
|
|
199
|
+
* emailDelaySeconds: 600,
|
|
200
|
+
* actions: "message",
|
|
201
|
+
* render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),
|
|
202
|
+
* },
|
|
203
|
+
* },
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*
|
|
207
|
+
* @stability Beta
|
|
208
|
+
*/
|
|
209
|
+
export function defineNotifications<
|
|
210
|
+
const C extends Record<string, NotificationCategoryDef>,
|
|
211
|
+
const A extends Record<string, NotificationActionSetDef>,
|
|
212
|
+
const T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>,
|
|
213
|
+
>(input: NotificationsInput<C, A, T>): NotificationRegistry<C, A, T> {
|
|
214
|
+
const problems: string[] = [];
|
|
215
|
+
const categories = input.categories as C & Record<string, NotificationCategoryDef>;
|
|
216
|
+
const actions = (input.actions ?? {}) as A & Record<string, NotificationActionSetDef>;
|
|
217
|
+
const types = input.types as T & Record<string, NotificationTypeDef>;
|
|
218
|
+
const own = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
|
|
219
|
+
|
|
220
|
+
for (const [id, c] of Object.entries(categories)) {
|
|
221
|
+
if (!KEY_PATTERN.test(id)) problems.push(`category "${id}": an id is lower case letters, digits, ".", "_" or "-"`);
|
|
222
|
+
if (!c.label?.trim()) problems.push(`category "${id}": needs a label`);
|
|
223
|
+
}
|
|
224
|
+
for (const [id, set] of Object.entries(actions)) {
|
|
225
|
+
if (!IOS_ID_PATTERN.test(id)) problems.push(`actions "${id}": a device category id is letters, digits or "_" (no ":" or "-")`);
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
for (const a of set.actions) {
|
|
228
|
+
if (!IOS_ID_PATTERN.test(a.id)) problems.push(`actions "${id}": action "${a.id}" is letters, digits or "_"`);
|
|
229
|
+
if (seen.has(a.id)) problems.push(`actions "${id}": action "${a.id}" appears twice`);
|
|
230
|
+
seen.add(a.id);
|
|
231
|
+
}
|
|
232
|
+
if (set.actions.length === 0 || set.actions.length > 4) problems.push(`actions "${id}": one to four actions`);
|
|
233
|
+
}
|
|
234
|
+
for (const [key, t] of Object.entries(types)) {
|
|
235
|
+
if (!KEY_PATTERN.test(key)) problems.push(`type "${key}": a key is lower case letters, digits, ".", "_" or "-"`);
|
|
236
|
+
if (!own(categories, t.category)) problems.push(`type "${key}": unknown category "${String(t.category)}"`);
|
|
237
|
+
const offered = Object.keys(t.channels ?? {});
|
|
238
|
+
if (offered.length === 0) problems.push(`type "${key}": offers no channel`);
|
|
239
|
+
for (const ch of offered) if (!(CHANNELS as readonly string[]).includes(ch)) problems.push(`type "${key}": unknown channel "${ch}"`);
|
|
240
|
+
if (t.actions !== undefined && !own(actions, t.actions)) problems.push(`type "${key}": unknown actions "${String(t.actions)}"`);
|
|
241
|
+
const delay = t.emailDelaySeconds ?? 0;
|
|
242
|
+
if (!Number.isInteger(delay) || delay < 0 || delay > MAX_EMAIL_DELAY_SECONDS) {
|
|
243
|
+
problems.push(`type "${key}": emailDelaySeconds is a whole number from 0 to ${MAX_EMAIL_DELAY_SECONDS}`);
|
|
244
|
+
}
|
|
245
|
+
if (delay > 0 && t.channels?.email === undefined) problems.push(`type "${key}": emailDelaySeconds needs the email channel`);
|
|
246
|
+
if (delay > 0 && t.channels?.in_app === undefined) problems.push(`type "${key}": an email delay skips the mail once read in the app, so it needs the in_app channel`);
|
|
247
|
+
if (t.secretLink && t.channels?.email === undefined) problems.push(`type "${key}": secretLink is for email, and it offers no email channel`);
|
|
248
|
+
if (typeof t.render !== "function") problems.push(`type "${key}": needs a render function`);
|
|
249
|
+
}
|
|
250
|
+
if (problems.length) throw new NotificationRegistryError(problems);
|
|
251
|
+
|
|
252
|
+
const hiddenPreview = input.hiddenPreview ?? { title: "New notification" };
|
|
253
|
+
const typeOf = (key: string): NotificationTypeDef => {
|
|
254
|
+
if (!own(types, key)) throw new NotificationRegistryError([`unknown notification type "${key}"`]);
|
|
255
|
+
return types[key]!;
|
|
256
|
+
};
|
|
257
|
+
const isCritical = (key: string) => {
|
|
258
|
+
const t = typeOf(key);
|
|
259
|
+
return Boolean(t.critical || categories[t.category]?.critical);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
categories,
|
|
264
|
+
actions,
|
|
265
|
+
types,
|
|
266
|
+
hiddenPreview,
|
|
267
|
+
type: typeOf,
|
|
268
|
+
has: (key) => own(types, key),
|
|
269
|
+
isCritical,
|
|
270
|
+
render(key, data) {
|
|
271
|
+
try {
|
|
272
|
+
const out = typeOf(key).render(data ?? {});
|
|
273
|
+
return {
|
|
274
|
+
title: typeof out?.title === "string" && out.title ? out.title : key,
|
|
275
|
+
...(typeof out?.body === "string" ? { body: out.body } : {}),
|
|
276
|
+
...(typeof out?.url === "string" && out.url ? { url: out.url } : {}),
|
|
277
|
+
...(out?.push && typeof out.push === "object" ? { push: out.push } : {}),
|
|
278
|
+
};
|
|
279
|
+
} catch {
|
|
280
|
+
return { title: key };
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
toTypeRows() {
|
|
284
|
+
return Object.entries(types).map(([key, t]) => ({
|
|
285
|
+
key,
|
|
286
|
+
category: t.category,
|
|
287
|
+
channels: { ...t.channels },
|
|
288
|
+
critical: isCritical(key),
|
|
289
|
+
transactional: Boolean(t.transactional),
|
|
290
|
+
email_delay_seconds: t.emailDelaySeconds ?? 0,
|
|
291
|
+
secret_link: Boolean(t.secretLink),
|
|
292
|
+
push_privacy: t.pushPrivacy ?? "default",
|
|
293
|
+
ios_category: t.actions ?? null,
|
|
294
|
+
}));
|
|
295
|
+
},
|
|
296
|
+
matrix() {
|
|
297
|
+
const rows: MatrixRow[] = [];
|
|
298
|
+
for (const [id, c] of Object.entries(categories)) {
|
|
299
|
+
const defaults: Partial<Record<Channel, boolean>> = {};
|
|
300
|
+
let critical = Boolean(c.critical);
|
|
301
|
+
for (const [key, t] of Object.entries(types)) {
|
|
302
|
+
if (t.category !== id) continue;
|
|
303
|
+
for (const [ch, on] of Object.entries(t.channels) as [Channel, boolean][]) defaults[ch] = Boolean(defaults[ch] || on);
|
|
304
|
+
if (isCritical(key)) critical = true;
|
|
305
|
+
}
|
|
306
|
+
const channels = CHANNELS.filter((ch) => ch in defaults);
|
|
307
|
+
if (channels.length) rows.push({ id, label: c.label, ...(c.description ? { description: c.description } : {}), critical, channels, defaults });
|
|
308
|
+
}
|
|
309
|
+
return rows;
|
|
310
|
+
},
|
|
311
|
+
deviceCategories() {
|
|
312
|
+
return Object.entries(actions).map(([identifier, set]) => ({
|
|
313
|
+
identifier,
|
|
314
|
+
actions: [...set.actions],
|
|
315
|
+
...(set.previewPlaceholder ? { previewPlaceholder: set.previewPlaceholder } : {}),
|
|
316
|
+
}));
|
|
317
|
+
},
|
|
318
|
+
androidChannels() {
|
|
319
|
+
return Object.entries(categories).map(([id, c]) => ({
|
|
320
|
+
id,
|
|
321
|
+
name: c.android?.name ?? c.label,
|
|
322
|
+
...(c.description ? { description: c.description } : {}),
|
|
323
|
+
importance: c.android?.importance ?? "default",
|
|
324
|
+
}));
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The actions a message notification shows: Reply (a text field) and Mark as
|
|
331
|
+
* read. Put it in `actions` under an id (`message`) and name that id in the
|
|
332
|
+
* message type's `actions`. A tap on either reaches the push block's
|
|
333
|
+
* `onResponse`; `notificationAction(response)` gives the action id, the typed
|
|
334
|
+
* text and the notification id (use it as the reply's client id, so a
|
|
335
|
+
* retried reply stays one reply).
|
|
336
|
+
*/
|
|
337
|
+
export const MESSAGE_ACTIONS = {
|
|
338
|
+
actions: [
|
|
339
|
+
{ id: "reply", title: "Reply", textInput: { placeholder: "Message", submitTitle: "Send" } },
|
|
340
|
+
{ id: "mark_read", title: "Mark as read" },
|
|
341
|
+
],
|
|
342
|
+
} as const satisfies NotificationActionSetDef;
|
|
343
|
+
|
|
344
|
+
/** The category and type of Admin -> Send a test (`genesis.test`, which the migration creates). Spread them into your registry. */
|
|
345
|
+
export const GENESIS_TEST_CATEGORY = { genesis: { label: "Tests", description: "Test notifications an admin sends themself." } } as const satisfies Record<string, NotificationCategoryDef>;
|
|
346
|
+
export const GENESIS_TEST_TYPE = {
|
|
347
|
+
"genesis.test": {
|
|
348
|
+
category: "genesis",
|
|
349
|
+
channels: { in_app: true, push: true, email: true },
|
|
350
|
+
critical: true,
|
|
351
|
+
transactional: true,
|
|
352
|
+
render: () => ({ title: "Test notification", body: "If you can read this, this channel works." }),
|
|
353
|
+
},
|
|
354
|
+
} as const satisfies Record<string, NotificationTypeDef<"genesis">>;
|