@ttt-productions/notification-core 0.15.9 → 0.15.11

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.
@@ -1 +1 @@
1
- {"version":3,"file":"NotificationList.d.ts","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAE7E;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,EACL,UAAU,EACV,eAAe,EACf,SAAS,EACT,SAAS,EACT,WAAW,EACX,oBAAoB,EACpB,eAAe,GAChB,EAAE,qBAAqB,+BAmNvB"}
1
+ {"version":3,"file":"NotificationList.d.ts","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK7E;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,MAAM,EACN,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,EACL,UAAU,EACV,eAAe,EACf,SAAS,EACT,SAAS,EACT,WAAW,EACX,oBAAoB,EACpB,eAAe,GAChB,EAAE,qBAAqB,+BAwQvB"}
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { Badge, Button, ListPagination, Separator, Spinner } from '@ttt-productions/ui-core/react';
5
5
  import { useActiveNotifications } from '../hooks/useActiveNotifications.js';
6
6
  import { useArchiveNotification } from '../hooks/useArchiveNotification.js';
7
7
  import { useArchiveAllNotifications } from '../hooks/useArchiveAllNotifications.js';
8
8
  import { NotificationEmptyState } from './NotificationEmptyState.js';
9
9
  import { formatRelativeTime } from './relative-time.js';
10
+ const FOCUSABLE = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
10
11
  /**
11
12
  * Scrollable list of active notifications with click-to-archive and clear-all.
12
13
  */
@@ -57,6 +58,32 @@ export function NotificationList({ config, userId, category, archiveFn, enqueueA
57
58
  setPendingArchiveIds(keepActive);
58
59
  setPendingClearAllIds(keepActive);
59
60
  }, [notifications]);
