{posts.content}
-diff --git a/public/language/en_GB/topic.json b/public/language/en_GB/topic.json index f177725d56..36daa3d044 100644 --- a/public/language/en_GB/topic.json +++ b/public/language/en_GB/topic.json @@ -36,6 +36,22 @@ "flag_title": "Flag this post for moderation", "flag_success": "This post has been flagged for moderation.", + "flag_manage_title": "Flagged post in %1", + "flag_manage_history": "Action History", + "flag_manage_no_history": "No event history to report", + "flag_manage_assignee": "Assignee", + "flag_manage_state": "State", + "flag_manage_state_open": "New/Open", + "flag_manage_state_wip": "Work in Progress", + "flag_manage_state_resolved": "Resolved", + "flag_manage_state_rejected": "Rejected", + "flag_manage_notes": "Shared Notes", + "flag_manage_update": "Update Flag Status", + "flag_manage_history_assignee": "Assigned to %1", + "flag_manage_history_state": "Updated state to %1", + "flag_manage_history_notes": "Updated flag notes", + "flag_manage_saved": "Flag Details Updated", + "deleted_message": "This topic has been deleted. Only users with topic management privileges can see it.", "following_topic.message": "You will now be receiving notifications when somebody posts to this topic.", diff --git a/public/less/flags.less b/public/less/flags.less index da44e61552..29b5998933 100644 --- a/public/less/flags.less +++ b/public/less/flags.less @@ -31,4 +31,8 @@ .user-icon-style(24px, 1.5rem); } } + + [component="posts/flag/history"] .avatar { + margin-right: 1rem; + } } \ No newline at end of file diff --git a/public/src/admin/manage/flags.js b/public/src/admin/manage/flags.js index a686c4a07e..ae44e9232c 100644 --- a/public/src/admin/manage/flags.js +++ b/public/src/admin/manage/flags.js @@ -4,8 +4,9 @@ define('admin/manage/flags', [ 'forum/infinitescroll', 'autocomplete', - 'Chart' -], function(infinitescroll, autocomplete, Chart) { + 'Chart', + 'components' +], function(infinitescroll, autocomplete, Chart, components) { var Flags = {}; @@ -21,6 +22,10 @@ define('admin/manage/flags', [ handleDelete(); handleInfiniteScroll(); handleGraphs(); + + updateFlagDetails(ajaxify.data.posts); + + components.get('posts/flags').on('click', '[component="posts/flag/update"]', updateFlag); }; function handleDismiss() { @@ -89,10 +94,14 @@ define('admin/manage/flags', [ after: $('[data-next]').attr('data-next') }, function(data, done) { if (data.posts && data.posts.length) { - app.parseAndTranslate('admin/manage/flags', 'posts', {posts: data.posts}, function(html) { + app.parseAndTranslate('admin/manage/flags', 'posts', { + posts: data.posts, + assignees: ajaxify.data.assignees + }, function(html) { $('[data-next]').attr('data-next', data.next); $('.post-container').append(html); html.find('img:not(.not-responsive)').addClass('img-responsive'); + updateFlagDetails(data.posts); done(); }); } else { @@ -150,5 +159,44 @@ define('admin/manage/flags', [ }); } + function updateFlagDetails(source) { + // As the flag details are returned in the API, update the form controls to show the correct data + + // Create reference hash for use in this method + source = source.reduce(function(memo, cur) { + memo[cur.pid] = cur.flagData; + return memo; + }, {}); + + components.get('posts/flag').each(function(idx, el) { + var pid = el.getAttribute('data-pid'); + var el = $(el); + + if (source[pid]) { + for(var prop in source[pid]) { + if (source[pid].hasOwnProperty(prop)) { + el.find('[name="' + prop + '"]').val(source[pid][prop]); + } + } + } + }); + } + + function updateFlag() { + var pid = $(this).parents('[component="posts/flag"]').attr('data-pid'); + var formData = $($(this).parents('form').get(0)).serializeArray(); + + socket.emit('posts.updateFlag', { + pid: pid, + data: formData + }, function(err) { + if (err) { + return app.alertError(err.message); + } else { + app.alertSuccess('[[topic:flag_manage_saved]]'); + } + }); + } + return Flags; }); \ No newline at end of file diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js index 340eda0a9d..f21cc773ad 100644 --- a/src/controllers/admin/flags.js +++ b/src/controllers/admin/flags.js @@ -2,6 +2,7 @@ var async = require('async'); var posts = require('../../posts'); +var user = require('../../user'); var analytics = require('../../analytics'); var flagsController = {}; @@ -25,15 +26,32 @@ flagsController.get = function(req, res, next) { }, analytics: function(next) { analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30, next); - } + }, + assignees: async.apply(user.getAdminsandGlobalMods) }, next); } ], function (err, results) { if (err) { return next(err); } + + // Minimise data set for assignees so tjs does less work + results.assignees = results.assignees.map(function(userObj) { + var keep = ['uid', 'username']; + for(var prop in userObj) { + if (userObj.hasOwnProperty(prop)) { + if (keep.indexOf(prop) === -1) { + delete userObj[prop]; + } + } + } + + return userObj; + }); + var data = { posts: results.posts, + assignees: results.assignees, analytics: results.analytics, next: stop + 1, byUsername: byUsername, diff --git a/src/posts/flags.js b/src/posts/flags.js index dcc2f32f1d..424a54084f 100644 --- a/src/posts/flags.js +++ b/src/posts/flags.js @@ -3,6 +3,7 @@ 'use strict'; var async = require('async'); +var winston = require('winston'); var db = require('../database'); var user = require('../user'); var analytics = require('../analytics'); @@ -171,7 +172,7 @@ module.exports = function(Posts) { }, next); }, posts: function(next) { - Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags']}, next); + Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next); } }, next); }, @@ -190,6 +191,8 @@ module.exports = function(Posts) { } results.posts.forEach(function(post, index) { + var history; + if (post) { post.flagReasons = reasons[index]; } @@ -197,6 +200,42 @@ module.exports = function(Posts) { next(null, results.posts); }); + }, + async.apply(Posts.expandFlagHistory), + function(posts, next) { + // Parse out flag data into its own object inside each post hash + posts = posts.map(function(postObj) { + for(var prop in postObj) { + postObj.flagData = postObj.flagData || {}; + + if (postObj.hasOwnProperty(prop) && prop.startsWith('flag:')) { + postObj.flagData[prop.slice(5)] = postObj[prop]; + + if (prop === 'flag:state') { + switch(postObj[prop]) { + case 'open': + postObj.flagData.labelClass = 'info'; + break; + case 'wip': + postObj.flagData.labelClass = 'warning'; + break; + case 'resolved': + postObj.flagData.labelClass = 'success'; + break; + case 'rejected': + postObj.flagData.labelClass = 'danger'; + break; + } + } + + delete postObj[prop]; + } + } + + return postObj; + }); + + setImmediate(next.bind(null, null, posts)); } ], callback); } @@ -226,4 +265,130 @@ module.exports = function(Posts) { } ], callback); }; + + Posts.updateFlagData = function(uid, pid, flagObj, callback) { + // Retrieve existing flag data to compare for history-saving purposes + var changes = []; + var changeset = {}; + var prop; + + Posts.getPostData(pid, function(err, postData) { + if (err) { + return callback(err); + } + + // Track new additions + for(prop in flagObj) { + if (flagObj.hasOwnProperty(prop) && !postData.hasOwnProperty('flag:' + prop)) { + changes.push(prop); + } + + // Generate changeset for object modification + if (flagObj.hasOwnProperty(prop)) { + changeset['flag:' + prop] = flagObj[prop]; + } + } + + // Track changed items + for(prop in postData) { + if ( + postData.hasOwnProperty(prop) && prop.startsWith('flag:') && + flagObj.hasOwnProperty(prop.slice(5)) && + postData[prop] !== flagObj[prop.slice(5)] + ) { + changes.push(prop.slice(5)); + } + } + + // Append changes to history string + if (changes.length) { + try { + var history = JSON.parse(postData['flag:history'] || '[]'); + + changes.forEach(function(property) { + switch(property) { + case 'assignee': // intentional fall-through + case 'state': + history.unshift({ + uid: uid, + type: property, + value: flagObj[property], + timestamp: Date.now() + }); + break; + + case 'notes': + history.unshift({ + uid: uid, + type: property, + timestamp: Date.now() + }); + } + }); + + changeset['flag:history'] = JSON.stringify(history); + } catch (e) { + winston.warn('[posts/updateFlagData] Unable to deserialise post flag history, likely malformed data'); + } + } + + // Save flag data into post hash + Posts.setPostFields(pid, changeset, callback); + }); + }; + + Posts.expandFlagHistory = function(posts, callback) { + // Expand flag history + async.map(posts, function(post, next) { + try { + var history = JSON.parse(post['flag:history'] || '[]'); + } catch (e) { + winston.warn('[posts/getFlags] Unable to deserialise post flag history, likely malformed data'); + callback(e); + } + + async.map(history, function(event, next) { + event.timestampISO = new Date(event.timestamp).toISOString(); + + async.parallel([ + function(next) { + user.getUserFields(event.uid, ['username', 'picture'], function(err, userData) { + if (err) { + return next(err); + } + + event.user = userData; + next(); + }); + }, + function(next) { + if (event.type === 'assignee') { + user.getUserField(parseInt(event.value, 10), 'username', function(err, username) { + if (err) { + return next(err); + } + + event.label = username || 'Unknown user'; + next(null); + }); + } else if (event.type === 'state') { + event.label = '[[topic:flag_manage_state_' + event.value + ']]'; + setImmediate(next); + } else { + setImmediate(next); + } + } + ], function(err) { + next(err, event); + }) + }, function(err, history) { + if (err) { + return next(err); + } + + post['flag:history'] = history; + next(null, post); + }); + }, callback); + } }; diff --git a/src/socket.io/posts/flag.js b/src/socket.io/posts/flag.js index 2ad5dcd2b8..5a44461ffb 100644 --- a/src/socket.io/posts/flag.js +++ b/src/socket.io/posts/flag.js @@ -163,7 +163,35 @@ module.exports = function(SocketPosts) { }, function (posts, next) { next(null, {posts: posts, next: stop + 1}); - }, + } ], callback); }; + + SocketPosts.updateFlag = function(socket, data, callback) { + if (!data || !(data.pid && data.data)) { + return callback('[[error:invalid-data]]'); + } + + var payload = {}; + + async.waterfall([ + function (next) { + user.isAdminOrGlobalMod(socket.uid, next); + }, + function (isAdminOrGlobalModerator, next) { + if (!isAdminOrGlobalModerator) { + return next(new Error('[[no-privileges]]')); + } + + // Translate form data into object + payload = data.data.reduce(function(memo, cur) { + memo[cur.name] = cur.value; + return memo; + }, payload); + + next(null, socket.uid, data.pid, payload); + }, + async.apply(posts.updateFlagData) + ], callback); + } }; diff --git a/src/user.js b/src/user.js index d04b7c8cce..48abf3f28c 100644 --- a/src/user.js +++ b/src/user.js @@ -2,6 +2,7 @@ var async = require('async'); +var groups = require('./groups'); var plugins = require('./plugins'); var db = require('./database'); var topics = require('./topics'); @@ -260,6 +261,19 @@ var utils = require('../public/src/utils'); }); }; + User.getAdminsandGlobalMods = function(callback) { + async.parallel({ + admins: async.apply(groups.getMembers, 'administrators', 0, -1), + mods: async.apply(groups.getMembers, 'Global Moderators', 0, -1) + }, function(err, results) { + if (err) { + return callback(err); + } + + User.getUsersData(results.admins.concat(results.mods), callback); + }); + }; + User.addInterstitials = function(callback) { plugins.registerHook('core', { hook: 'filter:register.interstitial', diff --git a/src/views/admin/manage/flags.tpl b/src/views/admin/manage/flags.tpl index 1bd1726b97..5a9dca8edf 100644 --- a/src/views/admin/manage/flags.tpl +++ b/src/views/admin/manage/flags.tpl @@ -44,7 +44,7 @@
{posts.content}
---- -
-- - - -
- -- - - - {../user.username} - : "{../reason}" -
{posts.content}
++++ +
+- + + +
+ ++ + + + {../user.username} + : "{posts.flagReasons.reason}" +