strapi-plugin-notifier 1.0.3 → 1.1.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.
@@ -34,18 +34,18 @@ const bootstrap = async ({ strapi }) => {
34
34
  await strapi.store({ environment: "", type: "plugin", name: "notifier" }).set({ key: "settings", value: effective });
35
35
  }
36
36
  };
37
- const kind = "collectionType";
38
- const collectionName = "notifier_notifications";
39
- const info = {
37
+ const kind$1 = "collectionType";
38
+ const collectionName$1 = "notifier_notifications";
39
+ const info$1 = {
40
40
  singularName: "notification",
41
41
  pluralName: "notifications",
42
42
  displayName: "Notification",
43
43
  description: "Admin-panel notifications managed by strapi-plugin-notifier"
44
44
  };
45
- const options = {
45
+ const options$1 = {
46
46
  draftAndPublish: false
47
47
  };
48
- const pluginOptions = {
48
+ const pluginOptions$1 = {
49
49
  "content-manager": {
50
50
  visible: false
51
51
  },
@@ -53,7 +53,7 @@ const pluginOptions = {
53
53
  visible: false
54
54
  }
55
55
  };
56
- const attributes = {
56
+ const attributes$1 = {
57
57
  title: {
58
58
  type: "string",
59
59
  required: true
@@ -84,6 +84,56 @@ const attributes = {
84
84
  },
85
85
  recipientRole: {
86
86
  type: "string"
87
+ },
88
+ mergeKey: {
89
+ type: "string"
90
+ },
91
+ mergeCount: {
92
+ type: "integer",
93
+ "default": 1
94
+ }
95
+ };
96
+ const schema$1 = {
97
+ kind: kind$1,
98
+ collectionName: collectionName$1,
99
+ info: info$1,
100
+ options: options$1,
101
+ pluginOptions: pluginOptions$1,
102
+ attributes: attributes$1
103
+ };
104
+ const notification$3 = { schema: schema$1 };
105
+ const kind = "collectionType";
106
+ const collectionName = "notifier_preferences";
107
+ const info = {
108
+ singularName: "notification-preference",
109
+ pluralName: "notification-preferences",
110
+ displayName: "Notification Preference",
111
+ description: "Per-user notification opt-out preferences for strapi-plugin-notifier"
112
+ };
113
+ const options = {
114
+ draftAndPublish: false
115
+ };
116
+ const pluginOptions = {
117
+ "content-manager": {
118
+ visible: false
119
+ },
120
+ "content-type-builder": {
121
+ visible: false
122
+ }
123
+ };
124
+ const attributes = {
125
+ userId: {
126
+ type: "integer",
127
+ required: true,
128
+ unique: true
129
+ },
130
+ globalOptOut: {
131
+ type: "boolean",
132
+ "default": false,
133
+ required: true
134
+ },
135
+ mutedTypes: {
136
+ type: "json"
87
137
  }
88
138
  };
89
139
  const schema = {
@@ -94,9 +144,9 @@ const schema = {
94
144
  pluginOptions,
95
145
  attributes
96
146
  };
97
- const notification$3 = { schema };
98
- const contentTypes = { notification: notification$3 };
99
- const UID = "plugin::notifier.notification";
147
+ const notificationPreference = { schema };
148
+ const contentTypes = { notification: notification$3, "notification-preference": notificationPreference };
149
+ const UID$1 = "plugin::notifier.notification";
100
150
  const accessFilter = (userId, roleCodes = []) => ({
101
151
  $or: [
102
152
  { recipientId: userId },
@@ -104,99 +154,185 @@ const accessFilter = (userId, roleCodes = []) => ({
104
154
  { recipientId: null, recipientRole: null }
105
155
  ]
106
156
  });
107
- const notification$2 = ({ strapi }) => ({
108
- findByRecipient(userId, roleCodes, { page = 1, pageSize = 20 } = {}) {
109
- return strapi.db.query(UID).findMany({
110
- where: accessFilter(userId, roleCodes),
111
- orderBy: { createdAt: "desc" },
112
- limit: pageSize,
113
- offset: (page - 1) * pageSize
114
- });
115
- },
116
- countByRecipient(userId, roleCodes) {
117
- return strapi.db.query(UID).count({ where: accessFilter(userId, roleCodes) });
118
- },
119
- countUnread(userId, roleCodes) {
120
- return strapi.db.query(UID).count({
121
- where: { ...accessFilter(userId, roleCodes), read: false }
122
- });
123
- },
124
- async markAsRead(id, userId, roleCodes) {
125
- const existing = await strapi.db.query(UID).findOne({ where: { id, ...accessFilter(userId, roleCodes) } });
126
- if (!existing) return null;
127
- return strapi.db.query(UID).update({ where: { id }, data: { read: true } });
128
- },
129
- markAllAsRead(userId, roleCodes) {
130
- return strapi.db.query(UID).updateMany({
131
- where: { ...accessFilter(userId, roleCodes), read: false },
132
- data: { read: true }
133
- });
134
- },
135
- async delete(id, userId, roleCodes) {
136
- const existing = await strapi.db.query(UID).findOne({ where: { id, ...accessFilter(userId, roleCodes) } });
137
- if (!existing) return null;
138
- return strapi.db.query(UID).delete({ where: { id } });
139
- },
140
- clearAll(userId, roleCodes) {
141
- return strapi.db.query(UID).deleteMany({ where: accessFilter(userId, roleCodes) });
142
- },
143
- create({ title, message, type = "info", url, recipientId, recipientRole }) {
144
- return strapi.db.query(UID).create({
145
- data: { title, message, type, url, read: false, recipientId, recipientRole }
146
- });
147
- },
148
- /** Retention cleanup: remove notifications older than maxDays and enforce per-user cap. */
149
- async cleanupOld(maxDays, maxPerUser) {
150
- if (maxDays > 0) {
151
- const cutoff = /* @__PURE__ */ new Date();
152
- cutoff.setDate(cutoff.getDate() - maxDays);
153
- await strapi.db.query(UID).deleteMany({
154
- where: { createdAt: { $lt: cutoff.toISOString() } }
157
+ const notification$2 = ({ strapi }) => {
158
+ const prefSvc = () => strapi.plugin("notifier").service("preference");
159
+ const optOutFilter = (pref) => {
160
+ if (pref.mutedTypes.length === 0) return {};
161
+ return { type: { $notIn: pref.mutedTypes } };
162
+ };
163
+ return {
164
+ async findByRecipient(userId, roleCodes, { page = 1, pageSize = 20 } = {}) {
165
+ const pref = await prefSvc().get(userId);
166
+ if (pref.globalOptOut) return [];
167
+ return strapi.db.query(UID$1).findMany({
168
+ where: { ...accessFilter(userId, roleCodes), ...optOutFilter(pref) },
169
+ orderBy: { createdAt: "desc" },
170
+ limit: pageSize,
171
+ offset: (page - 1) * pageSize
155
172
  });
156
- }
157
- if (maxPerUser > 0) {
158
- const rows = await strapi.db.query(UID).findMany({
159
- select: ["recipientId"],
160
- where: { recipientId: { $notNull: true } }
173
+ },
174
+ async countByRecipient(userId, roleCodes) {
175
+ const pref = await prefSvc().get(userId);
176
+ if (pref.globalOptOut) return 0;
177
+ return strapi.db.query(UID$1).count({
178
+ where: { ...accessFilter(userId, roleCodes), ...optOutFilter(pref) }
179
+ });
180
+ },
181
+ async countUnread(userId, roleCodes) {
182
+ const pref = await prefSvc().get(userId);
183
+ if (pref.globalOptOut) return 0;
184
+ return strapi.db.query(UID$1).count({
185
+ where: { ...accessFilter(userId, roleCodes), ...optOutFilter(pref), read: false }
161
186
  });
162
- const ids = [...new Set(rows.map((r) => r.recipientId))];
163
- for (const recipientId of ids) {
164
- const total = await strapi.db.query(UID).count({ where: { recipientId } });
165
- if (total > maxPerUser) {
166
- const excess = await strapi.db.query(UID).findMany({
167
- where: { recipientId },
168
- orderBy: { createdAt: "asc" },
169
- limit: total - maxPerUser,
170
- select: ["id"]
171
- });
172
- const excessIds = excess.map((e) => e.id);
173
- await strapi.db.query(UID).deleteMany({ where: { id: { $in: excessIds } } });
187
+ },
188
+ async markAsRead(id, userId, roleCodes) {
189
+ const existing = await strapi.db.query(UID$1).findOne({ where: { id, ...accessFilter(userId, roleCodes) } });
190
+ if (!existing) return null;
191
+ return strapi.db.query(UID$1).update({ where: { id }, data: { read: true } });
192
+ },
193
+ markAllAsRead(userId, roleCodes) {
194
+ return strapi.db.query(UID$1).updateMany({
195
+ where: { ...accessFilter(userId, roleCodes), read: false },
196
+ data: { read: true }
197
+ });
198
+ },
199
+ async delete(id, userId, roleCodes) {
200
+ const existing = await strapi.db.query(UID$1).findOne({ where: { id, ...accessFilter(userId, roleCodes) } });
201
+ if (!existing) return null;
202
+ return strapi.db.query(UID$1).delete({ where: { id } });
203
+ },
204
+ clearAll(userId, roleCodes) {
205
+ return strapi.db.query(UID$1).deleteMany({ where: accessFilter(userId, roleCodes) });
206
+ },
207
+ /**
208
+ * Find a recent notification with the same merge key and recipient.
209
+ * Used by the notifier service to decide whether to merge or create.
210
+ */
211
+ findMergeCandidate(mergeKey, recipientId, recipientRole, windowMs) {
212
+ const cutoff = new Date(Date.now() - windowMs);
213
+ return strapi.db.query(UID$1).findOne({
214
+ where: {
215
+ mergeKey,
216
+ ...recipientId != null ? { recipientId } : { recipientId: null },
217
+ ...recipientRole != null ? { recipientRole } : { recipientRole: null },
218
+ createdAt: { $gt: cutoff.toISOString() }
219
+ },
220
+ orderBy: { createdAt: "desc" }
221
+ });
222
+ },
223
+ /** Increment mergeCount on an existing notification and optionally rewrite its message. */
224
+ mergeInto(id, newCount, cfg, title) {
225
+ const data = { mergeCount: newCount };
226
+ if (cfg.rewriteMessage) {
227
+ data.message = `${newCount}× ${title}`;
228
+ }
229
+ return strapi.db.query(UID$1).update({ where: { id }, data });
230
+ },
231
+ create({ title, message, type = "info", url, recipientId, recipientRole, mergeKey }) {
232
+ return strapi.db.query(UID$1).create({
233
+ data: { title, message, type, url, read: false, recipientId, recipientRole, mergeKey, mergeCount: 1 }
234
+ });
235
+ },
236
+ /** Batch-insert multiple notifications in parallel. Does NOT apply opt-out or merge — callers must handle that. */
237
+ createMany(items) {
238
+ return Promise.all(items.map((item) => this.create(item)));
239
+ },
240
+ /** Retention cleanup: remove notifications older than maxDays and enforce per-user cap. */
241
+ async cleanupOld(maxDays, maxPerUser) {
242
+ if (maxDays > 0) {
243
+ const cutoff = /* @__PURE__ */ new Date();
244
+ cutoff.setDate(cutoff.getDate() - maxDays);
245
+ await strapi.db.query(UID$1).deleteMany({
246
+ where: { createdAt: { $lt: cutoff.toISOString() } }
247
+ });
248
+ }
249
+ if (maxPerUser > 0) {
250
+ const rows = await strapi.db.query(UID$1).findMany({
251
+ select: ["recipientId"],
252
+ where: { recipientId: { $notNull: true } }
253
+ });
254
+ const ids = [...new Set(rows.map((r) => r.recipientId))];
255
+ for (const recipientId of ids) {
256
+ const total = await strapi.db.query(UID$1).count({ where: { recipientId } });
257
+ if (total > maxPerUser) {
258
+ const excess = await strapi.db.query(UID$1).findMany({
259
+ where: { recipientId },
260
+ orderBy: { createdAt: "asc" },
261
+ limit: total - maxPerUser,
262
+ select: ["id"]
263
+ });
264
+ const excessIds = excess.map((e) => e.id);
265
+ await strapi.db.query(UID$1).deleteMany({ where: { id: { $in: excessIds } } });
266
+ }
174
267
  }
175
268
  }
176
269
  }
177
- }
178
- });
270
+ };
271
+ };
179
272
  const notifier = ({ strapi }) => {
180
273
  const svc = () => strapi.plugin("notifier").service("notification");
181
- return {
182
- send({ title, message, type, url, to }) {
274
+ const prefSvc = () => strapi.plugin("notifier").service("preference");
275
+ const settingsSvc = () => strapi.plugin("notifier").service("settings");
276
+ const computeMergeKey = (opts, keyFields) => keyFields.map((f) => opts[f] ?? "").join("\0");
277
+ const applySend = async (opts, mergeCfg) => {
278
+ const recipientId = opts.to?.userId ?? null;
279
+ const recipientRole = opts.to?.role ?? null;
280
+ const type = opts.type ?? "info";
281
+ if (recipientId != null) {
282
+ const pref = await prefSvc().get(recipientId);
283
+ if (prefSvc().isOptedOut(pref, type)) return null;
284
+ }
285
+ if (mergeCfg.enabled) {
286
+ const mergeKey = computeMergeKey(opts, mergeCfg.keyFields);
287
+ const windowMs = mergeCfg.windowMinutes * 60 * 1e3;
288
+ const candidate = await svc().findMergeCandidate(mergeKey, recipientId, recipientRole, windowMs);
289
+ if (candidate) {
290
+ const newCount = (candidate.mergeCount ?? 1) + 1;
291
+ return svc().mergeInto(candidate.id, newCount, mergeCfg, opts.title);
292
+ }
183
293
  return svc().create({
184
- title,
185
- message,
294
+ title: opts.title,
295
+ message: opts.message,
186
296
  type,
187
- url,
188
- recipientId: to?.userId,
189
- recipientRole: to?.role
297
+ url: opts.url,
298
+ recipientId: recipientId ?? void 0,
299
+ recipientRole: recipientRole ?? void 0,
300
+ mergeKey
190
301
  });
302
+ }
303
+ return svc().create({
304
+ title: opts.title,
305
+ message: opts.message,
306
+ type,
307
+ url: opts.url,
308
+ recipientId: recipientId ?? void 0,
309
+ recipientRole: recipientRole ?? void 0
310
+ });
311
+ };
312
+ return {
313
+ async send(opts) {
314
+ const { merge } = await settingsSvc().getEffective();
315
+ return applySend(opts, merge);
316
+ },
317
+ async broadcast(opts) {
318
+ const { merge } = await settingsSvc().getEffective();
319
+ return applySend(opts, merge);
191
320
  },
192
- broadcast(opts) {
193
- return svc().create({ ...opts });
321
+ async toRole(role, opts) {
322
+ const { merge } = await settingsSvc().getEffective();
323
+ return applySend({ ...opts, to: { role } }, merge);
194
324
  },
195
- toRole(role, opts) {
196
- return svc().create({ ...opts, recipientRole: role });
325
+ async toUser(userId, opts) {
326
+ const { merge } = await settingsSvc().getEffective();
327
+ return applySend({ ...opts, to: { userId } }, merge);
197
328
  },
198
- toUser(userId, opts) {
199
- return svc().create({ ...opts, recipientId: userId });
329
+ /**
330
+ * Send multiple notifications in one call. Opt-out and merge are applied per item.
331
+ * Settings are fetched once and reused across all items.
332
+ */
333
+ async sendBatch(items) {
334
+ const { merge } = await settingsSvc().getEffective();
335
+ return Promise.all(items.map((item) => applySend(item, merge)));
200
336
  }
201
337
  };
202
338
  };
@@ -209,6 +345,13 @@ const DEFAULT_SETTINGS = {
209
345
  defaultRecipient: "broadcast",
210
346
  allowedRoles: []
211
347
  },
348
+ merge: {
349
+ enabled: false,
350
+ windowMinutes: 60,
351
+ keyFields: ["title", "type"],
352
+ countBadge: true,
353
+ rewriteMessage: false
354
+ },
212
355
  ui: {
213
356
  pollInterval: 3e4,
214
357
  pageSize: 20,
@@ -231,6 +374,7 @@ const DEFAULT_SETTINGS = {
231
374
  const mergeWithDefaults = (overrides = {}) => ({
232
375
  retention: { ...DEFAULT_SETTINGS.retention, ...overrides.retention ?? {} },
233
376
  delivery: { ...DEFAULT_SETTINGS.delivery, ...overrides.delivery ?? {} },
377
+ merge: { ...DEFAULT_SETTINGS.merge, ...overrides.merge ?? {} },
234
378
  ui: {
235
379
  ...DEFAULT_SETTINGS.ui,
236
380
  ...overrides.ui ?? {},
@@ -262,6 +406,7 @@ const settings$2 = ({ strapi }) => ({
262
406
  return {
263
407
  retention: { ...withPluginsConfig.retention, ...fromStore.retention ?? {} },
264
408
  delivery: { ...withPluginsConfig.delivery, ...fromStore.delivery ?? {} },
409
+ merge: { ...withPluginsConfig.merge, ...fromStore.merge ?? {} },
265
410
  ui: {
266
411
  ...withPluginsConfig.ui,
267
412
  ...fromStore.ui ?? {},
@@ -283,6 +428,7 @@ const settings$2 = ({ strapi }) => ({
283
428
  const next = {
284
429
  retention: { ...current.retention, ...patch.retention ?? {} },
285
430
  delivery: { ...current.delivery, ...patch.delivery ?? {} },
431
+ merge: { ...current.merge, ...patch.merge ?? {} },
286
432
  ui: {
287
433
  ...current.ui,
288
434
  ...patch.ui ?? {},
@@ -302,7 +448,49 @@ const settings$2 = ({ strapi }) => ({
302
448
  await getStore(strapi).delete({ key: STORE_KEY });
303
449
  }
304
450
  });
305
- const services = { notification: notification$2, notifier, settings: settings$2 };
451
+ const UID = "plugin::notifier.notification-preference";
452
+ const DEFAULT_PREF = (userId) => ({
453
+ userId,
454
+ globalOptOut: false,
455
+ mutedTypes: []
456
+ });
457
+ const preference$2 = ({ strapi }) => ({
458
+ async get(userId) {
459
+ const row = await strapi.db.query(UID).findOne({ where: { userId } });
460
+ if (!row) return DEFAULT_PREF(userId);
461
+ return {
462
+ userId: row.userId,
463
+ globalOptOut: row.globalOptOut ?? false,
464
+ mutedTypes: row.mutedTypes ?? []
465
+ };
466
+ },
467
+ async upsert(userId, patch) {
468
+ const existing = await strapi.db.query(UID).findOne({ where: { userId } });
469
+ if (existing) {
470
+ const updated = await strapi.db.query(UID).update({
471
+ where: { id: existing.id },
472
+ data: patch
473
+ });
474
+ return {
475
+ userId: updated.userId,
476
+ globalOptOut: updated.globalOptOut ?? false,
477
+ mutedTypes: updated.mutedTypes ?? []
478
+ };
479
+ }
480
+ const created = await strapi.db.query(UID).create({
481
+ data: { userId, globalOptOut: false, mutedTypes: [], ...patch }
482
+ });
483
+ return {
484
+ userId: created.userId,
485
+ globalOptOut: created.globalOptOut ?? false,
486
+ mutedTypes: created.mutedTypes ?? []
487
+ };
488
+ },
489
+ isOptedOut(pref, type) {
490
+ return pref.globalOptOut || pref.mutedTypes.includes(type);
491
+ }
492
+ });
493
+ const services = { notification: notification$2, notifier, settings: settings$2, preference: preference$2 };
306
494
  const getUserRoleCodes = async (strapi, userId) => {
307
495
  const user = await strapi.db.query("admin::user").findOne({
308
496
  where: { id: userId },
@@ -383,7 +571,25 @@ const settings$1 = ({ strapi }) => ({
383
571
  ctx.body = { data: defaults };
384
572
  }
385
573
  });
386
- const controllers = { notification: notification$1, config: config$1, settings: settings$1 };
574
+ const preference$1 = ({ strapi }) => ({
575
+ async findMine(ctx) {
576
+ const adminUser = ctx.state.user;
577
+ if (!adminUser) return ctx.unauthorized();
578
+ const pref = await strapi.plugin("notifier").service("preference").get(adminUser.id);
579
+ ctx.body = { data: pref };
580
+ },
581
+ async updateMine(ctx) {
582
+ const adminUser = ctx.state.user;
583
+ if (!adminUser) return ctx.unauthorized();
584
+ const { globalOptOut, mutedTypes } = ctx.request.body;
585
+ const patch = {};
586
+ if (typeof globalOptOut === "boolean") patch.globalOptOut = globalOptOut;
587
+ if (Array.isArray(mutedTypes)) patch.mutedTypes = mutedTypes;
588
+ const updated = await strapi.plugin("notifier").service("preference").upsert(adminUser.id, patch);
589
+ ctx.body = { data: updated };
590
+ }
591
+ });
592
+ const controllers = { notification: notification$1, config: config$1, settings: settings$1, preference: preference$1 };
387
593
  const notification = {
388
594
  type: "admin",
389
595
  routes: [
@@ -453,7 +659,24 @@ const settings = {
453
659
  }
454
660
  ]
455
661
  };
456
- const adminRoutes = { notification, config, settings };
662
+ const preference = {
663
+ type: "admin",
664
+ routes: [
665
+ {
666
+ method: "GET",
667
+ path: "/preferences/me",
668
+ handler: "preference.findMine",
669
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
670
+ },
671
+ {
672
+ method: "PUT",
673
+ path: "/preferences/me",
674
+ handler: "preference.updateMine",
675
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
676
+ }
677
+ ]
678
+ };
679
+ const adminRoutes = { notification, config, settings, preference };
457
680
  const index = {
458
681
  register,
459
682
  bootstrap,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-notifier",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "A highly configurable notification inbox plugin for the Strapi v5 admin panel",
5
5
  "keywords": [
6
6
  "strapi",
@@ -41,6 +41,8 @@
41
41
  "watch": "npx strapi-plugin watch",
42
42
  "watch:link": "npx strapi-plugin watch:link",
43
43
  "verify": "npx strapi-plugin verify",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
44
46
  "prepublishOnly": "npm run build"
45
47
  },
46
48
  "devDependencies": {
@@ -58,7 +60,8 @@
58
60
  "react-dom": "^18.0.0",
59
61
  "react-router-dom": "^6.0.0",
60
62
  "styled-components": "^6.4.2",
61
- "typescript": "^5.0.0"
63
+ "typescript": "^5.0.0",
64
+ "vitest": "^4.1.8"
62
65
  },
63
66
  "peerDependencies": {
64
67
  "@strapi/admin": "^5.0.0",
@@ -0,0 +1,145 @@
1
+ import { vi } from 'vitest';
2
+ import type { NotificationType } from '../server/src/config';
3
+ import type { UserPreference } from '../server/src/services/preference';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // DB query mock
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const makeDbQuery = (overrides: Record<string, unknown> = {}) => ({
10
+ findOne: vi.fn().mockResolvedValue(null),
11
+ findMany: vi.fn().mockResolvedValue([]),
12
+ create: vi.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
13
+ Promise.resolve({ id: 1, ...data })
14
+ ),
15
+ update: vi.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
16
+ Promise.resolve({ id: 1, ...data })
17
+ ),
18
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
19
+ delete: vi.fn().mockResolvedValue({ id: 1 }),
20
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
21
+ count: vi.fn().mockResolvedValue(0),
22
+ ...overrides,
23
+ });
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Preference helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const makeDefaultPref = (overrides: Partial<UserPreference> = {}): UserPreference => ({
30
+ userId: 1,
31
+ globalOptOut: false,
32
+ mutedTypes: [],
33
+ ...overrides,
34
+ });
35
+
36
+ export const makePrefService = (prefOverrides: Partial<UserPreference> = {}) => {
37
+ const pref = makeDefaultPref(prefOverrides);
38
+ return {
39
+ get: vi.fn().mockResolvedValue(pref),
40
+ isOptedOut: (p: UserPreference, type: NotificationType) =>
41
+ p.globalOptOut || p.mutedTypes.includes(type),
42
+ upsert: vi.fn().mockResolvedValue(pref),
43
+ };
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Notification service mock (for notifier tests)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export const makeNotificationService = (overrides: Record<string, unknown> = {}) => ({
51
+ create: vi.fn().mockResolvedValue({ id: 1, mergeCount: 1 }),
52
+ createMany: vi.fn().mockResolvedValue([{ id: 1 }]),
53
+ findMergeCandidate: vi.fn().mockResolvedValue(null),
54
+ mergeInto: vi.fn().mockResolvedValue({ id: 1, mergeCount: 2 }),
55
+ findByRecipient: vi.fn().mockResolvedValue([]),
56
+ countByRecipient: vi.fn().mockResolvedValue(0),
57
+ countUnread: vi.fn().mockResolvedValue(0),
58
+ markAsRead: vi.fn().mockResolvedValue({ id: 1, read: true }),
59
+ markAllAsRead: vi.fn().mockResolvedValue({}),
60
+ delete: vi.fn().mockResolvedValue({ id: 1 }),
61
+ clearAll: vi.fn().mockResolvedValue({}),
62
+ ...overrides,
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Settings mock
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export const makeSettingsService = (overrides: Record<string, unknown> = {}) => ({
70
+ getEffective: vi.fn().mockResolvedValue({
71
+ retention: { maxDays: 90, maxPerUser: 500 },
72
+ delivery: { defaultRecipient: 'broadcast', allowedRoles: [] },
73
+ merge: {
74
+ enabled: false,
75
+ windowMinutes: 60,
76
+ keyFields: ['title', 'type'],
77
+ countBadge: true,
78
+ rewriteMessage: false,
79
+ },
80
+ ui: {
81
+ pollInterval: 30000,
82
+ pageSize: 20,
83
+ defaultFilter: 'all',
84
+ badge: { enabled: true, color: '#ee5e52' },
85
+ theme: {
86
+ bellSize: '1.5em',
87
+ accent: { info: '#4945ff', success: '#328048', warning: '#d97706', error: '#d02b20' },
88
+ },
89
+ },
90
+ ...overrides,
91
+ }),
92
+ get: vi.fn(),
93
+ update: vi.fn(),
94
+ reset: vi.fn(),
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Strapi instance mocks
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Minimal strapi mock for notification service tests. */
102
+ export const makeNotificationStrapi = (
103
+ dbOverrides: Record<string, unknown> = {},
104
+ prefOverrides: Partial<UserPreference> = {}
105
+ ) => {
106
+ const db = makeDbQuery(dbOverrides);
107
+ const prefSvc = makePrefService(prefOverrides);
108
+ const strapi = {
109
+ db: { query: vi.fn().mockReturnValue(db) },
110
+ plugin: vi.fn().mockReturnValue({ service: vi.fn().mockReturnValue(prefSvc) }),
111
+ };
112
+ return { strapi, _db: db, _prefSvc: prefSvc };
113
+ };
114
+
115
+ /** Minimal strapi mock for preference service tests. */
116
+ export const makePreferenceStrapi = (dbOverrides: Record<string, unknown> = {}) => {
117
+ const db = makeDbQuery(dbOverrides);
118
+ const strapi = { db: { query: vi.fn().mockReturnValue(db) } };
119
+ return { strapi, _db: db };
120
+ };
121
+
122
+ /** Minimal strapi mock for notifier service tests. */
123
+ export const makeNotifierStrapi = (
124
+ notifOverrides: Record<string, unknown> = {},
125
+ prefOverrides: Partial<UserPreference> = {},
126
+ settingsOverrides: Record<string, unknown> = {}
127
+ ) => {
128
+ const notifSvc = makeNotificationService(notifOverrides);
129
+ const prefSvc = makePrefService(prefOverrides);
130
+ const settSvc = makeSettingsService(settingsOverrides);
131
+
132
+ const serviceMap: Record<string, unknown> = {
133
+ notification: notifSvc,
134
+ preference: prefSvc,
135
+ settings: settSvc,
136
+ };
137
+
138
+ const strapi = {
139
+ plugin: vi.fn().mockReturnValue({
140
+ service: vi.fn().mockImplementation((name: string) => serviceMap[name]),
141
+ }),
142
+ };
143
+
144
+ return { strapi, _notifSvc: notifSvc, _prefSvc: prefSvc, _settSvc: settSvc };
145
+ };