@12-apps/notifications 4.10.3 → 4.12.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.
Files changed (51) hide show
  1. package/ADOPTING.md +31 -2
  2. package/dist/{chunk-BW723CX2.js → chunk-2IAHFIXS.js} +51 -38
  3. package/dist/chunk-2IAHFIXS.js.map +1 -0
  4. package/dist/{chunk-6W7INOYQ.js → chunk-CPQKKLPS.js} +1 -1
  5. package/dist/{chunk-6W7INOYQ.js.map → chunk-CPQKKLPS.js.map} +1 -1
  6. package/dist/{chunk-ZIR3ILFH.js → chunk-FBBPS2LT.js} +2 -2
  7. package/dist/{chunk-SWOWHIFE.js → chunk-GK6GSC2J.js} +37 -2
  8. package/dist/chunk-GK6GSC2J.js.map +1 -0
  9. package/dist/{chunk-H55A4LHG.js → chunk-PNY6S6WH.js} +39 -28
  10. package/dist/chunk-PNY6S6WH.js.map +1 -0
  11. package/dist/{chunk-WVRODNXQ.js → chunk-WZBX7YCE.js} +32 -15
  12. package/dist/{chunk-WVRODNXQ.js.map → chunk-WZBX7YCE.js.map} +1 -1
  13. package/dist/{create-api-notifications-CcPYrM3p.d.ts → create-api-notifications-BTudlaSC.d.ts} +15 -6
  14. package/dist/{create-web-notifications-DV3Y8k7e.d.ts → create-web-notifications-2xxbKnrW.d.ts} +61 -14
  15. package/dist/{generators-qAD4fNPq.d.ts → generators-CQYdJfB5.d.ts} +1 -1
  16. package/dist/hono/index.d.ts +5 -5
  17. package/dist/hono/index.js +3 -3
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +9 -3
  20. package/dist/{jobs--fex87-q.d.ts → jobs-CaovU4GM.d.ts} +1 -1
  21. package/dist/manifest/server.d.ts +6 -6
  22. package/dist/manifest/server.js +4 -4
  23. package/dist/manifest/web.d.ts +3 -3
  24. package/dist/manifest/web.js +2 -2
  25. package/dist/{panel-V2ULFC4Y.js → panel-MKI4PTNZ.js} +2 -2
  26. package/dist/{preferences-screen-SXUIGECY.js → preferences-screen-S3ZHX5LB.js} +2 -2
  27. package/dist/react/index.d.ts +4 -4
  28. package/dist/react/index.js +2 -2
  29. package/dist/server/index.d.ts +7 -7
  30. package/dist/server/index.js +4 -4
  31. package/dist/{types-BlqZkCWZ.d.ts → types-H_aFzLA0.d.ts} +33 -0
  32. package/dist/web-push/index.d.ts +2 -2
  33. package/dist/{web-push-Dnyaha2z.d.ts → web-push-C6U-5JCV.d.ts} +1 -1
  34. package/dist/{wire-BG1kuoXX.d.ts → wire-Bn6aA2nL.d.ts} +65 -2
  35. package/package.json +2 -2
  36. package/src/index.ts +4 -0
  37. package/src/preferences-core.ts +107 -0
  38. package/src/react/bell-badge.ts +147 -0
  39. package/src/react/bell-button.tsx +19 -31
  40. package/src/react/create-web-notifications.tsx +55 -6
  41. package/src/react/hooks.ts +61 -13
  42. package/src/react/index.ts +13 -0
  43. package/src/server/preferences.ts +29 -9
  44. package/src/server/router.ts +37 -7
  45. package/src/types.ts +33 -0
  46. package/dist/chunk-BW723CX2.js.map +0 -1
  47. package/dist/chunk-H55A4LHG.js.map +0 -1
  48. package/dist/chunk-SWOWHIFE.js.map +0 -1
  49. /package/dist/{chunk-ZIR3ILFH.js.map → chunk-FBBPS2LT.js.map} +0 -0
  50. /package/dist/{panel-V2ULFC4Y.js.map → panel-MKI4PTNZ.js.map} +0 -0
  51. /package/dist/{preferences-screen-SXUIGECY.js.map → preferences-screen-S3ZHX5LB.js.map} +0 -0
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 origin host's test ids ship inside. |
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 that wants the chrome around them; `useBellBadge` + `Panel` for one that draws its own trigger; `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
  | **Mail layout** | `@12-apps/notifications/email` | `renderEmail(document)` → `{ subject, html, text }`. Framework-free and dependency-free. Turn it on for the EMAIL transport by declaring `layout`; the copy packs are `@12-apps/notifications/email/locales`. |
26
26
  | **Previews** | `@12-apps/notifications/email/previews` | The preview catalogue and its two route descriptors. `@12-apps/notifications/email/previews/hono` is the one-call mount; `.../previews/react` is the operator screen. A SECOND wiring manifest — see "The mail layout and its preview console". |
@@ -495,7 +495,14 @@ createWebNotifications({
495
495
  with `LIVE_SUBJECT_KEY` precisely so the phone collapses them, and the row is
496
496
  still the record the reader re-reads later. It is a reason to decide which
497
497
  surface owns the COUNT before you turn live activities on, because this
498
- package does not decide it for you.
498
+ package does not decide it for you yet.
499
+
500
+ Answering it means the server saying which unread rows name which subject.
501
+ That was built and pulled, on contract grounds rather than arithmetic ones:
502
+ the extra field breaks an adopter that publishes this response as a closed
503
+ schema, and every host pays the read — including one with no live activities
504
+ at all. The way through is an opt-in the surface asks for. See the docblock on
505
+ `src/react/bell-badge.ts` for the full account.
499
506
  - It leaves when your hook stops returning it. There is no dismiss, no read and
500
507
  no delete — the subject finishing is the only exit, which is what stops the
501
508
  section becoming a second inbox.
@@ -512,6 +519,28 @@ data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
512
519
  (`LiveActivity` itself comes from either entry — the root or `./react` — so the
513
520
  hook and the type it returns are one import line.)
514
521
 
522
+ **If you draw your own bell**, take `useBellBadge` from the factory rather than
523
+ `useUnreadCount`:
524
+
525
+ ```ts
526
+ const { count, hasNew } = notifications.useBellBadge({ enabled: signedIn });
527
+ ```
528
+
529
+ `useUnreadCount` counts inbox rows and knows nothing about what is happening
530
+ right now, so a host rendering it in its own chrome gets a bell showing NOTHING
531
+ while a pinned entry sits inside the panel it opens. `useBellBadge` is the hook
532
+ this package's own bell uses, so the two cannot disagree — `hasNew` is the
533
+ `primary`/`neutral` decision above, said in a way your chrome can paint however
534
+ it likes. It counts what that bell counts, double count included.
535
+
536
+ It returns a new object each render, like any hook returning a literal. Reading
537
+ `count` and `hasNew` straight into your JSX — what the bells above do — needs
538
+ nothing; putting the object itself in a dependency array or through
539
+ `React.memo` wants a `useMemo` on your side.
540
+
541
+ Without `liveActivities` configured it is `useUnreadCount` plus
542
+ `hasNew: count > 0`.
543
+
515
544
  `formatWebPush` turns it into `tag` on the push payload; your service worker
516
545
  passes `tag` to `showNotification` and the tray keeps ONE entry per subject
517
546
  instead of one per stage. The worker is still yours (rule 12) — a worker that
@@ -2,38 +2,6 @@ import {
2
2
  __name
3
3
  } from "./chunk-7QVYU63E.js";
4
4
 
5
- // src/react/bell-icon.tsx
6
- import { Box } from "@12-apps/ui/mui/Box";
7
- import { jsx, jsxs } from "react/jsx-runtime";
8
- function BellIcon({
9
- size = 28,
10
- dim = false
11
- }) {
12
- return /* @__PURE__ */ jsxs(
13
- Box,
14
- {
15
- component: "svg",
16
- viewBox: "0 0 24 24",
17
- "aria-hidden": true,
18
- sx: {
19
- width: size,
20
- height: size,
21
- fill: "none",
22
- stroke: "currentColor",
23
- opacity: dim ? 0.4 : 1
24
- },
25
- strokeWidth: 1.8,
26
- strokeLinecap: "round",
27
- strokeLinejoin: "round",
28
- children: [
29
- /* @__PURE__ */ jsx("path", { d: "M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" }),
30
- /* @__PURE__ */ jsx("path", { d: "M10 20a2 2 0 0 0 4 0" })
31
- ]
32
- }
33
- );
34
- }
35
- __name(BellIcon, "BellIcon");
36
-
37
5
  // src/react/inbox-state.ts
38
6
  var PAGE_SIZE = 20;
39
7
  var BADGE_POLL_MS = 6e4;
@@ -166,10 +134,18 @@ function useInboxState(store) {
166
134
  return useSyncExternalStore(store.subscribe, store.getState, store.getState);
167
135
  }
168
136
  __name(useInboxState, "useInboxState");
169
- function useUnreadCount(store, options = {}) {
137
+ var NOTHING_TO_SHOW = {
138
+ unread: 0,
139
+ items: [],
140
+ status: "idle",
141
+ nextCursor: null,
142
+ loadingMore: false
143
+ };
144
+ function useBadgeState(store, options = {}) {
170
145
  const enabled = options.enabled ?? true;
171
146
  const subscribe = options.subscribe;
172
- const { unread } = useInboxState(store);
147
+ const live = useInboxState(store);
148
+ const state = enabled ? live : NOTHING_TO_SHOW;
173
149
  options.useSignal?.(() => {
174
150
  if (enabled) store.invalidate();
175
151
  });
@@ -189,7 +165,11 @@ function useUnreadCount(store, options = {}) {
189
165
  unsubscribe?.();
190
166
  };
191
167
  }, [store, enabled, subscribe]);
192
- return enabled ? unread : 0;
168
+ return state;
169
+ }
170
+ __name(useBadgeState, "useBadgeState");
171
+ function useUnreadCount(store, options = {}) {
172
+ return useBadgeState(store, options).unread;
193
173
  }
194
174
  __name(useUnreadCount, "useUnreadCount");
195
175
  function useInboxList(store, open) {
@@ -201,14 +181,47 @@ function useInboxList(store, open) {
201
181
  }
202
182
  __name(useInboxList, "useInboxList");
203
183
 
184
+ // src/react/bell-icon.tsx
185
+ import { Box } from "@12-apps/ui/mui/Box";
186
+ import { jsx, jsxs } from "react/jsx-runtime";
187
+ function BellIcon({
188
+ size = 28,
189
+ dim = false
190
+ }) {
191
+ return /* @__PURE__ */ jsxs(
192
+ Box,
193
+ {
194
+ component: "svg",
195
+ viewBox: "0 0 24 24",
196
+ "aria-hidden": true,
197
+ sx: {
198
+ width: size,
199
+ height: size,
200
+ fill: "none",
201
+ stroke: "currentColor",
202
+ opacity: dim ? 0.4 : 1
203
+ },
204
+ strokeWidth: 1.8,
205
+ strokeLinecap: "round",
206
+ strokeLinejoin: "round",
207
+ children: [
208
+ /* @__PURE__ */ jsx("path", { d: "M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" }),
209
+ /* @__PURE__ */ jsx("path", { d: "M10 20a2 2 0 0 0 4 0" })
210
+ ]
211
+ }
212
+ );
213
+ }
214
+ __name(BellIcon, "BellIcon");
215
+
204
216
  export {
205
- BellIcon,
206
217
  PAGE_SIZE,
207
218
  BADGE_POLL_MS,
208
219
  BADGE_RECONCILE_MS,
209
220
  createInboxStore,
210
221
  useInboxState,
222
+ useBadgeState,
211
223
  useUnreadCount,
212
- useInboxList
224
+ useInboxList,
225
+ BellIcon
213
226
  };
214
- //# sourceMappingURL=chunk-BW723CX2.js.map
227
+ //# sourceMappingURL=chunk-2IAHFIXS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/inbox-state.ts","../src/react/hooks.ts","../src/react/bell-icon.tsx"],"sourcesContent":["import type { InboxNotification } from '../wire';\n\nimport type { NotificationsApiClient } from './api';\n\n/**\n * The inbox's client state, as ONE store shared by the bell and the panel.\n *\n * They have to share it: marking a row read in the panel must move the badge in\n * the same tick, and an arrival must add a row to the list AND to the count.\n * the origin got that for free from a react-query cache the host had already\n * mounted; a published package cannot assume one — a query client is a host\n * decision, and requiring a particular one (or a particular version of one) is\n * the kind of dependency that keeps a package out of a host that made the other\n * choice. So the sharing is explicit and dependency-free: one subscribable\n * store, read through `useSyncExternalStore`.\n *\n * Optimistic on every write, with invalidate-on-error: the badge and the list\n * update instantly, and a failed write refetches the server truth rather than\n * leaving the screen asserting something the database does not say.\n */\n\nexport const PAGE_SIZE = 20;\n\n/** The badge's poll while nothing is pushing to us. */\nexport const BADGE_POLL_MS = 60_000;\n\n/**\n * The badge's interval while a realtime connection is live.\n *\n * Five minutes, not \"never\": this is the reconcile that catches an event the bus\n * dropped, and it costs one COUNT per open tab per five minutes. Deliberately\n * far slower than an operational screen's — a bell badge is ambient, and the\n * arrival that matters is pushed within milliseconds anyway. The poll does NOT\n * stop, which is the standing contract: a dropped event must cost latency and\n * never correctness.\n */\nexport const BADGE_RECONCILE_MS = 300_000;\n\nexport type InboxListStatus = 'idle' | 'pending' | 'ready' | 'error';\n\nexport interface InboxState {\n unread: number;\n items: InboxNotification[];\n status: InboxListStatus;\n /** A cursor means there is another page. */\n nextCursor: string | null;\n loadingMore: boolean;\n}\n\nexport interface InboxStore {\n getState(): InboxState;\n subscribe(listener: () => void): () => void;\n /** Load the first page (idempotent while one is in flight). */\n open(): void;\n /** Refetch the badge count. */\n refreshBadge(): void;\n /** Refetch both — what a realtime hint or a failed write triggers. */\n invalidate(): void;\n loadMore(): void;\n markRead(ids: readonly string[]): void;\n markAllRead(): void;\n remove(id: string): void;\n}\n\nconst EMPTY: InboxState = {\n unread: 0,\n items: [],\n status: 'idle',\n nextCursor: null,\n loadingMore: false,\n};\n\n/** The mutable cell the functions below share, so each one stays small. */\ninterface Cell {\n state: InboxState;\n listeners: Set<() => void>;\n /** Fences a stale reload: a newer one must always win. */\n request: number;\n}\n\nfunction patch(cell: Cell, next: Partial<InboxState>): void {\n cell.state = { ...cell.state, ...next };\n for (const listener of cell.listeners) listener();\n}\n\n/** Refetch the badge count. The number is always one the server just gave us. */\nfunction refreshBadge(cell: Cell, api: NotificationsApiClient): void {\n void api\n .unreadCount()\n .then((unread) => patch(cell, { unread }))\n .catch(() => undefined);\n}\n\n/**\n * Reload page one, discarding whatever the optimistic path had produced.\n * `request` fences it: a reload that started before a newer one must not land\n * after it and reinstate stale rows.\n */\nfunction reloadList(cell: Cell, api: NotificationsApiClient): void {\n const token = (cell.request += 1);\n patch(cell, { status: cell.state.items.length > 0 ? cell.state.status : 'pending' });\n void api\n .listNotifications({ limit: PAGE_SIZE })\n .then((page) => {\n if (token !== cell.request) return;\n patch(cell, { items: page.items, nextCursor: page.nextCursor, status: 'ready' });\n })\n .catch(() => {\n if (token !== cell.request) return;\n patch(cell, { status: 'error' });\n });\n}\n\nfunction invalidate(cell: Cell, api: NotificationsApiClient): void {\n refreshBadge(cell, api);\n if (cell.state.status !== 'idle') reloadList(cell, api);\n}\n\n/** Apply an optimistic edit; on failure, take the server's word instead. */\nfunction write(\n cell: Cell,\n api: NotificationsApiClient,\n apply: () => void,\n send: () => Promise<{ ok: boolean }>,\n): void {\n apply();\n void send()\n .then((result) => {\n if (!result.ok) invalidate(cell, api);\n })\n .catch(() => invalidate(cell, api));\n}\n\nfunction bumpUnread(cell: Cell, delta: number): void {\n patch(cell, { unread: Math.max(0, cell.state.unread + delta) });\n}\n\nfunction loadMore(cell: Cell, api: NotificationsApiClient): void {\n const cursor = cell.state.nextCursor;\n if (!cursor || cell.state.loadingMore) return;\n patch(cell, { loadingMore: true });\n void api\n .listNotifications({ cursor, limit: PAGE_SIZE })\n .then((page) => {\n patch(cell, {\n items: [...cell.state.items, ...page.items],\n nextCursor: page.nextCursor,\n loadingMore: false,\n });\n })\n .catch(() => patch(cell, { loadingMore: false }));\n}\n\nfunction markRead(cell: Cell, api: NotificationsApiClient, ids: readonly string[]): void {\n const readAt = new Date().toISOString();\n let flipped = 0;\n const items = cell.state.items.map((item) => {\n if (!ids.includes(item.id) || item.readAt !== null) return item;\n flipped += 1;\n return { ...item, readAt };\n });\n if (flipped === 0) return;\n write(\n cell,\n api,\n () => {\n patch(cell, { items });\n bumpUnread(cell, -flipped);\n },\n () => api.markRead(ids),\n );\n}\n\nfunction remove(cell: Cell, api: NotificationsApiClient, id: string): void {\n const target = cell.state.items.find((item) => item.id === id);\n if (!target) return;\n const items = cell.state.items.filter((item) => item.id !== id);\n write(\n cell,\n api,\n () => {\n patch(cell, { items });\n if (target.readAt === null) bumpUnread(cell, -1);\n },\n () => api.remove([id]),\n );\n}\n\nexport function createInboxStore(api: NotificationsApiClient): InboxStore {\n const cell: Cell = { state: EMPTY, listeners: new Set(), request: 0 };\n return {\n getState: () => cell.state,\n subscribe(listener) {\n cell.listeners.add(listener);\n return () => cell.listeners.delete(listener);\n },\n open() {\n if (cell.state.status === 'idle') reloadList(cell, api);\n },\n refreshBadge: () => refreshBadge(cell, api),\n invalidate: () => invalidate(cell, api),\n loadMore: () => loadMore(cell, api),\n markRead: (ids) => markRead(cell, api, ids),\n markAllRead() {\n const readAt = new Date().toISOString();\n const items = cell.state.items.map((item) => ({ ...item, readAt: item.readAt ?? readAt }));\n write(\n cell,\n api,\n () => patch(cell, { items, unread: 0 }),\n () => api.markAllRead(),\n );\n },\n remove: (id) => remove(cell, api, id),\n };\n}\n","import { useEffect, useSyncExternalStore } from 'react';\n\nimport {\n BADGE_POLL_MS,\n BADGE_RECONCILE_MS,\n type InboxState,\n type InboxStore,\n} from './inbox-state';\n\n/**\n * The two hooks the bell and the panel use, and the realtime seam between them.\n *\n * A host that has a message bus passes `subscribe`; one that has not passes\n * nothing and keeps the 60 s poll. The bell ships in this package and mounts in\n * whatever embeds it, so it must not require the host to have adopted anything.\n */\n\n/**\n * How the surface learns an inbox changed without asking.\n *\n * Called once per mounted bell with a callback that means only \"ask again\" — no\n * payload, so the number on screen is always one the server just gave us.\n * Returns its own teardown. A host wires this to whatever it already has.\n */\nexport type NotificationsSubscribe = (onHint: () => void) => () => void;\n\n/**\n * The same wiring, as a HOOK — for a host whose realtime connection lives in\n * React context rather than in a module.\n *\n * `subscribe` above is supplied at FACTORY time, which is module scope, and a\n * context-bound connection cannot be reached from there: the provider holding\n * it is inside the tree. A host in that shape (a `<UserRealtimeProvider>` and a\n * `useUserTopics` hook, which is the common one) had no way to pass anything at\n * all, and the badge simply never heard an event.\n *\n * So this is the second door, and it is the one `@12-apps/app-shell` already\n * uses for the same problem — its consent dialog takes a `useSignal` hook for\n * exactly this reason. Two packages solving one problem two ways is how an\n * adopter ends up believing the feature is unavailable to it.\n *\n * Called during render, so it may use context and hooks freely. Pass one or\n * the other; passing both runs both, which is a host's business.\n */\nexport type NotificationsSignalHook = (onHint: () => void) => void;\n\nexport function useInboxState(store: InboxStore): InboxState {\n return useSyncExternalStore(store.subscribe, store.getState, store.getState);\n}\n\n/** What both badge hooks below take, and what the bell passes them. */\nexport interface BadgeSyncOptions {\n enabled?: boolean;\n subscribe?: NotificationsSubscribe;\n useSignal?: NotificationsSignalHook;\n}\n\n/**\n * What a disabled badge reads, instead of whatever the store happens to hold.\n *\n * A CONSTANT, so `useSyncExternalStore`'s identity comparison sees no change\n * across the renders of a signed-out session.\n */\nconst NOTHING_TO_SHOW: InboxState = {\n unread: 0,\n items: [],\n status: 'idle',\n nextCursor: null,\n loadingMore: false,\n};\n\n/**\n * The badge's server state, kept fresh: pushed while a subscription is live,\n * polled otherwise.\n *\n * The whole state rather than the count, because every badge hook that layers\n * on top of it needs the poll and the subscription mounted exactly ONCE per\n * bell — read through two hooks, a bell that showed both a number and a tone\n * would open two of everything.\n *\n * ## `enabled` gates the ANSWER, not only the fetching\n *\n * It gates the poll and the subscription, which is the obvious half. It also\n * blanks the returned state, which is the half that was missing and matters\n * more: the store is per FACTORY and a host builds one at module scope for the\n * whole app, so signing out does not empty it — `refreshBadge` swallows the 401\n * and leaves the last number in place. Without this, a hook told there is\n * nobody signed in hands back the PREVIOUS reader's unread count and their\n * inbox rows.\n *\n * Deliberately here rather than at each caller. It was at each caller, three\n * times, in three shapes, and two of them were dead weight no test could reach\n * — which is what an invariant looks like just before one copy of it goes\n * missing.\n *\n * INTERNAL. Not exported from `./index`: it hands back rows as well as a count,\n * and a host wanting a number has `useUnreadCount` or the factory's\n * `useBellBadge`.\n */\nexport function useBadgeState(store: InboxStore, options: BadgeSyncOptions = {}): InboxState {\n const enabled = options.enabled ?? true;\n const subscribe = options.subscribe;\n const live = useInboxState(store);\n const state = enabled ? live : NOTHING_TO_SHOW;\n\n // Called unconditionally — it is a hook, so it cannot sit behind `enabled`.\n // The host's own hook decides what to do when there is nothing to hear.\n options.useSignal?.(() => {\n if (enabled) store.invalidate();\n });\n\n useEffect(() => {\n if (!enabled) return;\n store.refreshBadge();\n const unsubscribe = subscribe?.(() => store.invalidate());\n // A live subscription relaxes the poll to the reconcile interval; without\n // one it stays the 60 s poll.\n const interval = setInterval(\n () => store.refreshBadge(),\n subscribe ? BADGE_RECONCILE_MS : BADGE_POLL_MS,\n );\n const onFocus = (): void => store.refreshBadge();\n globalThis.addEventListener?.('focus', onFocus);\n return () => {\n clearInterval(interval);\n globalThis.removeEventListener?.('focus', onFocus);\n unsubscribe?.();\n };\n }, [store, enabled, subscribe]);\n\n return state;\n}\n\n/**\n * The bell badge number, for a host with its own trigger chrome.\n *\n * A host that also publishes live activities wants `useBellBadge` from the\n * factory instead — this one counts inbox rows and knows nothing about what is\n * happening right now.\n */\nexport function useUnreadCount(store: InboxStore, options: BadgeSyncOptions = {}): number {\n return useBadgeState(store, options).unread;\n}\n\n/** The panel's list — only fetches while the panel is open. */\nexport function useInboxList(store: InboxStore, open: boolean): InboxState {\n const state = useInboxState(store);\n useEffect(() => {\n if (open) store.open();\n }, [store, open]);\n return state;\n}\n","/** Inline SVG bell (no icon-library dependency in this package). */\nimport type { JSX } from 'react';\n\nimport { Box } from '@12-apps/ui/mui/Box';\n\nexport function BellIcon({\n size = 28,\n dim = false,\n}: {\n size?: number;\n dim?: boolean;\n}): JSX.Element {\n return (\n <Box\n component=\"svg\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n sx={{\n width: size,\n height: size,\n fill: 'none',\n stroke: 'currentColor',\n opacity: dim ? 0.4 : 1,\n }}\n strokeWidth={1.8}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6\" />\n <path d=\"M10 20a2 2 0 0 0 4 0\" />\n </Box>\n );\n}\n"],"mappings":"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","names":[]}
@@ -19,4 +19,4 @@ export {
19
19
  NOTIFICATION_CHANNELS,
20
20
  taxonomyOf
21
21
  };
22
- //# sourceMappingURL=chunk-6W7INOYQ.js.map
22
+ //# sourceMappingURL=chunk-CPQKKLPS.js.map
@@ -1 +1 @@
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 /**\n * Structured extras for consumers that want more than text.\n *\n * ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties\n * this notification to a live activity, and the WEB_PUSH transport turns it\n * into the tray `tag` that collapses every push about one subject onto a\n * single entry. A host already using that name for something else acquires\n * that behaviour without asking for it — so it is named here, on the field a\n * generator actually writes, and not only where the feature is documented.\n */\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;AA2LrE,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":[]}
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 /**\n * Structured extras for consumers that want more than text.\n *\n * ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties\n * this notification to a live activity, and the WEB_PUSH transport turns it\n * into the tray `tag` that collapses every push about one subject onto a\n * single entry. A host already using that name for something else acquires\n * that behaviour without asking for it — so it is named here, on the field a\n * generator actually writes, and not only where the feature is documented.\n */\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 * The channels this type may EVER use — AVAILABILITY, not preference.\n *\n * Absent (the default) means every channel, which is what every generator\n * written before this field keeps doing. A declared list is a hard cap the\n * router applies AFTER preferences: a user whose stored row explicitly\n * enables a channel this type does not offer still does not get it, because\n * the channel was never on offer for this message. That is the difference\n * from {@link channelDefaults} — a default is a starting point a user can\n * move, availability is the set of starting points that exist.\n *\n * It is per TYPE because the category is too coarse to say it: the three\n * comanda kitchen messages sit in `orders` next to `order.paid`, and a diner\n * three metres from the food wants a push, not correspondence, while the\n * buyer of a delivery order still wants the e-mail. Declaring `[\"WEB_PUSH\"]`\n * on the mesa messages says that without splitting the category or taking\n * `order.paid`'s e-mail away with it.\n *\n * Unknown entries are ignored and order is irrelevant — the list is coerced\n * onto {@link NOTIFICATION_CHANNELS}. An EMPTY list is legal and means no\n * transport channel at all; the inbox record is written regardless, because\n * the inbox is not a channel a user opts out of.\n */\n channels?: readonly NotificationChannel[];\n /**\n * This type's starting toggles, overriding the category's defaults for the\n * channels it names and only where the user has made NO explicit choice.\n *\n * Stored preferences still win over it — that is what makes it a default.\n * To take a channel away from a user who asked for it, declare\n * {@link channels} instead.\n */\n channelDefaults?: Partial<Record<NotificationChannel, boolean>>;\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;AA4NrE,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":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  UnknownNotificationRecipientError,
3
3
  UnknownNotificationTypeError
4
- } from "./chunk-SWOWHIFE.js";
4
+ } from "./chunk-GK6GSC2J.js";
5
5
  import {
6
6
  __name
7
7
  } from "./chunk-7QVYU63E.js";
