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.
- package/CHANGELOG.md +36 -0
- package/README.md +130 -20
- package/dist/_chunks/{Index-C5mgbISF.js → Index-DVk2enQ2.js} +19 -1
- package/dist/_chunks/{Index-DOQrGurB.mjs → Index-_PTtaZsz.mjs} +19 -1
- package/dist/_chunks/{SettingsPage-CRsuB4cw.mjs → SettingsPage-CuTIslG0.mjs} +94 -2
- package/dist/_chunks/{SettingsPage-Cft7agRa.js → SettingsPage-DYxNBA3h.js} +92 -0
- package/dist/_chunks/{index-7hrPEwa_.mjs → index-BgPnE501.mjs} +2 -2
- package/dist/_chunks/{index-DrwLcZBZ.js → index-CKh_iBP2.js} +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +315 -92
- package/dist/server/index.mjs +315 -92
- package/package.json +5 -2
- package/tests/helpers.ts +145 -0
- package/tests/unit/config.test.ts +64 -0
- package/tests/unit/notification.test.ts +263 -0
- package/tests/unit/notifier.test.ts +348 -0
- package/tests/unit/preference.test.ts +122 -0
- package/vitest.config.ts +9 -0
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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:
|
|
189
|
-
recipientRole:
|
|
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
|
-
|
|
193
|
-
|
|
321
|
+
async toRole(role, opts) {
|
|
322
|
+
const { merge } = await settingsSvc().getEffective();
|
|
323
|
+
return applySend({ ...opts, to: { role } }, merge);
|
|
194
324
|
},
|
|
195
|
-
|
|
196
|
-
|
|
325
|
+
async toUser(userId, opts) {
|
|
326
|
+
const { merge } = await settingsSvc().getEffective();
|
|
327
|
+
return applySend({ ...opts, to: { userId } }, merge);
|
|
197
328
|
},
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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",
|
package/tests/helpers.ts
ADDED
|
@@ -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
|
+
};
|