strapi-plugin-notifier 1.1.0 → 1.2.1
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/README.md +161 -4
- package/dist/_chunks/{Index-_PTtaZsz.mjs → Index-1ChU1M3b.mjs} +4 -4
- package/dist/_chunks/{Index-DVk2enQ2.js → Index-cFnGw4rg.js} +4 -4
- package/dist/_chunks/{SettingsPage-DYxNBA3h.js → SettingsPage-DKTIHVrI.js} +11 -2
- package/dist/_chunks/{SettingsPage-CuTIslG0.mjs → SettingsPage-DVaHqffU.mjs} +12 -3
- package/dist/_chunks/{index-CKh_iBP2.js → index-BMiYJk-5.js} +2 -2
- package/dist/_chunks/{index-BgPnE501.mjs → index-BauNAWBC.mjs} +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +119 -15
- package/dist/server/index.mjs +119 -15
- package/package.json +1 -1
- package/tests/unit/rules.test.ts +470 -0
package/README.md
CHANGED
|
@@ -10,9 +10,10 @@ A first-class notification system for Strapi v5 admin panels. Adds a live bell i
|
|
|
10
10
|
- **Batch sending** — send multiple notifications in one call, settings fetched once
|
|
11
11
|
- **Merge** — collapse repeated notifications into one item with a count badge (fully configurable)
|
|
12
12
|
- **User opt-out** — each admin user can mute individual notification types or opt out entirely
|
|
13
|
-
- **
|
|
13
|
+
- **Declarative rules** — trigger notifications from lifecycle hooks, eventHub events, or cron schedules — no code required beyond `config/plugins.ts`
|
|
14
|
+
- **Configurable** — poll interval, page size, retention policy, UI accent colours, merge rules, and cleanup schedule
|
|
14
15
|
- **Settings panel** — runtime configuration via Settings → Notifier (persisted in Strapi plugin store)
|
|
15
|
-
- **Retention cron** —
|
|
16
|
+
- **Retention cron** — configurable cleanup schedule (default: 3 AM daily), respects maxDays and maxPerUser limits
|
|
16
17
|
- **Strapi v5 only**
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
@@ -47,8 +48,9 @@ export default {
|
|
|
47
48
|
enabled: true,
|
|
48
49
|
config: {
|
|
49
50
|
retention: {
|
|
50
|
-
maxDays: 90,
|
|
51
|
-
maxPerUser: 500,
|
|
51
|
+
maxDays: 90, // delete notifications older than N days
|
|
52
|
+
maxPerUser: 500, // cap notifications stored per user
|
|
53
|
+
cleanupCron: '0 3 * * *', // cron schedule for cleanup job (default: 3 AM daily)
|
|
52
54
|
},
|
|
53
55
|
delivery: {
|
|
54
56
|
pollIntervalMs: 30_000, // how often the Bell polls (ms)
|
|
@@ -214,6 +216,161 @@ Content-Type: application/json
|
|
|
214
216
|
| `globalOptOut` | `boolean` | `false` | Suppress all notifications for this user |
|
|
215
217
|
| `mutedTypes` | `NotificationType[]` | `[]` | Suppress only these notification types |
|
|
216
218
|
|
|
219
|
+
## Declarative rules
|
|
220
|
+
|
|
221
|
+
Instead of calling the notifier service in code, you can declare rules in `config/plugins.ts`. The plugin wires up the listeners at bootstrap — notifications fire automatically with no further code changes needed.
|
|
222
|
+
|
|
223
|
+
Three trigger types are supported: `lifecycle`, `event`, and `cron`.
|
|
224
|
+
|
|
225
|
+
### `on: 'lifecycle'` — content-type create/update/delete
|
|
226
|
+
|
|
227
|
+
Fires when Strapi's DB lifecycle executes for a given content-type and action.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// config/plugins.ts
|
|
231
|
+
notifier: {
|
|
232
|
+
enabled: true,
|
|
233
|
+
config: {
|
|
234
|
+
rules: [
|
|
235
|
+
{
|
|
236
|
+
on: 'lifecycle',
|
|
237
|
+
model: 'api::article.article',
|
|
238
|
+
action: 'afterCreate', // afterCreate | afterUpdate | afterDelete | afterPublish | afterUnpublish
|
|
239
|
+
notification: {
|
|
240
|
+
title: 'New article: {{entry.title}}',
|
|
241
|
+
message: 'Created by {{entry.createdBy.firstname}}',
|
|
242
|
+
type: 'info',
|
|
243
|
+
to: { role: 'strapi-editor' },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`entry` in templates is the DB record produced by the lifecycle action (`result` for after-hooks, `params.data` for before-hooks).
|
|
252
|
+
|
|
253
|
+
### `on: 'event'` — strapi.eventHub subscription
|
|
254
|
+
|
|
255
|
+
Fires on any named event published to Strapi's internal event hub (lifecycle events, media events, plugin events, or your own custom events emitted with `strapi.eventHub.emit()`).
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
{
|
|
259
|
+
on: 'event',
|
|
260
|
+
event: 'media.upload', // any strapi.eventHub event name
|
|
261
|
+
filter: { uid: 'api::article.article' }, // optional shallow key=value filter on payload
|
|
262
|
+
notification: {
|
|
263
|
+
title: 'File uploaded: {{media.name}}',
|
|
264
|
+
type: 'info',
|
|
265
|
+
to: 'broadcast',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Custom events from your own services:**
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// In your service
|
|
274
|
+
strapi.eventHub.emit('order.placed', { order });
|
|
275
|
+
|
|
276
|
+
// In config/plugins.ts
|
|
277
|
+
{
|
|
278
|
+
on: 'event',
|
|
279
|
+
event: 'order.placed',
|
|
280
|
+
notification: {
|
|
281
|
+
title: 'New order: #{{order.id}}',
|
|
282
|
+
message: '{{order.customerName}} — €{{order.total}}',
|
|
283
|
+
type: 'success',
|
|
284
|
+
to: { role: 'strapi-super-admin' },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### `on: 'cron'` — time-based notifications
|
|
290
|
+
|
|
291
|
+
Fires on a cron schedule. Standard 5-field cron expressions.
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
{
|
|
295
|
+
on: 'cron',
|
|
296
|
+
schedule: '0 9 * * 1', // Every Monday at 9 AM
|
|
297
|
+
notification: {
|
|
298
|
+
title: 'Weekly reminder: review pending content',
|
|
299
|
+
type: 'warning',
|
|
300
|
+
to: { role: 'strapi-editor' },
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Notification templates
|
|
306
|
+
|
|
307
|
+
Every field in `notification` accepts either a **static value** or a **function** for dynamic content.
|
|
308
|
+
|
|
309
|
+
**String interpolation** — use `{{path.to.field}}` dot-notation to resolve values from the event context:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
notification: {
|
|
313
|
+
title: 'New order from {{entry.customer.name}}',
|
|
314
|
+
message: 'Total: {{entry.total}} — shipped to {{entry.address.city}}',
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Function templates** — for logic that can't be expressed as a template string:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
notification: {
|
|
322
|
+
title: (ctx) => `${ctx.entry.priority === 'high' ? '🔴' : ''} Order #${ctx.entry.id}`,
|
|
323
|
+
type: (ctx) => ctx.entry.priority === 'high' ? 'error' : 'info',
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Targeting in rules
|
|
328
|
+
|
|
329
|
+
The `to` field supports the same options as the programmatic API, plus a `userIdFrom` path resolver:
|
|
330
|
+
|
|
331
|
+
| Value | Behaviour |
|
|
332
|
+
|-----------------------------|---------------------------------------------------------|
|
|
333
|
+
| `'broadcast'` (or omitted) | Send to all admin users |
|
|
334
|
+
| `{ role: 'strapi-editor' }` | Send to all users with this role code |
|
|
335
|
+
| `{ userId: 42 }` | Send to a specific admin user |
|
|
336
|
+
| `{ userIdFrom: 'entry.createdBy.id' }` | Resolve user ID via dot-path into the event context — useful for "notify the author" patterns |
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// Notify the author when their content is rejected
|
|
340
|
+
{
|
|
341
|
+
on: 'lifecycle',
|
|
342
|
+
model: 'api::article.article',
|
|
343
|
+
action: 'afterUpdate',
|
|
344
|
+
when: (ctx) => ctx.entry.status === 'rejected',
|
|
345
|
+
notification: {
|
|
346
|
+
title: 'Your article was rejected',
|
|
347
|
+
to: { userIdFrom: 'entry.createdBy.id' },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `when` guard
|
|
353
|
+
|
|
354
|
+
Add a `when` function to conditionally skip the notification. Return `false` to suppress it.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
{
|
|
358
|
+
on: 'lifecycle',
|
|
359
|
+
model: 'api::article.article',
|
|
360
|
+
action: 'afterUpdate',
|
|
361
|
+
when: (ctx) => ctx.entry.publishedAt != null, // only fire when published
|
|
362
|
+
notification: {
|
|
363
|
+
title: 'Article published: {{entry.title}}',
|
|
364
|
+
type: 'success',
|
|
365
|
+
to: 'broadcast',
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`when` is not available on `cron` rules (there is no event context to filter on).
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
217
374
|
## API routes
|
|
218
375
|
|
|
219
376
|
All routes require `admin::isAuthenticatedAdmin`. The plugin mounts under `/notifier/`.
|
|
@@ -2,7 +2,7 @@ import { jsxs, jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
3
3
|
import { Box, Flex, Typography, Button, IconButton, Loader } from "@strapi/design-system";
|
|
4
4
|
import { useFetchClient } from "@strapi/admin/strapi-admin";
|
|
5
|
-
import { g as getConfig, s as setNotifications, a as getNotifications, m as markAsRead, b as markAllAsRead, c as clearNotification, d as clearAll, e as subscribeToNotifications, u as usePluginConfig } from "./index-
|
|
5
|
+
import { g as getConfig, s as setNotifications, a as getNotifications, m as markAsRead, b as markAllAsRead, c as clearNotification, d as clearAll, e as subscribeToNotifications, u as usePluginConfig } from "./index-BauNAWBC.mjs";
|
|
6
6
|
import { Cross } from "@strapi/icons";
|
|
7
7
|
const toStoreItem = (n) => ({
|
|
8
8
|
id: String(n.id),
|
|
@@ -186,7 +186,7 @@ function NotificationItem({
|
|
|
186
186
|
style: {
|
|
187
187
|
borderLeft: `4px solid ${accent}`,
|
|
188
188
|
backgroundColor: bg,
|
|
189
|
-
cursor:
|
|
189
|
+
cursor: "pointer",
|
|
190
190
|
transition: "background 0.15s"
|
|
191
191
|
},
|
|
192
192
|
padding: 4,
|
|
@@ -222,8 +222,8 @@ function NotificationItem({
|
|
|
222
222
|
),
|
|
223
223
|
notification.url && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
|
|
224
224
|
] }),
|
|
225
|
-
notification.message && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600",
|
|
226
|
-
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral400",
|
|
225
|
+
notification.message && /* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: notification.message }) }),
|
|
226
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral400", children: new Date(notification.createdAt).toLocaleString() }) })
|
|
227
227
|
] }),
|
|
228
228
|
/* @__PURE__ */ jsx(
|
|
229
229
|
IconButton,
|
|
@@ -4,7 +4,7 @@ const jsxRuntime = require("react/jsx-runtime");
|
|
|
4
4
|
const react = require("react");
|
|
5
5
|
const designSystem = require("@strapi/design-system");
|
|
6
6
|
const strapiAdmin = require("@strapi/admin/strapi-admin");
|
|
7
|
-
const index = require("./index-
|
|
7
|
+
const index = require("./index-BMiYJk-5.js");
|
|
8
8
|
const icons = require("@strapi/icons");
|
|
9
9
|
const toStoreItem = (n) => ({
|
|
10
10
|
id: String(n.id),
|
|
@@ -188,7 +188,7 @@ function NotificationItem({
|
|
|
188
188
|
style: {
|
|
189
189
|
borderLeft: `4px solid ${accent}`,
|
|
190
190
|
backgroundColor: bg,
|
|
191
|
-
cursor:
|
|
191
|
+
cursor: "pointer",
|
|
192
192
|
transition: "background 0.15s"
|
|
193
193
|
},
|
|
194
194
|
padding: 4,
|
|
@@ -224,8 +224,8 @@ function NotificationItem({
|
|
|
224
224
|
),
|
|
225
225
|
notification.url && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
|
|
226
226
|
] }),
|
|
227
|
-
notification.message && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600",
|
|
228
|
-
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral400",
|
|
227
|
+
notification.message && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 1, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: notification.message }) }),
|
|
228
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral400", children: new Date(notification.createdAt).toLocaleString() }) })
|
|
229
229
|
] }),
|
|
230
230
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
231
231
|
designSystem.IconButton,
|
|
@@ -5,7 +5,7 @@ const react = require("react");
|
|
|
5
5
|
const designSystem = require("@strapi/design-system");
|
|
6
6
|
const strapiAdmin = require("@strapi/admin/strapi-admin");
|
|
7
7
|
const DEFAULT = {
|
|
8
|
-
retention: { maxDays: 90, maxPerUser: 500 },
|
|
8
|
+
retention: { maxDays: 90, maxPerUser: 500, cleanupCron: "0 3 * * *" },
|
|
9
9
|
delivery: { pollIntervalMs: 3e4, pageSize: 20 },
|
|
10
10
|
merge: {
|
|
11
11
|
enabled: false,
|
|
@@ -104,7 +104,16 @@ function SettingsPage() {
|
|
|
104
104
|
style: { flex: 1 }
|
|
105
105
|
}
|
|
106
106
|
)
|
|
107
|
-
] })
|
|
107
|
+
] }),
|
|
108
|
+
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 4, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
109
|
+
designSystem.TextInput,
|
|
110
|
+
{
|
|
111
|
+
label: "Cleanup schedule (cron)",
|
|
112
|
+
hint: 'Standard 5-field cron expression. Default "0 3 * * *" runs at 3 AM daily. Takes effect on next restart.',
|
|
113
|
+
value: settings.retention.cleanupCron,
|
|
114
|
+
onChange: (e) => set("retention.cleanupCron", e.target.value)
|
|
115
|
+
}
|
|
116
|
+
) })
|
|
108
117
|
] }),
|
|
109
118
|
/* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
|
|
110
119
|
/* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", paddingBottom: 4, as: "h2", children: "Delivery" }),
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from "react";
|
|
3
|
-
import { Box, Flex, Typography, Button, NumberInput, Toggle, Divider, Checkbox
|
|
3
|
+
import { Box, Flex, Typography, Button, NumberInput, TextInput, Toggle, Divider, Checkbox } from "@strapi/design-system";
|
|
4
4
|
import { useFetchClient } from "@strapi/admin/strapi-admin";
|
|
5
5
|
const DEFAULT = {
|
|
6
|
-
retention: { maxDays: 90, maxPerUser: 500 },
|
|
6
|
+
retention: { maxDays: 90, maxPerUser: 500, cleanupCron: "0 3 * * *" },
|
|
7
7
|
delivery: { pollIntervalMs: 3e4, pageSize: 20 },
|
|
8
8
|
merge: {
|
|
9
9
|
enabled: false,
|
|
@@ -102,7 +102,16 @@ function SettingsPage() {
|
|
|
102
102
|
style: { flex: 1 }
|
|
103
103
|
}
|
|
104
104
|
)
|
|
105
|
-
] })
|
|
105
|
+
] }),
|
|
106
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 4, children: /* @__PURE__ */ jsx(
|
|
107
|
+
TextInput,
|
|
108
|
+
{
|
|
109
|
+
label: "Cleanup schedule (cron)",
|
|
110
|
+
hint: 'Standard 5-field cron expression. Default "0 3 * * *" runs at 3 AM daily. Takes effect on next restart.',
|
|
111
|
+
value: settings.retention.cleanupCron,
|
|
112
|
+
onChange: (e) => set("retention.cleanupCron", e.target.value)
|
|
113
|
+
}
|
|
114
|
+
) })
|
|
106
115
|
] }),
|
|
107
116
|
/* @__PURE__ */ jsxs(Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
|
|
108
117
|
/* @__PURE__ */ jsx(Typography, { variant: "delta", paddingBottom: 4, as: "h2", children: "Delivery" }),
|
|
@@ -177,7 +177,7 @@ const index = {
|
|
|
177
177
|
defaultMessage: "Notifications"
|
|
178
178
|
},
|
|
179
179
|
permissions: [],
|
|
180
|
-
Component: () => Promise.resolve().then(() => require("./Index-
|
|
180
|
+
Component: () => Promise.resolve().then(() => require("./Index-cFnGw4rg.js"))
|
|
181
181
|
});
|
|
182
182
|
app.createSettingSection(
|
|
183
183
|
{
|
|
@@ -195,7 +195,7 @@ const index = {
|
|
|
195
195
|
},
|
|
196
196
|
id: `${pluginId}.settings`,
|
|
197
197
|
to: `/settings/${pluginId}`,
|
|
198
|
-
Component: () => Promise.resolve().then(() => require("./SettingsPage-
|
|
198
|
+
Component: () => Promise.resolve().then(() => require("./SettingsPage-DKTIHVrI.js")),
|
|
199
199
|
permissions: [
|
|
200
200
|
{ action: `plugin::${pluginId}.settings.read`, subject: null }
|
|
201
201
|
]
|
|
@@ -176,7 +176,7 @@ const index = {
|
|
|
176
176
|
defaultMessage: "Notifications"
|
|
177
177
|
},
|
|
178
178
|
permissions: [],
|
|
179
|
-
Component: () => import("./Index-
|
|
179
|
+
Component: () => import("./Index-1ChU1M3b.mjs")
|
|
180
180
|
});
|
|
181
181
|
app.createSettingSection(
|
|
182
182
|
{
|
|
@@ -194,7 +194,7 @@ const index = {
|
|
|
194
194
|
},
|
|
195
195
|
id: `${pluginId}.settings`,
|
|
196
196
|
to: `/settings/${pluginId}`,
|
|
197
|
-
Component: () => import("./SettingsPage-
|
|
197
|
+
Component: () => import("./SettingsPage-DVaHqffU.mjs"),
|
|
198
198
|
permissions: [
|
|
199
199
|
{ action: `plugin::${pluginId}.settings.read`, subject: null }
|
|
200
200
|
]
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -14,19 +14,6 @@ const register = async ({ strapi }) => {
|
|
|
14
14
|
pluginName: "notifier"
|
|
15
15
|
}
|
|
16
16
|
]);
|
|
17
|
-
strapi.cron.add({
|
|
18
|
-
notifierCleanup: {
|
|
19
|
-
task: async ({ strapi: s }) => {
|
|
20
|
-
const settings2 = await s.plugin("notifier").service("settings").getEffective();
|
|
21
|
-
const { maxDays, maxPerUser } = settings2.retention;
|
|
22
|
-
await s.plugin("notifier").service("notification").cleanupOld(maxDays, maxPerUser);
|
|
23
|
-
},
|
|
24
|
-
options: {
|
|
25
|
-
rule: "0 3 * * *"
|
|
26
|
-
// 3 AM daily — configurable in cron settings if needed
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
17
|
};
|
|
31
18
|
const bootstrap = async ({ strapi }) => {
|
|
32
19
|
const stored = await strapi.store({ environment: "", type: "plugin", name: "notifier" }).get({ key: "settings" });
|
|
@@ -34,6 +21,21 @@ const bootstrap = async ({ strapi }) => {
|
|
|
34
21
|
const effective = await strapi.plugin("notifier").service("settings").getEffective();
|
|
35
22
|
await strapi.store({ environment: "", type: "plugin", name: "notifier" }).set({ key: "settings", value: effective });
|
|
36
23
|
}
|
|
24
|
+
const pluginConfig = strapi.config.get("plugin::notifier", {});
|
|
25
|
+
const rules2 = pluginConfig.rules ?? [];
|
|
26
|
+
if (rules2.length > 0) {
|
|
27
|
+
strapi.plugin("notifier").service("rules").setup(rules2);
|
|
28
|
+
}
|
|
29
|
+
const { retention } = await strapi.plugin("notifier").service("settings").getEffective();
|
|
30
|
+
strapi.cron.add({
|
|
31
|
+
notifierCleanup: {
|
|
32
|
+
task: async ({ strapi: s }) => {
|
|
33
|
+
const s_retention = (await s.plugin("notifier").service("settings").getEffective()).retention;
|
|
34
|
+
await s.plugin("notifier").service("notification").cleanupOld(s_retention.maxDays, s_retention.maxPerUser);
|
|
35
|
+
},
|
|
36
|
+
options: { rule: retention.cleanupCron }
|
|
37
|
+
}
|
|
38
|
+
});
|
|
37
39
|
};
|
|
38
40
|
const kind$1 = "collectionType";
|
|
39
41
|
const collectionName$1 = "notifier_notifications";
|
|
@@ -340,7 +342,8 @@ const notifier = ({ strapi }) => {
|
|
|
340
342
|
const DEFAULT_SETTINGS = {
|
|
341
343
|
retention: {
|
|
342
344
|
maxDays: 90,
|
|
343
|
-
maxPerUser: 500
|
|
345
|
+
maxPerUser: 500,
|
|
346
|
+
cleanupCron: "0 3 * * *"
|
|
344
347
|
},
|
|
345
348
|
delivery: {
|
|
346
349
|
defaultRecipient: "broadcast",
|
|
@@ -491,7 +494,108 @@ const preference$2 = ({ strapi }) => ({
|
|
|
491
494
|
return pref.globalOptOut || pref.mutedTypes.includes(type);
|
|
492
495
|
}
|
|
493
496
|
});
|
|
494
|
-
|
|
497
|
+
function getByPath(obj, path) {
|
|
498
|
+
return path.split(".").reduce((acc, key) => {
|
|
499
|
+
if (acc == null || typeof acc !== "object") return void 0;
|
|
500
|
+
return acc[key];
|
|
501
|
+
}, obj);
|
|
502
|
+
}
|
|
503
|
+
function interpolate(template, ctx) {
|
|
504
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (_, path) => {
|
|
505
|
+
const val = getByPath(ctx, path.trim());
|
|
506
|
+
return val == null ? "" : String(val);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
function resolveStr(tpl, ctx, flat) {
|
|
510
|
+
return typeof tpl === "function" ? tpl(ctx) : interpolate(tpl, flat);
|
|
511
|
+
}
|
|
512
|
+
function resolve(tpl, ctx) {
|
|
513
|
+
return typeof tpl === "function" ? tpl(ctx) : tpl;
|
|
514
|
+
}
|
|
515
|
+
function resolveTarget(tpl, ctx, flat) {
|
|
516
|
+
const target = typeof tpl === "function" ? tpl(ctx) : tpl;
|
|
517
|
+
if (!target || target === "broadcast") return {};
|
|
518
|
+
if ("userId" in target) return { to: { userId: target.userId } };
|
|
519
|
+
if ("role" in target) return { to: { role: target.role } };
|
|
520
|
+
if ("userIdFrom" in target) {
|
|
521
|
+
const val = getByPath(flat, target.userIdFrom);
|
|
522
|
+
if (val != null) return { to: { userId: Number(val) } };
|
|
523
|
+
}
|
|
524
|
+
return {};
|
|
525
|
+
}
|
|
526
|
+
async function fire(strapi, notification2, ctx) {
|
|
527
|
+
const flat = ctx;
|
|
528
|
+
const title = resolveStr(notification2.title, ctx, flat);
|
|
529
|
+
const message = notification2.message ? resolveStr(notification2.message, ctx, flat) : void 0;
|
|
530
|
+
const type = resolve(notification2.type, ctx) ?? "info";
|
|
531
|
+
const url = notification2.url ? resolveStr(notification2.url, ctx, flat) : void 0;
|
|
532
|
+
const targeting = resolveTarget(notification2.to, ctx, flat);
|
|
533
|
+
await strapi.plugin("notifier").service("notifier").send({
|
|
534
|
+
title,
|
|
535
|
+
...message !== void 0 && { message },
|
|
536
|
+
type,
|
|
537
|
+
...url !== void 0 && { url },
|
|
538
|
+
...targeting
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
const rules = ({ strapi }) => ({
|
|
542
|
+
setup(rules2) {
|
|
543
|
+
for (const rule of rules2) {
|
|
544
|
+
if (rule.on === "lifecycle") {
|
|
545
|
+
const { model, action, when, notification: notification2 } = rule;
|
|
546
|
+
strapi.db.lifecycles.subscribe({
|
|
547
|
+
models: [model],
|
|
548
|
+
async [action](event) {
|
|
549
|
+
try {
|
|
550
|
+
const ctx = {
|
|
551
|
+
entry: event.result ?? event.params?.data ?? {},
|
|
552
|
+
params: event.params,
|
|
553
|
+
model
|
|
554
|
+
};
|
|
555
|
+
if (when && !when(ctx)) return;
|
|
556
|
+
await fire(strapi, notification2, ctx);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
strapi.log.error(`[notifier] lifecycle rule ${model}:${action} failed — ${err}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
} else if (rule.on === "event") {
|
|
563
|
+
const { event, filter, when, notification: notification2 } = rule;
|
|
564
|
+
strapi.eventHub.on(event, async (data) => {
|
|
565
|
+
try {
|
|
566
|
+
if (filter) {
|
|
567
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
568
|
+
if (data[k] !== v) return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const ctx = { event, ...data };
|
|
572
|
+
if (when && !when(ctx)) return;
|
|
573
|
+
await fire(strapi, notification2, ctx);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
strapi.log.error(`[notifier] event rule "${event}" failed — ${err}`);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
} else if (rule.on === "cron") {
|
|
579
|
+
const { schedule, notification: notification2 } = rule;
|
|
580
|
+
const key = `notifierCron_${Math.random().toString(36).slice(2, 9)}`;
|
|
581
|
+
strapi.cron.add({
|
|
582
|
+
[key]: {
|
|
583
|
+
task: async ({ strapi: s }) => {
|
|
584
|
+
try {
|
|
585
|
+
const ctx = { scheduledAt: /* @__PURE__ */ new Date() };
|
|
586
|
+
await fire(s, notification2, ctx);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
strapi.log.error(`[notifier] cron rule "${schedule}" failed — ${err}`);
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
options: { rule: schedule }
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
const services = { notification: notification$2, notifier, settings: settings$2, preference: preference$2, rules };
|
|
495
599
|
const getUserRoleCodes = async (strapi, userId) => {
|
|
496
600
|
const user = await strapi.db.query("admin::user").findOne({
|
|
497
601
|
where: { id: userId },
|
package/dist/server/index.mjs
CHANGED
|
@@ -13,19 +13,6 @@ const register = async ({ strapi }) => {
|
|
|
13
13
|
pluginName: "notifier"
|
|
14
14
|
}
|
|
15
15
|
]);
|
|
16
|
-
strapi.cron.add({
|
|
17
|
-
notifierCleanup: {
|
|
18
|
-
task: async ({ strapi: s }) => {
|
|
19
|
-
const settings2 = await s.plugin("notifier").service("settings").getEffective();
|
|
20
|
-
const { maxDays, maxPerUser } = settings2.retention;
|
|
21
|
-
await s.plugin("notifier").service("notification").cleanupOld(maxDays, maxPerUser);
|
|
22
|
-
},
|
|
23
|
-
options: {
|
|
24
|
-
rule: "0 3 * * *"
|
|
25
|
-
// 3 AM daily — configurable in cron settings if needed
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
16
|
};
|
|
30
17
|
const bootstrap = async ({ strapi }) => {
|
|
31
18
|
const stored = await strapi.store({ environment: "", type: "plugin", name: "notifier" }).get({ key: "settings" });
|
|
@@ -33,6 +20,21 @@ const bootstrap = async ({ strapi }) => {
|
|
|
33
20
|
const effective = await strapi.plugin("notifier").service("settings").getEffective();
|
|
34
21
|
await strapi.store({ environment: "", type: "plugin", name: "notifier" }).set({ key: "settings", value: effective });
|
|
35
22
|
}
|
|
23
|
+
const pluginConfig = strapi.config.get("plugin::notifier", {});
|
|
24
|
+
const rules2 = pluginConfig.rules ?? [];
|
|
25
|
+
if (rules2.length > 0) {
|
|
26
|
+
strapi.plugin("notifier").service("rules").setup(rules2);
|
|
27
|
+
}
|
|
28
|
+
const { retention } = await strapi.plugin("notifier").service("settings").getEffective();
|
|
29
|
+
strapi.cron.add({
|
|
30
|
+
notifierCleanup: {
|
|
31
|
+
task: async ({ strapi: s }) => {
|
|
32
|
+
const s_retention = (await s.plugin("notifier").service("settings").getEffective()).retention;
|
|
33
|
+
await s.plugin("notifier").service("notification").cleanupOld(s_retention.maxDays, s_retention.maxPerUser);
|
|
34
|
+
},
|
|
35
|
+
options: { rule: retention.cleanupCron }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
36
38
|
};
|
|
37
39
|
const kind$1 = "collectionType";
|
|
38
40
|
const collectionName$1 = "notifier_notifications";
|
|
@@ -339,7 +341,8 @@ const notifier = ({ strapi }) => {
|
|
|
339
341
|
const DEFAULT_SETTINGS = {
|
|
340
342
|
retention: {
|
|
341
343
|
maxDays: 90,
|
|
342
|
-
maxPerUser: 500
|
|
344
|
+
maxPerUser: 500,
|
|
345
|
+
cleanupCron: "0 3 * * *"
|
|
343
346
|
},
|
|
344
347
|
delivery: {
|
|
345
348
|
defaultRecipient: "broadcast",
|
|
@@ -490,7 +493,108 @@ const preference$2 = ({ strapi }) => ({
|
|
|
490
493
|
return pref.globalOptOut || pref.mutedTypes.includes(type);
|
|
491
494
|
}
|
|
492
495
|
});
|
|
493
|
-
|
|
496
|
+
function getByPath(obj, path) {
|
|
497
|
+
return path.split(".").reduce((acc, key) => {
|
|
498
|
+
if (acc == null || typeof acc !== "object") return void 0;
|
|
499
|
+
return acc[key];
|
|
500
|
+
}, obj);
|
|
501
|
+
}
|
|
502
|
+
function interpolate(template, ctx) {
|
|
503
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (_, path) => {
|
|
504
|
+
const val = getByPath(ctx, path.trim());
|
|
505
|
+
return val == null ? "" : String(val);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
function resolveStr(tpl, ctx, flat) {
|
|
509
|
+
return typeof tpl === "function" ? tpl(ctx) : interpolate(tpl, flat);
|
|
510
|
+
}
|
|
511
|
+
function resolve(tpl, ctx) {
|
|
512
|
+
return typeof tpl === "function" ? tpl(ctx) : tpl;
|
|
513
|
+
}
|
|
514
|
+
function resolveTarget(tpl, ctx, flat) {
|
|
515
|
+
const target = typeof tpl === "function" ? tpl(ctx) : tpl;
|
|
516
|
+
if (!target || target === "broadcast") return {};
|
|
517
|
+
if ("userId" in target) return { to: { userId: target.userId } };
|
|
518
|
+
if ("role" in target) return { to: { role: target.role } };
|
|
519
|
+
if ("userIdFrom" in target) {
|
|
520
|
+
const val = getByPath(flat, target.userIdFrom);
|
|
521
|
+
if (val != null) return { to: { userId: Number(val) } };
|
|
522
|
+
}
|
|
523
|
+
return {};
|
|
524
|
+
}
|
|
525
|
+
async function fire(strapi, notification2, ctx) {
|
|
526
|
+
const flat = ctx;
|
|
527
|
+
const title = resolveStr(notification2.title, ctx, flat);
|
|
528
|
+
const message = notification2.message ? resolveStr(notification2.message, ctx, flat) : void 0;
|
|
529
|
+
const type = resolve(notification2.type, ctx) ?? "info";
|
|
530
|
+
const url = notification2.url ? resolveStr(notification2.url, ctx, flat) : void 0;
|
|
531
|
+
const targeting = resolveTarget(notification2.to, ctx, flat);
|
|
532
|
+
await strapi.plugin("notifier").service("notifier").send({
|
|
533
|
+
title,
|
|
534
|
+
...message !== void 0 && { message },
|
|
535
|
+
type,
|
|
536
|
+
...url !== void 0 && { url },
|
|
537
|
+
...targeting
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const rules = ({ strapi }) => ({
|
|
541
|
+
setup(rules2) {
|
|
542
|
+
for (const rule of rules2) {
|
|
543
|
+
if (rule.on === "lifecycle") {
|
|
544
|
+
const { model, action, when, notification: notification2 } = rule;
|
|
545
|
+
strapi.db.lifecycles.subscribe({
|
|
546
|
+
models: [model],
|
|
547
|
+
async [action](event) {
|
|
548
|
+
try {
|
|
549
|
+
const ctx = {
|
|
550
|
+
entry: event.result ?? event.params?.data ?? {},
|
|
551
|
+
params: event.params,
|
|
552
|
+
model
|
|
553
|
+
};
|
|
554
|
+
if (when && !when(ctx)) return;
|
|
555
|
+
await fire(strapi, notification2, ctx);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
strapi.log.error(`[notifier] lifecycle rule ${model}:${action} failed — ${err}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
} else if (rule.on === "event") {
|
|
562
|
+
const { event, filter, when, notification: notification2 } = rule;
|
|
563
|
+
strapi.eventHub.on(event, async (data) => {
|
|
564
|
+
try {
|
|
565
|
+
if (filter) {
|
|
566
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
567
|
+
if (data[k] !== v) return;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const ctx = { event, ...data };
|
|
571
|
+
if (when && !when(ctx)) return;
|
|
572
|
+
await fire(strapi, notification2, ctx);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
strapi.log.error(`[notifier] event rule "${event}" failed — ${err}`);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
} else if (rule.on === "cron") {
|
|
578
|
+
const { schedule, notification: notification2 } = rule;
|
|
579
|
+
const key = `notifierCron_${Math.random().toString(36).slice(2, 9)}`;
|
|
580
|
+
strapi.cron.add({
|
|
581
|
+
[key]: {
|
|
582
|
+
task: async ({ strapi: s }) => {
|
|
583
|
+
try {
|
|
584
|
+
const ctx = { scheduledAt: /* @__PURE__ */ new Date() };
|
|
585
|
+
await fire(s, notification2, ctx);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
strapi.log.error(`[notifier] cron rule "${schedule}" failed — ${err}`);
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
options: { rule: schedule }
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
const services = { notification: notification$2, notifier, settings: settings$2, preference: preference$2, rules };
|
|
494
598
|
const getUserRoleCodes = async (strapi, userId) => {
|
|
495
599
|
const user = await strapi.db.query("admin::user").findOne({
|
|
496
600
|
where: { id: userId },
|
package/package.json
CHANGED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import makeRulesService, { interpolate, getByPath } from '../../server/src/services/rules';
|
|
3
|
+
import type { NotifierRule } from '../../server/src/config';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const makeSend = () => vi.fn().mockResolvedValue({ id: 1 });
|
|
10
|
+
|
|
11
|
+
/** Builds a minimal strapi mock and returns it alongside a reference to `send`. */
|
|
12
|
+
const makeStrapi = (send = makeSend()) => {
|
|
13
|
+
let lifecycleCallback: ((event: any) => Promise<void>) | undefined;
|
|
14
|
+
let eventCallback: ((data: any) => Promise<void>) | undefined;
|
|
15
|
+
let cronTask: ((ctx: { strapi: any }) => Promise<void>) | undefined;
|
|
16
|
+
|
|
17
|
+
const strapi = {
|
|
18
|
+
db: {
|
|
19
|
+
lifecycles: {
|
|
20
|
+
subscribe: vi.fn((opts: any) => {
|
|
21
|
+
// Capture whichever action key is present (afterCreate, afterUpdate, …)
|
|
22
|
+
const actionKey = Object.keys(opts).find((k) => k !== 'models');
|
|
23
|
+
if (actionKey) lifecycleCallback = opts[actionKey];
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
eventHub: {
|
|
28
|
+
on: vi.fn((_event: string, cb: any) => { eventCallback = cb; }),
|
|
29
|
+
},
|
|
30
|
+
cron: {
|
|
31
|
+
add: vi.fn((tasks: any) => {
|
|
32
|
+
const key = Object.keys(tasks)[0];
|
|
33
|
+
cronTask = tasks[key].task;
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
plugin: vi.fn().mockReturnValue({
|
|
37
|
+
service: vi.fn().mockReturnValue({ send }),
|
|
38
|
+
}),
|
|
39
|
+
log: { error: vi.fn() },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return { strapi, send, getLifecycleCallback: () => lifecycleCallback, getEventCallback: () => eventCallback, getCronTask: () => cronTask };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Unit: interpolate / getByPath
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe('getByPath', () => {
|
|
50
|
+
it('resolves shallow key', () => expect(getByPath({ a: 1 }, 'a')).toBe(1));
|
|
51
|
+
it('resolves nested key', () => expect(getByPath({ a: { b: 2 } }, 'a.b')).toBe(2));
|
|
52
|
+
it('returns undefined for missing path', () => expect(getByPath({ a: 1 }, 'a.b.c')).toBeUndefined());
|
|
53
|
+
it('returns undefined for null intermediate', () => expect(getByPath({ a: null }, 'a.b')).toBeUndefined());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('interpolate', () => {
|
|
57
|
+
it('replaces a simple token', () => {
|
|
58
|
+
expect(interpolate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('replaces a nested token', () => {
|
|
62
|
+
expect(interpolate('Title: {{entry.title}}', { entry: { title: 'My Post' } })).toBe('Title: My Post');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('replaces multiple tokens', () => {
|
|
66
|
+
expect(interpolate('{{a}} and {{b}}', { a: 'foo', b: 'bar' })).toBe('foo and bar');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replaces missing token with empty string', () => {
|
|
70
|
+
expect(interpolate('{{missing}}', {})).toBe('');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('leaves non-token text unchanged', () => {
|
|
74
|
+
expect(interpolate('plain text', {})).toBe('plain text');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Lifecycle rules
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
describe('lifecycle rule', () => {
|
|
83
|
+
it('subscribes to the correct model and action', () => {
|
|
84
|
+
const { strapi } = makeStrapi();
|
|
85
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
86
|
+
|
|
87
|
+
svc.setup([{
|
|
88
|
+
on: 'lifecycle',
|
|
89
|
+
model: 'api::article.article',
|
|
90
|
+
action: 'afterCreate',
|
|
91
|
+
notification: { title: 'Created', type: 'info' },
|
|
92
|
+
}]);
|
|
93
|
+
|
|
94
|
+
expect(strapi.db.lifecycles.subscribe).toHaveBeenCalledOnce();
|
|
95
|
+
const opts = strapi.db.lifecycles.subscribe.mock.calls[0][0];
|
|
96
|
+
expect(opts.models).toEqual(['api::article.article']);
|
|
97
|
+
expect(typeof opts.afterCreate).toBe('function');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('fires send with interpolated title', async () => {
|
|
101
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
102
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
103
|
+
|
|
104
|
+
svc.setup([{
|
|
105
|
+
on: 'lifecycle',
|
|
106
|
+
model: 'api::article.article',
|
|
107
|
+
action: 'afterCreate',
|
|
108
|
+
notification: { title: 'New: {{entry.title}}', type: 'success' },
|
|
109
|
+
}]);
|
|
110
|
+
|
|
111
|
+
await getLifecycleCallback()!({ result: { title: 'Hello World' } });
|
|
112
|
+
|
|
113
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'New: Hello World', type: 'success' }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('fires send with function title', async () => {
|
|
117
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
118
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
119
|
+
|
|
120
|
+
svc.setup([{
|
|
121
|
+
on: 'lifecycle',
|
|
122
|
+
model: 'api::article.article',
|
|
123
|
+
action: 'afterCreate',
|
|
124
|
+
notification: { title: (ctx) => `Entry #${ctx.entry.id}`, type: 'info' },
|
|
125
|
+
}]);
|
|
126
|
+
|
|
127
|
+
await getLifecycleCallback()!({ result: { id: 42 } });
|
|
128
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'Entry #42' }));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('skips send when when() returns false', async () => {
|
|
132
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
133
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
134
|
+
|
|
135
|
+
svc.setup([{
|
|
136
|
+
on: 'lifecycle',
|
|
137
|
+
model: 'api::article.article',
|
|
138
|
+
action: 'afterCreate',
|
|
139
|
+
when: (ctx) => ctx.entry.published === true,
|
|
140
|
+
notification: { title: 'Published', type: 'info' },
|
|
141
|
+
}]);
|
|
142
|
+
|
|
143
|
+
await getLifecycleCallback()!({ result: { published: false } });
|
|
144
|
+
expect(send).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('fires send when when() returns true', async () => {
|
|
148
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
149
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
150
|
+
|
|
151
|
+
svc.setup([{
|
|
152
|
+
on: 'lifecycle',
|
|
153
|
+
model: 'api::article.article',
|
|
154
|
+
action: 'afterCreate',
|
|
155
|
+
when: (ctx) => ctx.entry.status === 'published',
|
|
156
|
+
notification: { title: 'Published', type: 'success' },
|
|
157
|
+
}]);
|
|
158
|
+
|
|
159
|
+
await getLifecycleCallback()!({ result: { status: 'published' } });
|
|
160
|
+
expect(send).toHaveBeenCalledOnce();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('resolves userIdFrom target from entry', async () => {
|
|
164
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
165
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
166
|
+
|
|
167
|
+
svc.setup([{
|
|
168
|
+
on: 'lifecycle',
|
|
169
|
+
model: 'api::article.article',
|
|
170
|
+
action: 'afterCreate',
|
|
171
|
+
notification: {
|
|
172
|
+
title: 'Your article was created',
|
|
173
|
+
to: { userIdFrom: 'entry.createdBy.id' },
|
|
174
|
+
},
|
|
175
|
+
}]);
|
|
176
|
+
|
|
177
|
+
await getLifecycleCallback()!({ result: { createdBy: { id: 7 } } });
|
|
178
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { userId: 7 } }));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('uses params.data as entry fallback for before-hooks', async () => {
|
|
182
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
183
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
184
|
+
|
|
185
|
+
svc.setup([{
|
|
186
|
+
on: 'lifecycle',
|
|
187
|
+
model: 'api::article.article',
|
|
188
|
+
action: 'beforeCreate',
|
|
189
|
+
notification: { title: '{{entry.title}}' },
|
|
190
|
+
}]);
|
|
191
|
+
|
|
192
|
+
// beforeCreate has no result, only params.data
|
|
193
|
+
await getLifecycleCallback()!({ params: { data: { title: 'Draft' } } });
|
|
194
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft' }));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('catches errors and logs them without throwing', async () => {
|
|
198
|
+
const send = vi.fn().mockRejectedValue(new Error('boom'));
|
|
199
|
+
const { strapi, getLifecycleCallback } = makeStrapi(send);
|
|
200
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
201
|
+
|
|
202
|
+
svc.setup([{
|
|
203
|
+
on: 'lifecycle',
|
|
204
|
+
model: 'api::article.article',
|
|
205
|
+
action: 'afterCreate',
|
|
206
|
+
notification: { title: 'Test' },
|
|
207
|
+
}]);
|
|
208
|
+
|
|
209
|
+
await expect(getLifecycleCallback()!({ result: {} })).resolves.toBeUndefined();
|
|
210
|
+
expect(strapi.log.error).toHaveBeenCalledOnce();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Event rules
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe('event rule', () => {
|
|
219
|
+
it('subscribes to the event hub with the given event name', () => {
|
|
220
|
+
const { strapi } = makeStrapi();
|
|
221
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
222
|
+
|
|
223
|
+
svc.setup([{
|
|
224
|
+
on: 'event',
|
|
225
|
+
event: 'media.upload',
|
|
226
|
+
notification: { title: 'Upload' },
|
|
227
|
+
}]);
|
|
228
|
+
|
|
229
|
+
expect(strapi.eventHub.on).toHaveBeenCalledWith('media.upload', expect.any(Function));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('fires send with interpolated title from event payload', async () => {
|
|
233
|
+
const { strapi, send, getEventCallback } = makeStrapi();
|
|
234
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
235
|
+
|
|
236
|
+
svc.setup([{
|
|
237
|
+
on: 'event',
|
|
238
|
+
event: 'entry.create',
|
|
239
|
+
notification: { title: 'New entry: {{entry.title}}', type: 'info' },
|
|
240
|
+
}]);
|
|
241
|
+
|
|
242
|
+
await getEventCallback()!({ entry: { title: 'My Post' } });
|
|
243
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'New entry: My Post' }));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('applies filter — skips when payload does not match', async () => {
|
|
247
|
+
const { strapi, send, getEventCallback } = makeStrapi();
|
|
248
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
249
|
+
|
|
250
|
+
svc.setup([{
|
|
251
|
+
on: 'event',
|
|
252
|
+
event: 'entry.create',
|
|
253
|
+
filter: { uid: 'api::article.article' },
|
|
254
|
+
notification: { title: 'Article created' },
|
|
255
|
+
}]);
|
|
256
|
+
|
|
257
|
+
await getEventCallback()!({ uid: 'api::comment.comment' });
|
|
258
|
+
expect(send).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('applies filter — fires when payload matches', async () => {
|
|
262
|
+
const { strapi, send, getEventCallback } = makeStrapi();
|
|
263
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
264
|
+
|
|
265
|
+
svc.setup([{
|
|
266
|
+
on: 'event',
|
|
267
|
+
event: 'entry.create',
|
|
268
|
+
filter: { uid: 'api::article.article' },
|
|
269
|
+
notification: { title: 'Article created' },
|
|
270
|
+
}]);
|
|
271
|
+
|
|
272
|
+
await getEventCallback()!({ uid: 'api::article.article', entry: { title: 'Hello' } });
|
|
273
|
+
expect(send).toHaveBeenCalledOnce();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('respects when() guard', async () => {
|
|
277
|
+
const { strapi, send, getEventCallback } = makeStrapi();
|
|
278
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
279
|
+
|
|
280
|
+
svc.setup([{
|
|
281
|
+
on: 'event',
|
|
282
|
+
event: 'entry.update',
|
|
283
|
+
when: (ctx) => (ctx.entry as any)?.status === 'published',
|
|
284
|
+
notification: { title: 'Published' },
|
|
285
|
+
}]);
|
|
286
|
+
|
|
287
|
+
await getEventCallback()!({ entry: { status: 'draft' } });
|
|
288
|
+
expect(send).not.toHaveBeenCalled();
|
|
289
|
+
|
|
290
|
+
await getEventCallback()!({ entry: { status: 'published' } });
|
|
291
|
+
expect(send).toHaveBeenCalledOnce();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('catches errors and logs them without throwing', async () => {
|
|
295
|
+
const send = vi.fn().mockRejectedValue(new Error('net'));
|
|
296
|
+
const { strapi, getEventCallback } = makeStrapi(send);
|
|
297
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
298
|
+
|
|
299
|
+
svc.setup([{ on: 'event', event: 'media.upload', notification: { title: 'Upload' } }]);
|
|
300
|
+
|
|
301
|
+
await expect(getEventCallback()!({})).resolves.toBeUndefined();
|
|
302
|
+
expect(strapi.log.error).toHaveBeenCalledOnce();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Cron rules
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe('cron rule', () => {
|
|
311
|
+
it('registers a cron task with the given schedule', () => {
|
|
312
|
+
const { strapi } = makeStrapi();
|
|
313
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
314
|
+
|
|
315
|
+
svc.setup([{ on: 'cron', schedule: '0 9 * * 1', notification: { title: 'Weekly' } }]);
|
|
316
|
+
|
|
317
|
+
expect(strapi.cron.add).toHaveBeenCalledOnce();
|
|
318
|
+
const tasks = strapi.cron.add.mock.calls[0][0];
|
|
319
|
+
const key = Object.keys(tasks)[0];
|
|
320
|
+
expect(tasks[key].options.rule).toBe('0 9 * * 1');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('fires send when the task runs', async () => {
|
|
324
|
+
const { strapi, send, getCronTask } = makeStrapi();
|
|
325
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
326
|
+
|
|
327
|
+
svc.setup([{
|
|
328
|
+
on: 'cron',
|
|
329
|
+
schedule: '0 9 * * 1',
|
|
330
|
+
notification: { title: 'Weekly reminder', type: 'warning', to: 'broadcast' },
|
|
331
|
+
}]);
|
|
332
|
+
|
|
333
|
+
await getCronTask()!({ strapi: strapi as any });
|
|
334
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ title: 'Weekly reminder', type: 'warning' }));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('uses distinct keys for multiple cron rules', () => {
|
|
338
|
+
const { strapi } = makeStrapi();
|
|
339
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
340
|
+
|
|
341
|
+
const rules: NotifierRule[] = [
|
|
342
|
+
{ on: 'cron', schedule: '0 9 * * 1', notification: { title: 'A' } },
|
|
343
|
+
{ on: 'cron', schedule: '0 17 * * 5', notification: { title: 'B' } },
|
|
344
|
+
];
|
|
345
|
+
svc.setup(rules);
|
|
346
|
+
|
|
347
|
+
const keys = strapi.cron.add.mock.calls.map((c: any) => Object.keys(c[0])[0]);
|
|
348
|
+
expect(new Set(keys).size).toBe(2);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('catches errors and logs them without throwing', async () => {
|
|
352
|
+
const send = vi.fn().mockRejectedValue(new Error('oops'));
|
|
353
|
+
const { strapi, getCronTask } = makeStrapi(send);
|
|
354
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
355
|
+
|
|
356
|
+
svc.setup([{ on: 'cron', schedule: '* * * * *', notification: { title: 'Fail' } }]);
|
|
357
|
+
|
|
358
|
+
await expect(getCronTask()!({ strapi: strapi as any })).resolves.toBeUndefined();
|
|
359
|
+
expect(strapi.log.error).toHaveBeenCalledOnce();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Targeting
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe('rule targeting', () => {
|
|
368
|
+
it('broadcast: sends with no `to` key', async () => {
|
|
369
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
370
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
371
|
+
|
|
372
|
+
svc.setup([{
|
|
373
|
+
on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
|
|
374
|
+
notification: { title: 'T', to: 'broadcast' },
|
|
375
|
+
}]);
|
|
376
|
+
|
|
377
|
+
await getLifecycleCallback()!({ result: {} });
|
|
378
|
+
const args = send.mock.calls[0][0];
|
|
379
|
+
expect(args.to).toBeUndefined();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('role: sends with to.role', async () => {
|
|
383
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
384
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
385
|
+
|
|
386
|
+
svc.setup([{
|
|
387
|
+
on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
|
|
388
|
+
notification: { title: 'T', to: { role: 'strapi-editor' } },
|
|
389
|
+
}]);
|
|
390
|
+
|
|
391
|
+
await getLifecycleCallback()!({ result: {} });
|
|
392
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { role: 'strapi-editor' } }));
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('userId: sends with to.userId', async () => {
|
|
396
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
397
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
398
|
+
|
|
399
|
+
svc.setup([{
|
|
400
|
+
on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
|
|
401
|
+
notification: { title: 'T', to: { userId: 3 } },
|
|
402
|
+
}]);
|
|
403
|
+
|
|
404
|
+
await getLifecycleCallback()!({ result: {} });
|
|
405
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { userId: 3 } }));
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('userIdFrom missing path: sends with no to key (falls back to broadcast)', async () => {
|
|
409
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
410
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
411
|
+
|
|
412
|
+
svc.setup([{
|
|
413
|
+
on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
|
|
414
|
+
notification: { title: 'T', to: { userIdFrom: 'entry.author.id' } },
|
|
415
|
+
}]);
|
|
416
|
+
|
|
417
|
+
await getLifecycleCallback()!({ result: {} });
|
|
418
|
+
const args = send.mock.calls[0][0];
|
|
419
|
+
expect(args.to).toBeUndefined();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('function to: resolves dynamically', async () => {
|
|
423
|
+
const { strapi, send, getLifecycleCallback } = makeStrapi();
|
|
424
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
425
|
+
|
|
426
|
+
svc.setup([{
|
|
427
|
+
on: 'lifecycle', model: 'api::x.x', action: 'afterCreate',
|
|
428
|
+
notification: {
|
|
429
|
+
title: 'T',
|
|
430
|
+
to: (ctx) => (ctx as any).entry.vip ? { role: 'strapi-super-admin' } : 'broadcast',
|
|
431
|
+
},
|
|
432
|
+
}]);
|
|
433
|
+
|
|
434
|
+
await getLifecycleCallback()!({ result: { vip: true } });
|
|
435
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: { role: 'strapi-super-admin' } }));
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Mixed rule sets
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
describe('setup with mixed rule types', () => {
|
|
444
|
+
it('wires all three trigger types independently', () => {
|
|
445
|
+
const { strapi } = makeStrapi();
|
|
446
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
447
|
+
|
|
448
|
+
const rules: NotifierRule[] = [
|
|
449
|
+
{ on: 'lifecycle', model: 'api::a.a', action: 'afterCreate', notification: { title: 'L' } },
|
|
450
|
+
{ on: 'event', event: 'media.upload', notification: { title: 'E' } },
|
|
451
|
+
{ on: 'cron', schedule: '0 3 * * *', notification: { title: 'C' } },
|
|
452
|
+
];
|
|
453
|
+
svc.setup(rules);
|
|
454
|
+
|
|
455
|
+
expect(strapi.db.lifecycles.subscribe).toHaveBeenCalledOnce();
|
|
456
|
+
expect(strapi.eventHub.on).toHaveBeenCalledOnce();
|
|
457
|
+
expect(strapi.cron.add).toHaveBeenCalledOnce();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('returns immediately without registering anything for empty rules array', () => {
|
|
461
|
+
const { strapi } = makeStrapi();
|
|
462
|
+
const svc = makeRulesService({ strapi: strapi as any });
|
|
463
|
+
|
|
464
|
+
svc.setup([]);
|
|
465
|
+
|
|
466
|
+
expect(strapi.db.lifecycles.subscribe).not.toHaveBeenCalled();
|
|
467
|
+
expect(strapi.eventHub.on).not.toHaveBeenCalled();
|
|
468
|
+
expect(strapi.cron.add).not.toHaveBeenCalled();
|
|
469
|
+
});
|
|
470
|
+
});
|