@@ -91,4 +91,4 @@ export {
91
91
  NOTIFICATIONS_DRAIN_LEASE_MS,
92
92
  NOTIFICATIONS_JOBS
93
93
  };
94
- //# sourceMappingURL=chunk-ZIR3ILFH.js.map
94
+ //# sourceMappingURL=chunk-FBBPS2LT.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  NOTIFICATION_CHANNELS
3
- } from "./chunk-6W7INOYQ.js";
3
+ } from "./chunk-CPQKKLPS.js";
4
4
  import {
5
5
  __name
6
6
  } from "./chunk-7QVYU63E.js";
@@ -77,10 +77,41 @@ function mergeStoredRow(stored, base) {
77
77
  return row;
78
78
  }
79
79
  __name(mergeStoredRow, "mergeStoredRow");
80
+ function availableChannelsOf(declared) {
81
+ if (!declared) return [...NOTIFICATION_CHANNELS];
82
+ const offered = new Set(declared);
83
+ return NOTIFICATION_CHANNELS.filter((channel) => offered.has(channel));
84
+ }
85
+ __name(availableChannelsOf, "availableChannelsOf");
86
+ function capToAvailable(channels, declared) {
87
+ if (!declared) return [...channels];
88
+ const offered = new Set(availableChannelsOf(declared));
89
+ return channels.filter((channel) => offered.has(channel));
90
+ }
91
+ __name(capToAvailable, "capToAvailable");
92
+ function resolveTypeChannels(input) {
93
+ const { stored, categoryDefaults, rules } = input;
94
+ const base = { ...categoryDefaults, ...rules?.channelDefaults };
95
+ const row = stored === void 0 || stored === null ? base : mergeStoredRow(stored, base);
96
+ return capToAvailable(enabledChannelsOf(row), rules?.channels);
97
+ }
98
+ __name(resolveTypeChannels, "resolveTypeChannels");
80
99
  function enabledChannelsOf(row) {
81
100
  return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);
