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.
@@ -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-BgPnE501.mjs";
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: notification.url ? "pointer" : "default",
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", style: { marginTop: 2 }, children: notification.message }),
226
- /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral400", style: { marginTop: 4 }, children: new Date(notification.createdAt).toLocaleString() })
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-CKh_iBP2.js");
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: notification.url ? "pointer" : "default",
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", style: { marginTop: 2 }, children: notification.message }),
228
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral400", style: { marginTop: 4 }, children: new Date(notification.createdAt).toLocaleString() })
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, TextInput } from "@strapi/design-system";
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-DVk2enQ2.js"))
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-DYxNBA3h.js")),
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-_PTtaZsz.mjs")
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-CuTIslG0.mjs"),
197
+ Component: () => import("./SettingsPage-DVaHqffU.mjs"),
198
198
  permissions: [
199
199
  { action: `plugin::${pluginId}.settings.read`, subject: null }
200
200
  ]
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-CKh_iBP2.js");
2
+ const index = require("../_chunks/index-BMiYJk-5.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-BgPnE501.mjs";
1
+ import { i } from "../_chunks/index-BauNAWBC.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -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
- const services = { notification: notification$2, notifier, settings: settings$2, preference: preference$2 };
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 },
@@ -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
- const services = { notification: notification$2, notifier, settings: settings$2, preference: preference$2 };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-notifier",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A highly configurable notification inbox plugin for the Strapi v5 admin panel",
5
5
  "keywords": [
6
6
  "strapi",
@@ -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
+ });