nodebb-plugin-internalnotes 1.0.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/.github/workflows/publish-npm.yml +33 -0
- package/LICENSE +21 -0
- package/NODEBB_STANDARDS_AUDIT.md +153 -0
- package/README.md +104 -0
- package/eslint.config.mjs +55 -0
- package/languages/en-GB/internalnotes.json +33 -0
- package/lib/controllers.js +9 -0
- package/library.js +434 -0
- package/package.json +32 -0
- package/plugin.json +53 -0
- package/public/lib/acp-main.js +5 -0
- package/public/lib/admin.js +11 -0
- package/public/lib/main.js +631 -0
- package/scss/internalnotes.scss +181 -0
- package/templates/admin/plugins/internalnotes.tpl +44 -0
package/library.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = require.main.require('./src/database');
|
|
4
|
+
const user = require.main.require('./src/user');
|
|
5
|
+
const groups = require.main.require('./src/groups');
|
|
6
|
+
const meta = require.main.require('./src/meta');
|
|
7
|
+
const notifications = require.main.require('./src/notifications');
|
|
8
|
+
const routeHelpers = require.main.require('./src/routes/helpers');
|
|
9
|
+
const controllerHelpers = require.main.require('./src/controllers/helpers');
|
|
10
|
+
const topics = require.main.require('./src/topics');
|
|
11
|
+
const privileges = require.main.require('./src/privileges');
|
|
12
|
+
const pagination = require.main.require('./src/pagination');
|
|
13
|
+
const helpers = require.main.require('./src/controllers/helpers');
|
|
14
|
+
|
|
15
|
+
const controllers = require('./lib/controllers');
|
|
16
|
+
|
|
17
|
+
const plugin = {};
|
|
18
|
+
|
|
19
|
+
plugin.init = async (params) => {
|
|
20
|
+
const { router } = params;
|
|
21
|
+
const middleware = require.main.require('./src/middleware');
|
|
22
|
+
routeHelpers.setupAdminPageRoute(router, '/admin/plugins/internalnotes', controllers.renderAdminPage);
|
|
23
|
+
routeHelpers.setupPageRoute(router, '/assigned', [middleware.ensureLoggedIn], plugin.renderAssignedPage);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
plugin.addRoutes = async ({ router, middleware, helpers }) => {
|
|
27
|
+
const ensurePrivileged = async (req, res, next) => {
|
|
28
|
+
const allowed = await canViewNotes(req.uid);
|
|
29
|
+
if (!allowed) {
|
|
30
|
+
return controllerHelpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]'));
|
|
31
|
+
}
|
|
32
|
+
next();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// --- Assignment routes (registered before /:noteId to avoid param collision) ---
|
|
36
|
+
|
|
37
|
+
routeHelpers.setupApiRoute(router, 'get', '/internalnotes/:tid/assign', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
38
|
+
const assignee = await getAssignee(req.params.tid);
|
|
39
|
+
helpers.formatApiResponse(200, res, { assignee });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
routeHelpers.setupApiRoute(router, 'put', '/internalnotes/:tid/assign', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
43
|
+
const { type, id } = req.body;
|
|
44
|
+
if (!type || !id) {
|
|
45
|
+
return helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]'));
|
|
46
|
+
}
|
|
47
|
+
const assignee = await assignTopic(req.params.tid, type, id, req.uid);
|
|
48
|
+
helpers.formatApiResponse(200, res, { assignee });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
routeHelpers.setupApiRoute(router, 'delete', '/internalnotes/:tid/assign', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
52
|
+
await unassignTopic(req.params.tid);
|
|
53
|
+
helpers.formatApiResponse(200, res, {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- Group search route ---
|
|
57
|
+
|
|
58
|
+
routeHelpers.setupApiRoute(router, 'get', '/internalnotes/groups/search', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
59
|
+
const query = (req.query.query || '').trim();
|
|
60
|
+
if (query.length < 1) {
|
|
61
|
+
return helpers.formatApiResponse(200, res, { groups: [] });
|
|
62
|
+
}
|
|
63
|
+
const groupList = await groups.search(query, { sort: 'count', filterHidden: true });
|
|
64
|
+
const results = groupList
|
|
65
|
+
.filter(g => g && !groups.isPrivilegeGroup(g.name))
|
|
66
|
+
.slice(0, 15)
|
|
67
|
+
.map(g => ({
|
|
68
|
+
name: g.name,
|
|
69
|
+
slug: g.slug,
|
|
70
|
+
memberCount: g.memberCount,
|
|
71
|
+
icon: g.icon || '',
|
|
72
|
+
labelColor: g.labelColor || '',
|
|
73
|
+
}));
|
|
74
|
+
helpers.formatApiResponse(200, res, { groups: results });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- Notes routes ---
|
|
78
|
+
|
|
79
|
+
routeHelpers.setupApiRoute(router, 'get', '/internalnotes/:tid', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
80
|
+
const notes = await getNotes(req.params.tid);
|
|
81
|
+
helpers.formatApiResponse(200, res, { notes });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
routeHelpers.setupApiRoute(router, 'post', '/internalnotes/:tid', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
85
|
+
const { content } = req.body;
|
|
86
|
+
if (!content || !content.trim()) {
|
|
87
|
+
return helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]'));
|
|
88
|
+
}
|
|
89
|
+
const note = await createNote(req.params.tid, req.uid, content.trim());
|
|
90
|
+
helpers.formatApiResponse(200, res, { note });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
routeHelpers.setupApiRoute(router, 'delete', '/internalnotes/:tid/:noteId', [middleware.ensureLoggedIn, ensurePrivileged], async (req, res) => {
|
|
94
|
+
await deleteNote(req.params.tid, req.params.noteId);
|
|
95
|
+
helpers.formatApiResponse(200, res, {});
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
plugin.addAdminNavigation = (header) => {
|
|
100
|
+
header.plugins.push({
|
|
101
|
+
route: '/plugins/internalnotes',
|
|
102
|
+
icon: 'fa-sticky-note',
|
|
103
|
+
name: 'Internal Notes',
|
|
104
|
+
});
|
|
105
|
+
return header;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
plugin.addInternalNotesToTopic = async (data) => {
|
|
109
|
+
if (!data || !data.topic) {
|
|
110
|
+
return data;
|
|
111
|
+
}
|
|
112
|
+
const allowed = await canViewNotes(data.uid);
|
|
113
|
+
data.topic.canViewInternalNotes = allowed;
|
|
114
|
+
if (allowed) {
|
|
115
|
+
data.topic.assignee = await getAssignee(data.topic.tid);
|
|
116
|
+
data.topic.internalNoteCount = await db.sortedSetCard(`internalnotes:tid:${data.topic.tid}`);
|
|
117
|
+
}
|
|
118
|
+
return data;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
plugin.addInternalNotesToTopics = async (data) => {
|
|
122
|
+
if (!data || !Array.isArray(data.topics) || !data.topics.length) {
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
const uid = data.uid || 0;
|
|
126
|
+
const allowed = await canViewNotes(uid);
|
|
127
|
+
if (!allowed) {
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
const assignees = await Promise.all(data.topics.map(t => getAssignee(t.tid)));
|
|
131
|
+
const noteCounts = await Promise.all(data.topics.map(t => db.sortedSetCard(`internalnotes:tid:${t.tid}`)));
|
|
132
|
+
data.topics.forEach((topic, i) => {
|
|
133
|
+
topic.canViewInternalNotes = true;
|
|
134
|
+
topic.assignee = assignees[i];
|
|
135
|
+
topic.internalNoteCount = noteCounts[i];
|
|
136
|
+
});
|
|
137
|
+
return data;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
plugin.addThreadTools = async (data) => {
|
|
141
|
+
const allowed = await canViewNotes(data.uid);
|
|
142
|
+
if (allowed) {
|
|
143
|
+
data.tools.push({
|
|
144
|
+
title: '[[internalnotes:thread-tool-notes]]',
|
|
145
|
+
class: 'toggle-internal-notes',
|
|
146
|
+
icon: 'fa-sticky-note',
|
|
147
|
+
});
|
|
148
|
+
data.tools.push({
|
|
149
|
+
title: '[[internalnotes:thread-tool-assign]]',
|
|
150
|
+
class: 'assign-topic-user',
|
|
151
|
+
icon: 'fa-user-plus',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return data;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
plugin.purgeTopicNotes = async ({ topic }) => {
|
|
158
|
+
if (!topic || !topic.tid) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const tid = topic.tid;
|
|
162
|
+
await removeTidFromAssigneeSet(tid);
|
|
163
|
+
const noteIds = await db.getSortedSetRange(`internalnotes:tid:${tid}`, 0, -1);
|
|
164
|
+
const keys = noteIds.map(id => `internalnote:${id}`);
|
|
165
|
+
await db.deleteAll(keys);
|
|
166
|
+
await db.delete(`internalnotes:tid:${tid}`);
|
|
167
|
+
await db.deleteObjectFields(`topic:${tid}`, ['assignee', 'assigneeType']);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
plugin.addNavigation = (menu) => {
|
|
171
|
+
menu = menu.concat([{
|
|
172
|
+
route: '/assigned',
|
|
173
|
+
title: '[[internalnotes:menu.assigned]]',
|
|
174
|
+
iconClass: 'fa-user-check',
|
|
175
|
+
textClass: 'visible-xs-inline',
|
|
176
|
+
text: '[[internalnotes:menu.assigned]]',
|
|
177
|
+
}]);
|
|
178
|
+
return menu;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
plugin.renderAssignedPage = async (req, res) => {
|
|
182
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
183
|
+
const uid = req.uid;
|
|
184
|
+
const [settings, tidsAll] = await Promise.all([
|
|
185
|
+
user.getSettings(uid),
|
|
186
|
+
getAssignedTids(uid),
|
|
187
|
+
]);
|
|
188
|
+
let tids = await privileges.topics.filterTids('read', tidsAll, uid);
|
|
189
|
+
const start = Math.max(0, (page - 1) * settings.topicsPerPage);
|
|
190
|
+
const stop = start + settings.topicsPerPage - 1;
|
|
191
|
+
const pageTids = tids.slice(start, stop + 1);
|
|
192
|
+
const topicsData = await topics.getTopicsByTids(pageTids, uid);
|
|
193
|
+
topics.calculateTopicIndices(topicsData, start);
|
|
194
|
+
const pageCount = Math.max(1, Math.ceil(tids.length / settings.topicsPerPage));
|
|
195
|
+
const data = {
|
|
196
|
+
topics: topicsData,
|
|
197
|
+
title: '[[internalnotes:menu.assigned]]',
|
|
198
|
+
breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[internalnotes:menu.assigned]]' }]),
|
|
199
|
+
pagination: pagination.create(page, pageCount),
|
|
200
|
+
};
|
|
201
|
+
res.render('recent', data);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// --- Permission helpers ---
|
|
205
|
+
|
|
206
|
+
async function canViewNotes(uid) {
|
|
207
|
+
if (parseInt(uid, 10) <= 0) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const [isAdmin, isGlobalMod] = await Promise.all([
|
|
211
|
+
user.isAdministrator(uid),
|
|
212
|
+
user.isGlobalModerator(uid),
|
|
213
|
+
]);
|
|
214
|
+
if (isAdmin) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
const settings = await meta.settings.get('internalnotes');
|
|
218
|
+
if (settings.allowGlobalMods === 'on' && isGlobalMod) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (settings.allowCategoryMods === 'on') {
|
|
222
|
+
const isModOfAny = await user.isModeratorOfAnyCategory(uid);
|
|
223
|
+
return isModOfAny;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Notes CRUD ---
|
|
229
|
+
|
|
230
|
+
async function getNotes(tid) {
|
|
231
|
+
const noteIds = await db.getSortedSetRevRange(`internalnotes:tid:${tid}`, 0, -1);
|
|
232
|
+
if (!noteIds.length) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
const keys = noteIds.map(id => `internalnote:${id}`);
|
|
236
|
+
const notes = await db.getObjects(keys);
|
|
237
|
+
const uids = [...new Set(notes.filter(Boolean).map(n => n.uid))];
|
|
238
|
+
const userData = await user.getUsersFields(uids, ['uid', 'username', 'picture', 'userslug']);
|
|
239
|
+
const userMap = {};
|
|
240
|
+
userData.forEach((u) => {
|
|
241
|
+
userMap[u.uid] = u;
|
|
242
|
+
});
|
|
243
|
+
return notes.filter(Boolean).map((note) => ({
|
|
244
|
+
...note,
|
|
245
|
+
user: userMap[note.uid] || {},
|
|
246
|
+
timestampISO: new Date(parseInt(note.timestamp, 10)).toISOString(),
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function createNote(tid, uid, content) {
|
|
251
|
+
const noteId = await db.incrObjectField('global', 'nextInternalNoteId');
|
|
252
|
+
const timestamp = Date.now();
|
|
253
|
+
const note = {
|
|
254
|
+
noteId,
|
|
255
|
+
tid: parseInt(tid, 10),
|
|
256
|
+
uid: parseInt(uid, 10),
|
|
257
|
+
content,
|
|
258
|
+
timestamp,
|
|
259
|
+
};
|
|
260
|
+
await Promise.all([
|
|
261
|
+
db.setObject(`internalnote:${noteId}`, note),
|
|
262
|
+
db.sortedSetAdd(`internalnotes:tid:${tid}`, timestamp, noteId),
|
|
263
|
+
]);
|
|
264
|
+
const userData = await user.getUserFields(uid, ['uid', 'username', 'picture', 'userslug']);
|
|
265
|
+
return {
|
|
266
|
+
...note,
|
|
267
|
+
user: userData,
|
|
268
|
+
timestampISO: new Date(timestamp).toISOString(),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function deleteNote(tid, noteId) {
|
|
273
|
+
await Promise.all([
|
|
274
|
+
db.delete(`internalnote:${noteId}`),
|
|
275
|
+
db.sortedSetRemove(`internalnotes:tid:${tid}`, noteId),
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- Assignment (user or group) ---
|
|
280
|
+
|
|
281
|
+
async function assignTopic(tid, type, id, callerUid) {
|
|
282
|
+
if (type === 'user') {
|
|
283
|
+
return assignToUser(tid, id, callerUid);
|
|
284
|
+
}
|
|
285
|
+
if (type === 'group') {
|
|
286
|
+
return assignToGroup(tid, id, callerUid);
|
|
287
|
+
}
|
|
288
|
+
throw new Error('[[error:invalid-data]]');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function removeTidFromAssigneeSet(tid) {
|
|
292
|
+
const topicData = await db.getObjectFields(`topic:${tid}`, ['assignee', 'assigneeType']);
|
|
293
|
+
if (!topicData || !topicData.assignee) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (topicData.assigneeType === 'group') {
|
|
297
|
+
await db.sortedSetRemove(`group:${topicData.assignee}:assignedTids`, tid);
|
|
298
|
+
} else {
|
|
299
|
+
await db.sortedSetRemove(`uid:${topicData.assignee}:assignedTids`, tid);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function assignToUser(tid, assigneeUid, callerUid) {
|
|
304
|
+
const parsedUid = parseInt(assigneeUid, 10);
|
|
305
|
+
if (parsedUid <= 0) {
|
|
306
|
+
await unassignTopic(tid);
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const exists = await user.exists(parsedUid);
|
|
310
|
+
if (!exists) {
|
|
311
|
+
throw new Error('[[error:no-user]]');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await removeTidFromAssigneeSet(tid);
|
|
315
|
+
const ts = Date.now();
|
|
316
|
+
await Promise.all([
|
|
317
|
+
db.setObject(`topic:${tid}`, { assignee: parsedUid, assigneeType: 'user' }),
|
|
318
|
+
db.sortedSetAdd(`uid:${parsedUid}:assignedTids`, ts, tid),
|
|
319
|
+
]);
|
|
320
|
+
|
|
321
|
+
if (parsedUid !== parseInt(callerUid, 10)) {
|
|
322
|
+
const topicData = await topics.getTopicFields(tid, ['title', 'slug']);
|
|
323
|
+
const notifObj = await notifications.create({
|
|
324
|
+
type: 'topic-assign',
|
|
325
|
+
bodyShort: `[[internalnotes:notif-assigned-user, ${topicData.title}]]`,
|
|
326
|
+
nid: `internalnotes:assign:${tid}:uid:${parsedUid}`,
|
|
327
|
+
from: callerUid,
|
|
328
|
+
path: `/topic/${topicData.slug}`,
|
|
329
|
+
tid: tid,
|
|
330
|
+
});
|
|
331
|
+
if (notifObj) {
|
|
332
|
+
await notifications.push(notifObj, [parsedUid]);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const userData = await user.getUserFields(parsedUid, ['uid', 'username', 'picture', 'userslug']);
|
|
337
|
+
return { type: 'user', user: userData };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function assignToGroup(tid, groupName, callerUid) {
|
|
341
|
+
if (!groupName) {
|
|
342
|
+
await unassignTopic(tid);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const exists = await groups.exists(groupName);
|
|
346
|
+
if (!exists) {
|
|
347
|
+
throw new Error('[[error:no-group]]');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await removeTidFromAssigneeSet(tid);
|
|
351
|
+
const ts = Date.now();
|
|
352
|
+
await Promise.all([
|
|
353
|
+
db.setObject(`topic:${tid}`, { assignee: groupName, assigneeType: 'group' }),
|
|
354
|
+
db.sortedSetAdd(`group:${groupName}:assignedTids`, ts, tid),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
const topicData = await topics.getTopicFields(tid, ['title', 'slug']);
|
|
358
|
+
const memberUids = await groups.getMembers(groupName, 0, -1);
|
|
359
|
+
const recipientUids = memberUids.filter(uid => uid !== parseInt(callerUid, 10));
|
|
360
|
+
if (recipientUids.length) {
|
|
361
|
+
const notifObj = await notifications.create({
|
|
362
|
+
type: 'topic-assign',
|
|
363
|
+
bodyShort: `[[internalnotes:notif-assigned-group, ${topicData.title}, ${groupName}]]`,
|
|
364
|
+
nid: `internalnotes:assign:${tid}:group:${groupName}`,
|
|
365
|
+
from: callerUid,
|
|
366
|
+
path: `/topic/${topicData.slug}`,
|
|
367
|
+
tid: tid,
|
|
368
|
+
});
|
|
369
|
+
if (notifObj) {
|
|
370
|
+
await notifications.push(notifObj, recipientUids);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const groupData = await groups.getGroupFields(groupName, ['name', 'slug', 'memberCount', 'icon', 'labelColor']);
|
|
375
|
+
return { type: 'group', group: groupData };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function unassignTopic(tid) {
|
|
379
|
+
await removeTidFromAssigneeSet(tid);
|
|
380
|
+
await db.deleteObjectFields(`topic:${tid}`, ['assignee', 'assigneeType']);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function getAssignee(tid) {
|
|
384
|
+
const topicData = await db.getObjectFields(`topic:${tid}`, ['assignee', 'assigneeType']);
|
|
385
|
+
if (!topicData || !topicData.assignee) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (topicData.assigneeType === 'group') {
|
|
390
|
+
const exists = await groups.exists(topicData.assignee);
|
|
391
|
+
if (!exists) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const groupData = await groups.getGroupFields(topicData.assignee, ['name', 'slug', 'memberCount', 'icon', 'labelColor']);
|
|
395
|
+
return { type: 'group', group: groupData };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const uid = parseInt(topicData.assignee, 10);
|
|
399
|
+
if (uid <= 0) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
const exists = await user.exists(uid);
|
|
403
|
+
if (!exists) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const userData = await user.getUserFields(uid, ['uid', 'username', 'picture', 'userslug']);
|
|
407
|
+
return { type: 'user', user: userData };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function getAssignedTids(uid) {
|
|
411
|
+
const byScore = {};
|
|
412
|
+
const addFromSet = async (setKey) => {
|
|
413
|
+
const list = await db.getSortedSetRevRangeWithScores(setKey, 0, -1);
|
|
414
|
+
for (const { value, score } of list) {
|
|
415
|
+
const tid = parseInt(value, 10);
|
|
416
|
+
if (tid && (!byScore[tid] || score > byScore[tid])) {
|
|
417
|
+
byScore[tid] = score;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
await addFromSet(`uid:${uid}:assignedTids`);
|
|
422
|
+
const userGroupsList = await groups.getUserGroups([uid]);
|
|
423
|
+
const groupNames = (userGroupsList && userGroupsList[0]) ? userGroupsList[0].map(g => g.name) : [];
|
|
424
|
+
for (const name of groupNames) {
|
|
425
|
+
await addFromSet(`group:${name}:assignedTids`);
|
|
426
|
+
}
|
|
427
|
+
const tids = Object.keys(byScore)
|
|
428
|
+
.map(t => ({ tid: parseInt(t, 10), score: byScore[t] }))
|
|
429
|
+
.sort((a, b) => b.score - a.score)
|
|
430
|
+
.map(x => x.tid);
|
|
431
|
+
return tids;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
module.exports = plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nodebb-plugin-internalnotes",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Add internal staff notes and assignees to topics in NodeBB. Notes are only visible to privileged users (moderators/admins).",
|
|
5
|
+
"main": "library.js",
|
|
6
|
+
"author": "BrutalBirdie",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/BrutalBirdie/nodebb-plugin-internalnotes"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "eslint ."
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"eslint": "^9.0.0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"nodebb",
|
|
19
|
+
"plugin",
|
|
20
|
+
"internal-notes",
|
|
21
|
+
"staff-notes",
|
|
22
|
+
"topic-assign",
|
|
23
|
+
"moderation"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/BrutalBirdie/nodebb-plugin-internalnotes/issues"
|
|
28
|
+
},
|
|
29
|
+
"nbbpm": {
|
|
30
|
+
"compatibility": "^4.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nodebb-plugin-internalnotes",
|
|
3
|
+
"url": "https://github.com/BrutalBirdie/nodebb-plugin-internalnotes",
|
|
4
|
+
"library": "./library.js",
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"hook": "static:app.load",
|
|
8
|
+
"method": "init"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"hook": "static:api.routes",
|
|
12
|
+
"method": "addRoutes"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"hook": "filter:admin.header.build",
|
|
16
|
+
"method": "addAdminNavigation"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"hook": "filter:navigation.available",
|
|
20
|
+
"method": "addNavigation"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"hook": "filter:topic.get",
|
|
24
|
+
"method": "addInternalNotesToTopic"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"hook": "filter:topics.get",
|
|
28
|
+
"method": "addInternalNotesToTopics"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"hook": "filter:topic.thread_tools",
|
|
32
|
+
"method": "addThreadTools"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"hook": "action:topic.purge",
|
|
36
|
+
"method": "purgeTopicNotes"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"scss": [
|
|
40
|
+
"scss/internalnotes.scss"
|
|
41
|
+
],
|
|
42
|
+
"scripts": [
|
|
43
|
+
"public/lib/main.js"
|
|
44
|
+
],
|
|
45
|
+
"acpScripts": [
|
|
46
|
+
"public/lib/acp-main.js"
|
|
47
|
+
],
|
|
48
|
+
"modules": {
|
|
49
|
+
"../admin/plugins/internalnotes.js": "./public/lib/admin.js"
|
|
50
|
+
},
|
|
51
|
+
"templates": "templates",
|
|
52
|
+
"languages": "languages"
|
|
53
|
+
}
|