@12-apps/notifications 4.9.0 → 4.10.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/ADOPTING.md +60 -1
- package/README.md +73 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/{chunk-M5XTROYM.js → chunk-I5QUMTCN.js} +141 -17
- package/dist/chunk-I5QUMTCN.js.map +1 -0
- package/dist/chunk-RTURLH5U.js +33 -0
- package/dist/chunk-RTURLH5U.js.map +1 -0
- package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
- package/dist/{chunk-KZG7QGFA.js → chunk-WVRODNXQ.js} +8 -4
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/chunk-ZY32PC34.js +231 -0
- package/dist/chunk-ZY32PC34.js.map +1 -0
- package/dist/{create-api-notifications-B-VMezVB.d.ts → create-api-notifications-CcPYrM3p.d.ts} +4 -4
- package/dist/{create-web-notifications-B-xQ6R0B.d.ts → create-web-notifications-DV3Y8k7e.d.ts} +89 -4
- package/dist/{generators-BsuXe8Mp.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +5 -5
- package/dist/hono/index.js +4 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +12 -2
- package/dist/{jobs-D64B_bg7.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/server.d.ts +6 -6
- package/dist/manifest/server.js +5 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +4 -3
- package/dist/manifest/web.js +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-OPB3DBLJ.js} +116 -61
- package/dist/panel-OPB3DBLJ.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/react/index.d.ts +119 -7
- package/dist/react/index.js +8 -2
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +5 -4
- package/dist/{types-BFmAY0P5.d.ts → types-BlqZkCWZ.d.ts} +10 -1
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-D-ysQd5h.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-_coV3tJk.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +2 -2
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/react/bell-button.tsx +112 -16
- package/src/react/create-web-notifications.tsx +40 -5
- package/src/react/index.ts +29 -0
- package/src/react/live-card.tsx +279 -0
- package/src/react/live-config.ts +79 -0
- package/src/react/live-section.tsx +179 -0
- package/src/react/live-seen.ts +138 -0
- package/src/react/panel-lazy.tsx +6 -0
- package/src/react/panel.tsx +144 -34
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-BGI4BQ66.js.map +0 -1
- package/dist/chunk-KZG7QGFA.js.map +0 -1
- package/dist/chunk-M5XTROYM.js.map +0 -1
- package/dist/chunk-O5BVUXPO.js +0 -22
- package/dist/chunk-O5BVUXPO.js.map +0 -1
- package/dist/panel-UFXNO4AF.js.map +0 -1
- /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
- /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
- /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
package/src/react/panel-lazy.tsx
CHANGED
|
@@ -36,12 +36,18 @@ import { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } fro
|
|
|
36
36
|
import type { NotificationMessages } from '../messages';
|
|
37
37
|
|
|
38
38
|
import type { InboxStore } from './inbox-state';
|
|
39
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
40
|
+
import type { LiveSeenStore } from './live-seen';
|
|
39
41
|
import type { NotificationsPanelProps } from './panel';
|
|
40
42
|
|
|
41
43
|
/** What the factory binds into the panel, and the host never passes. */
|
|
42
44
|
interface PanelParts {
|
|
43
45
|
store: InboxStore;
|
|
44
46
|
messages: NotificationMessages;
|
|
47
|
+
/** Absent unless the host turned live activities on — see `./live-config`. */
|
|
48
|
+
live?: LiveActivitiesConfig;
|
|
49
|
+
/** Travels with `live`: where the panel records what the reader has seen. */
|
|
50
|
+
liveSeen?: LiveSeenStore;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export function lazyNotificationsPanel(
|
package/src/react/panel.tsx
CHANGED
|
@@ -17,12 +17,16 @@ import { Box } from '@12-apps/ui/mui/Box';
|
|
|
17
17
|
import { useMediaQuery } from '@12-apps/ui/mui/useMediaQuery';
|
|
18
18
|
import { useTheme } from '@12-apps/ui/mui/styles';
|
|
19
19
|
|
|
20
|
+
import type { LiveActivity } from '../live';
|
|
20
21
|
import type { NotificationMessages } from '../messages';
|
|
21
22
|
import type { InboxNotification } from '../wire';
|
|
22
23
|
|
|
23
24
|
import { BellIcon } from './bell-icon';
|
|
24
25
|
import { useInboxList } from './hooks';
|
|
25
26
|
import type { InboxState, InboxStore } from './inbox-state';
|
|
27
|
+
import type { LiveActivitiesConfig } from './live-config';
|
|
28
|
+
import type { LiveSeenStore } from './live-seen';
|
|
29
|
+
import { LiveSection } from './live-section';
|
|
26
30
|
import { NotificationRow } from './row';
|
|
27
31
|
|
|
28
32
|
interface PanelBodyProps {
|
|
@@ -32,6 +36,14 @@ interface PanelBodyProps {
|
|
|
32
36
|
onLoadMore: () => void;
|
|
33
37
|
onOpen: (notification: InboxNotification) => void;
|
|
34
38
|
onDelete: (id: string) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Whether the live section above has anything in it.
|
|
41
|
+
*
|
|
42
|
+
* The empty state is a CLAIM about the whole panel — "nenhuma notificação" —
|
|
43
|
+
* and a live entry is a notification, so an inbox with no rows under a pinned
|
|
44
|
+
* pedido is not empty. Without this the panel said both things at once.
|
|
45
|
+
*/
|
|
46
|
+
hasLive: boolean;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
/** The scrollable panel body: loading / error / empty / the list + pager. */
|
|
@@ -42,6 +54,7 @@ function PanelBody({
|
|
|
42
54
|
onLoadMore,
|
|
43
55
|
onOpen,
|
|
44
56
|
onDelete,
|
|
57
|
+
hasLive,
|
|
45
58
|
}: PanelBodyProps): JSX.Element {
|
|
46
59
|
if (state.status === 'pending' || state.status === 'idle') {
|
|
47
60
|
return (
|
|
@@ -65,7 +78,7 @@ function PanelBody({
|
|
|
65
78
|
/>
|
|
66
79
|
);
|
|
67
80
|
}
|
|
68
|
-
if (state.items.length === 0) {
|
|
81
|
+
if (state.items.length === 0 && !hasLive) {
|
|
69
82
|
return (
|
|
70
83
|
<EmptyState
|
|
71
84
|
variant="illustrated"
|
|
@@ -77,7 +90,12 @@ function PanelBody({
|
|
|
77
90
|
);
|
|
78
91
|
}
|
|
79
92
|
return (
|
|
80
|
-
|
|
93
|
+
// A stable anchor for the inbox half, present whether or not it has rows.
|
|
94
|
+
// The panel's claim is that what is HAPPENING sits above what has already
|
|
95
|
+
// happened, and until this existed the only thing below the live section to
|
|
96
|
+
// point at was the empty state — which is exactly what stops rendering when
|
|
97
|
+
// something is live.
|
|
98
|
+
<Box data-testid="notifications-inbox">
|
|
81
99
|
{state.items.map((notification) => (
|
|
82
100
|
<NotificationRow
|
|
83
101
|
key={notification.id}
|
|
@@ -105,6 +123,43 @@ function PanelBody({
|
|
|
105
123
|
);
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The two open gestures, which are ONE deep-link path with a mark-read in front
|
|
128
|
+
* of half of it.
|
|
129
|
+
*
|
|
130
|
+
* Lifted out of the component because both kinds of entry follow a link the
|
|
131
|
+
* same way and a host with no router follows neither — one rule, stated once,
|
|
132
|
+
* rather than the same three lines written twice.
|
|
133
|
+
*/
|
|
134
|
+
function usePanelOpeners(
|
|
135
|
+
store: InboxStore,
|
|
136
|
+
onClose: () => void,
|
|
137
|
+
onNavigate?: (link: string) => void,
|
|
138
|
+
): {
|
|
139
|
+
openNotification: (notification: InboxNotification) => void;
|
|
140
|
+
openLive: (activity: LiveActivity) => void;
|
|
141
|
+
} {
|
|
142
|
+
const follow = useCallback(
|
|
143
|
+
(link: string | null) => {
|
|
144
|
+
if (!link || !onNavigate) return;
|
|
145
|
+
onClose();
|
|
146
|
+
onNavigate(link);
|
|
147
|
+
},
|
|
148
|
+
[onClose, onNavigate],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
openNotification: useCallback(
|
|
153
|
+
(notification: InboxNotification) => {
|
|
154
|
+
if (notification.readAt === null) store.markRead([notification.id]);
|
|
155
|
+
follow(notification.link);
|
|
156
|
+
},
|
|
157
|
+
[store, follow],
|
|
158
|
+
),
|
|
159
|
+
openLive: useCallback((activity: LiveActivity) => follow(activity.link), [follow]),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
108
163
|
export interface NotificationsPanelProps {
|
|
109
164
|
open: boolean;
|
|
110
165
|
onClose: () => void;
|
|
@@ -112,15 +167,70 @@ export interface NotificationsPanelProps {
|
|
|
112
167
|
onNavigate?: (link: string) => void;
|
|
113
168
|
}
|
|
114
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Everything BELOW the live section: the mark-all control and the list.
|
|
172
|
+
*
|
|
173
|
+
* Its own component rather than a block inside the panel because it is rendered
|
|
174
|
+
* from two places — through `LiveSection`, and directly when the host turned
|
|
175
|
+
* live activities off — and because the panel is at its line ceiling. "Marcar
|
|
176
|
+
* todas como lidas" belongs to the INBOX and travels with it: a live entry has
|
|
177
|
+
* nothing to mark.
|
|
178
|
+
*/
|
|
179
|
+
function PanelInbox({
|
|
180
|
+
state,
|
|
181
|
+
messages,
|
|
182
|
+
store,
|
|
183
|
+
onOpen,
|
|
184
|
+
hasLive,
|
|
185
|
+
}: {
|
|
186
|
+
state: InboxState;
|
|
187
|
+
messages: NotificationMessages;
|
|
188
|
+
store: InboxStore;
|
|
189
|
+
onOpen: (notification: InboxNotification) => void;
|
|
190
|
+
hasLive: boolean;
|
|
191
|
+
}): JSX.Element {
|
|
192
|
+
const hasUnread = state.items.some((item) => item.readAt === null);
|
|
193
|
+
return (
|
|
194
|
+
<>
|
|
195
|
+
{hasUnread ? (
|
|
196
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pb: 1 }}>
|
|
197
|
+
<Button
|
|
198
|
+
variant="ghost"
|
|
199
|
+
color="primary"
|
|
200
|
+
size="xs"
|
|
201
|
+
onClick={() => store.markAllRead()}
|
|
202
|
+
dataTestId="notifications-mark-all-read"
|
|
203
|
+
>
|
|
204
|
+
{messages.markAllRead}
|
|
205
|
+
</Button>
|
|
206
|
+
</Box>
|
|
207
|
+
) : null}
|
|
208
|
+
<PanelBody
|
|
209
|
+
state={state}
|
|
210
|
+
messages={messages}
|
|
211
|
+
onRetry={() => store.invalidate()}
|
|
212
|
+
onLoadMore={() => store.loadMore()}
|
|
213
|
+
onOpen={onOpen}
|
|
214
|
+
onDelete={(id) => store.remove(id)}
|
|
215
|
+
hasLive={hasLive}
|
|
216
|
+
/>
|
|
217
|
+
</>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
115
221
|
export function NotificationsPanel({
|
|
116
222
|
open,
|
|
117
223
|
onClose,
|
|
118
224
|
onNavigate,
|
|
119
225
|
store,
|
|
120
226
|
messages,
|
|
227
|
+
live,
|
|
228
|
+
liveSeen,
|
|
121
229
|
}: NotificationsPanelProps & {
|
|
122
230
|
store: InboxStore;
|
|
123
231
|
messages: NotificationMessages;
|
|
232
|
+
live?: LiveActivitiesConfig;
|
|
233
|
+
liveSeen?: LiveSeenStore;
|
|
124
234
|
}): JSX.Element {
|
|
125
235
|
// `useTheme` from @mui/material/styles falls back to the DEFAULT theme when
|
|
126
236
|
// no provider is mounted, where the callback form of `useMediaQuery` would
|
|
@@ -130,18 +240,20 @@ export function NotificationsPanel({
|
|
|
130
240
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
131
241
|
const state = useInboxList(store, open);
|
|
132
242
|
|
|
133
|
-
const openNotification =
|
|
134
|
-
(notification: InboxNotification) => {
|
|
135
|
-
if (notification.readAt === null) store.markRead([notification.id]);
|
|
136
|
-
if (notification.link && onNavigate) {
|
|
137
|
-
onClose();
|
|
138
|
-
onNavigate(notification.link);
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
[store, onClose, onNavigate],
|
|
142
|
-
);
|
|
243
|
+
const { openNotification, openLive } = usePanelOpeners(store, onClose, onNavigate);
|
|
143
244
|
|
|
144
|
-
|
|
245
|
+
// A function rather than an element so the live count can reach it: with a
|
|
246
|
+
// live section it is called by `LiveSection` — the only place the host's hook
|
|
247
|
+
// may be called — and without one it is called here with zero.
|
|
248
|
+
const renderInbox = (liveCount: number): JSX.Element => (
|
|
249
|
+
<PanelInbox
|
|
250
|
+
state={state}
|
|
251
|
+
messages={messages}
|
|
252
|
+
store={store}
|
|
253
|
+
onOpen={openNotification}
|
|
254
|
+
hasLive={liveCount > 0}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
145
257
|
|
|
146
258
|
return (
|
|
147
259
|
<Drawer
|
|
@@ -154,27 +266,25 @@ export function NotificationsPanel({
|
|
|
154
266
|
>
|
|
155
267
|
<DrawerHeader onClose={onClose}>{messages.panelTitle}</DrawerHeader>
|
|
156
268
|
<DrawerContent>
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
onDelete={(id) => store.remove(id)}
|
|
177
|
-
/>
|
|
269
|
+
{/*
|
|
270
|
+
Above the mark-all control as well as above the list, deliberately:
|
|
271
|
+
"marcar todas como lidas" belongs to the INBOX, and a live entry has
|
|
272
|
+
nothing to mark. A control between the two blocks would read as
|
|
273
|
+
applying to both.
|
|
274
|
+
*/}
|
|
275
|
+
{live ? (
|
|
276
|
+
<LiveSection
|
|
277
|
+
config={live}
|
|
278
|
+
messages={messages}
|
|
279
|
+
active={open}
|
|
280
|
+
{...(onNavigate ? { onOpen: openLive } : {})}
|
|
281
|
+
{...(liveSeen ? { seen: liveSeen } : {})}
|
|
282
|
+
>
|
|
283
|
+
{renderInbox}
|
|
284
|
+
</LiveSection>
|
|
285
|
+
) : (
|
|
286
|
+
renderInbox(0)
|
|
287
|
+
)}
|
|
178
288
|
</DrawerContent>
|
|
179
289
|
</Drawer>
|
|
180
290
|
);
|
|
@@ -4,9 +4,21 @@ import type { NotificationMessages } from '../messages';
|
|
|
4
4
|
* "há 5 min"-style relative timestamp, falling back to an absolute date for
|
|
5
5
|
* anything older than a week. Every word comes from the messages table, so a
|
|
6
6
|
* host in another locale changes the copy and the locale together.
|
|
7
|
+
*
|
|
8
|
+
* `now` is a parameter rather than a read, and the live section is why. A
|
|
9
|
+
* relative phrase is only true for the instant it was computed, so something
|
|
10
|
+
* has to CAUSE the render that recomputes it — and the live entries' own data
|
|
11
|
+
* cannot: a host backed by react-query gets the previous object back whenever a
|
|
12
|
+
* poll is deep-equal (`structuralSharing`, on by default), which within one
|
|
13
|
+
* stage it always is. The section therefore ticks a clock and hands it down.
|
|
14
|
+
* Defaulted, so every existing caller reads the wall clock exactly as before.
|
|
7
15
|
*/
|
|
8
|
-
export function relativeTime(
|
|
9
|
-
|
|
16
|
+
export function relativeTime(
|
|
17
|
+
iso: string,
|
|
18
|
+
messages: NotificationMessages,
|
|
19
|
+
now: number = Date.now(),
|
|
20
|
+
): string {
|
|
21
|
+
const elapsedMs = now - new Date(iso).getTime();
|
|
10
22
|
const minutes = Math.round(elapsedMs / 60_000);
|
|
11
23
|
if (minutes < 1) return messages.justNow;
|
|
12
24
|
if (minutes < 60) return messages.minutesAgo(minutes);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { livePushTag } from '../../live';
|
|
1
2
|
import type {
|
|
2
3
|
NotificationContent,
|
|
3
4
|
NotificationLogger,
|
|
@@ -52,6 +53,26 @@ export interface WebPushMessage {
|
|
|
52
53
|
body: string;
|
|
53
54
|
link: string | null;
|
|
54
55
|
data: Record<string, unknown>;
|
|
56
|
+
/**
|
|
57
|
+
* The OS tray tag, or `null` for an ordinary event.
|
|
58
|
+
*
|
|
59
|
+
* Present for a notification about a LIVE SUBJECT (see `../../live`), and
|
|
60
|
+
* this is the whole of the live feature's reach onto a locked phone: a tag
|
|
61
|
+
* makes the next push about the same subject REPLACE the one already in the
|
|
62
|
+
* tray — silently — instead of stacking under it. Four stages then cost one
|
|
63
|
+
* entry and one buzz, and the entry that remains is the current one.
|
|
64
|
+
*
|
|
65
|
+
* {@link formatWebPush} always sets it, `null` included, so on the WIRE the
|
|
66
|
+
* key is always there and a service worker reads one shape. A worker that
|
|
67
|
+
* ignores it keeps today's behaviour exactly, which is what makes this safe
|
|
68
|
+
* to ship ahead of the workers.
|
|
69
|
+
*
|
|
70
|
+
* OPTIONAL in the type, and only in the type: a host that hand-writes its own
|
|
71
|
+
* `NotificationTransport<WebPushMessage>` — a fixture, a second driver —
|
|
72
|
+
* would otherwise stop compiling on a field it has no way to know about and
|
|
73
|
+
* no reason to set.
|
|
74
|
+
*/
|
|
75
|
+
tag?: string | null;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
export interface WebPushDriverDeclaration extends DriverDeclarationBase {
|
|
@@ -120,6 +141,7 @@ export function formatWebPush(content: NotificationContent): WebPushMessage {
|
|
|
120
141
|
body: content.body,
|
|
121
142
|
link: content.link ?? null,
|
|
122
143
|
data: content.data ?? {},
|
|
144
|
+
tag: livePushTag(content.data),
|
|
123
145
|
};
|
|
124
146
|
}
|
|
125
147
|
|
package/src/types.ts
CHANGED
|
@@ -64,7 +64,16 @@ export interface NotificationContent {
|
|
|
64
64
|
body: string;
|
|
65
65
|
/** In-app deep link (a same-origin path such as `/orders/123`). */
|
|
66
66
|
link?: string;
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Structured extras for consumers that want more than text.
|
|
69
|
+
*
|
|
70
|
+
* ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties
|
|
71
|
+
* this notification to a live activity, and the WEB_PUSH transport turns it
|
|
72
|
+
* into the tray `tag` that collapses every push about one subject onto a
|
|
73
|
+
* single entry. A host already using that name for something else acquires
|
|
74
|
+
* that behaviour without asking for it — so it is named here, on the field a
|
|
75
|
+
* generator actually writes, and not only where the feature is documented.
|
|
76
|
+
*/
|
|
68
77
|
data?: Record<string, unknown>;
|
|
69
78
|
}
|
|
70
79
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Core types of the channel-agnostic notification system (12-15).\n *\n * Three decoupled layers, each open for extension without touching the others:\n * - GENERATORS map a typed domain event to agnostic content (title/body/…).\n * - The CHANNEL ROUTER always writes the notification-centre inbox record,\n * then fans out one delivery per enabled channel.\n * - TRANSPORTS format the agnostic content for one channel and send it.\n *\n * Nothing here knows about a concrete channel's wire format — that lives\n * entirely inside each transport adapter — and nothing here knows about a\n * concrete DOMAIN either: the event `type` set, the preference categories and\n * the channel list are all host config (see {@link NotificationTaxonomy}).\n */\n\n/** Transport channels a notification can fan out to (DB CHECK mirrors this). */\nexport const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;\nexport type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];\n\n/**\n * The preference categories are the HOST's, and required.\n *\n * There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',\n * 'system']` here — one product's set — and `taxonomyOf` fell back to it\n * whenever a host passed none. The docstring argued the case itself: \"it is\n * product vocabulary, not machinery\", and then shipped the vocabulary anyway as\n * the default, which is the only part a forgetful host would ever see.\n *\n * The consequence was quiet rather than loud: the settings screen renders four\n * rows a foreign host never chose, its own categories are absent, and every\n * preference a user sets is filed against a taxonomy nothing else in that\n * system uses. Nothing throws, because `category` is deliberately a free string\n * — the packaged migration puts **no CHECK** on it, precisely because a closed\n * set would be wrong for every host but the first. That freedom is what made\n * the default undetectable.\n *\n * `channel` and `status` are different and keep their CHECKs: those ARE this\n * library's own closed sets.\n */\nexport type NotificationCategory = string;\n\n/**\n * Per-channel delivery lifecycle (DB CHECK mirrors this).\n *\n * `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,\n * so two dispatchers can never both send the same delivery. A row left\n * `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once\n * it is older than the cutoff.\n *\n * `DEAD` is terminal: the attempt ceiling was reached (or the recipient no\n * longer exists), and no sweep will pick the row up again. Without it a\n * permanently invalid destination is a billed provider call on every sweep,\n * forever, and the sweep's working set only grows.\n */\nexport type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';\n\n/**\n * Channel-agnostic content a generator produces. This is what the inbox stores\n * verbatim and what every transport's formatter receives — no channel may leak\n * its wire format into it.\n */\nexport interface NotificationContent {\n title: string;\n body: string;\n /** In-app deep link (a same-origin path such as `/orders/123`). */\n link?: string;\n /** Structured extras for consumers that want more than text. */\n data?: Record<string, unknown>;\n}\n\n/** Who receives a notification. `clientId` scopes it to a tenant when set. */\nexport interface NotificationRecipient {\n userId: string;\n clientId?: string;\n}\n\n/**\n * A typed domain event handed to `notify`. `type` selects the registered\n * generator; `payload` is that generator's typed input. Callers never touch\n * channels, formatting, or preferences.\n */\nexport interface NotificationEvent<TPayload = unknown> {\n type: string;\n recipient: NotificationRecipient;\n payload: TPayload;\n}\n\n/**\n * Maps one domain event type to agnostic content. Registered through the\n * server config (or `registerGenerator` for a late arrival); adding a\n * generator never touches existing generators, the router, or any transport\n * (open/closed).\n */\n/**\n * Who the content is being rendered FOR — the reader, at the moment the\n * generator is asked.\n *\n * A notification is stored as rendered TEXT: title and body are columns, so\n * the language is chosen once, when the row is written, and never again. That\n * makes this the only honest place to ask. A generator is registered at BOOT —\n * a host that resolved its words there would pin every future reader to\n * whichever language the process happened to start in, invisibly, because a\n * single-locale host cannot tell the difference.\n *\n * The tag is the RECIPIENT's, never the request's. The person who triggers a\n * notification is routinely not the person who reads it: an invite is sent\n * because an administrator acted and is read by the invitee. Reading\n * `Accept-Language` here would be a bug that only ever surfaces as somebody\n * being told things in a language they do not speak.\n *\n * Absent means \"nobody said\" — a host with one audience, or one that stores no\n * per-user language, populates nothing and every generator answers with its\n * own default exactly as it did before this existed.\n */\nexport interface NotificationGenerateContext {\n readonly locale?: string | null;\n}\n\nexport interface NotificationGenerator<TPayload = unknown> {\n /** The event key, dot-namespaced (\"order.paid\"). One generator per type. */\n type: string;\n /** The preference category the router gates this type's fan-out on. */\n category: NotificationCategory;\n /**\n * Render this event's content for ONE recipient.\n *\n * `context` is OPTIONAL, and that is what keeps every generator written\n * before it working: a one-parameter `generate` is assignable to this\n * signature unchanged. A host that passes nothing is stating a fact — it has\n * no language for this reader — rather than asserting a default.\n */\n generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;\n}\n\n/**\n * The recipient as a transport sees them: resolved destinations only. Built by\n * the router from the host's contact directory + the push subscriptions this\n * package owns; transports use it to answer\n * {@link NotificationTransport.supports}.\n */\nexport interface TransportRecipient {\n userId: string;\n email: string | null;\n /**\n * The recipient's own language, when the host's contact directory states\n * one. Absent means \"nobody said\" — see {@link NotificationGenerateContext}.\n */\n locale?: string | null;\n /** Phone as the host stores it (transports normalize per provider rules). */\n phone: string | null;\n /** How many active browser push subscriptions the user holds. */\n pushSubscriptionCount: number;\n}\n\n/**\n * One pluggable channel adapter: a FORMATTER (agnostic content → channel\n * message) plus a SENDER. Adding a channel = registering one of these; the\n * router dispatches through the registry and needs no change.\n *\n * `send` resolves on success and THROWS on failure — the router records the\n * error on the delivery row and isolates it from other channels. Sends must be\n * retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.\n */\nexport interface NotificationTransport<TMessage = unknown> {\n channel: NotificationChannel;\n /**\n * Whether this recipient is addressable on this channel right now — the\n * destination exists (e-mail / phone / push subscription) AND the provider\n * is configured. `false` simply skips the channel (no delivery row).\n */\n supports(recipient: TransportRecipient): boolean;\n /** Transform the agnostic content into this channel's message shape. */\n format(content: NotificationContent): TMessage;\n /** Deliver the formatted message to the recipient. Throws on failure. */\n send(message: TMessage, recipient: TransportRecipient): Promise<void>;\n}\n\n/**\n * The host's product vocabulary. Everything below the surface (routing,\n * delivery rows, retries, the wire) is identical for every host; WHICH\n * categories exist and how they are labelled is not.\n */\nexport interface NotificationTaxonomy {\n /** The preference categories, in the order the settings screen lists them. */\n categories: readonly NotificationCategory[];\n}\n\n/**\n * The taxonomy in force. `categories` is REQUIRED — see above.\n *\n * The empty check was already here and stays: an empty list and a missing one\n * are the same mistake, and both now fail at assembly rather than rendering an\n * empty settings screen or somebody else's four rows.\n */\nexport function taxonomyOf(config: {\n categories: readonly NotificationCategory[];\n}): NotificationTaxonomy {\n const categories = config.categories;\n if (!categories || categories.length === 0) {\n throw new Error(\n '@12-apps/notifications: `categories` is required and must not be empty — ' +\n 'the preference categories are the host\\'s product vocabulary.',\n );\n }\n return { categories: [...categories] };\n}\n\n/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */\nexport interface NotificationLogger {\n info(message: string, ...meta: unknown[]): void;\n error(message: string, ...meta: unknown[]): void;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AAkLrE,SAAS,WAAW,QAEF;AACvB,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,cAAc,WAAW,WAAW,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,EAAE,YAAY,CAAC,GAAG,UAAU,EAAE;AACvC;AAXgB;","names":[]}
|