strapi-plugin-notifier 1.1.0 → 1.2.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/_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
|
@@ -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
|
+
});
|