82
101
  }
83
102
  __name(enabledChannelsOf, "enabledChannelsOf");
103
+ function explicitChoicesOf(stored) {
104
+ const choices = {};
105
+ if (stored && typeof stored === "object") {
106
+ const record = stored;
107
+ for (const channel of NOTIFICATION_CHANNELS) {
108
+ const value = record[channel];
109
+ if (typeof value === "boolean") choices[channel] = value;
110
+ }
111
+ }
112
+ return choices;
113
+ }
114
+ __name(explicitChoicesOf, "explicitChoicesOf");
84
115
  function mergeChoices(current, choices) {
85
116
  return { ...current, ...choices };
86
117
  }
@@ -126,9 +157,13 @@ export {
126
157
  DEFAULT_CHANNEL_ROW,
127
158
  defaultChannelMatrix,
128
159
  mergeStoredRow,
160
+ availableChannelsOf,
161
+ capToAvailable,
162
+ resolveTypeChannels,
129
163
  enabledChannelsOf,
164
+ explicitChoicesOf,
130
165
  mergeChoices,
131
166
  normalizePhoneE164,
132
167
  inboxWire
133
168
  };
134
- //# sourceMappingURL=chunk-SWOWHIFE.js.map
169
+ //# sourceMappingURL=chunk-GK6GSC2J.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/generators.ts","../src/preferences-core.ts","../src/phone.ts","../src/wire.ts"],"sourcesContent":["/** Thrown by `notify` when no generator is registered for the event type. */\nexport class UnknownNotificationTypeError extends Error {\n readonly type: string;\n constructor(type: string) {\n super(`No notification generator registered for type \"${type}\".`);\n this.name = 'UnknownNotificationTypeError';\n this.type = type;\n Object.setPrototypeOf(this, UnknownNotificationTypeError.prototype);\n }\n}\n\n/** Thrown by `notify` when the recipient has no contact record in the host. */\nexport class UnknownNotificationRecipientError extends Error {\n readonly userId: string;\n constructor(userId: string) {\n super(`notify(): unknown recipient user \"${userId}\".`);\n this.name = 'UnknownNotificationRecipientError';\n this.userId = userId;\n Object.setPrototypeOf(this, UnknownNotificationRecipientError.prototype);\n }\n}\n","import { UnknownNotificationTypeError } from './errors';\nimport type { NotificationGenerator } from './types';\n\n/**\n * Generator registry: one {@link NotificationGenerator} per event `type`.\n *\n * INSTANCE state, not a module-level Map. the origin's own was\n * process-wide, which is what a package of loose functions forces; a factory\n * config does not need it, and one registry per mount is what makes a test (or\n * a second mount) able to hold its own set without clearing anyone else's.\n * Domain modules still register from the OUTSIDE — that is the open/closed\n * seam the whole pipeline is built on — either through the server config's\n * `generators` array or through `registerGenerator` for a late arrival.\n */\nexport interface NotificationGeneratorRegistry {\n /** Register (last-wins, so a re-import is idempotent). */\n register<TPayload>(generator: NotificationGenerator<TPayload>): void;\n /** Resolve for `type`, throwing {@link UnknownNotificationTypeError}. */\n resolve(type: string): NotificationGenerator<never>;\n /** Whether a generator is registered for `type` (emit-site guard). */\n has(type: string): boolean;\n /** Every registered type, for diagnostics. */\n types(): string[];\n}\n\nexport function createGeneratorRegistry(\n initial: readonly NotificationGenerator<never>[] = [],\n): NotificationGeneratorRegistry {\n const generators = new Map<string, NotificationGenerator<never>>();\n const registry: NotificationGeneratorRegistry = {\n register(generator) {\n generators.set(generator.type, generator as NotificationGenerator<never>);\n },\n resolve(type) {\n const generator = generators.get(type);\n if (!generator) throw new UnknownNotificationTypeError(type);\n return generator;\n },\n has: (type) => generators.has(type),\n types: () => [...generators.keys()],\n };\n for (const generator of initial) registry.register(generator);\n return registry;\n}\n","import {\n NOTIFICATION_CHANNELS,\n type NotificationCategory,\n type NotificationChannel,\n} from './types';\n\n/**\n * The preference POLICY, with no storage in it (12-15): which channels a\n * category defaults to, how a stored JSON row is coerced onto the closed\n * channel set, and how a partial save merges. `./server`'s store is the only\n * thing that touches a database, so every rule here is unit-testable without\n * one — and the react half can render the same defaults before the first read\n * lands.\n *\n * Storage stores only EXPLICIT choices (one row per (user, category)); a\n * missing row — or a missing channel key inside a row — falls back to\n * {@link DEFAULT_CHANNEL_ROW}. Defaults: the free, low-friction channels\n * (e-mail + web push) on; the paid per-message channels (SMS + WhatsApp) off\n * until the user opts in. A host that disagrees passes `channelDefaults`.\n */\n\n/** One category's channel toggles. */\nexport type ChannelRow = Record<NotificationChannel, boolean>;\n\n/** A user's full category × channel matrix. */\nexport type ChannelMatrix = Record<NotificationCategory, ChannelRow>;\n\n/** The policy applied when a user never touched a category's toggles. */\nexport const DEFAULT_CHANNEL_ROW: ChannelRow = {\n EMAIL: true,\n SMS: false,\n WHATSAPP: false,\n WEB_PUSH: true,\n};\n\n/** The default matrix for one taxonomy (what the settings UI starts from). */\nexport function defaultChannelMatrix(\n categories: readonly NotificationCategory[],\n channelDefaults: Partial<ChannelRow> = {},\n): ChannelMatrix {\n const row = { ...DEFAULT_CHANNEL_ROW, ...channelDefaults };\n return Object.fromEntries(\n categories.map((category) => [category, { ...row }]),\n ) as ChannelMatrix;\n}\n\n/**\n * Coerce a stored JSON `channels` map onto the closed channel set, filling the\n * gaps from `base`. A stored row that predates a channel keeps that channel's\n * default rather than reading as \"off\", which is what lets a new transport ship\n * without a data migration.\n *\n * The consequence, and the rule it implies: a channel ADDED later turns itself\n * ON for a user who had explicitly switched every channel in that category off,\n * because their stored row has no key for it. That is harmless for the four\n * shipped channels — the two that cost money default off — so **a new channel\n * must be added with a `false` default** unless the user's existing consent\n * already covers it. The alternative (reading a missing key as \"off\") would need\n * a data migration for every existing row on every channel that ever ships.\n */\nexport function mergeStoredRow(stored: unknown, base: ChannelRow): ChannelRow {\n const row = { ...base };\n if (stored && typeof stored === 'object') {\n const record = stored as Record<string, unknown>;\n for (const channel of NOTIFICATION_CHANNELS) {\n const value = record[channel];\n if (typeof value === 'boolean') row[channel] = value;\n }\n }\n return row;\n}\n\n/**\n * What ONE notification type says about its own channels, independent of the\n * user's category preferences: {@link NotificationGenerator.channels} (the hard\n * availability cap) and {@link NotificationGenerator.channelDefaults} (a\n * starting point the user can still move).\n *\n * Structurally what a generator already is, rather than the generator itself,\n * so the policy here stays free of the registry and the router.\n */\nexport interface TypeChannelRules {\n channels?: readonly NotificationChannel[];\n channelDefaults?: Partial<ChannelRow>;\n}\n\n/**\n * The channels a type may EVER use, coerced onto the closed channel set.\n *\n * `undefined` — a generator that never declared a list — means every channel,\n * which is what keeps every generator written before the field working. An\n * EMPTY list means no transport channel at all and is legal: the inbox record\n * is written by the router regardless, and the inbox is not a channel a user\n * opts out of.\n *\n * Filtering through {@link NOTIFICATION_CHANNELS} rather than returning the\n * declaration is deliberate: it drops a value that is not a channel (a typo, a\n * channel removed from the set since) instead of carrying it into an\n * intersection where it would silently match nothing anyway, and it fixes the\n * order so two declarations of the same set compare equal.\n */\nexport function availableChannelsOf(\n declared: readonly NotificationChannel[] | undefined,\n): NotificationChannel[] {\n if (!declared) return [...NOTIFICATION_CHANNELS];\n const offered = new Set<string>(declared);\n return NOTIFICATION_CHANNELS.filter((channel) => offered.has(channel));\n}\n\n/**\n * Drop the channels a type does not offer. Applied by the router AFTER every\n * other gate, so no later stage can hand back a channel the type never offered\n * — including the plan gate's own error fallback, which degrades to the free\n * channels and would otherwise restore an e-mail this type had just refused.\n */\nexport function capToAvailable(\n channels: readonly NotificationChannel[],\n declared: readonly NotificationChannel[] | undefined,\n): NotificationChannel[] {\n if (!declared) return [...channels];\n const offered = new Set(availableChannelsOf(declared));\n return channels.filter((channel) => offered.has(channel));\n}\n\n/**\n * The channels one (user, category, TYPE) actually enables — the whole policy\n * in one pure function, so the router's gate can be argued about without a\n * database.\n *\n * The order is the meaning:\n * 1. the category's defaults, with the TYPE's defaults over them — a type\n * moves the starting point;\n * 2. the user's stored row over that — an explicit choice beats any default,\n * which is what makes step 1 a default rather than a rule;\n * 3. the type's AVAILABILITY over everything — a channel this type does not\n * offer is gone even when the user's stored row explicitly asked for it,\n * because it was never on offer for this message.\n *\n * Step 3 overriding a stored `true` is the one place a user's saved choice is\n * discarded, and it is the point of the field: the answer they gave was about\n * the CATEGORY, and a category can hold messages this channel was never right\n * for. Step 2 beating step 1 is what keeps the other field a default.\n */\nexport function resolveTypeChannels(input: {\n /** The stored `channels` JSON for this (user, category), if any. */\n stored?: unknown;\n /** The category's effective default row (host defaults already merged). */\n categoryDefaults: ChannelRow;\n /** The type's own declarations. */\n rules?: TypeChannelRules;\n}): NotificationChannel[] {\n const { stored, categoryDefaults, rules } = input;\n const base: ChannelRow = { ...categoryDefaults, ...rules?.channelDefaults };\n const row = stored === undefined || stored === null ? base : mergeStoredRow(stored, base);\n return capToAvailable(enabledChannelsOf(row), rules?.channels);\n}\n\n/** The channels enabled by one effective row — the router's gate. */\nexport function enabledChannelsOf(row: ChannelRow): NotificationChannel[] {\n return NOTIFICATION_CHANNELS.filter((channel) => row[channel]);\n}\n\n/**\n * The channels a stored row states an EXPLICIT choice for, dropping everything\n * else — the inverse of {@link mergeStoredRow}, which fills the gaps in.\n *\n * The package's storage model is \"only explicit choices are stored; a missing\n * key falls back to the default\", and this is what lets a writer keep that\n * promise. Merging a save onto the user's EFFECTIVE row instead turns every\n * defaulted channel into an explicit one the moment they touch any switch, and\n * the row can never say \"no opinion\" about a channel again.\n */\nexport function explicitChoicesOf(stored: unknown): Partial<ChannelRow> {\n const choices: Partial<ChannelRow> = {};\n if (stored && typeof stored === 'object') {\n const record = stored as Record<string, unknown>;\n for (const channel of NOTIFICATION_CHANNELS) {\n const value = record[channel];\n if (typeof value === 'boolean') choices[channel] = value;\n }\n }\n return choices;\n}\n\n/**\n * What a PUT writes for one category: the caller's toggles merged over the\n * user's CURRENT effective row. A single-toggle save (how the settings UI\n * writes) must never reset the category's other channels back to their\n * defaults, which is exactly what a whole-row write would do.\n */\nexport function mergeChoices(\n current: ChannelRow,\n choices: Partial<ChannelRow>,\n): ChannelRow {\n return { ...current, ...choices };\n}\n","/**\n * Shared phone-destination rules for the SMS + WhatsApp transports.\n *\n * Providers need E.164 (`+5531999998888`); a host stores the phone as the user\n * entered it. Best-effort normalization: an explicit `+` prefix is trusted; a\n * bare 10/11-digit number is assumed to belong to `defaultCountryCode` and\n * prefixed; anything else is unusable and makes the channel unavailable for\n * that recipient.\n *\n * `defaultCountryCode` is REQUIRED, and that is the whole point of it being a\n * parameter. It used to default to `55` (Brazil, the first host's market),\n * which a published package must not do: a US adopter that never set it turned\n * `4155552671` into `+554155552671` — a plausible Brazilian mobile — and sent a\n * stranger the customer's order details. There is no country this package could\n * assume that is not wrong for every other adopter, so it assumes none and the\n * omission is a compile error rather than a wrong number. the origin passes\n * `'55'` explicitly.\n *\n * NOTE: \"verified phone\" is approximated by \"has a normalizable phone on\n * file\" — a host with a real verification flow should tighten its contact\n * directory to only return verified numbers, which is the single seam both\n * transports funnel through.\n */\n\n/** Options for {@link normalizePhoneE164}. */\nexport interface PhoneNormalizeOptions {\n /**\n * Country calling code for a bare local number, digits only (`'55'`, `'1'`).\n * Required: see the module docstring for why there is no default.\n */\n defaultCountryCode: string;\n}\n\n/** A local subscriber number: area code (2) + 8 or 9 digits. */\nconst isLocal = (digits: string): boolean => digits.length === 10 || digits.length === 11;\n\n/** An already-international number. E.164 allows 8..15 digits. */\nconst international = (digits: string): string | null =>\n digits.length >= 8 && digits.length <= 15 ? `+${digits}` : null;\n\n/** Normalize a stored phone to E.164, or null when it can't be inferred. */\nexport function normalizePhoneE164(\n raw: string | null | undefined,\n options: PhoneNormalizeOptions,\n): string | null {\n if (!raw) return null;\n const country = options.defaultCountryCode;\n const trimmed = raw.trim();\n const digits = trimmed.replace(/\\D/g, '');\n if (trimmed.startsWith('+')) return international(digits);\n if (isLocal(digits)) return `+${country}${digits}`;\n // A bare number that already carries the country code.\n if (digits.startsWith(country) && isLocal(digits.slice(country.length))) {\n return `+${digits}`;\n }\n return null;\n}\n","/**\n * The inbox WIRE shape — the one contract the two halves share.\n *\n * It lives in the root entry rather than in `./server` or `./react` because\n * both halves need it and neither owns it: the api serializes to it, the panel\n * deserializes from it, and a change here is a change to both at once. That is\n * the same reason the response envelope and the route paths are the package's\n * and not the host's.\n */\n\n/** One inbox entry as the notification centre renders it. */\nexport interface InboxNotification {\n id: string;\n type: string;\n category: string;\n title: string;\n body: string;\n link: string | null;\n data: Record<string, unknown>;\n /** ISO-8601, or null while unread. */\n readAt: string | null;\n /** ISO-8601. */\n createdAt: string;\n}\n\n/** One page of the owner's inbox. */\nexport interface ListNotificationsResult {\n items: InboxNotification[];\n /** Cursor for the next page, or null when this page is the last. */\n nextCursor: string | null;\n}\n\n/** A stored notification row, as the db seam hands it back. */\nexport interface NotificationRow {\n id: string;\n userId: string;\n clientId: string | null;\n type: string;\n category: string;\n title: string;\n body: string;\n link: string | null;\n data: unknown;\n readAt: Date | null;\n deletedAt: Date | null;\n createdAt: Date;\n}\n\n/** Row → wire. Dates become ISO strings; a null `data` becomes `{}`. */\nexport function inboxWire(row: NotificationRow): InboxNotification {\n return {\n id: row.id,\n type: row.type,\n category: row.category,\n title: row.title,\n body: row.body,\n link: row.link,\n data: (row.data ?? {}) as Record<string, unknown>,\n readAt: row.readAt ? row.readAt.toISOString() : null,\n createdAt: row.createdAt.toISOString(),\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  BellIcon,
3
3
  createInboxStore,
