@12-apps/notifications 2.1.0 → 4.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 +7 -7
- package/README.md +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +1 -1
- package/prisma/notifications.prisma +1 -1
- package/scripts/sync-notifications-schema.mjs +1 -1
- package/src/generators.ts +1 -1
- package/src/index.ts +1 -1
- package/src/messages.ts +51 -104
- package/src/phone.ts +1 -1
- package/src/react/create-web-notifications.tsx +1 -1
- package/src/react/inbox-state.ts +1 -1
- package/src/react/web-push-setup.tsx +1 -1
- package/src/server/by-permission.ts +2 -2
- package/src/server/context.ts +14 -14
- package/src/server/create-api-notifications.ts +3 -3
- package/src/server/db.ts +1 -1
- package/src/server/routes.ts +3 -3
- package/src/server/transports/drivers.ts +2 -2
- package/src/server/transports/registry.ts +1 -1
package/ADOPTING.md
CHANGED
|
@@ -20,7 +20,7 @@ sweep — endpoints and screens included.
|
|
|
20
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
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
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
|
|
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 origin host's test ids ship inside. |
|
|
24
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
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
26
|
|
|
@@ -35,7 +35,7 @@ sweep — endpoints and screens included.
|
|
|
35
35
|
someone else's inbox would be a different surface with a different actor.
|
|
36
36
|
|
|
37
37
|
2. **`contacts` is not optional, and it is not the `users` table.** A transport
|
|
38
|
-
needs an address: `getContact(userId) → { email, phone } | null`.
|
|
38
|
+
needs an address: `getContact(userId) → { email, phone } | null`. The origin host's
|
|
39
39
|
router read `users.email` / `users.phone` directly, which is the one thing in
|
|
40
40
|
the pipeline that was always the host's — a package cannot know the shape of
|
|
41
41
|
an identity table, and a host with phone VERIFICATION wants to answer the
|
|
@@ -105,8 +105,8 @@ sweep — endpoints and screens included.
|
|
|
105
105
|
it with hand-written SQL. The argument shapes are CLOSED (documented in
|
|
106
106
|
`src/server/db.ts`), so a non-Prisma host has a finite surface to fill.
|
|
107
107
|
|
|
108
|
-
5. **`categories` is product vocabulary.**
|
|
109
|
-
|
|
108
|
+
5. **`categories` is product vocabulary, and REQUIRED.** There is no default:
|
|
109
|
+
the host declares its own taxonomy, and the preferences screen
|
|
110
110
|
renders whatever the api half was given — the taxonomy travels on the
|
|
111
111
|
`GET /notification-preferences` payload, so the two halves cannot disagree
|
|
112
112
|
about it. The packaged migration deliberately puts **no CHECK** on
|
|
@@ -115,7 +115,7 @@ sweep — endpoints and screens included.
|
|
|
115
115
|
6. **`generators` are registered from the OUTSIDE, and stay the host's.** A
|
|
116
116
|
generator maps a domain event to `{ title, body, link, data }`, and the
|
|
117
117
|
events are exactly what does not port: `order.paid`, `stock.low` and
|
|
118
|
-
`short-payment` are
|
|
118
|
+
`short-payment` are the origin host's, not any host's. Pass them in `generators`,
|
|
119
119
|
or call `registerGenerator` for a module imported later. The generator's
|
|
120
120
|
`category` is what the router gates fan-out on.
|
|
121
121
|
|
|
@@ -184,7 +184,7 @@ sweep — endpoints and screens included.
|
|
|
184
184
|
notified — and the log line that tells "nobody holds it" apart from "every
|
|
185
185
|
dispatch failed". Two rules for your implementation:
|
|
186
186
|
|
|
187
|
-
- `listCandidates` must be **bounded to people who hold a role**.
|
|
187
|
+
- `listCandidates` must be **bounded to people who hold a role**. The origin host's
|
|
188
188
|
requires a role grant, which keeps a store's storefront BUYERS out of a loop
|
|
189
189
|
that resolves permissions one user at a time.
|
|
190
190
|
- `getPermissions` must be **scoped to the tenant**. Unioning a user's grants
|
|
@@ -222,7 +222,7 @@ sweep — endpoints and screens included.
|
|
|
222
222
|
|
|
223
223
|
13. **These endpoints are cookie-authenticated WRITES. CSRF is yours.** Eight of
|
|
224
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
|
|
225
|
+
so the host, not this package, owns the cross-site question. In the origin host it
|
|
226
226
|
is fully mitigated by the Auth.js cookie's `SameSite=Lax`; a host
|
|
227
227
|
authenticating with `SameSite=None`, or one whose `resolveActor` trusts a
|
|
228
228
|
header a proxy sets, inherits an unguarded write surface. The one that matters
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"exports": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@12-apps/ui": "^
|
|
25
|
+
"@12-apps/ui": "^5.0.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"hono": ">=4.0.0",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
--
|
|
25
25
|
-- ============================ REPLAY SAFETY ================================
|
|
26
26
|
-- Every statement is guarded, because the first adopters ALREADY HAVE these
|
|
27
|
-
-- tables:
|
|
27
|
+
-- tables: the origin host created them by hand before the package existed, so this
|
|
28
28
|
-- migration must be a no-op there and correct on an empty database.
|
|
29
29
|
--
|
|
30
30
|
-- The guards are per COLUMN, not per table. `CREATE TABLE IF NOT EXISTS` alone
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// and for a stronger reason: the category set is HOST vocabulary (`categories`
|
|
23
23
|
// on the server config), so a closed set in the schema would be wrong for every
|
|
24
24
|
// adopter but the first. A host that wants its own taxonomy enforced adds the
|
|
25
|
-
// CHECK in a migration of its own —
|
|
25
|
+
// CHECK in a migration of its own — the origin host does. Only `channel` and `status`
|
|
26
26
|
// on the delivery row are closed here, because those two are the LIBRARY's.
|
|
27
27
|
model Notification {
|
|
28
28
|
id String @id @default(uuid())
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* The host package that owns the schema folder MUST also declare this package
|
|
21
21
|
* as a dependency, so the source of the copy is present in every build context.
|
|
22
22
|
*
|
|
23
|
-
* Default host path follows the
|
|
23
|
+
* Default host path follows the origin host layout
|
|
24
24
|
* (`packages/prisma/prisma/schema/`); another repo passes its own schema folder
|
|
25
25
|
* as the positional argument, or sets NOTIFICATIONS_HOST_SCHEMA_DIR.
|
|
26
26
|
*/
|
package/src/generators.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { NotificationGenerator } from './types';
|
|
|
4
4
|
/**
|
|
5
5
|
* Generator registry: one {@link NotificationGenerator} per event `type`.
|
|
6
6
|
*
|
|
7
|
-
* INSTANCE state, not a module-level Map.
|
|
7
|
+
* INSTANCE state, not a module-level Map. the origin's own was
|
|
8
8
|
* process-wide, which is what a package of loose functions forces; a factory
|
|
9
9
|
* config does not need it, and one registry per mount is what makes a test (or
|
|
10
10
|
* a second mount) able to hold its own set without clearing anyone else's.
|
package/src/index.ts
CHANGED
package/src/messages.ts
CHANGED
|
@@ -1,21 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Every sentence this package can say to a USER,
|
|
2
|
+
* Every sentence this package can say to a USER, stated by the HOST.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The copy lives in ONE table rather than in each screen so the api half and
|
|
5
|
+
* the react half can never disagree about a sentence — the 401 body the wire
|
|
6
|
+
* returns and the error the panel renders come from the same key.
|
|
7
|
+
*
|
|
8
|
+
* THE pt-BR TABLE THAT USED TO BE THE DEFAULT IS GONE. Its own docstring said
|
|
9
|
+
* what it was: "the product copy the surface shipped with", labelled in the
|
|
10
|
+
* source as one named application's "exact copy". A description of one adopter,
|
|
11
|
+
* shipped inside the package every other adopter installs, and reached by
|
|
12
|
+
* saying nothing.
|
|
13
|
+
*
|
|
14
|
+
* `categoryLabels` is the sharpest of the forty. The categories themselves
|
|
15
|
+
* became required config in the release before this one, precisely because
|
|
16
|
+
* WHICH categories exist is product vocabulary — and their LABELS kept
|
|
17
|
+
* defaulting, so a host that declared `['loans', 'fines']` got a labels map
|
|
18
|
+
* describing somebody else's four. Required categories with defaulted labels
|
|
19
|
+
* for a different host's categories is not a smaller version of the bug; it is
|
|
20
|
+
* the same bug with a compile-time gesture in front of it.
|
|
21
|
+
*
|
|
22
|
+
* So `messages` is REQUIRED and whole. The interface is the checklist, and the
|
|
23
|
+
* compiler names the sentences a host has not written yet.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The sentences the SERVER half renders — and the whole of what a backend mount
|
|
27
|
+
* has to state.
|
|
28
|
+
*
|
|
29
|
+
* Split out when `messages` became required. Requiring the full forty on a
|
|
30
|
+
* server config would have made a backend-only adopter write three dozen
|
|
31
|
+
* sentences for screens it does not serve, which is the kind of tax that gets a
|
|
32
|
+
* required-config migration reverted rather than adopted. These four are the
|
|
33
|
+
* ones the router and the route descriptors actually put on a wire.
|
|
10
34
|
*/
|
|
11
|
-
export interface
|
|
12
|
-
// --- the wire ------------------------------------------------------------
|
|
35
|
+
export interface NotificationWireMessages {
|
|
13
36
|
unauthenticated: string;
|
|
14
37
|
invalidBody: string;
|
|
15
38
|
operationFailed: string;
|
|
16
39
|
/** `POST /notifications/mark-read` with neither `ids` nor `all`. */
|
|
17
40
|
markReadTargetRequired: string;
|
|
41
|
+
}
|
|
18
42
|
|
|
43
|
+
/** Every sentence, wire and screen — what the REACT half needs. */
|
|
44
|
+
export interface NotificationMessages extends NotificationWireMessages {
|
|
19
45
|
// --- the inbox panel -----------------------------------------------------
|
|
20
46
|
panelTitle: string;
|
|
21
47
|
markAllRead: string;
|
|
@@ -58,99 +84,20 @@ export interface NotificationMessages {
|
|
|
58
84
|
devicePushEnabling: string;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
emptyBody: 'Você está em dia — novidades aparecem aqui.',
|
|
78
|
-
openBell: 'Abrir notificações',
|
|
79
|
-
openBellWithUnread: (count) => `Abrir notificações (${count} não lidas)`,
|
|
80
|
-
unreadSuffix: 'não lida',
|
|
81
|
-
deleteOne: (title) => `Excluir notificação: ${title}`,
|
|
82
|
-
|
|
83
|
-
justNow: 'agora',
|
|
84
|
-
minutesAgo: (minutes) => `há ${minutes} min`,
|
|
85
|
-
hoursAgo: (hours) => `há ${hours} h`,
|
|
86
|
-
daysAgo: (days) => (days === 1 ? 'há 1 dia' : `há ${days} dias`),
|
|
87
|
-
dateLocale: 'pt-BR',
|
|
88
|
-
|
|
89
|
-
preferencesTitle: 'Notificações',
|
|
90
|
-
preferencesLead:
|
|
91
|
-
'Escolha como quer ser avisado, por tipo de assunto. O sino do app sempre recebe tudo.',
|
|
92
|
-
channelLabels: {
|
|
93
|
-
EMAIL: 'E-mail',
|
|
94
|
-
SMS: 'SMS',
|
|
95
|
-
WHATSAPP: 'WhatsApp',
|
|
96
|
-
WEB_PUSH: 'Navegador',
|
|
97
|
-
},
|
|
98
|
-
channelUnavailableHints: {
|
|
99
|
-
EMAIL: 'Envio de e-mail não está configurado neste ambiente.',
|
|
100
|
-
SMS: 'Cadastre um telefone no seu perfil para receber SMS.',
|
|
101
|
-
WHATSAPP: 'Cadastre um telefone no seu perfil para receber WhatsApp.',
|
|
102
|
-
WEB_PUSH: 'Alertas do navegador não estão configurados neste ambiente.',
|
|
103
|
-
},
|
|
104
|
-
categoryLabels: {
|
|
105
|
-
orders: {
|
|
106
|
-
title: 'Pedidos',
|
|
107
|
-
description: 'Confirmações e andamento dos seus pedidos.',
|
|
108
|
-
},
|
|
109
|
-
payments: {
|
|
110
|
-
title: 'Pagamentos',
|
|
111
|
-
description: 'Cobranças, comprovantes e falhas de pagamento.',
|
|
112
|
-
},
|
|
113
|
-
stock: {
|
|
114
|
-
title: 'Estoque',
|
|
115
|
-
description: 'Alertas de estoque das lojas que você administra.',
|
|
116
|
-
},
|
|
117
|
-
system: {
|
|
118
|
-
title: 'Sistema',
|
|
119
|
-
description: 'Avisos da sua conta e da plataforma.',
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
categoryFallbackTitle: (category) => category,
|
|
123
|
-
devicePushTitle: 'Alertas neste navegador',
|
|
124
|
-
devicePushIdle: 'Permita notificações para receber alertas mesmo com o site fechado.',
|
|
125
|
-
devicePushOn: 'Este navegador está recebendo alertas.',
|
|
126
|
-
devicePushDenied:
|
|
127
|
-
'Permissão negada — habilite notificações nas configurações do navegador.',
|
|
128
|
-
devicePushFailed: 'Não foi possível ativar. Tente novamente.',
|
|
129
|
-
devicePushEnable: 'Ativar',
|
|
130
|
-
devicePushEnabling: 'Ativando...',
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
/** The messages in force, defaulted to the pt-BR product copy. */
|
|
134
|
-
export function messagesOf(config: {
|
|
135
|
-
messages?: Partial<NotificationMessages>;
|
|
136
|
-
}): NotificationMessages {
|
|
137
|
-
const overrides = config.messages ?? {};
|
|
138
|
-
return {
|
|
139
|
-
...DEFAULT_NOTIFICATION_MESSAGES,
|
|
140
|
-
...overrides,
|
|
141
|
-
// Nested records merge per KEY. A host relabelling one channel must not
|
|
142
|
-
// erase the labels for the other three, which a shallow spread would do.
|
|
143
|
-
channelLabels: {
|
|
144
|
-
...DEFAULT_NOTIFICATION_MESSAGES.channelLabels,
|
|
145
|
-
...overrides.channelLabels,
|
|
146
|
-
},
|
|
147
|
-
channelUnavailableHints: {
|
|
148
|
-
...DEFAULT_NOTIFICATION_MESSAGES.channelUnavailableHints,
|
|
149
|
-
...overrides.channelUnavailableHints,
|
|
150
|
-
},
|
|
151
|
-
categoryLabels: {
|
|
152
|
-
...DEFAULT_NOTIFICATION_MESSAGES.categoryLabels,
|
|
153
|
-
...overrides.categoryLabels,
|
|
154
|
-
},
|
|
155
|
-
};
|
|
87
|
+
/**
|
|
88
|
+
* The messages in force.
|
|
89
|
+
*
|
|
90
|
+
* A pass-through rather than a merge: there is nothing left to merge WITH, and
|
|
91
|
+
* that is the point of the change. The old version spread the host's table over
|
|
92
|
+
* the origin's, including PER KEY inside `channelLabels`,
|
|
93
|
+
* `channelUnavailableHints` and `categoryLabels` — so a host that relabelled one
|
|
94
|
+
* channel kept the origin's wording for the other three, and a host that
|
|
95
|
+
* labelled its own two categories kept the origin's four sitting beside them in
|
|
96
|
+
* the same screen.
|
|
97
|
+
*
|
|
98
|
+
* Kept as a function because all three mounts read it off a config object, and
|
|
99
|
+
* because a later rule (a blank-string refusal, say) belongs in one place.
|
|
100
|
+
*/
|
|
101
|
+
export function messagesOf<T extends NotificationWireMessages>(config: { messages: T }): T {
|
|
102
|
+
return config.messages;
|
|
156
103
|
}
|
package/src/phone.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `4155552671` into `+554155552671` — a plausible Brazilian mobile — and sent a
|
|
14
14
|
* stranger the customer's order details. There is no country this package could
|
|
15
15
|
* assume that is not wrong for every other adopter, so it assumes none and the
|
|
16
|
-
* omission is a compile error rather than a wrong number.
|
|
16
|
+
* omission is a compile error rather than a wrong number. the origin passes
|
|
17
17
|
* `'55'` explicitly.
|
|
18
18
|
*
|
|
19
19
|
* NOTE: "verified phone" is approximated by "has a normalizable phone on
|
|
@@ -37,7 +37,7 @@ export interface NotificationsWebConfig {
|
|
|
37
37
|
/** How the surface reaches its data. Default: same-origin fetch. */
|
|
38
38
|
transport?: NotificationsTransport;
|
|
39
39
|
/** User-facing copy overrides (pt-BR product copy by default). */
|
|
40
|
-
messages
|
|
40
|
+
messages: NotificationMessages;
|
|
41
41
|
/**
|
|
42
42
|
* How the surface learns an inbox changed without asking — the host's message
|
|
43
43
|
* bus. Without it the badge keeps its 60 s poll, which is the standing
|
package/src/react/inbox-state.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { NotificationsApiClient } from './api';
|
|
|
7
7
|
*
|
|
8
8
|
* They have to share it: marking a row read in the panel must move the badge in
|
|
9
9
|
* the same tick, and an arrival must add a row to the list AND to the count.
|
|
10
|
-
*
|
|
10
|
+
* the origin got that for free from a react-query cache the host had already
|
|
11
11
|
* mounted; a published package cannot assume one — a query client is a host
|
|
12
12
|
* decision, and requiring a particular one (or a particular version of one) is
|
|
13
13
|
* the kind of dependency that keeps a package out of a host that made the other
|
|
@@ -33,7 +33,7 @@ export interface WebPushSetupConfig {
|
|
|
33
33
|
* asks no permission, creates no subscription, and fails with nothing a user
|
|
34
34
|
* could act on. The check is a config seam rather than a dependency because
|
|
35
35
|
* "is this an installable iOS browser" is a question a host's PWA layer
|
|
36
|
-
* already answers (
|
|
36
|
+
* already answers (the origin passes
|
|
37
37
|
* `() => isIosInstallable() && !isStandalone()` from `@12-apps/pwa`).
|
|
38
38
|
*/
|
|
39
39
|
needsInstallFirst?: () => boolean;
|
|
@@ -24,7 +24,7 @@ import type { NotificationRouter } from './router';
|
|
|
24
24
|
* wrong.
|
|
25
25
|
*
|
|
26
26
|
* The two QUERIES are the host's, through {@link NotificationAudienceDirectory}
|
|
27
|
-
* — because an authorization engine is host machinery. In
|
|
27
|
+
* — because an authorization engine is host machinery. In the extraction origin this
|
|
28
28
|
* module could not live in a package at all: it needed `notify()` AND the RBAC
|
|
29
29
|
* engine, and neither package could see the other. Inverting the dependency
|
|
30
30
|
* (the host answers, the package asks) is what makes it portable.
|
|
@@ -34,7 +34,7 @@ import type { NotificationRouter } from './router';
|
|
|
34
34
|
* The host's authorization engine, as this fan-out needs it.
|
|
35
35
|
*
|
|
36
36
|
* `listCandidates` must be BOUNDED to people who actually hold a role at the
|
|
37
|
-
* tenant.
|
|
37
|
+
* tenant. that host's implementation requires a role grant, which is what
|
|
38
38
|
* keeps a store's storefront BUYERS — who all carry a default membership — out
|
|
39
39
|
* of a loop that resolves permissions one user at a time.
|
|
40
40
|
*
|
package/src/server/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { NotificationWireMessages } from '../messages';
|
|
2
2
|
import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -96,7 +96,7 @@ export function guarded(
|
|
|
96
96
|
// Body / query parsing — the request contract, in the package
|
|
97
97
|
// ---------------------------------------------------------------------------
|
|
98
98
|
|
|
99
|
-
function asRecord(body: unknown, messages:
|
|
99
|
+
function asRecord(body: unknown, messages: NotificationWireMessages): Record<string, unknown> {
|
|
100
100
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
101
101
|
throw new NotificationsApiError(400, messages.invalidBody);
|
|
102
102
|
}
|
|
@@ -104,7 +104,7 @@ function asRecord(body: unknown, messages: NotificationMessages): Record<string,
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/** 1..100 non-empty string ids. */
|
|
107
|
-
function parseIds(value: unknown, messages:
|
|
107
|
+
function parseIds(value: unknown, messages: NotificationWireMessages): string[] {
|
|
108
108
|
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
|
109
109
|
throw new NotificationsApiError(400, messages.invalidBody);
|
|
110
110
|
}
|
|
@@ -120,7 +120,7 @@ function parseIds(value: unknown, messages: NotificationMessages): string[] {
|
|
|
120
120
|
* Query strings arrive as strings; the store clamps, but a non-number is a
|
|
121
121
|
* client bug and must not silently read as "the default page".
|
|
122
122
|
*/
|
|
123
|
-
function parseLimit(raw: string | undefined, messages:
|
|
123
|
+
function parseLimit(raw: string | undefined, messages: NotificationWireMessages): number | undefined {
|
|
124
124
|
if (raw === undefined || raw === '') return undefined;
|
|
125
125
|
const limit = Number(raw);
|
|
126
126
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
@@ -131,7 +131,7 @@ function parseLimit(raw: string | undefined, messages: NotificationMessages): nu
|
|
|
131
131
|
|
|
132
132
|
function parseFilter(
|
|
133
133
|
raw: string | undefined,
|
|
134
|
-
messages:
|
|
134
|
+
messages: NotificationWireMessages,
|
|
135
135
|
): 'all' | 'unread' | undefined {
|
|
136
136
|
if (raw === undefined) return undefined;
|
|
137
137
|
if (raw !== 'all' && raw !== 'unread') {
|
|
@@ -143,7 +143,7 @@ function parseFilter(
|
|
|
143
143
|
/** `GET <mount>/notifications` — `filter`, `cursor`, `limit`. */
|
|
144
144
|
export function parseListQuery(
|
|
145
145
|
query: Record<string, string | undefined>,
|
|
146
|
-
messages:
|
|
146
|
+
messages: NotificationWireMessages,
|
|
147
147
|
): { filter?: 'all' | 'unread'; cursor?: string; limit?: number } {
|
|
148
148
|
const filter = parseFilter(query.filter, messages);
|
|
149
149
|
const limit = parseLimit(query.limit, messages);
|
|
@@ -157,7 +157,7 @@ export function parseListQuery(
|
|
|
157
157
|
/** `POST <mount>/notifications/mark-read` — explicit ids, or `all: true`. */
|
|
158
158
|
export function parseMarkReadBody(
|
|
159
159
|
body: unknown,
|
|
160
|
-
messages:
|
|
160
|
+
messages: NotificationWireMessages,
|
|
161
161
|
): { all: true } | { ids: string[] } {
|
|
162
162
|
const record = asRecord(body, messages);
|
|
163
163
|
const wantsAll = record.all === true;
|
|
@@ -171,7 +171,7 @@ export function parseMarkReadBody(
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/** `POST <mount>/notifications/delete` — 1..100 ids. */
|
|
174
|
-
export function parseDeleteBody(body: unknown, messages:
|
|
174
|
+
export function parseDeleteBody(body: unknown, messages: NotificationWireMessages): string[] {
|
|
175
175
|
return parseIds(asRecord(body, messages).ids, messages);
|
|
176
176
|
}
|
|
177
177
|
|
|
@@ -181,7 +181,7 @@ export function parseDeleteBody(body: unknown, messages: NotificationMessages):
|
|
|
181
181
|
*/
|
|
182
182
|
export function parsePreferencesBody(
|
|
183
183
|
body: unknown,
|
|
184
|
-
messages:
|
|
184
|
+
messages: NotificationWireMessages,
|
|
185
185
|
): Record<string, Partial<Record<NotificationChannel, boolean>>> {
|
|
186
186
|
const record = asRecord(body, messages);
|
|
187
187
|
const parsed: Record<string, Partial<Record<NotificationChannel, boolean>>> = {};
|
|
@@ -194,7 +194,7 @@ export function parsePreferencesBody(
|
|
|
194
194
|
/** One category's toggles, narrowed onto the closed channel set. */
|
|
195
195
|
function parseToggles(
|
|
196
196
|
value: unknown,
|
|
197
|
-
messages:
|
|
197
|
+
messages: NotificationWireMessages,
|
|
198
198
|
): Partial<Record<NotificationChannel, boolean>> {
|
|
199
199
|
const toggles = asRecord(value, messages);
|
|
200
200
|
const row: Partial<Record<NotificationChannel, boolean>> = {};
|
|
@@ -210,7 +210,7 @@ function parseToggles(
|
|
|
210
210
|
const MAX_ENDPOINT_CHARS = 2000;
|
|
211
211
|
const MAX_KEY_CHARS = 500;
|
|
212
212
|
|
|
213
|
-
function parseEndpoint(value: unknown, messages:
|
|
213
|
+
function parseEndpoint(value: unknown, messages: NotificationWireMessages): string {
|
|
214
214
|
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ENDPOINT_CHARS) {
|
|
215
215
|
throw new NotificationsApiError(400, messages.invalidBody);
|
|
216
216
|
}
|
|
@@ -231,7 +231,7 @@ function parseEndpoint(value: unknown, messages: NotificationMessages): string {
|
|
|
231
231
|
/** `POST <mount>/push-subscriptions` — `PushSubscription.toJSON()`. */
|
|
232
232
|
export function parsePushSubscriptionBody(
|
|
233
233
|
body: unknown,
|
|
234
|
-
messages:
|
|
234
|
+
messages: NotificationWireMessages,
|
|
235
235
|
): { endpoint: string; keys: { p256dh: string; auth: string } } {
|
|
236
236
|
const record = asRecord(body, messages);
|
|
237
237
|
const keys = asRecord(record.keys, messages);
|
|
@@ -248,7 +248,7 @@ export function parsePushSubscriptionBody(
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/** `DELETE <mount>/push-subscriptions` — by endpoint. */
|
|
251
|
-
export function parsePushEndpointBody(body: unknown, messages:
|
|
251
|
+
export function parsePushEndpointBody(body: unknown, messages: NotificationWireMessages): string {
|
|
252
252
|
return parseEndpoint(asRecord(body, messages).endpoint, messages);
|
|
253
253
|
}
|
|
254
254
|
|
|
@@ -262,7 +262,7 @@ export function parsePushEndpointBody(body: unknown, messages: NotificationMessa
|
|
|
262
262
|
*/
|
|
263
263
|
export function parsePushEndpointQuery(
|
|
264
264
|
query: Record<string, string | undefined>,
|
|
265
|
-
messages:
|
|
265
|
+
messages: NotificationWireMessages,
|
|
266
266
|
): string | undefined {
|
|
267
267
|
if (query.endpoint === undefined || query.endpoint === '') return undefined;
|
|
268
268
|
return parseEndpoint(query.endpoint, messages);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createGeneratorRegistry, type NotificationGeneratorRegistry } from '../generators';
|
|
2
|
-
import { messagesOf, type
|
|
2
|
+
import { messagesOf, type NotificationWireMessages } from '../messages';
|
|
3
3
|
import type { ChannelRow } from '../preferences-core';
|
|
4
4
|
import {
|
|
5
5
|
taxonomyOf,
|
|
@@ -105,7 +105,7 @@ export interface NotificationsServerConfig {
|
|
|
105
105
|
/** The host's authorization engine, for `notifyByPermission`. */
|
|
106
106
|
audience?: NotificationAudienceDirectory;
|
|
107
107
|
/** User-facing copy overrides (pt-BR product copy by default). */
|
|
108
|
-
messages
|
|
108
|
+
messages: NotificationWireMessages;
|
|
109
109
|
/** The host's logger. Defaults to the console. */
|
|
110
110
|
logger?: NotificationLogger;
|
|
111
111
|
}
|
|
@@ -133,7 +133,7 @@ export interface ApiNotifications {
|
|
|
133
133
|
/** The declared transports, for diagnostics and availability probes. */
|
|
134
134
|
transports: TransportRegistry;
|
|
135
135
|
/** The copy in force, so a host's own screens can reuse a sentence. */
|
|
136
|
-
messages:
|
|
136
|
+
messages: NotificationWireMessages;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/** Drop the keys the host left unset, so an absent seam stays absent. */
|
package/src/server/db.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* own stores actually pass — so a non-Prisma implementation has a finite,
|
|
10
10
|
* documented surface to satisfy instead of "all of Prisma".
|
|
11
11
|
*
|
|
12
|
-
* What is NOT here: the `users` table.
|
|
12
|
+
* What is NOT here: the `users` table. the origin's router read
|
|
13
13
|
* `users.email/phone` directly to answer "can this channel reach them", which
|
|
14
14
|
* is the one thing in the pipeline that belonged to the host all along — a
|
|
15
15
|
* package cannot know the shape of a host's identity table, and a host with
|
package/src/server/routes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { NotificationWireMessages } from '../messages';
|
|
2
2
|
import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -25,7 +25,7 @@ import type { TransportRegistry } from './transports/registry';
|
|
|
25
25
|
* Nine routes, and the paths are the PACKAGE's: the shipped react client
|
|
26
26
|
* builds these URLs, so a host that renamed one would be a host whose own bell
|
|
27
27
|
* stopped working. The host names only where the whole block is mounted
|
|
28
|
-
* (
|
|
28
|
+
* (the origin mounts it at `/api/account`).
|
|
29
29
|
*
|
|
30
30
|
* Route ORDER is preserved by every adapter. Nothing here is shaped `/:id`, so
|
|
31
31
|
* no sibling can capture a literal — but the order is still the contract,
|
|
@@ -40,7 +40,7 @@ interface NotificationRoutesDeps {
|
|
|
40
40
|
transports: TransportRegistry;
|
|
41
41
|
contacts: NotificationContactDirectory;
|
|
42
42
|
categories: readonly string[];
|
|
43
|
-
messages:
|
|
43
|
+
messages: NotificationWireMessages;
|
|
44
44
|
/** Told when a write actually changed something (for a realtime hint). */
|
|
45
45
|
onInboxChanged?: (userId: string) => void;
|
|
46
46
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The DRIVER port — the reason a host adds a vendor with a config entry and no
|
|
3
3
|
* code (12-15).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* the origin's transports each read `process.env` directly:
|
|
6
6
|
* `NOTIFICATIONS_EMAIL_PROVIDER=resend` plus `RESEND_API_KEY`, and a second
|
|
7
7
|
* vendor meant editing the package. That is exactly backwards for a published
|
|
8
8
|
* package — it cannot know a host's variable names, and it must not be the
|
|
@@ -101,7 +101,7 @@ export function resolveDriver<TDeclaration extends DriverDeclarationBase, TDrive
|
|
|
101
101
|
*
|
|
102
102
|
* A relative path is useless in an inbox and a fabricated localhost link is
|
|
103
103
|
* worse than none, so with no `appUrl` configured the link is simply dropped —
|
|
104
|
-
*
|
|
104
|
+
* The origin's rule, kept.
|
|
105
105
|
*/
|
|
106
106
|
export function absoluteLink(link: string | undefined, appUrl: string | undefined): string | null {
|
|
107
107
|
if (!link || !appUrl) return null;
|
|
@@ -19,7 +19,7 @@ import { whatsAppTransport } from './whatsapp';
|
|
|
19
19
|
* transports are untouched (open/closed).
|
|
20
20
|
*
|
|
21
21
|
* A mount declares its channels and gets a registry; nothing is process-wide.
|
|
22
|
-
*
|
|
22
|
+
* The origin registered its four transports as an IMPORT SIDE EFFECT of the
|
|
23
23
|
* package's root entry, which made "which channels are on" a property of the
|
|
24
24
|
* module graph rather than of any configuration — importing the inbox helpers
|
|
25
25
|
* in a unit test silently armed four transports.
|