@actuate-media/cms-admin 0.12.1 → 0.13.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.
- package/dist/__tests__/components/notification-bell.test.js +146 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -1
- package/dist/__tests__/lib/notifications-client.test.js +129 -1
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +30 -9
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/NotificationBell.js +57 -5
- package/dist/components/NotificationBell.js.map +1 -1
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/notifications-client.d.ts +53 -0
- package/dist/lib/notifications-client.d.ts.map +1 -1
- package/dist/lib/notifications-client.js +89 -1
- package/dist/lib/notifications-client.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/notification-bell.test.tsx +167 -1
- package/src/__tests__/lib/notifications-client.test.ts +145 -1
- package/src/components/NotificationBell.tsx +84 -12
- package/src/lib/api.ts +11 -0
- package/src/lib/notifications-client.ts +147 -1
|
@@ -5,10 +5,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
5
5
|
import {
|
|
6
6
|
type Notification,
|
|
7
7
|
type NotificationOutcome,
|
|
8
|
+
type NotificationStreamHandle,
|
|
9
|
+
type NotificationStreamOptions,
|
|
8
10
|
listNotifications as defaultListNotifications,
|
|
9
11
|
markAllNotificationsRead as defaultMarkAllRead,
|
|
10
12
|
markNotificationRead as defaultMarkRead,
|
|
11
13
|
notificationSummary,
|
|
14
|
+
subscribeNotificationStream as defaultSubscribeStream,
|
|
12
15
|
unreadCount as defaultUnreadCount,
|
|
13
16
|
} from '../lib/notifications-client.js'
|
|
14
17
|
|
|
@@ -20,25 +23,41 @@ import {
|
|
|
20
23
|
*
|
|
21
24
|
* Design choices:
|
|
22
25
|
*
|
|
23
|
-
* - **
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* - **SSE push, with poll fallback (Phase 7).** A persistent
|
|
27
|
+
* `EventSource` subscribes to `/api/cms/notifications/stream` for
|
|
28
|
+
* instant delivery; new notifications bump the badge immediately
|
|
29
|
+
* instead of after the next 30 s poll tick. The poll loop is kept
|
|
30
|
+
* alive at a back-pressure-friendly cadence (default 60 s — twice
|
|
31
|
+
* the legacy 30 s value because SSE handles the common case) and
|
|
32
|
+
* acts as a backstop for:
|
|
33
|
+
* 1. multi-node deployments where the in-process bus only
|
|
34
|
+
* publishes to subscribers on the same node, and
|
|
35
|
+
* 2. proxies / browsers that silently drop the EventSource
|
|
36
|
+
* without firing `onerror`.
|
|
29
37
|
* - **Optimistic read state.** When the user clicks a notification we
|
|
30
38
|
* mark it read locally before the server confirms — so the bell
|
|
31
39
|
* counter feels instant. Server failures roll the state back via
|
|
32
40
|
* the `onError` toast.
|
|
33
41
|
* - **API injection.** The dependency-injection seam matches the rest
|
|
34
42
|
* of the admin (`CommentSidePanel.api`) so unit tests can substitute
|
|
35
|
-
* in-memory fakes
|
|
43
|
+
* in-memory fakes — including the EventSource subscriber, which
|
|
44
|
+
* isn't available in JSDOM by default.
|
|
36
45
|
*/
|
|
37
46
|
export interface NotificationsApi {
|
|
38
47
|
list: (opts: { includeRead?: boolean }) => Promise<NotificationOutcome<Notification[]>>
|
|
39
48
|
unread: () => Promise<NotificationOutcome<number>>
|
|
40
49
|
markRead: (id: string) => Promise<NotificationOutcome<Notification>>
|
|
41
50
|
markAllRead: () => Promise<NotificationOutcome<number>>
|
|
51
|
+
/**
|
|
52
|
+
* Open a per-user SSE subscription. Implementations MUST always
|
|
53
|
+
* return a handle — even in environments without `EventSource`
|
|
54
|
+
* (SSR, JSDOM without a polyfill), where a no-op `{ close() {} }`
|
|
55
|
+
* is the right answer. The bell's effect calls `handle.close()`
|
|
56
|
+
* unconditionally on unmount; a `null` return would crash.
|
|
57
|
+
* Omitting the member entirely is the supported way to opt out of
|
|
58
|
+
* SSE (the bell then runs poll-only).
|
|
59
|
+
*/
|
|
60
|
+
subscribe?: (opts: NotificationStreamOptions) => NotificationStreamHandle
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
const defaultApi: NotificationsApi = {
|
|
@@ -46,10 +65,16 @@ const defaultApi: NotificationsApi = {
|
|
|
46
65
|
unread: () => defaultUnreadCount(),
|
|
47
66
|
markRead: (id) => defaultMarkRead(id),
|
|
48
67
|
markAllRead: () => defaultMarkAllRead(),
|
|
68
|
+
subscribe: (opts) => defaultSubscribeStream(opts),
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
export interface NotificationBellProps {
|
|
52
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Polling cadence in milliseconds for the fallback loop. Default
|
|
74
|
+
* 60,000 (twice the pre-SSE value — push handles the common case).
|
|
75
|
+
* Pass 0 to disable polling entirely (relies on SSE only — only
|
|
76
|
+
* safe on single-node deployments).
|
|
77
|
+
*/
|
|
53
78
|
pollIntervalMs?: number
|
|
54
79
|
/** Optional click hook — called with the notification when a row is clicked. */
|
|
55
80
|
onSelect?: (notification: Notification) => void
|
|
@@ -61,7 +86,7 @@ export interface NotificationBellProps {
|
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
export function NotificationBell({
|
|
64
|
-
pollIntervalMs =
|
|
89
|
+
pollIntervalMs = 60_000,
|
|
65
90
|
onSelect,
|
|
66
91
|
onError,
|
|
67
92
|
api = defaultApi,
|
|
@@ -72,6 +97,15 @@ export function NotificationBell({
|
|
|
72
97
|
const [items, setItems] = useState<Notification[]>([])
|
|
73
98
|
const [loading, setLoading] = useState(false)
|
|
74
99
|
const mountedRef = useRef(true)
|
|
100
|
+
// Set of notification IDs we've already rendered. The SSE handler
|
|
101
|
+
// consults this synchronously so duplicate deliveries (REST refresh
|
|
102
|
+
// + SSE push, or two SSE retries) don't double-bump the badge.
|
|
103
|
+
// Kept in sync with `items` via an effect — the items state is the
|
|
104
|
+
// source of truth; this ref is a derived O(1) lookup index.
|
|
105
|
+
const seenIdsRef = useRef<Set<string>>(new Set())
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
seenIdsRef.current = new Set(items.map((n) => n.id))
|
|
108
|
+
}, [items])
|
|
75
109
|
useEffect(() => {
|
|
76
110
|
return () => {
|
|
77
111
|
mountedRef.current = false
|
|
@@ -111,6 +145,34 @@ export function NotificationBell({
|
|
|
111
145
|
return () => clearInterval(id)
|
|
112
146
|
}, [refreshUnread, pollIntervalMs])
|
|
113
147
|
|
|
148
|
+
// SSE push subscription. Bumps the badge + prepends the new row to
|
|
149
|
+
// the open dropdown the instant the server publishes. Falls back
|
|
150
|
+
// silently when the api/runtime doesn't support EventSource — the
|
|
151
|
+
// poll loop above keeps the bell correct in that case.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!api.subscribe) return
|
|
154
|
+
const handle = api.subscribe({
|
|
155
|
+
onNotification: (n) => {
|
|
156
|
+
if (!mountedRef.current) return
|
|
157
|
+
// Defensive: SSE events can race with REST refreshes (or with
|
|
158
|
+
// their own retries). The seen-id ref is updated
|
|
159
|
+
// synchronously so a second delivery of the same row is a
|
|
160
|
+
// no-op for BOTH `items` and the unread badge — guarding only
|
|
161
|
+
// setItems would still let the counter drift.
|
|
162
|
+
if (seenIdsRef.current.has(n.id)) return
|
|
163
|
+
seenIdsRef.current.add(n.id)
|
|
164
|
+
setItems((arr) => [n, ...arr])
|
|
165
|
+
if (!n.readAt) setUnread((v) => v + 1)
|
|
166
|
+
},
|
|
167
|
+
onError: () => {
|
|
168
|
+
// Don't surface every transient SSE disconnect as a UI error —
|
|
169
|
+
// the browser auto-reconnects, and the poll loop keeps state
|
|
170
|
+
// converging. We deliberately stay quiet here.
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
return () => handle.close()
|
|
174
|
+
}, [api])
|
|
175
|
+
|
|
114
176
|
useEffect(() => {
|
|
115
177
|
if (!open) return
|
|
116
178
|
void refreshList(false)
|
|
@@ -138,14 +200,24 @@ export function NotificationBell({
|
|
|
138
200
|
)
|
|
139
201
|
|
|
140
202
|
const handleMarkAll = useCallback(async () => {
|
|
141
|
-
|
|
203
|
+
// Snapshot ONLY the previously-unread IDs (and their original
|
|
204
|
+
// readAt values, which are null by definition). On rollback we
|
|
205
|
+
// restore those specific rows and leave anything that arrived
|
|
206
|
+
// via SSE while the request was in flight alone — otherwise a
|
|
207
|
+
// failed mark-all-read would drop fresh notifications.
|
|
208
|
+
const previouslyUnreadIds = items.filter((n) => n.readAt === null).map((n) => n.id)
|
|
209
|
+
if (previouslyUnreadIds.length === 0) return
|
|
210
|
+
const ids = new Set(previouslyUnreadIds)
|
|
142
211
|
const now = new Date().toISOString()
|
|
143
|
-
setItems(
|
|
212
|
+
setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: n.readAt ?? now } : n)))
|
|
144
213
|
setUnread(0)
|
|
145
214
|
const out = await api.markAllRead()
|
|
146
215
|
if (!out.ok) {
|
|
147
216
|
onError?.(out.error)
|
|
148
|
-
|
|
217
|
+
// Functional rollback: only revert the rows we optimistically
|
|
218
|
+
// marked. Anything mid-flight (SSE arrivals, locally-added) is
|
|
219
|
+
// untouched.
|
|
220
|
+
setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: null } : n)))
|
|
149
221
|
await refreshUnread()
|
|
150
222
|
}
|
|
151
223
|
}, [api, items, onError, refreshUnread])
|
package/src/lib/api.ts
CHANGED
|
@@ -6,6 +6,17 @@ export function setApiBase(path: string) {
|
|
|
6
6
|
basePath = path
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Current API base path. Exposed so non-`fetch` consumers (in
|
|
11
|
+
* particular `EventSource` for SSE channels) can resolve URLs against
|
|
12
|
+
* the same prefix configured via `setApiBase()`. Keep this read-only
|
|
13
|
+
* — mutation must always flow through `setApiBase()` so we never have
|
|
14
|
+
* two callers with diverging views of the base.
|
|
15
|
+
*/
|
|
16
|
+
export function getApiBase(): string {
|
|
17
|
+
return basePath
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
function getCsrfToken(): string {
|
|
10
21
|
if (typeof document === 'undefined') return ''
|
|
11
22
|
const match = document.cookie.match(/(?:^|;\s*)actuate_csrf=([^;]*)/)
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* (which carries Prisma types that don't belong in the admin bundle).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { cmsApi } from './api.js'
|
|
14
|
+
import { cmsApi, getApiBase } from './api.js'
|
|
15
15
|
|
|
16
16
|
export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention'
|
|
17
17
|
|
|
@@ -125,6 +125,152 @@ export async function markAllNotificationsRead(): Promise<NotificationOutcome<nu
|
|
|
125
125
|
return { ok: true, result: res.data.count }
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Subscribe to the per-user SSE push channel that ships notifications
|
|
130
|
+
* the instant they're persisted server-side. Replaces the 30-second
|
|
131
|
+
* poll loop in the notification bell.
|
|
132
|
+
*
|
|
133
|
+
* The browser's native `EventSource` handles reconnection for us — on
|
|
134
|
+
* a server-initiated close (the stream caps at ~4 minutes by design),
|
|
135
|
+
* the connection re-opens automatically. We DO NOT abort the poll
|
|
136
|
+
* fallback in callers: SSE is a latency-win, the poll is what keeps
|
|
137
|
+
* multi-node deployments correct (each node only pushes to its own
|
|
138
|
+
* subscribers).
|
|
139
|
+
*
|
|
140
|
+
* Returns a `close()` function that callers MUST invoke on unmount —
|
|
141
|
+
* otherwise the browser keeps the connection open and we leak event
|
|
142
|
+
* listeners.
|
|
143
|
+
*
|
|
144
|
+
* `EventSource` itself isn't available in the JSDOM test environment;
|
|
145
|
+
* callers can inject a `factory` to substitute a test double. In
|
|
146
|
+
* production we fall back to the global `EventSource` when no factory
|
|
147
|
+
* is supplied.
|
|
148
|
+
*/
|
|
149
|
+
export interface NotificationStreamOptions {
|
|
150
|
+
/** Called for every `event: notification` frame. */
|
|
151
|
+
onNotification: (n: Notification) => void
|
|
152
|
+
/** Called once on the `event: ready` frame. */
|
|
153
|
+
onReady?: (payload: { userId: string }) => void
|
|
154
|
+
/** Called on connection errors. Useful for falling back to the poll loop. */
|
|
155
|
+
onError?: (event: Event) => void
|
|
156
|
+
/**
|
|
157
|
+
* Optional factory so unit tests can inject a fake `EventSource`,
|
|
158
|
+
* or a null sentinel when the environment lacks one (SSR, JSDOM
|
|
159
|
+
* without a polyfill). Production code can rely on the default
|
|
160
|
+
* (global `EventSource`).
|
|
161
|
+
*/
|
|
162
|
+
factory?: (url: string) => EventSourceLike | null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Minimal subset of the `EventSource` interface our subscriber relies
|
|
167
|
+
* on. Browsers ship the full DOM type; the admin only needs add/remove
|
|
168
|
+
* event listeners plus `close()`.
|
|
169
|
+
*/
|
|
170
|
+
export interface EventSourceLike {
|
|
171
|
+
addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void
|
|
172
|
+
removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void
|
|
173
|
+
close: () => void
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface NotificationStreamHandle {
|
|
177
|
+
/** Tear down the EventSource and stop receiving events. Idempotent. */
|
|
178
|
+
close: () => void
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function subscribeNotificationStream(
|
|
182
|
+
opts: NotificationStreamOptions,
|
|
183
|
+
): NotificationStreamHandle {
|
|
184
|
+
// The `cmsApi()` helper handles base-path / origin resolution for
|
|
185
|
+
// fetch calls, but EventSource takes a raw URL. We mirror its
|
|
186
|
+
// logic locally — `/api/cms/notifications/stream` is correct for
|
|
187
|
+
// both the local dev server and any production deploy that mounts
|
|
188
|
+
// the CMS API at the default prefix. Custom prefixes are handled by
|
|
189
|
+
// the consumer override (factory).
|
|
190
|
+
// Resolve against `setApiBase()` so a consumer mounting the CMS at
|
|
191
|
+
// a non-default prefix (e.g. `/cms-api`) still hits the right SSE
|
|
192
|
+
// endpoint — `EventSource` doesn't go through `cmsApi()`, so we
|
|
193
|
+
// can't rely on its url-construction logic.
|
|
194
|
+
const url = `${getApiBase()}/notifications/stream`
|
|
195
|
+
const make = opts.factory ?? defaultEventSourceFactory
|
|
196
|
+
const source = make(url)
|
|
197
|
+
if (!source) {
|
|
198
|
+
// No EventSource available (SSR? JSDOM without polyfill? Or the
|
|
199
|
+
// injected factory deliberately returns null in tests). Return a
|
|
200
|
+
// no-op handle so callers can compose without conditional logic.
|
|
201
|
+
return { close: () => {} }
|
|
202
|
+
}
|
|
203
|
+
let closed = false
|
|
204
|
+
|
|
205
|
+
const onReady = (event: MessageEvent | Event) => {
|
|
206
|
+
if (!opts.onReady) return
|
|
207
|
+
const data = parseEventData(event)
|
|
208
|
+
if (data && typeof (data as { userId?: unknown }).userId === 'string') {
|
|
209
|
+
opts.onReady({ userId: (data as { userId: string }).userId })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const onNotification = (event: MessageEvent | Event) => {
|
|
213
|
+
const data = parseEventData(event)
|
|
214
|
+
if (data && isNotification(data)) {
|
|
215
|
+
opts.onNotification(data)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const onError = (event: Event) => {
|
|
219
|
+
opts.onError?.(event)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
source.addEventListener('ready', onReady)
|
|
223
|
+
source.addEventListener('notification', onNotification)
|
|
224
|
+
source.addEventListener('error', onError)
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
close: () => {
|
|
228
|
+
if (closed) return
|
|
229
|
+
closed = true
|
|
230
|
+
source.removeEventListener?.('ready', onReady)
|
|
231
|
+
source.removeEventListener?.('notification', onNotification)
|
|
232
|
+
source.removeEventListener?.('error', onError)
|
|
233
|
+
source.close()
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function defaultEventSourceFactory(url: string): EventSourceLike | null {
|
|
239
|
+
if (typeof EventSource === 'undefined') return null
|
|
240
|
+
// `withCredentials: true` so the admin session cookie travels with
|
|
241
|
+
// the connection upgrade — every notification stream requires auth
|
|
242
|
+
// and the same-origin cookie is the only credential we trust here.
|
|
243
|
+
return new EventSource(url, { withCredentials: true }) as unknown as EventSourceLike
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseEventData(event: MessageEvent | Event): unknown {
|
|
247
|
+
// EventSource emits MessageEvents with `data: string`. Plain
|
|
248
|
+
// `Event` (e.g. the `error` event) has no payload; ignore.
|
|
249
|
+
const data = (event as MessageEvent).data
|
|
250
|
+
if (typeof data !== 'string') return null
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(data)
|
|
253
|
+
} catch {
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isNotification(value: unknown): value is Notification {
|
|
259
|
+
if (!value || typeof value !== 'object') return false
|
|
260
|
+
const v = value as Record<string, unknown>
|
|
261
|
+
// Defensive: don't trust the wire blindly — verify the discriminant
|
|
262
|
+
// properties our consumers will read. A drift in the server-side
|
|
263
|
+
// wire format must surface as "missing fields" in the test suite,
|
|
264
|
+
// not as runtime errors deep in the bell's render path.
|
|
265
|
+
return (
|
|
266
|
+
typeof v.id === 'string' &&
|
|
267
|
+
typeof v.userId === 'string' &&
|
|
268
|
+
typeof v.createdAt === 'string' &&
|
|
269
|
+
!!v.payload &&
|
|
270
|
+
typeof (v.payload as { kind?: unknown }).kind === 'string'
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
128
274
|
/**
|
|
129
275
|
* Cheap single-line summary for the bell list — the server gives us a
|
|
130
276
|
* structured payload, but the dropdown wants one human-readable string
|