@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.
Files changed (53) hide show
  1. package/dist/chunk-BW723CX2.js +214 -0
  2. package/dist/chunk-BW723CX2.js.map +1 -0
  3. package/dist/chunk-CQZMTFPY.js +76 -0
  4. package/dist/chunk-CQZMTFPY.js.map +1 -0
  5. package/dist/{chunk-WU6QJLSZ.js → chunk-CUZW62JS.js} +2 -2
  6. package/dist/{chunk-AHNRSA6U.js → chunk-HHMRCMQU.js} +2 -2
  7. package/dist/chunk-M2TVBVH2.js +15 -0
  8. package/dist/chunk-M2TVBVH2.js.map +1 -0
  9. package/dist/chunk-MMLV4EZT.js +263 -0
  10. package/dist/chunk-MMLV4EZT.js.map +1 -0
  11. package/dist/chunk-O5BVUXPO.js +22 -0
  12. package/dist/chunk-O5BVUXPO.js.map +1 -0
  13. package/dist/{chunk-ORXJH3VM.js → chunk-WHBMPHQE.js} +28 -18
  14. package/dist/chunk-WHBMPHQE.js.map +1 -0
  15. package/dist/{chunk-4TTYQVPK.js → chunk-XE7HZVMH.js} +2 -9
  16. package/dist/chunk-XE7HZVMH.js.map +1 -0
  17. package/dist/{create-api-notifications-CgBdjfyF.d.ts → create-api-notifications-B3u6Kx3x.d.ts} +28 -5
  18. package/dist/{create-web-notifications-Du3hTs7P.d.ts → create-web-notifications-BHCzaU2y.d.ts} +14 -3
  19. package/dist/hono/index.d.ts +2 -2
  20. package/dist/hono/index.js +4 -3
  21. package/dist/hono/index.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +5 -3
  24. package/dist/{jobs-DhDjrAX5.d.ts → jobs-BDZ7aGHV.d.ts} +1 -1
  25. package/dist/manifest/server.d.ts +3 -3
  26. package/dist/manifest/server.js +5 -4
  27. package/dist/manifest/server.js.map +1 -1
  28. package/dist/manifest/web.d.ts +2 -2
  29. package/dist/manifest/web.js +3 -2
  30. package/dist/manifest/web.js.map +1 -1
  31. package/dist/panel-UFXNO4AF.js +243 -0
  32. package/dist/panel-UFXNO4AF.js.map +1 -0
  33. package/dist/preferences-screen-IOW6Y2H2.js +294 -0
  34. package/dist/preferences-screen-IOW6Y2H2.js.map +1 -0
  35. package/dist/react/index.d.ts +3 -3
  36. package/dist/react/index.js +17 -11
  37. package/dist/server/index.d.ts +4 -4
  38. package/dist/server/index.js +5 -4
  39. package/dist/{wire-5IRin4zH.d.ts → wire-6dzyfDE7.d.ts} +31 -5
  40. package/package.json +2 -2
  41. package/src/messages.ts +37 -4
  42. package/src/react/create-web-notifications.tsx +19 -10
  43. package/src/react/page-lazy.tsx +73 -0
  44. package/src/react/panel-lazy.tsx +74 -0
  45. package/src/server/context.ts +10 -0
  46. package/src/server/create-api-notifications.ts +30 -6
  47. package/src/server/routes.ts +32 -16
  48. package/dist/chunk-4TTYQVPK.js.map +0 -1
  49. package/dist/chunk-ORXJH3VM.js.map +0 -1
  50. package/dist/chunk-YE24MDS6.js +0 -1022
  51. package/dist/chunk-YE24MDS6.js.map +0 -1
  52. /package/dist/{chunk-WU6QJLSZ.js.map → chunk-CUZW62JS.js.map} +0 -0
  53. /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
+ }
@@ -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 { messagesOf, type NotificationWireMessages } from '../messages';
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
- /** User-facing copy overrides (pt-BR product copy by default). */
108
- messages: NotificationWireMessages;
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
- /** The copy in force, so a host's own screens can reuse a sentence. */
136
- messages: NotificationWireMessages;
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
- const messages = messagesOf(config);
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
 
@@ -1,4 +1,8 @@
1
- import type { NotificationWireMessages } from '../messages';
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
- messages: NotificationWireMessages;
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(await deps.inbox.list(actor.userId, parseListQuery(query, deps.messages))),
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.messages);
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.messages),
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(actor.userId, parsePreferencesBody(body, deps.messages));
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.messages);
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.messages);
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.messages),
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":[]}