4
+ useBadgeState,
4
5
  useUnreadCount
5
- } from "./chunk-BW723CX2.js";
6
+ } from "./chunk-2IAHFIXS.js";
6
7
  import {
7
8
  messagesOf
8
9
  } from "./chunk-M2TVBVH2.js";
@@ -104,10 +105,8 @@ __name(httpNotificationsTransport, "httpNotificationsTransport");
104
105
  // src/react/create-web-notifications.tsx
105
106
  import { useState as useState2 } from "react";
106
107
 
107
- // src/react/bell-button.tsx
108
- import { useSyncExternalStore } from "react";
109
- import { Badge } from "@12-apps/ui/data-display/Badge";
110
- import { Box } from "@12-apps/ui/mui/Box";
108
+ // src/react/bell-badge.ts
109
+ import { useMemo, useSyncExternalStore } from "react";
111
110
 
112
111
  // src/react/live-seen.ts
113
112
  var STORAGE_KEY = "12a.notifications.live-seen";
@@ -175,7 +174,26 @@ function hasUnseenActivity(activities, seen) {
175
174
  }
176
175
  __name(hasUnseenActivity, "hasUnseenActivity");
177
176
 
177
+ // src/react/bell-badge.ts
178
+ function useInboxBellBadge(store, options = {}) {
179
+ const { unread } = useBadgeState(store, options);
180
+ return useMemo(() => ({ count: unread, hasNew: unread > 0 }), [unread]);
181
+ }
182
+ __name(useInboxBellBadge, "useInboxBellBadge");
183
+ function useLiveBellBadge(store, live, seen, options = {}) {
184
+ const enabled = options.enabled ?? true;
185
+ const { unread } = useBadgeState(store, options);
186
+ const activities = live.useActivities({ active: enabled });
187
+ const seenAt = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
188
+ const count = enabled ? unread + activities.length : 0;
189
+ const hasNew = enabled && (unread > 0 || hasUnseenActivity(activities, seenAt));
190
+ return useMemo(() => ({ count, hasNew }), [count, hasNew]);
191
+ }
192
+ __name(useLiveBellBadge, "useLiveBellBadge");
193
+
178
194
  // src/react/bell-button.tsx
