strapi-plugin-notifier 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 datrine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # strapi-plugin-notifier
2
+
3
+ A first-class notification system for Strapi v5 admin panels. Adds a live bell icon to the sidebar, a full notification inbox, a runtime Settings panel, and a simple API to send notifications from anywhere in your Strapi application.
4
+
5
+ ## Features
6
+
7
+ - **Live bell icon** — badge count updated by polling the server (interval configurable)
8
+ - **Notification inbox** — filter by type, mark as read, dismiss, load more, clear all
9
+ - **Targeting** — broadcast to all admins, or target by user ID or role code
10
+ - **Configurable** — poll interval, page size, retention policy, and UI accent colours
11
+ - **Settings panel** — runtime configuration via Settings → Notifier (persisted in Strapi plugin store)
12
+ - **Retention cron** — daily cleanup at 3 AM, respects maxDays and maxPerUser limits
13
+ - **Strapi v5 only**
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install strapi-plugin-notifier
19
+ # or
20
+ yarn add strapi-plugin-notifier
21
+ ```
22
+
23
+ Enable the plugin in `config/plugins.ts`:
24
+
25
+ ```typescript
26
+ export default {
27
+ notifier: {
28
+ enabled: true,
29
+ config: {
30
+ // all fields are optional — see Configuration below
31
+ },
32
+ },
33
+ };
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ All configuration is optional. Built-in defaults apply unless overridden.
39
+
40
+ ```typescript
41
+ // config/plugins.ts
42
+ export default {
43
+ notifier: {
44
+ enabled: true,
45
+ config: {
46
+ retention: {
47
+ maxDays: 90, // delete notifications older than N days
48
+ maxPerUser: 500, // cap notifications stored per user
49
+ },
50
+ delivery: {
51
+ pollIntervalMs: 30_000, // how often the Bell polls (ms)
52
+ pageSize: 20, // notifications per page in inbox
53
+ },
54
+ ui: {
55
+ theme: {
56
+ accent: {
57
+ info: '#4945ff',
58
+ success: '#5cb85c',
59
+ warning: '#f0ad4e',
60
+ error: '#ee5e52',
61
+ },
62
+ },
63
+ },
64
+ },
65
+ },
66
+ };
67
+ ```
68
+
69
+ Settings can also be updated at runtime from the Strapi admin panel under **Settings → Notifier**. Runtime settings take precedence over `config/plugins.ts`.
70
+
71
+ ## Sending notifications
72
+
73
+ Use the `notifier` service from anywhere in your Strapi code (services, controllers, lifecycles, webhooks):
74
+
75
+ ```typescript
76
+ const notifier = strapi.plugin('notifier').service('notifier');
77
+
78
+ // Broadcast to all admin users
79
+ notifier.broadcast({ title: 'Maintenance scheduled', type: 'warning' });
80
+
81
+ // Send to a specific role
82
+ notifier.toRole('strapi-editor', {
83
+ title: 'New content submitted',
84
+ message: 'An article is waiting for review.',
85
+ url: '/content-manager/collection-types/api::article.article',
86
+ });
87
+
88
+ // Send to a specific admin user (by user ID)
89
+ notifier.toUser(42, {
90
+ title: 'Your export is ready',
91
+ type: 'success',
92
+ url: '/uploads/export-2024.csv',
93
+ });
94
+
95
+ // Generic send with full control
96
+ notifier.send({
97
+ title: 'Hello',
98
+ message: 'World',
99
+ type: 'info',
100
+ url: 'https://example.com',
101
+ to: { role: 'strapi-super-admin' },
102
+ });
103
+ ```
104
+
105
+ ### Notification options
106
+
107
+ | Field | Type | Required | Default |
108
+ |-----------|-------------------------------------------|----------|-------------|
109
+ | `title` | `string` | Yes | — |
110
+ | `message` | `string` | No | — |
111
+ | `type` | `'info' \| 'success' \| 'warning' \| 'error'` | No | `'info'` |
112
+ | `url` | `string` | No | — |
113
+
114
+ ## API routes
115
+
116
+ All routes require `admin::isAuthenticatedAdmin`. The plugin mounts under `/notifier/`.
117
+
118
+ | Method | Path | Description |
119
+ |--------|---------------------------------|--------------------------------------|
120
+ | GET | `/notifier/notifications` | List notifications (paginated) |
121
+ | PUT | `/notifier/notifications/read-all` | Mark all as read |
122
+ | DELETE | `/notifier/notifications` | Clear all notifications |
123
+ | PUT | `/notifier/notifications/:id/read` | Mark one as read |
124
+ | DELETE | `/notifier/notifications/:id` | Clear one notification |
125
+ | GET | `/notifier/config` | Fetch UI config (safe for frontend) |
126
+ | GET | `/notifier/settings` | Get full settings (requires permission) |
127
+ | PUT | `/notifier/settings` | Update settings (requires permission) |
128
+ | DELETE | `/notifier/settings` | Reset settings to defaults |
129
+
130
+ Query parameters for `GET /notifier/notifications`:
131
+
132
+ | Param | Default | Description |
133
+ |------------|---------|---------------------|
134
+ | `page` | `1` | Page number |
135
+ | `pageSize` | `20` | Results per page |
136
+
137
+ ## Permissions
138
+
139
+ Two permissions are registered under the **Notifier** plugin section in **Settings → Roles**:
140
+
141
+ - `plugin::notifier.settings.read` — view the settings panel
142
+ - `plugin::notifier.settings.update` — save or reset settings
143
+
144
+ ## Content type
145
+
146
+ Notifications are stored in `plugin::notifier.notification` (collection: `notifier_notifications`). The content type is hidden from Content Manager and Content-Type Builder by default.
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,310 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const React = require("react");
5
+ const designSystem = require("@strapi/design-system");
6
+ require("react-dom/client");
7
+ const icons = require("@strapi/icons");
8
+ const index = require("./index-7WJGsVEY.js");
9
+ require("@strapi/icons/symbols");
10
+ const toStoreItem = (n) => ({
11
+ id: String(n.id),
12
+ title: n.title,
13
+ message: n.message ?? "",
14
+ type: n.type ?? "info",
15
+ read: n.read,
16
+ createdAt: n.createdAt,
17
+ url: n.url
18
+ });
19
+ const useNotificationActions = () => {
20
+ const { get, put, del } = index.useFetchClient();
21
+ const [isLoading, setIsLoading] = React.useState(false);
22
+ const [pagination, setPagination] = React.useState(null);
23
+ const currentPage = React.useRef(1);
24
+ const fetchPage = React.useCallback(
25
+ async (page, append = false) => {
26
+ const { pageSize } = index.getConfig();
27
+ setIsLoading(true);
28
+ try {
29
+ const { data } = await get(
30
+ `/notifier/notifications?page=${page}&pageSize=${pageSize}`
31
+ );
32
+ if (Array.isArray(data?.data)) {
33
+ const incoming = data.data.map(toStoreItem);
34
+ if (append) {
35
+ index.setNotifications([...index.getNotifications(), ...incoming]);
36
+ } else {
37
+ index.setNotifications(incoming);
38
+ }
39
+ setPagination(data.pagination ?? null);
40
+ currentPage.current = page;
41
+ }
42
+ } finally {
43
+ setIsLoading(false);
44
+ }
45
+ },
46
+ [get]
47
+ );
48
+ const fetchAll = React.useCallback(() => fetchPage(1, false), [fetchPage]);
49
+ const loadMore = React.useCallback(() => {
50
+ if (!pagination || currentPage.current >= pagination.pageCount) return;
51
+ fetchPage(currentPage.current + 1, true);
52
+ }, [fetchPage, pagination]);
53
+ const handleMarkAsRead = React.useCallback(
54
+ async (id) => {
55
+ index.markAsRead(id);
56
+ try {
57
+ await put(`/notifier/notifications/${id}/read`);
58
+ } catch {
59
+ }
60
+ },
61
+ [put]
62
+ );
63
+ const handleMarkAllAsRead = React.useCallback(async () => {
64
+ index.markAllAsRead();
65
+ try {
66
+ await put("/notifier/notifications/read-all");
67
+ } catch {
68
+ }
69
+ }, [put]);
70
+ const handleClear = React.useCallback(
71
+ async (id) => {
72
+ index.clearNotification(id);
73
+ try {
74
+ await del(`/notifier/notifications/${id}`);
75
+ } catch {
76
+ }
77
+ },
78
+ [del]
79
+ );
80
+ const handleClearAll = React.useCallback(async () => {
81
+ index.clearAll();
82
+ try {
83
+ await del("/notifier/notifications");
84
+ } catch {
85
+ }
86
+ }, [del]);
87
+ const hasMore = pagination ? currentPage.current < pagination.pageCount : false;
88
+ return {
89
+ fetchAll,
90
+ loadMore,
91
+ handleMarkAsRead,
92
+ handleMarkAllAsRead,
93
+ handleClear,
94
+ handleClearAll,
95
+ isLoading,
96
+ hasMore
97
+ };
98
+ };
99
+ const FILTERS = ["all", "unread", "info", "success", "warning", "error"];
100
+ const FILTER_LABELS = {
101
+ all: "All",
102
+ unread: "Unread",
103
+ info: "Info",
104
+ success: "Success",
105
+ warning: "Warning",
106
+ error: "Error"
107
+ };
108
+ function InboxHeader({
109
+ filter,
110
+ onFilterChange,
111
+ onMarkAllAsRead,
112
+ onClearAll
113
+ }) {
114
+ return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { paddingBottom: 4, children: [
115
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 4, children: [
116
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "alpha", children: "Notifications" }),
117
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, children: [
118
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "tertiary", size: "S", onClick: onMarkAllAsRead, children: "Mark all as read" }),
119
+ /* @__PURE__ */ jsxRuntime.jsx(
120
+ designSystem.Button,
121
+ {
122
+ variant: "danger-light",
123
+ size: "S",
124
+ onClick: onClearAll,
125
+ children: "Clear all"
126
+ }
127
+ )
128
+ ] })
129
+ ] }),
130
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { gap: 2, wrap: "wrap", children: FILTERS.map((f) => /* @__PURE__ */ jsxRuntime.jsx(
131
+ "button",
132
+ {
133
+ onClick: () => onFilterChange(f),
134
+ style: {
135
+ padding: "4px 12px",
136
+ borderRadius: "16px",
137
+ border: "1px solid",
138
+ borderColor: filter === f ? "#4945ff" : "#dcdce4",
139
+ backgroundColor: filter === f ? "#f0f0ff" : "transparent",
140
+ color: filter === f ? "#4945ff" : "#32324d",
141
+ cursor: "pointer",
142
+ fontSize: "12px",
143
+ fontWeight: filter === f ? 600 : 400,
144
+ transition: "all 0.15s"
145
+ },
146
+ children: FILTER_LABELS[f]
147
+ },
148
+ f
149
+ )) })
150
+ ] });
151
+ }
152
+ const useNotifications = () => {
153
+ const [notifications, setNotifications] = React.useState(index.getNotifications);
154
+ React.useEffect(() => index.subscribeToNotifications(setNotifications), []);
155
+ return notifications;
156
+ };
157
+ const UNREAD_BG = {
158
+ info: "#f0f0ff",
159
+ success: "#f0fff4",
160
+ warning: "#fffbf0",
161
+ error: "#fff5f5"
162
+ };
163
+ function NotificationItem({
164
+ notification,
165
+ onMarkAsRead,
166
+ onClear
167
+ }) {
168
+ const config = index.usePluginConfig();
169
+ const accent = config.theme.accent[notification.type];
170
+ const handleClick = () => {
171
+ if (!notification.read) onMarkAsRead(notification.id);
172
+ if (notification.url) {
173
+ if (notification.url.startsWith("http")) {
174
+ window.open(notification.url, "_blank", "noopener,noreferrer");
175
+ } else {
176
+ window.location.href = notification.url;
177
+ }
178
+ }
179
+ };
180
+ const handleDismiss = (e) => {
181
+ e.stopPropagation();
182
+ onClear(notification.id);
183
+ };
184
+ const bg = !notification.read ? UNREAD_BG[notification.type] : "transparent";
185
+ return /* @__PURE__ */ jsxRuntime.jsx(
186
+ designSystem.Box,
187
+ {
188
+ onClick: handleClick,
189
+ style: {
190
+ borderLeft: `4px solid ${accent}`,
191
+ backgroundColor: bg,
192
+ cursor: notification.url ? "pointer" : "default",
193
+ transition: "background 0.15s"
194
+ },
195
+ padding: 4,
196
+ children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "flex-start", gap: 2, children: [
197
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { style: { flex: 1, minWidth: 0 }, children: [
198
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 1, alignItems: "center", children: [
199
+ /* @__PURE__ */ jsxRuntime.jsx(
200
+ designSystem.Typography,
201
+ {
202
+ variant: "omega",
203
+ fontWeight: notification.read ? "normal" : "bold",
204
+ style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
205
+ children: notification.title
206
+ }
207
+ ),
208
+ notification.url && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
209
+ ] }),
210
+ notification.message && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", style: { marginTop: 2 }, children: notification.message }),
211
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral400", style: { marginTop: 4 }, children: new Date(notification.createdAt).toLocaleString() })
212
+ ] }),
213
+ /* @__PURE__ */ jsxRuntime.jsx(
214
+ designSystem.IconButton,
215
+ {
216
+ label: "Dismiss",
217
+ onClick: handleDismiss,
218
+ variant: "ghost",
219
+ size: "S",
220
+ children: /* @__PURE__ */ jsxRuntime.jsx(icons.Cross, {})
221
+ }
222
+ )
223
+ ] })
224
+ }
225
+ );
226
+ }
227
+ const MESSAGES = {
228
+ all: "No notifications yet.",
229
+ unread: "No unread notifications.",
230
+ info: "No info notifications.",
231
+ success: "No success notifications.",
232
+ warning: "No warning notifications.",
233
+ error: "No error notifications."
234
+ };
235
+ function EmptyInbox({ filter = "all" }) {
236
+ return /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { padding: 8, style: { textAlign: "center" }, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", textColor: "neutral500", children: MESSAGES[filter] }) });
237
+ }
238
+ const applyFilter = (items, filter) => {
239
+ if (filter === "all") return items;
240
+ if (filter === "unread") return items.filter((n) => !n.read);
241
+ return items.filter((n) => n.type === filter);
242
+ };
243
+ function NotificationList({
244
+ filter,
245
+ onMarkAsRead,
246
+ onClear,
247
+ isLoading,
248
+ hasMore,
249
+ onLoadMore
250
+ }) {
251
+ const notifications = useNotifications();
252
+ const filtered = applyFilter(notifications, filter);
253
+ if (isLoading && notifications.length === 0) {
254
+ return /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { justifyContent: "center", padding: 8, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Loader, { small: true, children: "Loading notifications…" }) });
255
+ }
256
+ if (filtered.length === 0) {
257
+ return /* @__PURE__ */ jsxRuntime.jsx(EmptyInbox, { filter });
258
+ }
259
+ return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
260
+ filtered.map((n) => /* @__PURE__ */ jsxRuntime.jsx(
261
+ NotificationItem,
262
+ {
263
+ notification: n,
264
+ onMarkAsRead,
265
+ onClear
266
+ },
267
+ n.id
268
+ )),
269
+ hasMore && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { justifyContent: "center", padding: 4, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "tertiary", onClick: onLoadMore, loading: isLoading, children: isLoading ? "Loading…" : "Load more" }) })
270
+ ] });
271
+ }
272
+ function Index() {
273
+ const [filter, setFilter] = React.useState("all");
274
+ const {
275
+ fetchAll,
276
+ loadMore,
277
+ handleMarkAsRead,
278
+ handleMarkAllAsRead,
279
+ handleClear,
280
+ handleClearAll,
281
+ isLoading,
282
+ hasMore
283
+ } = useNotificationActions();
284
+ React.useEffect(() => {
285
+ fetchAll();
286
+ }, []);
287
+ return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { padding: 8, style: { height: "100%" }, children: [
288
+ /* @__PURE__ */ jsxRuntime.jsx(
289
+ InboxHeader,
290
+ {
291
+ filter,
292
+ onFilterChange: setFilter,
293
+ onMarkAllAsRead: handleMarkAllAsRead,
294
+ onClearAll: handleClearAll
295
+ }
296
+ ),
297
+ /* @__PURE__ */ jsxRuntime.jsx(
298
+ NotificationList,
299
+ {
300
+ filter,
301
+ onMarkAsRead: handleMarkAsRead,
302
+ onClear: handleClear,
303
+ isLoading,
304
+ hasMore,
305
+ onLoadMore: loadMore
306
+ }
307
+ )
308
+ ] });
309
+ }
310
+ exports.default = Index;