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