195
+ import { Badge } from "@12-apps/ui/data-display/Badge";
196
+ import { Box } from "@12-apps/ui/mui/Box";
179
197
  import { jsx } from "react/jsx-runtime";
180
198
  var triggerSx = {
181
199
  display: "inline-flex",
@@ -235,12 +253,12 @@ function BellButton({
235
253
  subscribe,
236
254
  useSignal
237
255
  }) {
238
- const count = useUnreadCount(store, {
256
+ const badge = useInboxBellBadge(store, {
239
257
  enabled,
240
258
  ...subscribe ? { subscribe } : {},
241
259
  ...useSignal ? { useSignal } : {}
242
260
  });
243
- return /* @__PURE__ */ jsx(BellTrigger, { onClick, count, hasNew: count > 0, messages });
261
+ return /* @__PURE__ */ jsx(BellTrigger, { onClick, ...badge, messages });
244
262
  }
245
263
  __name(BellButton, "BellButton");
246
264
  function LiveBellButton({
@@ -253,23 +271,12 @@ function LiveBellButton({
253
271
  live,
254
272
  seen
255
273
  }) {
256
- const unread = useUnreadCount(store, {
274
+ const badge = useLiveBellBadge(store, live, seen, {
257
275
  enabled,
258
276
  ...subscribe ? { subscribe } : {},
259
277
  ...useSignal ? { useSignal } : {}
260
278
  });
261
- const activities = live.useActivities({ active: enabled });
262
- const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
263
- const liveCount = enabled ? activities.length : 0;
264
- return /* @__PURE__ */ jsx(
265
- BellTrigger,
266
- {
267
- onClick,
268
- count: unread + liveCount,
269
- hasNew: unread > 0 || enabled && hasUnseenActivity(activities, seenIso),
270
- messages
271
- }
272
- );
279
+ return /* @__PURE__ */ jsx(BellTrigger, { onClick, ...badge, messages });
273
280
  }
274
281
  __name(LiveBellButton, "LiveBellButton");
275
282
 
@@ -278,7 +285,7 @@ import { Suspense, lazy, useEffect, useState } from "react";
278
285
  import { jsx as jsx2 } from "react/jsx-runtime";
279
286
  function lazyNotificationsPanel(parts) {
280
287
  const Bound = lazy(async () => {
281
- const { NotificationsPanel } = await import("./panel-V2ULFC4Y.js");
288
+ const { NotificationsPanel } = await import("./panel-MKI4PTNZ.js");
282
289
  return {
283
290
  default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx2(NotificationsPanel, { ...props, ...parts }), "default")
284
291
  };
@@ -299,7 +306,7 @@ import { Suspense as Suspense2, lazy as lazy2 } from "react";
299
306
  import { jsx as jsx3 } from "react/jsx-runtime";
300
307
  function lazyPreferencesPage(parts) {
301
308
  const Bound = lazy2(async () => {
302
- const { PreferencesScreen } = await import("./preferences-screen-SXUIGECY.js");
309
+ const { PreferencesScreen } = await import("./preferences-screen-S3ZHX5LB.js");
303
310
  return {
304
311
  default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx3(PreferencesScreen, { ...props, ...parts }), "default")
305
312
  };
@@ -312,6 +319,13 @@ __name(lazyPreferencesPage, "lazyPreferencesPage");
312
319
 
313
320
  // src/react/create-web-notifications.tsx
314
321
  import { Fragment, jsx as jsx4, jsxs } from "react/jsx-runtime";
322
+ function bindBadgeHooks(store, subscribeOption, liveSeen, live) {
323
+ return {
324
+ useUnreadCount: /* @__PURE__ */ __name((options = {}) => useUnreadCount(store, { ...options, ...subscribeOption }), "useUnreadCount"),
325
+ useBellBadge: live ? (options = {}) => useLiveBellBadge(store, live, liveSeen, { ...options, ...subscribeOption }) : (options = {}) => useInboxBellBadge(store, { ...options, ...subscribeOption })
326
+ };
327
+ }
328
+ __name(bindBadgeHooks, "bindBadgeHooks");
315
329
  function createWebNotifications(config) {
316
330
  const messages = messagesOf(config);
317
331
  const api = createNotificationsApiClient(
@@ -343,10 +357,7 @@ function createWebNotifications(config) {
343
357
  messages,
344
358
  ...live ? { live, liveSeen } : {}
345
359
  });
346
- function useBoundUnreadCount(options = {}) {
347
- return useUnreadCount(store, { ...options, ...subscribeOption });
348
- }
349
- __name(useBoundUnreadCount, "useBoundUnreadCount");
360
+ const badgeHooks = bindBadgeHooks(store, subscribeOption, liveSeen, live);
350
361
  function BellWithPanel({
351
362
  enabled = true,
352
363
  onNavigate
@@ -370,7 +381,7 @@ function createWebNotifications(config) {
370
381
  BellButton: Bell,
371
382
  Panel,
372
383
  BellWithPanel,
373
- useUnreadCount: useBoundUnreadCount,
384
+ ...badgeHooks,
374
385
  store,
375
386
  api,
376
387
  messages
@@ -384,4 +395,4 @@ export {
384
395
  httpNotificationsTransport,
385
396
  createWebNotifications
386
397
  };
387
- //# sourceMappingURL=chunk-H55A4LHG.js.map
398
+ //# sourceMappingURL=chunk-PNY6S6WH.js.map