@12-apps/notifications 4.6.0 → 4.7.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/dist/chunk-BW723CX2.js +214 -0
- package/dist/chunk-BW723CX2.js.map +1 -0
- package/dist/chunk-CQZMTFPY.js +76 -0
- package/dist/chunk-CQZMTFPY.js.map +1 -0
- package/dist/{chunk-WU6QJLSZ.js → chunk-CUZW62JS.js} +2 -2
- package/dist/{chunk-AHNRSA6U.js → chunk-HHMRCMQU.js} +2 -2
- package/dist/chunk-M2TVBVH2.js +15 -0
- package/dist/chunk-M2TVBVH2.js.map +1 -0
- package/dist/chunk-MMLV4EZT.js +263 -0
- package/dist/chunk-MMLV4EZT.js.map +1 -0
- package/dist/chunk-O5BVUXPO.js +22 -0
- package/dist/chunk-O5BVUXPO.js.map +1 -0
- package/dist/{chunk-ORXJH3VM.js → chunk-WHBMPHQE.js} +28 -18
- package/dist/chunk-WHBMPHQE.js.map +1 -0
- package/dist/{chunk-4TTYQVPK.js → chunk-XE7HZVMH.js} +2 -9
- package/dist/chunk-XE7HZVMH.js.map +1 -0
- package/dist/{create-api-notifications-CgBdjfyF.d.ts → create-api-notifications-B3u6Kx3x.d.ts} +28 -5
- package/dist/{create-web-notifications-Du3hTs7P.d.ts → create-web-notifications-BHCzaU2y.d.ts} +14 -3
- package/dist/hono/index.d.ts +2 -2
- package/dist/hono/index.js +4 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -3
- package/dist/{jobs-DhDjrAX5.d.ts → jobs-BDZ7aGHV.d.ts} +1 -1
- package/dist/manifest/server.d.ts +3 -3
- package/dist/manifest/server.js +5 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +2 -2
- package/dist/manifest/web.js +3 -2
- package/dist/manifest/web.js.map +1 -1
- package/dist/panel-UFXNO4AF.js +243 -0
- package/dist/panel-UFXNO4AF.js.map +1 -0
- package/dist/preferences-screen-IOW6Y2H2.js +294 -0
- package/dist/preferences-screen-IOW6Y2H2.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +17 -11
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.js +5 -4
- package/dist/{wire-5IRin4zH.d.ts → wire-6dzyfDE7.d.ts} +31 -5
- package/package.json +2 -2
- package/src/messages.ts +37 -4
- package/src/react/create-web-notifications.tsx +19 -10
- package/src/react/page-lazy.tsx +73 -0
- package/src/react/panel-lazy.tsx +74 -0
- package/src/server/context.ts +10 -0
- package/src/server/create-api-notifications.ts +30 -6
- package/src/server/routes.ts +32 -16
- package/dist/chunk-4TTYQVPK.js.map +0 -1
- package/dist/chunk-ORXJH3VM.js.map +0 -1
- package/dist/chunk-YE24MDS6.js +0 -1022
- package/dist/chunk-YE24MDS6.js.map +0 -1
- /package/dist/{chunk-WU6QJLSZ.js.map → chunk-CUZW62JS.js.map} +0 -0
- /package/dist/{chunk-AHNRSA6U.js.map → chunk-HHMRCMQU.js.map} +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routed preferences screen, fetched when a host actually routes to it.
|
|
3
|
+
*
|
|
4
|
+
* `createWebNotifications` returns two different KINDS of thing, and its own
|
|
5
|
+
* docstring says so: `page` is "the standalone surface … the one thing a host
|
|
6
|
+
* routes to", while the bell and the panel "are a PAIR a host drops into its own
|
|
7
|
+
* chrome". Chrome is on screen from the first paint; a routed surface is not.
|
|
8
|
+
*
|
|
9
|
+
* A static import made that distinction invisible to a bundler. Every host that
|
|
10
|
+
* put the bell in its header also shipped the preferences matrix — its channel
|
|
11
|
+
* toggles, the per-browser push enable step, and the design-system `Switch`
|
|
12
|
+
* behind them — in the same chunk as the header. A storefront paid for a
|
|
13
|
+
* settings screen a shopper never opens, before its first screen could render;
|
|
14
|
+
* a host that renders its OWN preferences page paid for this one twice.
|
|
15
|
+
*
|
|
16
|
+
* So `page` now loads on demand. Nothing else moves: the bell, the panel and
|
|
17
|
+
* `BellWithPanel` stay exactly as eager as the chrome they belong to, because
|
|
18
|
+
* that is what they are.
|
|
19
|
+
*
|
|
20
|
+
* NO PREFETCH, deliberately, and this is the opposite call from a surface a
|
|
21
|
+
* host opens from chrome it already has. A routed surface is reached by
|
|
22
|
+
* NAVIGATION, and every host here already code-splits its routes — so the
|
|
23
|
+
* fetch happens while the route is being entered, which is the moment a
|
|
24
|
+
* prefetch would have been trying to anticipate. Warming it at factory time
|
|
25
|
+
* would put the screen back on the boot path of every app, which is the whole
|
|
26
|
+
* cost this removes.
|
|
27
|
+
*/
|
|
28
|
+
import { Suspense, lazy, type ComponentType, type JSX } from 'react';
|
|
29
|
+
|
|
30
|
+
import type { NotificationMessages } from '../messages';
|
|
31
|
+
|
|
32
|
+
import type { NotificationsApiClient } from './api';
|
|
33
|
+
import type { PreferencesScreenProps } from './preferences-screen';
|
|
34
|
+
import type { WebPushSetupConfig } from './web-push-setup';
|
|
35
|
+
|
|
36
|
+
/** What the factory binds into the screen, and the host never passes. */
|
|
37
|
+
interface PreferencesPageParts {
|
|
38
|
+
api: NotificationsApiClient;
|
|
39
|
+
messages: NotificationMessages;
|
|
40
|
+
webPush: WebPushSetupConfig;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The routed screen, bound and loaded on first render.
|
|
45
|
+
*
|
|
46
|
+
* `lazy` memoises its factory, so the binding below happens once however many
|
|
47
|
+
* times a host mounts the page — the same guarantee the direct call gave.
|
|
48
|
+
*
|
|
49
|
+
* The fallback is `null` because a host routes to this: whatever it renders
|
|
50
|
+
* around the route is already on screen, and a second spinner inside it would
|
|
51
|
+
* be one more thing appearing and disappearing during a navigation the host is
|
|
52
|
+
* already indicating.
|
|
53
|
+
*/
|
|
54
|
+
export function lazyPreferencesPage(
|
|
55
|
+
parts: PreferencesPageParts,
|
|
56
|
+
): ComponentType<PreferencesScreenProps> {
|
|
57
|
+
const Bound = lazy(async () => {
|
|
58
|
+
const { PreferencesScreen } = await import('./preferences-screen');
|
|
59
|
+
return {
|
|
60
|
+
default: (props: PreferencesScreenProps): JSX.Element => (
|
|
61
|
+
<PreferencesScreen {...props} {...parts} />
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return function NotificationsPreferencesPage(props: PreferencesScreenProps): JSX.Element {
|
|
67
|
+
return (
|
|
68
|
+
<Suspense fallback={null}>
|
|
69
|
+
<Bound {...props} />
|
|
70
|
+
</Suspense>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox slide-over, fetched the first time somebody opens it.
|
|
3
|
+
*
|
|
4
|
+
* The bell and the panel are a PAIR a host drops into its chrome, and that is
|
|
5
|
+
* still true — but only the BELL is on screen when a page paints. The panel is
|
|
6
|
+
* behind a tap, and a static import made every host pay for it up front: the
|
|
7
|
+
* design-system `Drawer` and, through it, MUI's `SwipeableDrawer`, `Modal`,
|
|
8
|
+
* `Slide` and the focus trap, plus the row, the empty state and the pager. On a
|
|
9
|
+
* storefront that is a slide-over most visits never open, parsed before the
|
|
10
|
+
* first screen can render.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the gate is "ever opened" rather than `open`
|
|
13
|
+
*
|
|
14
|
+
* `lazy` fetches when a component first RENDERS, so a boundary that still
|
|
15
|
+
* rendered the panel while closed would fetch immediately and buy nothing. This
|
|
16
|
+
* renders `null` until the panel has been open once, which is what actually
|
|
17
|
+
* defers the download to the tap.
|
|
18
|
+
*
|
|
19
|
+
* And once opened it STAYS mounted. Unmounting on close would throw away the
|
|
20
|
+
* drawer's transition state, so the panel would vanish instead of sliding out,
|
|
21
|
+
* and the entrance animation would re-run on every reopen — which someone
|
|
22
|
+
* working through an inbox does repeatedly. The fetch happens once.
|
|
23
|
+
*
|
|
24
|
+
* The initial state reads `open` rather than starting at `false`, so a host that
|
|
25
|
+
* mounts the panel already open renders it in the same commit instead of a frame
|
|
26
|
+
* later.
|
|
27
|
+
*
|
|
28
|
+
* ## Why `null` for the fallback
|
|
29
|
+
*
|
|
30
|
+
* The only frame this can show anything is the one right after the tap, where a
|
|
31
|
+
* spinner reads as a stall rather than as progress. The chunk is small and
|
|
32
|
+
* same-origin.
|
|
33
|
+
*/
|
|
34
|
+
import { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } from 'react';
|
|
35
|
+
|
|
36
|
+
import type { NotificationMessages } from '../messages';
|
|
37
|
+
|
|
38
|
+
import type { InboxStore } from './inbox-state';
|
|
39
|
+
import type { NotificationsPanelProps } from './panel';
|
|
40
|
+
|
|
41
|
+
/** What the factory binds into the panel, and the host never passes. */
|
|
42
|
+
interface PanelParts {
|
|
43
|
+
store: InboxStore;
|
|
44
|
+
messages: NotificationMessages;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function lazyNotificationsPanel(
|
|
48
|
+
parts: PanelParts,
|
|
49
|
+
): ComponentType<NotificationsPanelProps> {
|
|
50
|
+
const Bound = lazy(async () => {
|
|
51
|
+
const { NotificationsPanel } = await import('./panel');
|
|
52
|
+
return {
|
|
53
|
+
default: (props: NotificationsPanelProps): JSX.Element => (
|
|
54
|
+
<NotificationsPanel {...props} {...parts} />
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return function NotificationsPanelSlot(props: NotificationsPanelProps): JSX.Element | null {
|
|
60
|
+
const [everOpened, setEverOpened] = useState(props.open);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (props.open) setEverOpened(true);
|
|
64
|
+
}, [props.open]);
|
|
65
|
+
|
|
66
|
+
if (!everOpened) return null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Suspense fallback={null}>
|
|
70
|
+
<Bound {...props} />
|
|
71
|
+
</Suspense>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -32,6 +32,16 @@ export interface NotificationsRequest {
|
|
|
32
32
|
body?: unknown;
|
|
33
33
|
/** Headers the surface reads (`user-agent`, for the device hint). */
|
|
34
34
|
headers?: Record<string, string | undefined>;
|
|
35
|
+
/**
|
|
36
|
+
* The language to answer this caller in, as a language tag (`pt-BR`,
|
|
37
|
+
* `en-US`) — the same field `@12-apps/wiring`'s `WireRequest` carries.
|
|
38
|
+
*
|
|
39
|
+
* Populated by the host's adapter, which is the only layer that can
|
|
40
|
+
* negotiate one. Absent is meaningful and not an error: a host with one
|
|
41
|
+
* audience never sets it, and this surface must then answer with the words
|
|
42
|
+
* it was configured with rather than invent a language.
|
|
43
|
+
*/
|
|
44
|
+
locale?: string;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/** What a handler answers with; the adapter maps it onto its response type. */
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createGeneratorRegistry, type NotificationGeneratorRegistry } from '../generators';
|
|
2
|
-
import
|
|
2
|
+
import type {
|
|
3
|
+
NotificationsCopySource,
|
|
4
|
+
NotificationWireMessages,
|
|
5
|
+
} from '../messages';
|
|
3
6
|
import type { ChannelRow } from '../preferences-core';
|
|
4
7
|
import {
|
|
5
8
|
taxonomyOf,
|
|
@@ -104,8 +107,15 @@ export interface NotificationsServerConfig {
|
|
|
104
107
|
onInboxChanged?: (userId: string) => void;
|
|
105
108
|
/** The host's authorization engine, for `notifyByPermission`. */
|
|
106
109
|
audience?: NotificationAudienceDirectory;
|
|
107
|
-
/**
|
|
108
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Every user-facing sentence this surface can produce — REQUIRED host config.
|
|
112
|
+
*
|
|
113
|
+
* A pack, or a RESOLVER for a host whose callers do not share a language.
|
|
114
|
+
* Passed to the routes UNRESOLVED: this factory runs once per process (and
|
|
115
|
+
* at least one host memoises its call), so resolving here would answer every
|
|
116
|
+
* later request in the language the process started with.
|
|
117
|
+
*/
|
|
118
|
+
messages: NotificationsCopySource<NotificationWireMessages>;
|
|
109
119
|
/** The host's logger. Defaults to the console. */
|
|
110
120
|
logger?: NotificationLogger;
|
|
111
121
|
}
|
|
@@ -132,8 +142,14 @@ export interface ApiNotifications {
|
|
|
132
142
|
registerGenerator: NotificationGeneratorRegistry['register'];
|
|
133
143
|
/** The declared transports, for diagnostics and availability probes. */
|
|
134
144
|
transports: TransportRegistry;
|
|
135
|
-
/**
|
|
136
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The copy source in force, so a host's own screens can reuse a sentence.
|
|
147
|
+
*
|
|
148
|
+
* The SOURCE rather than a resolved pack, for the same reason the routes get
|
|
149
|
+
* one: a host screen serving two readers must be able to ask per reader.
|
|
150
|
+
* Read it with `messagesOf({ messages }, locale)`.
|
|
151
|
+
*/
|
|
152
|
+
messages: NotificationsCopySource<NotificationWireMessages>;
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
/** Drop the keys the host left unset, so an absent seam stays absent. */
|
|
@@ -150,7 +166,15 @@ const consoleLogger: NotificationLogger = {
|
|
|
150
166
|
};
|
|
151
167
|
|
|
152
168
|
export function createApiNotifications(config: NotificationsServerConfig): ApiNotifications {
|
|
153
|
-
|
|
169
|
+
/**
|
|
170
|
+
* The SOURCE travels; nothing is resolved here.
|
|
171
|
+
*
|
|
172
|
+
* This factory runs once per process — and at least one host memoises its
|
|
173
|
+
* call behind an `if (assembled) return assembled;` — so a `messagesOf(config)`
|
|
174
|
+
* on this line would word every later request in the language the process
|
|
175
|
+
* started with. The handlers resolve per request instead.
|
|
176
|
+
*/
|
|
177
|
+
const messages = config.messages;
|
|
154
178
|
const taxonomy = taxonomyOf(config);
|
|
155
179
|
const logger = config.logger ?? consoleLogger;
|
|
156
180
|
|
package/src/server/routes.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
messagesOf,
|
|
3
|
+
type NotificationsCopySource,
|
|
4
|
+
type NotificationWireMessages,
|
|
5
|
+
} from '../messages';
|
|
2
6
|
import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../types';
|
|
3
7
|
|
|
4
8
|
import {
|
|
@@ -40,7 +44,11 @@ interface NotificationRoutesDeps {
|
|
|
40
44
|
transports: TransportRegistry;
|
|
41
45
|
contacts: NotificationContactDirectory;
|
|
42
46
|
categories: readonly string[];
|
|
43
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The SOURCE, not a resolved pack — the route table is built once per
|
|
49
|
+
* process and every handler below runs per request.
|
|
50
|
+
*/
|
|
51
|
+
messages: NotificationsCopySource<NotificationWireMessages>;
|
|
44
52
|
/** Told when a write actually changed something (for a realtime hint). */
|
|
45
53
|
onInboxChanged?: (userId: string) => void;
|
|
46
54
|
}
|
|
@@ -98,8 +106,13 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
98
106
|
{
|
|
99
107
|
method: 'GET',
|
|
100
108
|
path: '/notifications',
|
|
101
|
-
handle: guarded(async ({ actor, query }) =>
|
|
102
|
-
ok(
|
|
109
|
+
handle: guarded(async ({ actor, query, locale }) =>
|
|
110
|
+
ok(
|
|
111
|
+
await deps.inbox.list(
|
|
112
|
+
actor.userId,
|
|
113
|
+
parseListQuery(query, messagesOf(deps, locale)),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
103
116
|
),
|
|
104
117
|
},
|
|
105
118
|
{
|
|
@@ -113,8 +126,8 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
113
126
|
{
|
|
114
127
|
method: 'POST',
|
|
115
128
|
path: '/notifications/mark-read',
|
|
116
|
-
handle: guarded(async ({ actor, body }) => {
|
|
117
|
-
const target = parseMarkReadBody(body, deps
|
|
129
|
+
handle: guarded(async ({ actor, body, locale }) => {
|
|
130
|
+
const target = parseMarkReadBody(body, messagesOf(deps, locale));
|
|
118
131
|
const updated =
|
|
119
132
|
'all' in target
|
|
120
133
|
? await deps.inbox.markAllRead(actor.userId)
|
|
@@ -131,10 +144,10 @@ function inboxRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
131
144
|
method: 'POST',
|
|
132
145
|
// POST, not DELETE, because the ids travel in a JSON body.
|
|
133
146
|
path: '/notifications/delete',
|
|
134
|
-
handle: guarded(async ({ actor, body }) => {
|
|
147
|
+
handle: guarded(async ({ actor, body, locale }) => {
|
|
135
148
|
const deleted = await deps.inbox.softDelete(
|
|
136
149
|
actor.userId,
|
|
137
|
-
parseDeleteBody(body, deps
|
|
150
|
+
parseDeleteBody(body, messagesOf(deps, locale)),
|
|
138
151
|
);
|
|
139
152
|
// Same rule as mark-read. A delete can move the badge too — an UNREAD
|
|
140
153
|
// row that is removed takes its place in the count with it.
|
|
@@ -155,10 +168,13 @@ function preferenceRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
155
168
|
{
|
|
156
169
|
method: 'PUT',
|
|
157
170
|
path: '/notification-preferences',
|
|
158
|
-
handle: guarded(async ({ actor, body }) => {
|
|
171
|
+
handle: guarded(async ({ actor, body, locale }) => {
|
|
159
172
|
// The dispatch pipeline reads these on every emit, so a save takes
|
|
160
173
|
// effect immediately — no cache to invalidate.
|
|
161
|
-
await deps.preferences.save(
|
|
174
|
+
await deps.preferences.save(
|
|
175
|
+
actor.userId,
|
|
176
|
+
parsePreferencesBody(body, messagesOf(deps, locale)),
|
|
177
|
+
);
|
|
162
178
|
return ok(await preferencesPayload(deps, actor.userId));
|
|
163
179
|
}),
|
|
164
180
|
},
|
|
@@ -170,8 +186,8 @@ function pushRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
170
186
|
{
|
|
171
187
|
method: 'GET',
|
|
172
188
|
path: '/push-subscriptions',
|
|
173
|
-
handle: guarded(async ({ actor, query }) => {
|
|
174
|
-
const endpoint = parsePushEndpointQuery(query, deps
|
|
189
|
+
handle: guarded(async ({ actor, query, locale }) => {
|
|
190
|
+
const endpoint = parsePushEndpointQuery(query, messagesOf(deps, locale));
|
|
175
191
|
return ok({
|
|
176
192
|
// null = web push is not configured on this deployment.
|
|
177
193
|
vapidPublicKey: deps.transports.webPushPublicKey(),
|
|
@@ -188,8 +204,8 @@ function pushRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
188
204
|
{
|
|
189
205
|
method: 'POST',
|
|
190
206
|
path: '/push-subscriptions',
|
|
191
|
-
handle: guarded(async ({ actor, body, headers }) => {
|
|
192
|
-
const input = parsePushSubscriptionBody(body, deps
|
|
207
|
+
handle: guarded(async ({ actor, body, headers, locale }) => {
|
|
208
|
+
const input = parsePushSubscriptionBody(body, messagesOf(deps, locale));
|
|
193
209
|
const userAgent = headers?.['user-agent'];
|
|
194
210
|
await deps.pushSubscriptions.save(actor.userId, {
|
|
195
211
|
...input,
|
|
@@ -202,10 +218,10 @@ function pushRoutes(deps: NotificationRoutesDeps): NotificationsRoute[] {
|
|
|
202
218
|
method: 'DELETE',
|
|
203
219
|
// The endpoint is a long opaque URL, unusable as a path param.
|
|
204
220
|
path: '/push-subscriptions',
|
|
205
|
-
handle: guarded(async ({ actor, body }) => {
|
|
221
|
+
handle: guarded(async ({ actor, body, locale }) => {
|
|
206
222
|
await deps.pushSubscriptions.remove(
|
|
207
223
|
actor.userId,
|
|
208
|
-
parsePushEndpointBody(body, deps
|
|
224
|
+
parsePushEndpointBody(body, messagesOf(deps, locale)),
|
|
209
225
|
);
|
|
210
226
|
return ok({ count: await deps.pushSubscriptions.count(actor.userId) });
|
|
211
227
|
}),
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/types.ts","../src/messages.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 */\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 generate: (payload: TPayload) => 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 /** 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","/**\n * Every sentence this package can say to a USER, stated by the HOST.\n *\n * The copy lives in ONE table rather than in each screen so the api half and\n * the react half can never disagree about a sentence — the 401 body the wire\n * returns and the error the panel renders come from the same key.\n *\n * THE pt-BR TABLE THAT USED TO BE THE DEFAULT IS GONE. Its own docstring said\n * what it was: \"the product copy the surface shipped with\", labelled in the\n * source as one named application's \"exact copy\". A description of one adopter,\n * shipped inside the package every other adopter installs, and reached by\n * saying nothing.\n *\n * `categoryLabels` is the sharpest of the forty. The categories themselves\n * became required config in the release before this one, precisely because\n * WHICH categories exist is product vocabulary — and their LABELS kept\n * defaulting, so a host that declared `['loans', 'fines']` got a labels map\n * describing somebody else's four. Required categories with defaulted labels\n * for a different host's categories is not a smaller version of the bug; it is\n * the same bug with a compile-time gesture in front of it.\n *\n * So `messages` is REQUIRED and whole. The interface is the checklist, and the\n * compiler names the sentences a host has not written yet.\n */\n/**\n * The sentences the SERVER half renders — and the whole of what a backend mount\n * has to state.\n *\n * Split out when `messages` became required. Requiring the full forty on a\n * server config would have made a backend-only adopter write three dozen\n * sentences for screens it does not serve, which is the kind of tax that gets a\n * required-config migration reverted rather than adopted. These four are the\n * ones the router and the route descriptors actually put on a wire.\n */\nexport interface NotificationWireMessages {\n unauthenticated: string;\n invalidBody: string;\n operationFailed: string;\n /** `POST /notifications/mark-read` with neither `ids` nor `all`. */\n markReadTargetRequired: string;\n}\n\n/** Every sentence, wire and screen — what the REACT half needs. */\nexport interface NotificationMessages extends NotificationWireMessages {\n // --- the inbox panel -----------------------------------------------------\n panelTitle: string;\n markAllRead: string;\n loading: string;\n loadMore: string;\n loadingMore: string;\n loadFailedTitle: string;\n loadFailedBody: string;\n retry: string;\n emptyTitle: string;\n emptyBody: string;\n openBell: string;\n /** `(count) => 'Abrir notificações (3 não lidas)'`. */\n openBellWithUnread: (count: number) => string;\n unreadSuffix: string;\n deleteOne: (title: string) => string;\n\n // --- relative timestamps -------------------------------------------------\n justNow: string;\n minutesAgo: (minutes: number) => string;\n hoursAgo: (hours: number) => string;\n daysAgo: (days: number) => string;\n /** Locale for the fallback absolute date on rows older than a week. */\n dateLocale: string;\n\n // --- the preferences screen ---------------------------------------------\n preferencesTitle: string;\n preferencesLead: string;\n channelLabels: Record<string, string>;\n channelUnavailableHints: Record<string, string>;\n categoryLabels: Record<string, { title: string; description: string }>;\n /** Fallback title for a category the host added but did not label. */\n categoryFallbackTitle: (category: string) => string;\n devicePushTitle: string;\n devicePushIdle: string;\n devicePushOn: string;\n devicePushDenied: string;\n devicePushFailed: string;\n devicePushEnable: string;\n devicePushEnabling: string;\n}\n\n/**\n * The messages in force.\n *\n * A pass-through rather than a merge: there is nothing left to merge WITH, and\n * that is the point of the change. The old version spread the host's table over\n * the origin's, including PER KEY inside `channelLabels`,\n * `channelUnavailableHints` and `categoryLabels` — so a host that relabelled one\n * channel kept the origin's wording for the other three, and a host that\n * labelled its own two categories kept the origin's four sitting beside them in\n * the same screen.\n *\n * Kept as a function because all three mounts read it off a config object, and\n * because a later rule (a blank-string refusal, say) belongs in one place.\n */\nexport function messagesOf<T extends NotificationWireMessages>(config: { messages: T }): T {\n return config.messages;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AA4IrE,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;;;ACxDT,SAAS,WAA+C,QAA4B;AACzF,SAAO,OAAO;AAChB;AAFgB;","names":[]}
|