@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/lib/api.js CHANGED
@@ -3,6 +3,16 @@ let basePath = DEFAULT_BASE;
3
3
  export function setApiBase(path) {
4
4
  basePath = path;
5
5
  }
6
+ /**
7
+ * Current API base path. Exposed so non-`fetch` consumers (in
8
+ * particular `EventSource` for SSE channels) can resolve URLs against
9
+ * the same prefix configured via `setApiBase()`. Keep this read-only
10
+ * — mutation must always flow through `setApiBase()` so we never have
11
+ * two callers with diverging views of the base.
12
+ */
13
+ export function getApiBase() {
14
+ return basePath;
15
+ }
6
16
  function getCsrfToken() {
7
17
  if (typeof document === 'undefined')
8
18
  return '';
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/lib/api.ts"],"names":[],"mappings":"AAAA,MAAM,YAAY,GAAG,UAAU,CAAA;AAE/B,IAAI,QAAQ,GAAG,YAAY,CAAA;AAE3B,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,QAAQ,GAAG,IAAI,CAAA;AACjB,CAAC;AAED,SAAS,YAAY;IACnB,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO,EAAE,CAAA;IAC9C,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,gCAAgC,CAAC,CAAA;IACrE,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AACzB,CAAC;AAED;;;;;;;;GAQG;AACH,IAAI,YAAY,GAAyB,IAAI,CAAA;AAE7C,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,IAAI,YAAY,EAAE;QAAE,OAAM;IAC1B,IAAI,YAAY;QAAE,OAAO,YAAY,CAAA;IACrC,YAAY,GAAG,CAAC,KAAK,IAAI,EAAE;QACzB,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,GAAG,QAAQ,YAAY,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAA;QAClE,CAAC;QAAC,MAAM,CAAC;YACP,oEAAoE;YACpE,mEAAmE;QACrE,CAAC;gBAAS,CAAC;YACT,YAAY,GAAG,IAAI,CAAA;QACrB,CAAC;IACH,CAAC,CAAC,EAAE,CAAA;IACJ,OAAO,YAAY,CAAA;AACrB,CAAC;AAED,SAAS,eAAe;IACtB,IAAI,OAAO,YAAY,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IACpD,OAAO,YAAY,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAA;AAC/C,CAAC;AAED,SAAS,iBAAiB,CAAC,QAAgB;IACzC,MAAM,MAAM,GAAG,eAAe,EAAE,CAAA;IAChC,IAAI,CAAC,MAAM;QAAE,OAAO,QAAQ,CAAA;IAC5B,MAAM,SAAS,GAAG,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;IACpD,OAAO,GAAG,QAAQ,GAAG,SAAS,UAAU,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAA;AACtE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,MAAM,CAC1B,QAAgB,EAChB,UAAuB,EAAE;IAEzB,MAAM,GAAG,GAAG,GAAG,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAA;IACvD,MAAM,UAAU,GAAG,OAAO,QAAQ,KAAK,WAAW,IAAI,OAAO,CAAC,IAAI,YAAY,QAAQ,CAAA;IAEtF,MAAM,OAAO,GAA2B;QACtC,GAAG,CAAE,OAAO,CAAC,OAAkC,IAAI,EAAE,CAAC;KACvD,CAAA;IAED,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,CAAC,cAAc,CAAC,GAAG,kBAAkB,CAAA;IAC9C,CAAC;IAED,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;IACtD,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QAC1C,sEAAsE;QACtE,uEAAuE;QACvE,wEAAwE;QACxE,wEAAwE;QACxE,sBAAsB;QACtB,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;YACpB,MAAM,eAAe,EAAE,CAAA;QACzB,CAAC;QACD,MAAM,IAAI,GAAG,YAAY,EAAE,CAAA;QAC3B,IAAI,IAAI;YAAE,OAAO,CAAC,cAAc,CAAC,GAAG,IAAI,CAAA;IAC1C,CAAC;IAED,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAA;QAC7E,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;QAE/C,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,mBAAmB,GAAG,CAAC,MAAM,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAA;QACtF,CAAC;QAED,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAA;IACxD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IACnF,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/lib/api.ts"],"names":[],"mappings":"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"}
@@ -58,6 +58,59 @@ export declare function listNotifications(opts?: ListNotificationsOptions): Prom
58
58
  export declare function unreadCount(): Promise<NotificationOutcome<number>>;
59
59
  export declare function markNotificationRead(id: string): Promise<NotificationOutcome<Notification>>;
60
60
  export declare function markAllNotificationsRead(): Promise<NotificationOutcome<number>>;
61
+ /**
62
+ * Subscribe to the per-user SSE push channel that ships notifications
63
+ * the instant they're persisted server-side. Replaces the 30-second
64
+ * poll loop in the notification bell.
65
+ *
66
+ * The browser's native `EventSource` handles reconnection for us — on
67
+ * a server-initiated close (the stream caps at ~4 minutes by design),
68
+ * the connection re-opens automatically. We DO NOT abort the poll
69
+ * fallback in callers: SSE is a latency-win, the poll is what keeps
70
+ * multi-node deployments correct (each node only pushes to its own
71
+ * subscribers).
72
+ *
73
+ * Returns a `close()` function that callers MUST invoke on unmount —
74
+ * otherwise the browser keeps the connection open and we leak event
75
+ * listeners.
76
+ *
77
+ * `EventSource` itself isn't available in the JSDOM test environment;
78
+ * callers can inject a `factory` to substitute a test double. In
79
+ * production we fall back to the global `EventSource` when no factory
80
+ * is supplied.
81
+ */
82
+ export interface NotificationStreamOptions {
83
+ /** Called for every `event: notification` frame. */
84
+ onNotification: (n: Notification) => void;
85
+ /** Called once on the `event: ready` frame. */
86
+ onReady?: (payload: {
87
+ userId: string;
88
+ }) => void;
89
+ /** Called on connection errors. Useful for falling back to the poll loop. */
90
+ onError?: (event: Event) => void;
91
+ /**
92
+ * Optional factory so unit tests can inject a fake `EventSource`,
93
+ * or a null sentinel when the environment lacks one (SSR, JSDOM
94
+ * without a polyfill). Production code can rely on the default
95
+ * (global `EventSource`).
96
+ */
97
+ factory?: (url: string) => EventSourceLike | null;
98
+ }
99
+ /**
100
+ * Minimal subset of the `EventSource` interface our subscriber relies
101
+ * on. Browsers ship the full DOM type; the admin only needs add/remove
102
+ * event listeners plus `close()`.
103
+ */
104
+ export interface EventSourceLike {
105
+ addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void;
106
+ removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void;
107
+ close: () => void;
108
+ }
109
+ export interface NotificationStreamHandle {
110
+ /** Tear down the EventSource and stop receiving events. Idempotent. */
111
+ close: () => void;
112
+ }
113
+ export declare function subscribeNotificationStream(opts: NotificationStreamOptions): NotificationStreamHandle;
61
114
  /**
62
115
  * Cheap single-line summary for the bell list — the server gives us a
63
116
  * structured payload, but the dropdown wants one human-readable string
@@ -1 +1 @@
1
- {"version":3,"file":"notifications-client.d.ts","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,MAAM,MAAM,gBAAgB,GAAG,eAAe,GAAG,kBAAkB,GAAG,iBAAiB,CAAA;AAEvF,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,kBAAkB,CAAA;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,iBAAiB,CAAA;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,MAAM,mBAAmB,GAC3B,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAA;AAEzB,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,gBAAgB,CAAA;IACtB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,mBAAmB,CAAA;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,GACvB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhD,MAAM,WAAW,wBAAwB;IACvC,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAyBD,wBAAsB,iBAAiB,CACrC,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,mBAAmB,CAAC,YAAY,EAAE,CAAC,CAAC,CAK9C;AAED,wBAAsB,WAAW,IAAI,OAAO,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAQxE;AAED,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,YAAY,CAAC,CAAC,CAOjG;AAED,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CASrF;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,YAAY,GAAG,MAAM,CAYtE"}
1
+ {"version":3,"file":"notifications-client.d.ts","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,MAAM,MAAM,gBAAgB,GAAG,eAAe,GAAG,kBAAkB,GAAG,iBAAiB,CAAA;AAEvF,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAA;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,kBAAkB,CAAA;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,iBAAiB,CAAA;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,MAAM,mBAAmB,GAC3B,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAA;AAEzB,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,gBAAgB,CAAA;IACtB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,mBAAmB,CAAA;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,GACvB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEhD,MAAM,WAAW,wBAAwB;IACvC,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAyBD,wBAAsB,iBAAiB,CACrC,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,mBAAmB,CAAC,YAAY,EAAE,CAAC,CAAC,CAK9C;AAED,wBAAsB,WAAW,IAAI,OAAO,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAQxE;AAED,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,YAAY,CAAC,CAAC,CAOjG;AAED,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CASrF;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,WAAW,yBAAyB;IACxC,oDAAoD;IACpD,cAAc,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,CAAA;IACzC,+CAA+C;IAC/C,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,6EAA6E;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,GAAG,IAAI,CAAA;CAClD;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,GAAG,KAAK,KAAK,IAAI,KAAK,IAAI,CAAA;IACzF,mBAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,GAAG,KAAK,KAAK,IAAI,KAAK,IAAI,CAAA;IAC7F,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,wBAAwB;IACvC,uEAAuE;IACvE,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,yBAAyB,GAC9B,wBAAwB,CAqD1B;AAsCD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,YAAY,GAAG,MAAM,CAYtE"}
@@ -10,7 +10,7 @@
10
10
  * shape here to avoid taking a hard dependency on the server module
11
11
  * (which carries Prisma types that don't belong in the admin bundle).
12
12
  */
13
- import { cmsApi } from './api.js';
13
+ import { cmsApi, getApiBase } from './api.js';
14
14
  function failure(error, status) {
15
15
  return { ok: false, error, status };
16
16
  }
@@ -63,6 +63,94 @@ export async function markAllNotificationsRead() {
63
63
  }
64
64
  return { ok: true, result: res.data.count };
65
65
  }