61
+ // A row that leaves the list while focus is inside it takes the focused control with it, so focus
62
+ // moves to the control of the row that followed it, or to the list itself (FRONTEND-203). A row's
63
+ // removal fires no blur, so the row still recorded as focused when the rows change is the row that
64
+ // held focus as it left.
65
+ const listRef = useRef(null);
66
+ const rowRefs = useRef(new Map());
67
+ const focusedRowId = useRef(null);
68
+ const renderedIds = useRef([]);
69
+ useLayoutEffect(() => {
70
+ const ids = isLoading ? [] : (notifications ?? []).map((notification) => notification.id);
71
+ const previousIds = renderedIds.current;
72
+ renderedIds.current = ids;
73
+ const leftId = focusedRowId.current;
74
+ if (leftId === null || ids.includes(leftId))
75
+ return;
76
+ focusedRowId.current = null;
77
+ const active = document.activeElement;
78
+ if (active !== null && active !== document.body && active.isConnected)
79
+ return;
80
+ const remaining = new Set(ids);
81
+ const followers = previousIds.slice(previousIds.indexOf(leftId) + 1).filter((id) => remaining.has(id));
82
+ const nextControl = followers
83
+ .map((id) => rowRefs.current.get(id)?.querySelector('.ntf-item-row-action')?.querySelector(FOCUSABLE))
84
+ .find((control) => control != null);
85
+ (nextControl ?? listRef.current)?.focus();
86
+ }, [notifications, isLoading]);
60
87
  const archiveOne = useCallback(async (notification) => {
61
88
  const clearPending = () => setPendingArchiveIds((current) => {
62
89
  const next = new Set(current);
@@ -80,7 +107,10 @@ export function NotificationList({ config, userId, category, archiveFn, enqueueA
80
107
  throw archiveError;
81
108
  }
82
109
  }, [archiveMutation]);
110
+ const hasNotifications = !!notifications && notifications.length > 0;
83
111
  const handleClearAll = useCallback(async () => {
112
+ if (!hasNotifications)
113
+ return;
84
114
  setClearIncomplete(false);
85
115
  setPendingClearAllIds(new Set((notifications ?? []).map((notification) => notification.id)));
86
116
  let result;
@@ -101,17 +131,28 @@ export function NotificationList({ config, userId, category, archiveFn, enqueueA
101
131
  return;
102
132
  }
103
133
  onClearAll?.();
104
- }, [archiveAllMutation, notifications, onClearAll]);
134
+ }, [archiveAllMutation, hasNotifications, notifications, onClearAll]);
105
135
  const getTypeIcon = useCallback((type) => {
106
136
  const typeConfig = config.types[type];
107
137
  return typeConfig?.icon ?? '🔔';
108
138
  }, [config]);
109
- const hasNotifications = !!notifications && notifications.length > 0;
110
139
  // A later page that came back empty keeps its pager so the user can step back; the
111
140
  // empty state is the answer for page 1 only.
112
141
  const showEmptyState = !isError && notifications !== undefined && notifications.length === 0 && page === 1;
113
142
  const isClearAllPending = archiveAllMutation.isPending || pendingClearAllIds.size > 0;
114
- return (_jsxs("div", { className: "ntf-list", children: [_jsxs("div", { className: "ntf-list-header", children: [title != null && _jsx("div", { className: "ntf-list-title", children: title }), _jsx(Button, { className: "ntf-list-clear-all", variant: "ghost", size: "sm", onClick: handleClearAll, disabled: !hasNotifications, pending: isClearAllPending, children: isClearAllPending ? 'Clearing...' : 'Clear All' }), clearIncomplete && !isClearAllPending && (_jsx("div", { className: "ntf-list-clear-error", role: "status", children: "Some notifications remain \u2014 try again." }))] }), _jsx(Separator, {}), _jsx("div", { className: "ntf-list-body", children: isLoading ? (_jsx("div", { className: "ntf-loading", children: _jsx(Spinner, { size: "md", label: "Loading notifications" }) })) : (_jsxs(_Fragment, { children: [(notifications ?? []).map((notification) => (_jsxs("div", { className: "ntf-item", children: [_jsx("div", { className: "ntf-item-icon", children: getTypeIcon(notification.type) }), _jsxs("div", { className: "ntf-item-content", children: [_jsx("div", { className: "ntf-item-title", children: notification.title }), _jsx("div", { className: "ntf-item-message", children: notification.message }), _jsx("div", { className: "ntf-item-timestamp", children: formatRelativeTime(notification.updatedAt) })] }), notification.count > 1 && (_jsx("div", { className: "ntf-item-count", children: _jsxs(Badge, { variant: "secondary", children: ["\u00D7", notification.count] }) })), renderRowAction && (_jsx("div", { className: "ntf-item-row-action", children: renderRowAction(notification, {
143
+ const titleId = useId();
144
+ return (_jsxs("div", { ref: listRef, className: "ntf-list", role: "group", "aria-labelledby": title != null ? titleId : undefined, tabIndex: -1, children: [_jsxs("div", { className: "ntf-list-header", children: [title != null && _jsx("div", { id: titleId, className: "ntf-list-title", children: title }), _jsx(Button, { type: "button", className: "ntf-list-clear-all", variant: "ghost", size: "sm", onClick: handleClearAll, "aria-disabled": !hasNotifications || undefined, pending: isClearAllPending, children: isClearAllPending ? 'Clearing...' : 'Clear All' }), clearIncomplete && !isClearAllPending && (_jsx("div", { className: "ntf-list-clear-error", role: "status", children: "Some notifications remain \u2014 try again." }))] }), _jsx(Separator, {}), _jsx("div", { className: "ntf-list-body", children: isLoading ? (_jsx("div", { className: "ntf-loading", children: _jsx(Spinner, { size: "md", label: "Loading notifications" }) })) : (_jsxs(_Fragment, { children: [(notifications ?? []).map((notification) => (_jsxs("div", { ref: (row) => {
145
+ if (row)
146
+ rowRefs.current.set(notification.id, row);
147
+ else
148
+ rowRefs.current.delete(notification.id);
149
+ }, className: "ntf-item", onFocus: () => {
150
+ focusedRowId.current = notification.id;
151
+ }, onBlur: (event) => {
152
+ if (!event.currentTarget.contains(event.relatedTarget)) {
153
+ focusedRowId.current = null;
154
+ }
155
+ }, children: [_jsx("div", { className: "ntf-item-icon", children: getTypeIcon(notification.type) }), _jsxs("div", { className: "ntf-item-content", children: [_jsx("div", { className: "ntf-item-title", children: notification.title }), _jsx("div", { className: "ntf-item-message", children: notification.message }), _jsx("div", { className: "ntf-item-timestamp", children: formatRelativeTime(notification.updatedAt) })] }), notification.count > 1 && (_jsx("div", { className: "ntf-item-count", children: _jsxs(Badge, { variant: "secondary", children: ["\u00D7", notification.count] }) })), renderRowAction && (_jsx("div", { className: "ntf-item-row-action", children: renderRowAction(notification, {
115
156
  archive: () => archiveOne(notification),
116
157
  isArchivePending: pendingClearAllIds.has(notification.id) || pendingArchiveIds.has(notification.id),
117
158
  }) }))] }, notification.id))), isError && error
@@ -1 +1 @@
1
- {"version":3,"file":"NotificationList.js","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { Badge, Button, ListPagination, Separator, Spinner } from '@ttt-productions/ui-core/react';\nimport { useActiveNotifications } from '../hooks/useActiveNotifications.js';\nimport { useArchiveNotification } from '../hooks/useArchiveNotification.js';\nimport { useArchiveAllNotifications } from '../hooks/useArchiveAllNotifications.js';\nimport { NotificationEmptyState } from './NotificationEmptyState.js';\nimport { formatRelativeTime } from './relative-time.js';\nimport type { NotificationDoc, NotificationListProps } from '../../types.js';\n\n/**\n * Scrollable list of active notifications with click-to-archive and clear-all.\n */\nexport function NotificationList({\n config,\n userId,\n category,\n archiveFn,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n title,\n onClearAll,\n refetchInterval,\n staleTime,\n emptyText,\n renderError,\n onRenderedRowsChange,\n renderRowAction,\n}: NotificationListProps) {\n const {\n data: notifications,\n isLoading,\n isFetching,\n isError,\n error,\n refetch,\n page,\n hasNextPage,\n hasPrevPage,\n nextPage,\n prevPage,\n } = useActiveNotifications({\n config,\n userId,\n category,\n refetchInterval,\n staleTime,\n });\n\n const archiveMutation = useArchiveNotification({\n userId,\n category,\n archiveFn,\n });\n\n const archiveAllMutation = useArchiveAllNotifications({\n userId,\n category,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n });\n\n // Surface a failed/incomplete clear instead of swallowing it. `onClearAll` (and the tray-closing\n // side effects the app wires to it) fires ONLY when the server job fully drained the category —\n // otherwise the user keeps the \"notifications remain — try again\" affordance. The poller resolves\n // with an explicit terminal result (`complete` | `incomplete` | `failed`) and only rejects on a\n // hard enqueue/poll throw.\n const [clearIncomplete, setClearIncomplete] = useState(false);\n const [pendingArchiveIds, setPendingArchiveIds] = useState<ReadonlySet<string>>(\n () => new Set(),\n );\n const [pendingClearAllIds, setPendingClearAllIds] = useState<ReadonlySet<string>>(\n () => new Set(),\n );\n\n const renderedRowsRef = useRef(onRenderedRowsChange);\n renderedRowsRef.current = onRenderedRowsChange;\n useEffect(() => {\n if (notifications) renderedRowsRef.current?.(notifications);\n }, [notifications]);\n\n // An archive the server confirmed stays visibly pending until the authoritative\n // active-notification query removes the row: the callable's answer alone does not\n // prove the read has caught up.\n useEffect(() => {\n if (!notifications) return;\n const activeIds = new Set(notifications.map((notification) => notification.id));\n const keepActive = (current: ReadonlySet<string>) => {\n const next = new Set([...current].filter((id) => activeIds.has(id)));\n return next.size === current.size ? current : next;\n };\n setPendingArchiveIds(keepActive);\n setPendingClearAllIds(keepActive);\n }, [notifications]);\n\n const archiveOne = useCallback(async (notification: NotificationDoc) => {\n const clearPending = () =>\n setPendingArchiveIds((current) => {\n const next = new Set(current);\n next.delete(notification.id);\n return next;\n });\n setPendingArchiveIds((current) => {\n const next = new Set(current);\n next.add(notification.id);\n return next;\n });\n try {\n const result = await archiveMutation.mutateAsync(notification);\n // Nothing was archived (the card changed after it was rendered): the row stays\n // active, so it is not left looking like it is clearing.\n if (!result.archived) clearPending();\n } catch (archiveError) {\n clearPending();\n throw archiveError;\n }\n }, [archiveMutation]);\n\n const handleClearAll = useCallback(async () => {\n setClearIncomplete(false);\n setPendingClearAllIds(new Set((notifications ?? []).map((notification) => notification.id)));\n let result;\n try {\n result = await archiveAllMutation.mutateAsync();\n } catch {\n // Hard failure (enqueue/poll threw): do not fire onClearAll; surface the retry affordance.\n setPendingClearAllIds(new Set());\n setClearIncomplete(true);\n return;\n }\n if (!result.complete) {\n // Job terminated without fully draining the category (incomplete/failed) — keep the\n // notifications and prompt a retry.\n setPendingClearAllIds(new Set());\n setClearIncomplete(true);\n return;\n }\n onClearAll?.();\n }, [archiveAllMutation, notifications, onClearAll]);\n\n const getTypeIcon = useCallback(\n (type: string) => {\n const typeConfig = config.types[type];\n return typeConfig?.icon ?? '🔔';\n },\n [config],\n );\n\n const hasNotifications = !!notifications && notifications.length > 0;\n // A later page that came back empty keeps its pager so the user can step back; the\n // empty state is the answer for page 1 only.\n const showEmptyState =\n !isError && notifications !== undefined && notifications.length === 0 && page === 1;\n const isClearAllPending = archiveAllMutation.isPending || pendingClearAllIds.size > 0;\n\n return (\n <div className=\"ntf-list\">\n <div className=\"ntf-list-header\">\n {title != null && <div className=\"ntf-list-title\">{title}</div>}\n <Button\n className=\"ntf-list-clear-all\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleClearAll}\n disabled={!hasNotifications}\n pending={isClearAllPending}\n >\n {isClearAllPending ? 'Clearing...' : 'Clear All'}\n </Button>\n {clearIncomplete && !isClearAllPending && (\n <div className=\"ntf-list-clear-error\" role=\"status\">\n Some notifications remain — try again.\n </div>\n )}\n </div>\n <Separator />\n\n <div className=\"ntf-list-body\">\n {isLoading ? (\n <div className=\"ntf-loading\">\n <Spinner size=\"md\" label=\"Loading notifications\" />\n </div>\n ) : (\n <>\n {(notifications ?? []).map((notification: NotificationDoc) => (\n <div key={notification.id} className=\"ntf-item\">\n <div className=\"ntf-item-icon\">\n {getTypeIcon(notification.type)}\n </div>\n <div className=\"ntf-item-content\">\n <div className=\"ntf-item-title\">{notification.title}</div>\n <div className=\"ntf-item-message\">{notification.message}</div>\n <div className=\"ntf-item-timestamp\">\n {formatRelativeTime(notification.updatedAt)}\n </div>\n </div>\n {notification.count > 1 && (\n <div className=\"ntf-item-count\">\n <Badge variant=\"secondary\">×{notification.count}</Badge>\n </div>\n )}\n {renderRowAction && (\n <div className=\"ntf-item-row-action\">\n {renderRowAction(notification, {\n archive: () => archiveOne(notification),\n isArchivePending:\n pendingClearAllIds.has(notification.id) || pendingArchiveIds.has(notification.id),\n })}\n </div>\n )}\n </div>\n ))}\n {isError && error\n ? renderError({\n error,\n retry: () => {\n void refetch();\n },\n retrying: isFetching,\n hasRows: hasNotifications,\n })\n : null}\n {showEmptyState && <NotificationEmptyState text={emptyText} />}\n <ListPagination\n pagination={{\n currentPage: page,\n canPreviousPage: hasPrevPage,\n canNextPage: hasNextPage,\n goToPreviousPage: prevPage,\n goToNextPage: nextPage,\n }}\n busy={isFetching}\n className=\"ntf-list-footer mt-0\"\n />\n </>\n )}\n </div>\n </div>\n );\n}\n"]}
1
+ {"version":3,"file":"NotificationList.js","sourceRoot":"","sources":["../../../src/react/components/NotificationList.tsx"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';\nimport { Badge, Button, ListPagination, Separator, Spinner } from '@ttt-productions/ui-core/react';\nimport { useActiveNotifications } from '../hooks/useActiveNotifications.js';\nimport { useArchiveNotification } from '../hooks/useArchiveNotification.js';\nimport { useArchiveAllNotifications } from '../hooks/useArchiveAllNotifications.js';\nimport { NotificationEmptyState } from './NotificationEmptyState.js';\nimport { formatRelativeTime } from './relative-time.js';\nimport type { NotificationDoc, NotificationListProps } from '../../types.js';\n\nconst FOCUSABLE =\n 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Scrollable list of active notifications with click-to-archive and clear-all.\n */\nexport function NotificationList({\n config,\n userId,\n category,\n archiveFn,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n title,\n onClearAll,\n refetchInterval,\n staleTime,\n emptyText,\n renderError,\n onRenderedRowsChange,\n renderRowAction,\n}: NotificationListProps) {\n const {\n data: notifications,\n isLoading,\n isFetching,\n isError,\n error,\n refetch,\n page,\n hasNextPage,\n hasPrevPage,\n nextPage,\n prevPage,\n } = useActiveNotifications({\n config,\n userId,\n category,\n refetchInterval,\n staleTime,\n });\n\n const archiveMutation = useArchiveNotification({\n userId,\n category,\n archiveFn,\n });\n\n const archiveAllMutation = useArchiveAllNotifications({\n userId,\n category,\n enqueueArchiveAllFn,\n getArchiveAllStatusFn,\n });\n\n // Surface a failed/incomplete clear instead of swallowing it. `onClearAll` (and the tray-closing\n // side effects the app wires to it) fires ONLY when the server job fully drained the category —\n // otherwise the user keeps the \"notifications remain — try again\" affordance. The poller resolves\n // with an explicit terminal result (`complete` | `incomplete` | `failed`) and only rejects on a\n // hard enqueue/poll throw.\n const [clearIncomplete, setClearIncomplete] = useState(false);\n const [pendingArchiveIds, setPendingArchiveIds] = useState<ReadonlySet<string>>(\n () => new Set(),\n );\n const [pendingClearAllIds, setPendingClearAllIds] = useState<ReadonlySet<string>>(\n () => new Set(),\n );\n\n const renderedRowsRef = useRef(onRenderedRowsChange);\n renderedRowsRef.current = onRenderedRowsChange;\n useEffect(() => {\n if (notifications) renderedRowsRef.current?.(notifications);\n }, [notifications]);\n\n // An archive the server confirmed stays visibly pending until the authoritative\n // active-notification query removes the row: the callable's answer alone does not\n // prove the read has caught up.\n useEffect(() => {\n if (!notifications) return;\n const activeIds = new Set(notifications.map((notification) => notification.id));\n const keepActive = (current: ReadonlySet<string>) => {\n const next = new Set([...current].filter((id) => activeIds.has(id)));\n return next.size === current.size ? current : next;\n };\n setPendingArchiveIds(keepActive);\n setPendingClearAllIds(keepActive);\n }, [notifications]);\n\n // A row that leaves the list while focus is inside it takes the focused control with it, so focus\n // moves to the control of the row that followed it, or to the list itself (FRONTEND-203). A row's\n // removal fires no blur, so the row still recorded as focused when the rows change is the row that\n // held focus as it left.\n const listRef = useRef<HTMLDivElement>(null);\n const rowRefs = useRef(new Map<string, HTMLDivElement>());\n const focusedRowId = useRef<string | null>(null);\n const renderedIds = useRef<readonly string[]>([]);\n useLayoutEffect(() => {\n const ids = isLoading ? [] : (notifications ?? []).map((notification) => notification.id);\n const previousIds = renderedIds.current;\n renderedIds.current = ids;\n const leftId = focusedRowId.current;\n if (leftId === null || ids.includes(leftId)) return;\n focusedRowId.current = null;\n const active = document.activeElement;\n if (active !== null && active !== document.body && active.isConnected) return;\n\n const remaining = new Set(ids);\n const followers = previousIds.slice(previousIds.indexOf(leftId) + 1).filter((id) => remaining.has(id));\n const nextControl = followers\n .map((id) => rowRefs.current.get(id)?.querySelector('.ntf-item-row-action')?.querySelector<HTMLElement>(FOCUSABLE))\n .find((control) => control != null);\n (nextControl ?? listRef.current)?.focus();\n }, [notifications, isLoading]);\n\n const archiveOne = useCallback(async (notification: NotificationDoc) => {\n const clearPending = () =>\n setPendingArchiveIds((current) => {\n const next = new Set(current);\n next.delete(notification.id);\n return next;\n });\n setPendingArchiveIds((current) => {\n const next = new Set(current);\n next.add(notification.id);\n return next;\n });\n try {\n const result = await archiveMutation.mutateAsync(notification);\n // Nothing was archived (the card changed after it was rendered): the row stays\n // active, so it is not left looking like it is clearing.\n if (!result.archived) clearPending();\n } catch (archiveError) {\n clearPending();\n throw archiveError;\n }\n }, [archiveMutation]);\n\n const hasNotifications = !!notifications && notifications.length > 0;\n\n const handleClearAll = useCallback(async () => {\n if (!hasNotifications) return;\n setClearIncomplete(false);\n setPendingClearAllIds(new Set((notifications ?? []).map((notification) => notification.id)));\n let result;\n try {\n result = await archiveAllMutation.mutateAsync();\n } catch {\n // Hard failure (enqueue/poll threw): do not fire onClearAll; surface the retry affordance.\n setPendingClearAllIds(new Set());\n setClearIncomplete(true);\n return;\n }\n if (!result.complete) {\n // Job terminated without fully draining the category (incomplete/failed) — keep the\n // notifications and prompt a retry.\n setPendingClearAllIds(new Set());\n setClearIncomplete(true);\n return;\n }\n onClearAll?.();\n }, [archiveAllMutation, hasNotifications, notifications, onClearAll]);\n\n const getTypeIcon = useCallback(\n (type: string) => {\n const typeConfig = config.types[type];\n return typeConfig?.icon ?? '🔔';\n },\n [config],\n );\n\n // A later page that came back empty keeps its pager so the user can step back; the\n // empty state is the answer for page 1 only.\n const showEmptyState =\n !isError && notifications !== undefined && notifications.length === 0 && page === 1;\n const isClearAllPending = archiveAllMutation.isPending || pendingClearAllIds.size > 0;\n const titleId = useId();\n\n return (\n <div\n ref={listRef}\n className=\"ntf-list\"\n role=\"group\"\n aria-labelledby={title != null ? titleId : undefined}\n tabIndex={-1}\n >\n <div className=\"ntf-list-header\">\n {title != null && <div id={titleId} className=\"ntf-list-title\">{title}</div>}\n {/* Unavailable with nothing to clear, but never natively disabled: the list empties under\n a Clear All that still holds focus, and focus must keep a destination (FRONTEND-203). */}\n <Button\n type=\"button\"\n className=\"ntf-list-clear-all\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={handleClearAll}\n aria-disabled={!hasNotifications || undefined}\n pending={isClearAllPending}\n >\n {isClearAllPending ? 'Clearing...' : 'Clear All'}\n </Button>\n {clearIncomplete && !isClearAllPending && (\n <div className=\"ntf-list-clear-error\" role=\"status\">\n Some notifications remain — try again.\n </div>\n )}\n </div>\n <Separator />\n\n <div className=\"ntf-list-body\">\n {isLoading ? (\n <div className=\"ntf-loading\">\n <Spinner size=\"md\" label=\"Loading notifications\" />\n </div>\n ) : (\n <>\n {(notifications ?? []).map((notification: NotificationDoc) => (\n <div\n key={notification.id}\n ref={(row) => {\n if (row) rowRefs.current.set(notification.id, row);\n else rowRefs.current.delete(notification.id);\n }}\n className=\"ntf-item\"\n onFocus={() => {\n focusedRowId.current = notification.id;\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n focusedRowId.current = null;\n }\n }}\n >\n <div className=\"ntf-item-icon\">\n {getTypeIcon(notification.type)}\n </div>\n <div className=\"ntf-item-content\">\n <div className=\"ntf-item-title\">{notification.title}</div>\n <div className=\"ntf-item-message\">{notification.message}</div>\n <div className=\"ntf-item-timestamp\">\n {formatRelativeTime(notification.updatedAt)}\n </div>\n </div>\n {notification.count > 1 && (\n <div className=\"ntf-item-count\">\n <Badge variant=\"secondary\">×{notification.count}</Badge>\n </div>\n )}\n {renderRowAction && (\n <div className=\"ntf-item-row-action\">\n {renderRowAction(notification, {\n archive: () => archiveOne(notification),\n isArchivePending:\n pendingClearAllIds.has(notification.id) || pendingArchiveIds.has(notification.id),\n })}\n </div>\n )}\n </div>\n ))}\n {isError && error\n ? renderError({\n error,\n retry: () => {\n void refetch();\n },\n retrying: isFetching,\n hasRows: hasNotifications,\n })\n : null}\n {showEmptyState && <NotificationEmptyState text={emptyText} />}\n <ListPagination\n pagination={{\n currentPage: page,\n canPreviousPage: hasPrevPage,\n canNextPage: hasNextPage,\n goToPreviousPage: prevPage,\n goToNextPage: nextPage,\n }}\n busy={isFetching}\n className=\"ntf-list-footer mt-0\"\n />\n </>\n )}\n </div>\n </div>\n );\n}\n"]}
@@ -87,13 +87,29 @@ export interface EnqueueResult {
87
87
  allResolved: boolean;
88
88
  }
89
89
  export type MaterializeOutcome = 'materialized' | 'already-materialized' | 'skipped-ineligible-recipient' | 'missing' | 'skipped-non-queued';
90
+ export interface MaterializeManyOptions {
91
+ /** Rows materialized at once (default 10). */
92
+ concurrency?: number;
93
+ /**
94
+ * Called for a row whose materialize threw and whose failure could not then be recorded either —
95
+ * `error` is the recording's own failure and `details.cause` the materialize failure it was
96
+ * recording. The row stays as it was (queued, attempt count
97
+ * unchanged), so a later run selects it again; the rest of the batch proceeds. The app's capture
98
+ * goes here; without it the ledger writes the failure to `console.error`.
99
+ */
100
+ onUnrecordedFailure?: (deliveryId: string, error: unknown, details: {
101
+ cause: unknown;
102
+ }) => void;
103
+ }
90
104
  export interface DeliveryLedger {
91
105
  enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;
92
106
  materialize(deliveryId: string): Promise<MaterializeOutcome>;
93
- /** Materialize many rows with bounded concurrency; transient throws record a retry/dead-letter outcome. */
94
- materializeMany(deliveryIds: string[], options?: {
95
- concurrency?: number;
96
- }): Promise<Record<MaterializeOutcome, number>>;
107
+ /**
108
+ * Materialize many rows with bounded concurrency. A row whose materialize throws has the failure
109
+ * recorded (a retry or a dead letter); each row is isolated, so one row's failure — even one
110
+ * whose recording also fails — never fails the batch or stops the rows behind it.
111
+ */
112
+ materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;
97
113
  recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;
98
114
  deadLetter(deliveryId: string, error: unknown): Promise<void>;
99
115
  replay(deliveryId: string): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"delivery-ledger.d.ts","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"delivery-ledger.d.ts","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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"}
@@ -314,6 +314,18 @@ export function createDeliveryLedger(db, config, options) {
314
314
  });
315
315
  });
316
316
  }
317
+ function reportUnrecordedFailure(options, deliveryId, error, cause) {
318
+ try {
319
+ if (options?.onUnrecordedFailure) {
320
+ options.onUnrecordedFailure(deliveryId, error, { cause });
321
+ return;
322
+ }
323
+ }
324
+ catch (handlerError) {
325
+ console.error('[notification-core] onUnrecordedFailure threw for delivery', deliveryId, handlerError);
326
+ }
327
+ console.error('[notification-core] A delivery failure could not be recorded', deliveryId, error, { cause });
328
+ }
317
329
  async function materializeMany(deliveryIds, options) {
318
330
  const concurrency = Math.max(1, options?.concurrency ?? 10);
319
331
  const tally = {
@@ -332,7 +344,12 @@ export function createDeliveryLedger(db, config, options) {
332
344
  tally[outcome] += 1;
333
345
  }
334
346
  catch (error) {
335
- await recordTransientFailure(id, error);
347
+ try {
348
+ await recordTransientFailure(id, error);
349
+ }
350
+ catch (recordError) {
351
+ reportUnrecordedFailure(options, id, recordError, error);
352
+ }
336
353
  }
337
354
  }
338
355
  }
@@ -1 +1 @@
1
- {"version":3,"file":"delivery-ledger.js","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Generic notification delivery ledger (notification redesign — P1).\n *\n * One Firestore doc per (recipient|shared, type, occurrence) is BOTH the queue\n * row AND the idempotency ledger. This package owns the GENERIC mechanism;\n * the consuming app owns the concrete collection name, the deterministic\n * `deliveryId`/`eventId`/`aggregationKey` construction, and the worker that\n * selects rows per `materializationClass`. The app passes fully-formed\n * `DeliveryRowInput`s; this module never invents domain ids.\n *\n * Key invariants (FROZEN — this module is their single source; the consuming app's\n * notification design doc explains the product rationale):\n * - Enqueue = create-if-absent. `ALREADY_EXISTS` is a per-row duplicate no-op,\n * never a failure — there is no marker-without-payload state, so the\n * \"marker created, payload lost\" crash window cannot exist.\n * - Materialize = ONE transaction that reads the delivery row + active card,\n * applies the aggregation exactly once (per strategy + caps), rotates the\n * opaque `activityGeneration`, resets `seenAt`, and flips the row to\n * `materialized` — so the \"applied but not marked\" double-apply window is gone.\n * Retrying a `materialized` row is a successful no-op.\n * - The same transaction asks the app whether the recipient may still receive\n * (`options.isRecipientEligible`); an ineligible recipient gets no card and the\n * row turns terminal `skipped`, so a row queued before (or retried after) the\n * recipient became ineligible can never recreate a card. Re-running it is a no-op.\n * - TTL (`expireAt`, a real Firestore Timestamp via `config.timestampFromMillis`)\n * is set ONLY at `materialized` / `skipped`; a `queued` or `deadLetter` row is NEVER TTL'd\n * so an unresolved delivery can't be silently deleted before replay.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { NotificationSystemConfig, NotificationDoc } from '../types.js';\nimport type { ServerFirestore, ServerDocRef, ServerTransaction } from './types.js';\nimport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\nconst DEFAULT_DELIVERIES_PATH = 'notificationDeliveries';\nconst DEFAULT_DELIVERY_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days\nconst DEFAULT_MAX_ATTEMPTS = 8;\nconst DEFAULT_COUNT_CAP = 5000;\nconst DEFAULT_ACTOR_CAP = 5;\nconst BACKOFF_BASE_MS = 60 * 1000; // 1 min\nconst BACKOFF_MAX_MS = 60 * 60 * 1000; // 1 hour\n\n/**\n * `skipped` is terminal: the recipient was not eligible when the row was materialized, so no card\n * was written. It is TTL'd like `materialized` and never retried.\n */\nexport type DeliveryState = 'queued' | 'materialized' | 'skipped' | 'deadLetter';\n\n/** Why a row was terminalized as `skipped`. */\nexport type DeliverySkipReason = 'recipientIneligible';\n\n/**\n * Ledger options the consuming app must decide.\n */\nexport interface DeliveryLedgerOptions {\n /**\n * Whether `recipientUid` may still receive a notification. Called INSIDE the materialize\n * transaction, after the delivery row is read and before anything is written, so the answer\n * and the card write commit together (BACKEND-116): read through `tx` only. A recipient that\n * answers false gets no card, and the row becomes `skipped`. Not called for a shared\n * (recipient-less) row. A consumer with no eligibility rule answers true.\n */\n isRecipientEligible: (recipientUid: string, tx: ServerTransaction) => Promise<boolean>;\n}\nexport type AggregationStrategy = 'increment' | 'staticRelight';\nexport type MaterializationClass =\n | 'directQueued'\n | 'realtimeFallback'\n | 'fanoutOrphan'\n | 'retry';\n\n/** The materialization payload carried on every delivery row. */\nexport interface DeliveryPayload {\n /** The acting user's uid, or `null`/`''` for a system / no-actor occurrence (N-M2). */\n actorId: string | null;\n metadata: Record<string, unknown>;\n occurrenceAt: number;\n}\n\n/** App-supplied, fully-formed delivery row (the app owns all id construction). */\nexport interface DeliveryRowInput {\n deliveryId: string;\n notificationType: string;\n eventId: string;\n /** `null` for shared-admin occurrences (the deliveryId hash uses the literal `'shared'`). */\n recipientUid: string | null;\n aggregationKey: string;\n strategy: AggregationStrategy;\n payload: DeliveryPayload;\n payloadVersion: number;\n materializationClass: MaterializationClass;\n}\n\nexport type EnqueueRowResult =\n | { deliveryId: string; outcome: 'created' }\n | { deliveryId: string; outcome: 'duplicate' }\n | { deliveryId: string; outcome: 'failed'; error: unknown };\n\nexport interface EnqueueResult {\n results: EnqueueRowResult[];\n /** True iff EVERY row is now represented by a created-or-pre-existing doc (page-cursor invariant input). */\n allResolved: boolean;\n}\n\nexport type MaterializeOutcome =\n | 'materialized'\n | 'already-materialized'\n | 'skipped-ineligible-recipient'\n | 'missing'\n | 'skipped-non-queued';\n\nexport interface DeliveryLedger {\n enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;\n materialize(deliveryId: string): Promise<MaterializeOutcome>;\n /** Materialize many rows with bounded concurrency; transient throws record a retry/dead-letter outcome. */\n materializeMany(deliveryIds: string[], options?: { concurrency?: number }): Promise<Record<MaterializeOutcome, number>>;\n recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;\n deadLetter(deliveryId: string, error: unknown): Promise<void>;\n replay(deliveryId: string): Promise<void>;\n}\n\n/** gRPC ALREADY_EXISTS (6) — the create-if-absent duplicate signal. */\nfunction isAlreadyExists(error: unknown): boolean {\n const e = error as { code?: number; message?: string } | undefined;\n return e?.code === 6 || /already.?exists/i.test(e?.message ?? '');\n}\n\nfunction backoffMs(attemptCount: number): number {\n const exp = Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, attemptCount - 1), BACKOFF_MAX_MS);\n // Full jitter — spread retries so a burst of failures doesn't synchronize.\n return Math.floor(exp / 2 + Math.random() * (exp / 2));\n}\n\n/**\n * Apply the aggregation strategy to produce the active-doc field delta.\n * PURE — exported for direct unit testing. `existing` is the current active doc\n * (or null to create). Always rotates `activityGeneration` and resets `seenAt`.\n */\nexport function applyAggregation(params: {\n strategy: AggregationStrategy;\n existing: Pick<NotificationDoc, 'count' | 'latestActorIds'> | null;\n actorId: string | null;\n countCap: number;\n actorCap: number;\n buildMessage: (count: number) => string;\n now: number;\n generation: string;\n}): { count: number; latestActorIds: string[]; message: string; activityGeneration: string; seenAt: number; updatedAt: number } {\n const { strategy, existing, actorId, countCap, actorCap, buildMessage, now, generation } = params;\n const prevActors = existing?.latestActorIds ?? [];\n // N-M2: a system / no-actor occurrence (null or '') is never injected into the actor\n // list — it would render as a blank avatar / unresolvable uid in `latestActorIds`.\n const latestActorIds = actorId\n ? [actorId, ...prevActors.filter((id) => id !== actorId)].slice(0, actorCap)\n : prevActors.slice(0, actorCap);\n // staticRelight never shows a count (stays 1); increment counts up to the cap.\n const count =\n strategy === 'staticRelight'\n ? 1\n : Math.min((existing?.count ?? 0) + 1, countCap);\n return {\n count,\n latestActorIds,\n message: buildMessage(count),\n activityGeneration: generation,\n seenAt: 0,\n updatedAt: now,\n };\n}\n\nexport function createDeliveryLedger(\n db: ServerFirestore,\n config: NotificationSystemConfig,\n options: DeliveryLedgerOptions,\n): DeliveryLedger {\n const { isRecipientEligible } = options;\n const deliveriesPath = config.deliveriesCollectionPath ?? DEFAULT_DELIVERIES_PATH;\n const ttlMs = config.deliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS;\n const maxAttempts = config.maxDeliveryAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const configTimestampFromMillis = config.timestampFromMillis;\n if (!configTimestampFromMillis) {\n throw new Error(\n '[notification-core] createDeliveryLedger requires config.timestampFromMillis (Firestore TTL needs a real Timestamp for expireAt).',\n );\n }\n // Bind to an explicitly non-optional type so the nested transaction closures\n // (called later) see it as defined, not `... | undefined`.\n const toTimestamp: (ms: number) => unknown = configTimestampFromMillis;\n\n const deliveries = db.collection(deliveriesPath);\n\n function deliveryRef(deliveryId: string): ServerDocRef {\n return deliveries.doc(deliveryId);\n }\n\n function buildDeliveryDoc(row: DeliveryRowInput, now: number): Record<string, unknown> {\n return {\n deliveryId: row.deliveryId,\n state: 'queued' as DeliveryState,\n notificationType: row.notificationType,\n eventId: row.eventId,\n recipientUid: row.recipientUid,\n aggregationKey: row.aggregationKey,\n strategy: row.strategy,\n payload: row.payload,\n payloadVersion: row.payloadVersion,\n materializationClass: row.materializationClass,\n attemptCount: 0,\n nextAttemptAt: now,\n lastError: null,\n createdAt: now,\n materializedAt: null,\n deadLetteredAt: null,\n // No expireAt while queued.\n };\n }\n\n function getTypeConfig(type: string) {\n const typeConfig = config.types[type];\n if (!typeConfig) throw new Error(`[notification-core] Unknown notification type: ${type}`);\n const categoryConfig = config.categories[typeConfig.category];\n if (!categoryConfig) throw new Error(`[notification-core] Unknown category: ${typeConfig.category}`);\n return { typeConfig, categoryConfig };\n }\n\n async function enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult> {\n if (rows.length === 0) return { results: [], allResolved: true };\n const now = Date.now();\n const settled = await Promise.allSettled(\n rows.map((row) =>\n deliveryRef(row.deliveryId)\n .create(buildDeliveryDoc(row, now))\n .then(() => ({ deliveryId: row.deliveryId, outcome: 'created' as const })),\n ),\n );\n const results: EnqueueRowResult[] = settled.map((s, i) => {\n const deliveryId = rows[i].deliveryId;\n if (s.status === 'fulfilled') return s.value;\n if (isAlreadyExists(s.reason)) return { deliveryId, outcome: 'duplicate' };\n return { deliveryId, outcome: 'failed', error: s.reason };\n });\n return { results, allResolved: results.every((r) => r.outcome !== 'failed') };\n }\n\n async function materialize(deliveryId: string): Promise<MaterializeOutcome> {\n return db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return 'missing';\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n const state = d.state as DeliveryState;\n if (state === 'materialized') return 'already-materialized';\n if (state === 'skipped') return 'skipped-ineligible-recipient';\n if (state !== 'queued') return 'skipped-non-queued';\n\n const notificationType = d.notificationType as string;\n const aggregationKey = d.aggregationKey as string;\n const recipientUid = (d.recipientUid as string | null) ?? null;\n\n // The recipient is checked in this transaction, so a recipient that became ineligible\n // (e.g. an erased account) never gets a card from a row queued before, or retried after.\n if (recipientUid !== null && !(await isRecipientEligible(recipientUid, tx))) {\n const skippedAt = Date.now();\n tx.update(dRef, {\n state: 'skipped' as DeliveryState,\n skipReason: 'recipientIneligible' as DeliverySkipReason,\n skippedAt,\n expireAt: toTimestamp(skippedAt + ttlMs),\n });\n return 'skipped-ineligible-recipient';\n }\n const strategy = (d.strategy as AggregationStrategy) ?? 'increment';\n const payload = (d.payload as DeliveryPayload) ?? { actorId: '', metadata: {}, occurrenceAt: Date.now() };\n const { typeConfig, categoryConfig } = getTypeConfig(notificationType);\n\n const activeRef = db.collection(categoryConfig.activePath).doc(\n buildActiveNotificationDocId({\n category: typeConfig.category,\n audienceType: categoryConfig.audienceType,\n targetUserId: recipientUid,\n dedupKey: aggregationKey,\n notificationType,\n }),\n );\n const activeSnap = await tx.get(activeRef); // all reads before writes\n\n const now = Date.now();\n const generation = randomUUID();\n const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;\n const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;\n const buildMessage = (count: number) => typeConfig.messagePattern(payload.metadata, count);\n // Latest-occurrence-derived fields — recomputed every materialize so an EXISTING\n // aggregate card reflects the newest activity (title/targetPath/metadata), not the\n // first occurrence's stale values (N-I4).\n const title = typeConfig.titlePattern(payload.metadata);\n // A type with no defaultTargetPath is linkless: the doc must OMIT the field\n // entirely (Firestore rejects `undefined` values). A pre-existing doc written\n // before a type went linkless keeps its stale targetPath until archived.\n const targetPath =\n typeConfig.defaultTargetPath === undefined\n ? undefined\n : typeof typeConfig.defaultTargetPath === 'function'\n ? typeConfig.defaultTargetPath(payload.metadata)\n : typeConfig.defaultTargetPath;\n const targetPathField = targetPath === undefined ? {} : { targetPath };\n\n if (activeSnap.exists) {\n const existing = (activeSnap.data() ?? {}) as Pick<NotificationDoc, 'count' | 'latestActorIds'>;\n const delta = applyAggregation({\n strategy,\n existing,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n tx.update(activeRef, { ...delta, title, ...targetPathField, metadata: payload.metadata });\n } else {\n const delta = applyAggregation({\n strategy,\n existing: null,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n const newDoc: Omit<NotificationDoc, 'id'> = {\n type: notificationType,\n dedupKey: aggregationKey,\n category: typeConfig.category,\n targetUserId: recipientUid,\n title,\n message: delta.message,\n count: delta.count,\n latestActorIds: delta.latestActorIds,\n ...targetPathField,\n metadata: payload.metadata,\n seenAt: 0,\n activityGeneration: generation,\n createdAt: now,\n updatedAt: now,\n };\n tx.set(activeRef, newDoc as Record<string, unknown>);\n }\n\n tx.update(dRef, {\n state: 'materialized' as DeliveryState,\n materializedAt: now,\n expireAt: toTimestamp(now + ttlMs),\n });\n return 'materialized';\n });\n }\n\n async function recordTransientFailure(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n const attemptCount = ((d.attemptCount as number) ?? 0) + 1;\n const now = Date.now();\n const message = error instanceof Error ? error.message : String(error);\n if (attemptCount >= maxAttempts) {\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount,\n lastError: message,\n deadLetteredAt: now,\n });\n } else {\n // n1: re-stamp the row into the reserved `retry` materialization lane on backoff. Without\n // this the row keeps its original class and competes for that lane's capacity while backed\n // off; the worker's reserved `retry` slice (separate CLASS_CAPS capacity, drained in\n // DRAIN_ORDER) would always select 0 rows. Re-stamping moves a backed-off row into the\n // anti-starvation lane so it drains there instead of starving in its original lane.\n tx.update(dRef, {\n attemptCount,\n nextAttemptAt: now + backoffMs(attemptCount),\n lastError: message,\n materializationClass: 'retry' as MaterializationClass,\n });\n }\n });\n }\n\n async function deadLetter(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount: ((d.attemptCount as number) ?? 0) + 1,\n lastError: error instanceof Error ? error.message : String(error),\n deadLetteredAt: Date.now(),\n });\n });\n }\n\n async function replay(deliveryId: string): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'deadLetter') return;\n tx.update(dRef, {\n state: 'queued' as DeliveryState,\n attemptCount: 0,\n nextAttemptAt: Date.now(),\n lastError: null,\n deadLetteredAt: null,\n expireAt: null, // clear any TTL set on a prior terminal\n });\n });\n }\n\n async function materializeMany(\n deliveryIds: string[],\n options?: { concurrency?: number },\n ): Promise<Record<MaterializeOutcome, number>> {\n const concurrency = Math.max(1, options?.concurrency ?? 10);\n const tally: Record<MaterializeOutcome, number> = {\n materialized: 0,\n 'already-materialized': 0,\n 'skipped-ineligible-recipient': 0,\n missing: 0,\n 'skipped-non-queued': 0,\n };\n let cursor = 0;\n async function worker(): Promise<void> {\n while (cursor < deliveryIds.length) {\n const id = deliveryIds[cursor++];\n try {\n const outcome = await materialize(id);\n tally[outcome] += 1;\n } catch (error) {\n await recordTransientFailure(id, error);\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(concurrency, deliveryIds.length) }, worker));\n return tally;\n }\n\n return { enqueue, materialize, materializeMany, recordTransientFailure, deadLetter, replay };\n}\n"]}
1
+ {"version":3,"file":"delivery-ledger.js","sourceRoot":"","sources":["../../src/server/delivery-ledger.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Generic notification delivery ledger (notification redesign — P1).\n *\n * One Firestore doc per (recipient|shared, type, occurrence) is BOTH the queue\n * row AND the idempotency ledger. This package owns the GENERIC mechanism;\n * the consuming app owns the concrete collection name, the deterministic\n * `deliveryId`/`eventId`/`aggregationKey` construction, and the worker that\n * selects rows per `materializationClass`. The app passes fully-formed\n * `DeliveryRowInput`s; this module never invents domain ids.\n *\n * Key invariants (FROZEN — this module is their single source; the consuming app's\n * notification design doc explains the product rationale):\n * - Enqueue = create-if-absent. `ALREADY_EXISTS` is a per-row duplicate no-op,\n * never a failure — there is no marker-without-payload state, so the\n * \"marker created, payload lost\" crash window cannot exist.\n * - Materialize = ONE transaction that reads the delivery row + active card,\n * applies the aggregation exactly once (per strategy + caps), rotates the\n * opaque `activityGeneration`, resets `seenAt`, and flips the row to\n * `materialized` — so the \"applied but not marked\" double-apply window is gone.\n * Retrying a `materialized` row is a successful no-op.\n * - The same transaction asks the app whether the recipient may still receive\n * (`options.isRecipientEligible`); an ineligible recipient gets no card and the\n * row turns terminal `skipped`, so a row queued before (or retried after) the\n * recipient became ineligible can never recreate a card. Re-running it is a no-op.\n * - TTL (`expireAt`, a real Firestore Timestamp via `config.timestampFromMillis`)\n * is set ONLY at `materialized` / `skipped`; a `queued` or `deadLetter` row is NEVER TTL'd\n * so an unresolved delivery can't be silently deleted before replay.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { NotificationSystemConfig, NotificationDoc } from '../types.js';\nimport type { ServerFirestore, ServerDocRef, ServerTransaction } from './types.js';\nimport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\nconst DEFAULT_DELIVERIES_PATH = 'notificationDeliveries';\nconst DEFAULT_DELIVERY_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days\nconst DEFAULT_MAX_ATTEMPTS = 8;\nconst DEFAULT_COUNT_CAP = 5000;\nconst DEFAULT_ACTOR_CAP = 5;\nconst BACKOFF_BASE_MS = 60 * 1000; // 1 min\nconst BACKOFF_MAX_MS = 60 * 60 * 1000; // 1 hour\n\n/**\n * `skipped` is terminal: the recipient was not eligible when the row was materialized, so no card\n * was written. It is TTL'd like `materialized` and never retried.\n */\nexport type DeliveryState = 'queued' | 'materialized' | 'skipped' | 'deadLetter';\n\n/** Why a row was terminalized as `skipped`. */\nexport type DeliverySkipReason = 'recipientIneligible';\n\n/**\n * Ledger options the consuming app must decide.\n */\nexport interface DeliveryLedgerOptions {\n /**\n * Whether `recipientUid` may still receive a notification. Called INSIDE the materialize\n * transaction, after the delivery row is read and before anything is written, so the answer\n * and the card write commit together (BACKEND-116): read through `tx` only. A recipient that\n * answers false gets no card, and the row becomes `skipped`. Not called for a shared\n * (recipient-less) row. A consumer with no eligibility rule answers true.\n */\n isRecipientEligible: (recipientUid: string, tx: ServerTransaction) => Promise<boolean>;\n}\nexport type AggregationStrategy = 'increment' | 'staticRelight';\nexport type MaterializationClass =\n | 'directQueued'\n | 'realtimeFallback'\n | 'fanoutOrphan'\n | 'retry';\n\n/** The materialization payload carried on every delivery row. */\nexport interface DeliveryPayload {\n /** The acting user's uid, or `null`/`''` for a system / no-actor occurrence (N-M2). */\n actorId: string | null;\n metadata: Record<string, unknown>;\n occurrenceAt: number;\n}\n\n/** App-supplied, fully-formed delivery row (the app owns all id construction). */\nexport interface DeliveryRowInput {\n deliveryId: string;\n notificationType: string;\n eventId: string;\n /** `null` for shared-admin occurrences (the deliveryId hash uses the literal `'shared'`). */\n recipientUid: string | null;\n aggregationKey: string;\n strategy: AggregationStrategy;\n payload: DeliveryPayload;\n payloadVersion: number;\n materializationClass: MaterializationClass;\n}\n\nexport type EnqueueRowResult =\n | { deliveryId: string; outcome: 'created' }\n | { deliveryId: string; outcome: 'duplicate' }\n | { deliveryId: string; outcome: 'failed'; error: unknown };\n\nexport interface EnqueueResult {\n results: EnqueueRowResult[];\n /** True iff EVERY row is now represented by a created-or-pre-existing doc (page-cursor invariant input). */\n allResolved: boolean;\n}\n\nexport type MaterializeOutcome =\n | 'materialized'\n | 'already-materialized'\n | 'skipped-ineligible-recipient'\n | 'missing'\n | 'skipped-non-queued';\n\nexport interface MaterializeManyOptions {\n /** Rows materialized at once (default 10). */\n concurrency?: number;\n /**\n * Called for a row whose materialize threw and whose failure could not then be recorded either —\n * `error` is the recording's own failure and `details.cause` the materialize failure it was\n * recording. The row stays as it was (queued, attempt count\n * unchanged), so a later run selects it again; the rest of the batch proceeds. The app's capture\n * goes here; without it the ledger writes the failure to `console.error`.\n */\n onUnrecordedFailure?: (deliveryId: string, error: unknown, details: { cause: unknown }) => void;\n}\n\nexport interface DeliveryLedger {\n enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult>;\n materialize(deliveryId: string): Promise<MaterializeOutcome>;\n /**\n * Materialize many rows with bounded concurrency. A row whose materialize throws has the failure\n * recorded (a retry or a dead letter); each row is isolated, so one row's failure — even one\n * whose recording also fails — never fails the batch or stops the rows behind it.\n */\n materializeMany(deliveryIds: string[], options?: MaterializeManyOptions): Promise<Record<MaterializeOutcome, number>>;\n recordTransientFailure(deliveryId: string, error: unknown): Promise<void>;\n deadLetter(deliveryId: string, error: unknown): Promise<void>;\n replay(deliveryId: string): Promise<void>;\n}\n\n/** gRPC ALREADY_EXISTS (6) — the create-if-absent duplicate signal. */\nfunction isAlreadyExists(error: unknown): boolean {\n const e = error as { code?: number; message?: string } | undefined;\n return e?.code === 6 || /already.?exists/i.test(e?.message ?? '');\n}\n\nfunction backoffMs(attemptCount: number): number {\n const exp = Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, attemptCount - 1), BACKOFF_MAX_MS);\n // Full jitter — spread retries so a burst of failures doesn't synchronize.\n return Math.floor(exp / 2 + Math.random() * (exp / 2));\n}\n\n/**\n * Apply the aggregation strategy to produce the active-doc field delta.\n * PURE — exported for direct unit testing. `existing` is the current active doc\n * (or null to create). Always rotates `activityGeneration` and resets `seenAt`.\n */\nexport function applyAggregation(params: {\n strategy: AggregationStrategy;\n existing: Pick<NotificationDoc, 'count' | 'latestActorIds'> | null;\n actorId: string | null;\n countCap: number;\n actorCap: number;\n buildMessage: (count: number) => string;\n now: number;\n generation: string;\n}): { count: number; latestActorIds: string[]; message: string; activityGeneration: string; seenAt: number; updatedAt: number } {\n const { strategy, existing, actorId, countCap, actorCap, buildMessage, now, generation } = params;\n const prevActors = existing?.latestActorIds ?? [];\n // N-M2: a system / no-actor occurrence (null or '') is never injected into the actor\n // list — it would render as a blank avatar / unresolvable uid in `latestActorIds`.\n const latestActorIds = actorId\n ? [actorId, ...prevActors.filter((id) => id !== actorId)].slice(0, actorCap)\n : prevActors.slice(0, actorCap);\n // staticRelight never shows a count (stays 1); increment counts up to the cap.\n const count =\n strategy === 'staticRelight'\n ? 1\n : Math.min((existing?.count ?? 0) + 1, countCap);\n return {\n count,\n latestActorIds,\n message: buildMessage(count),\n activityGeneration: generation,\n seenAt: 0,\n updatedAt: now,\n };\n}\n\nexport function createDeliveryLedger(\n db: ServerFirestore,\n config: NotificationSystemConfig,\n options: DeliveryLedgerOptions,\n): DeliveryLedger {\n const { isRecipientEligible } = options;\n const deliveriesPath = config.deliveriesCollectionPath ?? DEFAULT_DELIVERIES_PATH;\n const ttlMs = config.deliveryTtlMs ?? DEFAULT_DELIVERY_TTL_MS;\n const maxAttempts = config.maxDeliveryAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const configTimestampFromMillis = config.timestampFromMillis;\n if (!configTimestampFromMillis) {\n throw new Error(\n '[notification-core] createDeliveryLedger requires config.timestampFromMillis (Firestore TTL needs a real Timestamp for expireAt).',\n );\n }\n // Bind to an explicitly non-optional type so the nested transaction closures\n // (called later) see it as defined, not `... | undefined`.\n const toTimestamp: (ms: number) => unknown = configTimestampFromMillis;\n\n const deliveries = db.collection(deliveriesPath);\n\n function deliveryRef(deliveryId: string): ServerDocRef {\n return deliveries.doc(deliveryId);\n }\n\n function buildDeliveryDoc(row: DeliveryRowInput, now: number): Record<string, unknown> {\n return {\n deliveryId: row.deliveryId,\n state: 'queued' as DeliveryState,\n notificationType: row.notificationType,\n eventId: row.eventId,\n recipientUid: row.recipientUid,\n aggregationKey: row.aggregationKey,\n strategy: row.strategy,\n payload: row.payload,\n payloadVersion: row.payloadVersion,\n materializationClass: row.materializationClass,\n attemptCount: 0,\n nextAttemptAt: now,\n lastError: null,\n createdAt: now,\n materializedAt: null,\n deadLetteredAt: null,\n // No expireAt while queued.\n };\n }\n\n function getTypeConfig(type: string) {\n const typeConfig = config.types[type];\n if (!typeConfig) throw new Error(`[notification-core] Unknown notification type: ${type}`);\n const categoryConfig = config.categories[typeConfig.category];\n if (!categoryConfig) throw new Error(`[notification-core] Unknown category: ${typeConfig.category}`);\n return { typeConfig, categoryConfig };\n }\n\n async function enqueue(rows: DeliveryRowInput[]): Promise<EnqueueResult> {\n if (rows.length === 0) return { results: [], allResolved: true };\n const now = Date.now();\n const settled = await Promise.allSettled(\n rows.map((row) =>\n deliveryRef(row.deliveryId)\n .create(buildDeliveryDoc(row, now))\n .then(() => ({ deliveryId: row.deliveryId, outcome: 'created' as const })),\n ),\n );\n const results: EnqueueRowResult[] = settled.map((s, i) => {\n const deliveryId = rows[i].deliveryId;\n if (s.status === 'fulfilled') return s.value;\n if (isAlreadyExists(s.reason)) return { deliveryId, outcome: 'duplicate' };\n return { deliveryId, outcome: 'failed', error: s.reason };\n });\n return { results, allResolved: results.every((r) => r.outcome !== 'failed') };\n }\n\n async function materialize(deliveryId: string): Promise<MaterializeOutcome> {\n return db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return 'missing';\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n const state = d.state as DeliveryState;\n if (state === 'materialized') return 'already-materialized';\n if (state === 'skipped') return 'skipped-ineligible-recipient';\n if (state !== 'queued') return 'skipped-non-queued';\n\n const notificationType = d.notificationType as string;\n const aggregationKey = d.aggregationKey as string;\n const recipientUid = (d.recipientUid as string | null) ?? null;\n\n // The recipient is checked in this transaction, so a recipient that became ineligible\n // (e.g. an erased account) never gets a card from a row queued before, or retried after.\n if (recipientUid !== null && !(await isRecipientEligible(recipientUid, tx))) {\n const skippedAt = Date.now();\n tx.update(dRef, {\n state: 'skipped' as DeliveryState,\n skipReason: 'recipientIneligible' as DeliverySkipReason,\n skippedAt,\n expireAt: toTimestamp(skippedAt + ttlMs),\n });\n return 'skipped-ineligible-recipient';\n }\n const strategy = (d.strategy as AggregationStrategy) ?? 'increment';\n const payload = (d.payload as DeliveryPayload) ?? { actorId: '', metadata: {}, occurrenceAt: Date.now() };\n const { typeConfig, categoryConfig } = getTypeConfig(notificationType);\n\n const activeRef = db.collection(categoryConfig.activePath).doc(\n buildActiveNotificationDocId({\n category: typeConfig.category,\n audienceType: categoryConfig.audienceType,\n targetUserId: recipientUid,\n dedupKey: aggregationKey,\n notificationType,\n }),\n );\n const activeSnap = await tx.get(activeRef); // all reads before writes\n\n const now = Date.now();\n const generation = randomUUID();\n const countCap = typeConfig.countCap ?? DEFAULT_COUNT_CAP;\n const actorCap = typeConfig.actorCap ?? DEFAULT_ACTOR_CAP;\n const buildMessage = (count: number) => typeConfig.messagePattern(payload.metadata, count);\n // Latest-occurrence-derived fields — recomputed every materialize so an EXISTING\n // aggregate card reflects the newest activity (title/targetPath/metadata), not the\n // first occurrence's stale values (N-I4).\n const title = typeConfig.titlePattern(payload.metadata);\n // A type with no defaultTargetPath is linkless: the doc must OMIT the field\n // entirely (Firestore rejects `undefined` values). A pre-existing doc written\n // before a type went linkless keeps its stale targetPath until archived.\n const targetPath =\n typeConfig.defaultTargetPath === undefined\n ? undefined\n : typeof typeConfig.defaultTargetPath === 'function'\n ? typeConfig.defaultTargetPath(payload.metadata)\n : typeConfig.defaultTargetPath;\n const targetPathField = targetPath === undefined ? {} : { targetPath };\n\n if (activeSnap.exists) {\n const existing = (activeSnap.data() ?? {}) as Pick<NotificationDoc, 'count' | 'latestActorIds'>;\n const delta = applyAggregation({\n strategy,\n existing,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n tx.update(activeRef, { ...delta, title, ...targetPathField, metadata: payload.metadata });\n } else {\n const delta = applyAggregation({\n strategy,\n existing: null,\n actorId: payload.actorId,\n countCap,\n actorCap,\n buildMessage,\n now,\n generation,\n });\n const newDoc: Omit<NotificationDoc, 'id'> = {\n type: notificationType,\n dedupKey: aggregationKey,\n category: typeConfig.category,\n targetUserId: recipientUid,\n title,\n message: delta.message,\n count: delta.count,\n latestActorIds: delta.latestActorIds,\n ...targetPathField,\n metadata: payload.metadata,\n seenAt: 0,\n activityGeneration: generation,\n createdAt: now,\n updatedAt: now,\n };\n tx.set(activeRef, newDoc as Record<string, unknown>);\n }\n\n tx.update(dRef, {\n state: 'materialized' as DeliveryState,\n materializedAt: now,\n expireAt: toTimestamp(now + ttlMs),\n });\n return 'materialized';\n });\n }\n\n async function recordTransientFailure(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n const attemptCount = ((d.attemptCount as number) ?? 0) + 1;\n const now = Date.now();\n const message = error instanceof Error ? error.message : String(error);\n if (attemptCount >= maxAttempts) {\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount,\n lastError: message,\n deadLetteredAt: now,\n });\n } else {\n // n1: re-stamp the row into the reserved `retry` materialization lane on backoff. Without\n // this the row keeps its original class and competes for that lane's capacity while backed\n // off; the worker's reserved `retry` slice (separate CLASS_CAPS capacity, drained in\n // DRAIN_ORDER) would always select 0 rows. Re-stamping moves a backed-off row into the\n // anti-starvation lane so it drains there instead of starving in its original lane.\n tx.update(dRef, {\n attemptCount,\n nextAttemptAt: now + backoffMs(attemptCount),\n lastError: message,\n materializationClass: 'retry' as MaterializationClass,\n });\n }\n });\n }\n\n async function deadLetter(deliveryId: string, error: unknown): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'queued') return;\n tx.update(dRef, {\n state: 'deadLetter' as DeliveryState,\n attemptCount: ((d.attemptCount as number) ?? 0) + 1,\n lastError: error instanceof Error ? error.message : String(error),\n deadLetteredAt: Date.now(),\n });\n });\n }\n\n async function replay(deliveryId: string): Promise<void> {\n await db.runTransaction(async (tx) => {\n const dRef = deliveryRef(deliveryId);\n const dSnap = await tx.get(dRef);\n if (!dSnap.exists) return;\n const d = (dSnap.data() ?? {}) as Record<string, unknown>;\n if ((d.state as DeliveryState) !== 'deadLetter') return;\n tx.update(dRef, {\n state: 'queued' as DeliveryState,\n attemptCount: 0,\n nextAttemptAt: Date.now(),\n lastError: null,\n deadLetteredAt: null,\n expireAt: null, // clear any TTL set on a prior terminal\n });\n });\n }\n\n function reportUnrecordedFailure(\n options: MaterializeManyOptions | undefined,\n deliveryId: string,\n error: unknown,\n cause: unknown,\n ): void {\n try {\n if (options?.onUnrecordedFailure) {\n options.onUnrecordedFailure(deliveryId, error, { cause });\n return;\n }\n } catch (handlerError) {\n console.error('[notification-core] onUnrecordedFailure threw for delivery', deliveryId, handlerError);\n }\n console.error('[notification-core] A delivery failure could not be recorded', deliveryId, error, { cause });\n }\n\n async function materializeMany(\n deliveryIds: string[],\n options?: MaterializeManyOptions,\n ): Promise<Record<MaterializeOutcome, number>> {\n const concurrency = Math.max(1, options?.concurrency ?? 10);\n const tally: Record<MaterializeOutcome, number> = {\n materialized: 0,\n 'already-materialized': 0,\n 'skipped-ineligible-recipient': 0,\n missing: 0,\n 'skipped-non-queued': 0,\n };\n let cursor = 0;\n async function worker(): Promise<void> {\n while (cursor < deliveryIds.length) {\n const id = deliveryIds[cursor++];\n try {\n const outcome = await materialize(id);\n tally[outcome] += 1;\n } catch (error) {\n try {\n await recordTransientFailure(id, error);\n } catch (recordError) {\n reportUnrecordedFailure(options, id, recordError, error);\n }\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(concurrency, deliveryIds.length) }, worker));\n return tally;\n }\n\n return { enqueue, materialize, materializeMany, recordTransientFailure, deadLetter, replay };\n}\n"]}
@@ -2,7 +2,7 @@ export { processBatchHelper } from './processBatchHelper.js';
2
2
  export { NotificationPermissionError } from './errors.js';
3
3
  export { buildActiveNotificationDocId } from './activeNotificationId.js';
4
4
  export { createDeliveryLedger, applyAggregation } from './delivery-ledger.js';
5
- export type { DeliveryLedger, DeliveryRowInput, DeliveryPayload, DeliveryState, DeliverySkipReason, DeliveryLedgerOptions, AggregationStrategy, MaterializationClass, EnqueueResult, EnqueueRowResult, MaterializeOutcome, } from './delivery-ledger.js';
5
+ export type { DeliveryLedger, DeliveryRowInput, DeliveryPayload, DeliveryState, DeliverySkipReason, DeliveryLedgerOptions, AggregationStrategy, MaterializationClass, EnqueueResult, EnqueueRowResult, MaterializeOutcome, MaterializeManyOptions, } from './delivery-ledger.js';
6
6
  export { markNotificationSeenWithGeneration, archiveNotificationWithGeneration, } from './observed-generation.js';
7
7
  export type { MarkSeenOutcome, ArchiveOutcome } from './observed-generation.js';
8
8
  export type { ServerFirestore, ServerCollectionRef, ServerQuery, ServerQuerySnapshot, ServerDocSnapshot, ServerDocRef, ServerWriteBatch, ServerTransaction, } from './types.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAGzE,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,YAAY,EACV,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,aAAa,EACb,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGhF,YAAY,EACV,eAAe,EACf,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAGzE,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,YAAY,EACV,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,sBAAsB,GACvB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGhF,YAAY,EACV,eAAe,EACf,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAEzE,+CAA+C;AAC/C,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAe9E,yEAAyE;AACzE,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC","sourcesContent":["// Server-side helpers (for Cloud Functions)\nexport { processBatchHelper } from './processBatchHelper.js';\nexport { NotificationPermissionError } from './errors.js';\nexport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\n// Delivery ledger (notification redesign — P1)\nexport { createDeliveryLedger, applyAggregation } from './delivery-ledger.js';\nexport type {\n DeliveryLedger,\n DeliveryRowInput,\n DeliveryPayload,\n DeliveryState,\n DeliverySkipReason,\n DeliveryLedgerOptions,\n AggregationStrategy,\n MaterializationClass,\n EnqueueResult,\n EnqueueRowResult,\n MaterializeOutcome,\n} from './delivery-ledger.js';\n\n// Observed-generation seen/archive protocol (notification redesign — P1)\nexport {\n markNotificationSeenWithGeneration,\n archiveNotificationWithGeneration,\n} from './observed-generation.js';\nexport type { MarkSeenOutcome, ArchiveOutcome } from './observed-generation.js';\n\n// Server types\nexport type {\n ServerFirestore,\n ServerCollectionRef,\n ServerQuery,\n ServerQuerySnapshot,\n ServerDocSnapshot,\n ServerDocRef,\n ServerWriteBatch,\n ServerTransaction,\n} from './types.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAEzE,+CAA+C;AAC/C,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAgB9E,yEAAyE;AACzE,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,0BAA0B,CAAC","sourcesContent":["// Server-side helpers (for Cloud Functions)\nexport { processBatchHelper } from './processBatchHelper.js';\nexport { NotificationPermissionError } from './errors.js';\nexport { buildActiveNotificationDocId } from './activeNotificationId.js';\n\n// Delivery ledger (notification redesign — P1)\nexport { createDeliveryLedger, applyAggregation } from './delivery-ledger.js';\nexport type {\n DeliveryLedger,\n DeliveryRowInput,\n DeliveryPayload,\n DeliveryState,\n DeliverySkipReason,\n DeliveryLedgerOptions,\n AggregationStrategy,\n MaterializationClass,\n EnqueueResult,\n EnqueueRowResult,\n MaterializeOutcome,\n MaterializeManyOptions,\n} from './delivery-ledger.js';\n\n// Observed-generation seen/archive protocol (notification redesign — P1)\nexport {\n markNotificationSeenWithGeneration,\n archiveNotificationWithGeneration,\n} from './observed-generation.js';\nexport type { MarkSeenOutcome, ArchiveOutcome } from './observed-generation.js';\n\n// Server types\nexport type {\n ServerFirestore,\n ServerCollectionRef,\n ServerQuery,\n ServerQuerySnapshot,\n ServerDocSnapshot,\n ServerDocRef,\n ServerWriteBatch,\n ServerTransaction,\n} from './types.js';\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/notification-core",
3
- "version": "0.15.9",
3
+ "version": "0.15.11",
4
4
  "description": "Shared notification system for TTT Productions apps — active/history two-tier architecture with dedup, batch processing, and themed UI components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@tanstack/react-query": ">=5.0.0",
45
- "@ttt-productions/query-core": "^0.14.6",
46
- "@ttt-productions/ui-core": "^0.11.20",
45
+ "@ttt-productions/query-core": "^0.14.10",
46
+ "@ttt-productions/ui-core": "^0.11.25",
47
47
  "firebase": ">=12.0.0",
48
48
  "react": ">=19.0.0",
49
49
  "react-dom": ">=19.0.0"
@@ -70,8 +70,8 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "@tanstack/react-query": "^5.0.0",
73
- "@ttt-productions/query-core": "^0.14.6",
74
- "@ttt-productions/ui-core": "^0.11.20",
73
+ "@ttt-productions/query-core": "^0.14.10",
74
+ "@ttt-productions/ui-core": "^0.11.25",
75
75
  "@types/react": "^19.0.0",
76
76
  "@types/react-dom": "^19.0.0",
77
77
  "firebase": "^12.14.0",
@@ -62,14 +62,16 @@
62
62
  }
63
63
 
64
64
  /* === NOTIFICATION ITEM === */
65
+ /* The row itself is inert — every affordance is a control in its action slot — so it wears no
66
+ * pointer cursor. Its hover tint helps the eye along a long list; it moves on theme-core's motion
67
+ * tokens, so an app's reduced-motion kill switch covers it. */
65
68
  .ntf-item {
66
69
  display: flex;
67
70
  align-items: flex-start;
68
71
  gap: 0.75rem;
69
72
  padding: 0.75rem 1rem;
70
- cursor: pointer;
71
73
  border-bottom: 1px solid hsl(var(--border) / 0.5);
72
- transition: background-color 150ms ease;
74
+ transition: background-color var(--motion-fast) var(--motion-ease);
73
75
  }
74
76
 
75
77
  .ntf-item:hover {
@@ -168,33 +170,6 @@
168
170
  color: hsl(var(--destructive-foreground));
169
171
  }
170
172
 
171
- .ntf-badge--hidden {
172
- display: none;
173
- }
174
-
175
- /* === HISTORY LIST === */
176
- .ntf-history-item {
177
- display: flex;
178
- align-items: flex-start;
179
- gap: 0.75rem;
180
- padding: 0.75rem 1rem;
181
- border-bottom: 1px solid hsl(var(--border) / 0.5);
182
- opacity: 0.7;
183
- }
184
-
185
- .ntf-history-item:last-child {
186
- border-bottom: none;
187
- }
188
-
189
- .ntf-history-item--clickable {
190
- cursor: pointer;
191
- transition: opacity 150ms ease;
192
- }
193
-
194
- .ntf-history-item--clickable:hover {
195
- opacity: 1;
196
- }
197
-
198
173
  /* === LOADING STATE === */
199
174
  .ntf-loading {
200
175
  display: flex;