66
+ export function subscribeNotificationStream(opts) {
67
+ // The `cmsApi()` helper handles base-path / origin resolution for
68
+ // fetch calls, but EventSource takes a raw URL. We mirror its
69
+ // logic locally — `/api/cms/notifications/stream` is correct for
70
+ // both the local dev server and any production deploy that mounts
71
+ // the CMS API at the default prefix. Custom prefixes are handled by
72
+ // the consumer override (factory).
73
+ // Resolve against `setApiBase()` so a consumer mounting the CMS at
74
+ // a non-default prefix (e.g. `/cms-api`) still hits the right SSE
75
+ // endpoint — `EventSource` doesn't go through `cmsApi()`, so we
76
+ // can't rely on its url-construction logic.
77
+ const url = `${getApiBase()}/notifications/stream`;
78
+ const make = opts.factory ?? defaultEventSourceFactory;
79
+ const source = make(url);
80
+ if (!source) {
81
+ // No EventSource available (SSR? JSDOM without polyfill? Or the
82
+ // injected factory deliberately returns null in tests). Return a
83
+ // no-op handle so callers can compose without conditional logic.
84
+ return { close: () => { } };
85
+ }
86
+ let closed = false;
87
+ const onReady = (event) => {
88
+ if (!opts.onReady)
89
+ return;
90
+ const data = parseEventData(event);
91
+ if (data && typeof data.userId === 'string') {
92
+ opts.onReady({ userId: data.userId });
93
+ }
94
+ };
95
+ const onNotification = (event) => {
96
+ const data = parseEventData(event);
97
+ if (data && isNotification(data)) {
98
+ opts.onNotification(data);
99
+ }
100
+ };
101
+ const onError = (event) => {
102
+ opts.onError?.(event);
103
+ };
104
+ source.addEventListener('ready', onReady);
105
+ source.addEventListener('notification', onNotification);
106
+ source.addEventListener('error', onError);
107
+ return {
108
+ close: () => {
109
+ if (closed)
110
+ return;
111
+ closed = true;
112
+ source.removeEventListener?.('ready', onReady);
113
+ source.removeEventListener?.('notification', onNotification);
114
+ source.removeEventListener?.('error', onError);
115
+ source.close();
116
+ },
117
+ };
118
+ }
119
+ function defaultEventSourceFactory(url) {
120
+ if (typeof EventSource === 'undefined')
121
+ return null;
122
+ // `withCredentials: true` so the admin session cookie travels with
123
+ // the connection upgrade — every notification stream requires auth
124
+ // and the same-origin cookie is the only credential we trust here.
125
+ return new EventSource(url, { withCredentials: true });
126
+ }
127
+ function parseEventData(event) {
128
+ // EventSource emits MessageEvents with `data: string`. Plain
129
+ // `Event` (e.g. the `error` event) has no payload; ignore.
130
+ const data = event.data;
131
+ if (typeof data !== 'string')
132
+ return null;
133
+ try {
134
+ return JSON.parse(data);
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ function isNotification(value) {
141
+ if (!value || typeof value !== 'object')
142
+ return false;
143
+ const v = value;
144
+ // Defensive: don't trust the wire blindly — verify the discriminant
145
+ // properties our consumers will read. A drift in the server-side
146
+ // wire format must surface as "missing fields" in the test suite,
147
+ // not as runtime errors deep in the bell's render path.
148
+ return (typeof v.id === 'string' &&
149
+ typeof v.userId === 'string' &&
150
+ typeof v.createdAt === 'string' &&
151
+ !!v.payload &&
152
+ typeof v.payload.kind === 'string');
153
+ }
66
154
  /**
67
155
  * Cheap single-line summary for the bell list — the server gives us a
68
156
  * structured payload, but the dropdown wants one human-readable string
@@ -1 +1 @@
1
- {"version":3,"file":"notifications-client.js","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"notifications-client.js","sourceRoot":"","sources":["../../src/lib/notifications-client.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -87,7 +87,7 @@
87
87
  "tailwindcss": "^4.0.0",
88
88
  "typescript": "^5.7.0",
89
89
  "vitest": "^3.0.0",
90
- "@actuate-media/cms-core": "0.22.0",
90
+ "@actuate-media/cms-core": "0.24.0",
91
91
  "@actuate-media/component-blocks": "0.2.0"
92
92
  },
93
93
  "scripts": {
@@ -4,7 +4,12 @@ import React from 'react'
4
4
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
5
 
6
6
  import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
7
- import type { Notification, NotificationOutcome } from '../../lib/notifications-client.js'
7
+ import type {
8
+ Notification,
9
+ NotificationOutcome,
10
+ NotificationStreamHandle,
11
+ NotificationStreamOptions,
12
+ } from '../../lib/notifications-client.js'
8
13
 
9
14
  function ok<T>(value: T): NotificationOutcome<T> {
10
15
  return { ok: true, result: value }
@@ -211,3 +216,164 @@ describe('NotificationBell — dropdown', () => {
211
216
  await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
212
217
  })
213
218
  })
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // SSE push channel (Phase 7) — verify the bell wires up subscribe(),
222
+ // processes incoming notifications, and tears down on unmount.
223
+ // ---------------------------------------------------------------------------
224
+ describe('NotificationBell — SSE push', () => {
225
+ function withSubscribe(api: NotificationsApi) {
226
+ let captured: NotificationStreamOptions | undefined
227
+ let closed = false
228
+ const handle: NotificationStreamHandle = {
229
+ close: () => {
230
+ closed = true
231
+ },
232
+ }
233
+ const subscribe = vi.fn((opts: NotificationStreamOptions) => {
234
+ captured = opts
235
+ return handle
236
+ })
237
+ return {
238
+ api: { ...api, subscribe },
239
+ push: (n: Notification) => captured?.onNotification(n),
240
+ pushError: (e: Event) => captured?.onError?.(e),
241
+ isClosed: () => closed,
242
+ }
243
+ }
244
+
245
+ it('subscribes on mount and bumps the badge when a new notification arrives', async () => {
246
+ const fake = createFakeApi()
247
+ const wired = withSubscribe(fake.api)
248
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
249
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
250
+ await act(async () => {
251
+ wired.push(row({ id: 'sse-1' }))
252
+ })
253
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
254
+ })
255
+
256
+ it('does not double-count a notification that arrives via both SSE and REST', async () => {
257
+ const fake = createFakeApi()
258
+ const wired = withSubscribe(fake.api)
259
+ fake.setRows([row({ id: 'race' })])
260
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
261
+ // Open the dropdown so the row is in `items` from the REST list.
262
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
263
+ fireEvent.click(screen.getByTestId('bell-button'))
264
+ await waitFor(() => expect(screen.getByTestId('bell-row-race')).toBeTruthy())
265
+ // Now push the same row via SSE — must NOT add a duplicate or
266
+ // bump the badge above 1.
267
+ await act(async () => {
268
+ wired.push(row({ id: 'race' }))
269
+ })
270
+ expect(screen.getAllByTestId('bell-row-race')).toHaveLength(1)
271
+ expect(screen.getByTestId('bell-badge').textContent).toBe('1')
272
+ })
273
+
274
+ it('prepends new pushed rows to the open dropdown', async () => {
275
+ const fake = createFakeApi()
276
+ const wired = withSubscribe(fake.api)
277
+ fake.setRows([row({ id: 'old' })])
278
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
279
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
280
+ fireEvent.click(screen.getByTestId('bell-button'))
281
+ await waitFor(() => expect(screen.getByTestId('bell-row-old')).toBeTruthy())
282
+ await act(async () => {
283
+ wired.push(row({ id: 'fresh' }))
284
+ })
285
+ const rows = screen.getAllByTestId(/^bell-row-/)
286
+ expect(rows[0]!.getAttribute('data-testid')).toBe('bell-row-fresh')
287
+ })
288
+
289
+ it('does NOT bump the badge when the pushed notification is already read', async () => {
290
+ const fake = createFakeApi()
291
+ const wired = withSubscribe(fake.api)
292
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
293
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
294
+ await act(async () => {
295
+ wired.push(row({ id: 'already-read', readAt: '2026-01-02T00:00:00.000Z' }))
296
+ })
297
+ // Already-read notifications still appear in the list when the
298
+ // user opens the dropdown with includeRead=true, but they must
299
+ // NOT register a fresh unread bump.
300
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
301
+ })
302
+
303
+ it('closes the SSE subscription when the bell unmounts', async () => {
304
+ const fake = createFakeApi()
305
+ const wired = withSubscribe(fake.api)
306
+ const { unmount } = render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
307
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
308
+ unmount()
309
+ expect(wired.isClosed()).toBe(true)
310
+ })
311
+
312
+ it('does not surface transient SSE errors via onError (poll loop handles convergence)', async () => {
313
+ const fake = createFakeApi()
314
+ const wired = withSubscribe(fake.api)
315
+ const onError = vi.fn()
316
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
317
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
318
+ await act(async () => {
319
+ wired.pushError(new Event('error'))
320
+ })
321
+ // Deliberately quiet — see NotificationBell SSE handler comment.
322
+ expect(onError).not.toHaveBeenCalled()
323
+ })
324
+
325
+ it('mark-all-read failure does not drop SSE rows that arrived mid-flight', async () => {
326
+ const fake = createFakeApi()
327
+ const wired = withSubscribe(fake.api)
328
+ fake.setRows([row({ id: 'before' })])
329
+ // Hold the markAllRead promise so we can interleave an SSE push.
330
+ let resolveMark: (v: NotificationOutcome<number>) => void = () => undefined
331
+ ;(wired.api.markAllRead as ReturnType<typeof vi.fn>).mockImplementationOnce(
332
+ () =>
333
+ new Promise((resolve) => {
334
+ resolveMark = resolve
335
+ }),
336
+ )
337
+ const onError = vi.fn()
338
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
339
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
340
+ fireEvent.click(screen.getByTestId('bell-button'))
341
+ await waitFor(() => expect(screen.getByTestId('bell-row-before')).toBeTruthy())
342
+
343
+ // Click mark-all — handler optimistically marks 'before' read,
344
+ // but the API promise is still pending.
345
+ await act(async () => {
346
+ fireEvent.click(screen.getByTestId('bell-mark-all'))
347
+ })
348
+ // While in flight, a fresh notification arrives via SSE.
349
+ await act(async () => {
350
+ wired.push(row({ id: 'mid-flight' }))
351
+ })
352
+
353
+ // Now resolve the API with a failure.
354
+ await act(async () => {
355
+ resolveMark({ ok: false, error: 'server', status: 500 })
356
+ // flush promise chain
357
+ await Promise.resolve()
358
+ })
359
+
360
+ expect(onError).toHaveBeenCalledWith('server')
361
+ // The original row was rolled back to unread...
362
+ expect(screen.getByTestId('bell-row-before').getAttribute('data-read')).toBe('false')
363
+ // ...and the SSE arrival is still present (not dropped by the
364
+ // legacy snapshot-replace rollback).
365
+ expect(screen.getByTestId('bell-row-mid-flight')).toBeTruthy()
366
+ })
367
+
368
+ it('still mounts cleanly when api.subscribe is undefined (no SSE support)', async () => {
369
+ const fake = createFakeApi()
370
+ // Strip the `subscribe` member entirely — defaultApi has it,
371
+ // but consumer-provided test APIs may not.
372
+ const apiWithoutSubscribe: NotificationsApi = { ...fake.api }
373
+ delete (apiWithoutSubscribe as { subscribe?: unknown }).subscribe
374
+ render(<NotificationBell api={apiWithoutSubscribe} pollIntervalMs={0} />)
375
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
376
+ // No crash, badge from unread() shows nothing, no items.
377
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
378
+ })
379
+ })
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  const cmsApiMock = vi.fn()
4
4
  vi.mock('../../lib/api.js', () => ({
5
5
  cmsApi: (...args: unknown[]) => cmsApiMock(...args),
6
+ // SSE uses EventSource, which doesn't go through cmsApi. The
7
+ // helper resolves the URL via getApiBase() — return the production
8
+ // default so tests don't need to know the prefix.
9
+ getApiBase: () => '/api/cms',
6
10
  }))
7
11
 
8
12
  const {
@@ -10,9 +14,14 @@ const {
10
14
  markAllNotificationsRead,
11
15
  markNotificationRead,
12
16
  notificationSummary,
17
+ subscribeNotificationStream,
13
18
  unreadCount,
14
19
  } = await import('../../lib/notifications-client.js')
15
- import type { Notification } from '../../lib/notifications-client.js'
20
+ import type {
21
+ EventSourceLike,
22
+ Notification,
23
+ NotificationStreamOptions,
24
+ } from '../../lib/notifications-client.js'
16
25
 
17
26
  function row(overrides: Partial<Notification> = {}): Notification {
18
27
  return {
@@ -193,3 +202,138 @@ describe('notificationSummary', () => {
193
202
  ).toMatch(/^Someone replied/)
194
203
  })
195
204
  })
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // SSE push channel — subscribeNotificationStream
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * In-memory `EventSource` double. Tests drive the wire by invoking
212
+ * `emit(event, payload)`; the subscriber dispatches the right
213
+ * listener. We deliberately keep the shape minimal — `EventSource`
214
+ * has dozens of properties our admin doesn't touch.
215
+ */
216
+ function makeFakeSource() {
217
+ const listeners = new Map<string, Set<(e: MessageEvent | Event) => void>>()
218
+ let closed = false
219
+ const source: EventSourceLike = {
220
+ addEventListener: (type, listener) => {
221
+ if (closed) return
222
+ let set = listeners.get(type)
223
+ if (!set) {
224
+ set = new Set()
225
+ listeners.set(type, set)
226
+ }
227
+ set.add(listener)
228
+ },
229
+ removeEventListener: (type, listener) => {
230
+ listeners.get(type)?.delete(listener)
231
+ },
232
+ close: () => {
233
+ closed = true
234
+ listeners.clear()
235
+ },
236
+ }
237
+ return {
238
+ source,
239
+ listenerCount: (type: string) => listeners.get(type)?.size ?? 0,
240
+ isClosed: () => closed,
241
+ emit(type: string, payload: unknown) {
242
+ const set = listeners.get(type)
243
+ if (!set) return
244
+ const event =
245
+ typeof payload === 'string'
246
+ ? ({ data: payload } as MessageEvent)
247
+ : payload === undefined
248
+ ? (new Event(type) as Event)
249
+ : ({ data: JSON.stringify(payload) } as MessageEvent)
250
+ for (const l of [...set]) l(event)
251
+ },
252
+ }
253
+ }
254
+
255
+ function startStream(
256
+ fake: ReturnType<typeof makeFakeSource>,
257
+ overrides: Partial<NotificationStreamOptions> = {},
258
+ ) {
259
+ return subscribeNotificationStream({
260
+ onNotification: () => undefined,
261
+ factory: () => fake.source,
262
+ ...overrides,
263
+ })
264
+ }
265
+
266
+ describe('subscribeNotificationStream', () => {
267
+ it('dispatches `notification` frames as Notification objects', () => {
268
+ const fake = makeFakeSource()
269
+ const seen: Notification[] = []
270
+ startStream(fake, { onNotification: (n) => seen.push(n) })
271
+ fake.emit('notification', row({ id: 'sse-1' }))
272
+ expect(seen.map((n) => n.id)).toEqual(['sse-1'])
273
+ })
274
+
275
+ it('dispatches `ready` frames once via onReady', () => {
276
+ const fake = makeFakeSource()
277
+ const readys: Array<{ userId: string }> = []
278
+ startStream(fake, { onReady: (p) => readys.push(p) })
279
+ fake.emit('ready', { userId: 'user-1' })
280
+ expect(readys).toEqual([{ userId: 'user-1' }])
281
+ })
282
+
283
+ it('ignores malformed payloads (defence against wire drift)', () => {
284
+ const fake = makeFakeSource()
285
+ const seen: Notification[] = []
286
+ startStream(fake, { onNotification: (n) => seen.push(n) })
287
+ // Missing id + payload — must not throw, must not deliver.
288
+ fake.emit('notification', { userId: 'user-1', createdAt: 'now' })
289
+ fake.emit('notification', 'not json')
290
+ expect(seen).toEqual([])
291
+ })
292
+
293
+ it('removes its listeners and closes the underlying source on close()', () => {
294
+ const fake = makeFakeSource()
295
+ const handle = startStream(fake)
296
+ expect(fake.listenerCount('notification')).toBe(1)
297
+ expect(fake.listenerCount('ready')).toBe(1)
298
+ expect(fake.listenerCount('error')).toBe(1)
299
+ handle.close()
300
+ expect(fake.isClosed()).toBe(true)
301
+ // Second close() is a no-op — guards against double-unmount loops.
302
+ handle.close()
303
+ expect(fake.isClosed()).toBe(true)
304
+ })
305
+
306
+ it('returns a no-op handle when no EventSource is available', () => {
307
+ // No factory + no global EventSource (vitest jsdom env doesn't
308
+ // ship one). The subscriber must NOT throw — admin code must
309
+ // remain mountable in SSR / Node environments.
310
+ const handle = subscribeNotificationStream({
311
+ onNotification: () => undefined,
312
+ factory: () => null,
313
+ })
314
+ expect(() => handle.close()).not.toThrow()
315
+ })
316
+
317
+ it('forwards error events to onError without crashing the page', () => {
318
+ const fake = makeFakeSource()
319
+ const errors: Event[] = []
320
+ startStream(fake, { onError: (e) => errors.push(e) })
321
+ fake.emit('error', undefined)
322
+ expect(errors).toHaveLength(1)
323
+ })
324
+
325
+ it('builds the stream URL from getApiBase() so consumers with a custom prefix connect to the right host', () => {
326
+ // Custom factory inspects the URL it's handed. Verifies the
327
+ // subscriber composes /<base>/notifications/stream rather than
328
+ // hardcoding the production prefix.
329
+ const seen: string[] = []
330
+ subscribeNotificationStream({
331
+ onNotification: () => undefined,
332
+ factory: (url) => {
333
+ seen.push(url)
334
+ return null
335
+ },
336
+ })
337
+ expect(seen).toEqual(['/api/cms/notifications/stream'])
338
+ })
339
+ })