From 1aa70c57eb60daa51bf1181335f37e5a46ffbb72 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 23 Nov 2016 19:41:35 -0500
Subject: [PATCH 01/48] WIP

---
 src/controllers/admin/flags.js |   3 +-
 src/flags.js                   | 484 +++++++++++++++++++++++++++++++++
 src/posts.js                   |   1 -
 src/posts/delete.js            |   3 +-
 src/posts/flags.js             | 417 ----------------------------
 src/socket.io/posts/flag.js    |   9 +-
 src/upgrade.js                 |   7 +-
 src/user/admin.js              |   3 +-
 8 files changed, 499 insertions(+), 428 deletions(-)
 create mode 100644 src/flags.js
 delete mode 100644 src/posts/flags.js

diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js
index 1b31a95ff4..80c31ba60a 100644
--- a/src/controllers/admin/flags.js
+++ b/src/controllers/admin/flags.js
@@ -5,6 +5,7 @@ var validator = require('validator');
 
 var posts = require('../../posts');
 var user = require('../../user');
+var flags = require('../../flags');
 var categories = require('../../categories');
 var analytics = require('../../analytics');
 var pagination = require('../../pagination');
@@ -94,7 +95,7 @@ function getFlagData(req, res, callback) {
 				sets.push('uid:' + uid + ':flag:pids');
 			}
 
-			posts.getFlags(sets, cid, req.uid, start, stop, next);
+			flags.get(sets, cid, req.uid, start, stop, next);
 		}
 	], callback);
 }
diff --git a/src/flags.js b/src/flags.js
new file mode 100644
index 0000000000..1e6294aecb
--- /dev/null
+++ b/src/flags.js
@@ -0,0 +1,484 @@
+
+
+'use strict';
+
+var async = require('async');
+var winston = require('winston');
+var db = require('./database');
+var user = require('./user');
+var analytics = require('./analytics');
+var topics = require('./topics');
+var posts = require('./posts');
+var utils = require('../public/src/utils');
+
+var Flags = {
+	_defaults: {
+		state: 'open'
+	}
+};
+
+Flags.create = function (type, id, uid, reason, callback) {
+	async.waterfall([
+		function (next) {
+			// Sanity checks
+			async.parallel([
+				async.apply(Flags.exists, type, id, uid),
+				async.apply(Flags.targetExists, type, id)
+			], function (err, checks) {
+				if (checks[0]) {
+					return next(new Error('[[error:already-flagged]]'));
+				} else if (!checks[1]) {
+					return next(new Error('[[error:invalid-data]]'));
+				} else {
+					next();
+				}
+			});
+		},
+		function (next) {
+			var flagId = utils.generateUUID();
+
+			async.parallel([
+				async.apply(db.setObject.bind(db), 'flag:' + flagId, _.defaults({
+					description: reason
+				}), Flags._defaults),
+				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', now, flagId)
+			], function (err) {
+				if (err) {
+					return next(err);
+				}
+			});
+		}
+	], function (err) {
+		if (err) {
+			return callback(err);
+		}
+
+		console.log('done', arguments);
+		process.exit();
+	});
+	// if (!parseInt(uid, 10) || !reason) {
+	// 	return callback();
+	// }
+
+	// async.waterfall([
+	// 	function (next) {
+	// 		async.parallel({
+	// 			hasFlagged: async.apply(Flags.isFlaggedByUser, post.pid, uid),
+	// 			exists: async.apply(Posts.exists, post.pid)
+	// 		}, next);
+	// 	},
+	// 	function (results, next) {
+	// 		if (!results.exists) {
+	// 			return next(new Error('[[error:no-post]]'));
+	// 		}
+
+	// 		if (results.hasFlagged) {
+	// 			return next(new Error('[[error:already-flagged]]'));
+	// 		}
+
+	// 		var now = Date.now();
+	// 		async.parallel([
+	// 			function (next) {
+	// 				db.sortedSetAdd('posts:flagged', now, post.pid, next);
+	// 			},
+	// 			function (next) {
+	// 				db.sortedSetIncrBy('posts:flags:count', 1, post.pid, next);
+	// 			},
+	// 			function (next) {
+	// 				db.incrObjectField('post:' + post.pid, 'flags', next);
+	// 			},
+	// 			function (next) {
+	// 				db.sortedSetAdd('pid:' + post.pid + ':flag:uids', now, uid, next);
+	// 			},
+	// 			function (next) {
+	// 				db.sortedSetAdd('pid:' + post.pid + ':flag:uid:reason', 0, uid + ':' + reason, next);
+	// 			},
+	// 			function (next) {
+	// 				if (parseInt(post.uid, 10)) {
+	// 					async.parallel([
+	// 						async.apply(db.sortedSetIncrBy, 'users:flags', 1, post.uid),
+	// 						async.apply(db.incrObjectField, 'user:' + post.uid, 'flags'),
+	// 						async.apply(db.sortedSetAdd, 'uid:' + post.uid + ':flag:pids', now, post.pid)
+	// 					], next);
+	// 				} else {
+	// 					next();
+	// 				}
+	// 			}
+	// 		], next);
+	// 	},
+	// 	function (data, next) {
+	// 		openNewFlag(post.pid, uid, next);
+	// 	}
+	// ], function (err) {
+	// 	if (err) {
+	// 		return callback(err);
+	// 	}
+	// 	analytics.increment('flags');
+	// 	callback();
+	// });
+};
+
+function openNewFlag(pid, uid, callback) {
+	db.sortedSetScore('posts:flags:count', pid, function (err, count) {
+		if (err) {
+			return callback(err);
+		}
+		if (count === 1) {	// Only update state on new flag
+			Flags.update(uid, pid, {
+				state: 'open'
+			}, callback);
+		} else {
+			callback();
+		}
+	});
+}
+
+Flags.exists = function (type, id, uid, callback) {
+	db.isObjectField('flagHash:flagId', [type, id, uid].join(':'), callback);
+};
+
+Flags.targetExists = function (type, id, callback) {
+	switch (type) {
+		case 'topic':
+			topics.exists(id, callback);
+			break;
+		
+		case 'post':
+			posts.exists(id, callback);
+			break;
+	}
+};
+
+/* new signature (type, id, uid, callback) */
+Flags.isFlaggedByUser = function (pid, uid, callback) {
+	db.isSortedSetMember('pid:' + pid + ':flag:uids', uid, callback);
+};
+
+Flags.dismiss = function (pid, callback) {
+	async.waterfall([
+		function (next) {
+			db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next);
+		},
+		function (postData, next) {
+			if (!postData.pid) {
+				return callback();
+			}
+			async.parallel([
+				function (next) {
+					if (parseInt(postData.uid, 10)) {
+						if (parseInt(postData.flags, 10) > 0) {
+							async.parallel([
+								async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid),
+								async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags)
+							], next);
+						} else {
+							next();
+						}
+					} else {
+						next();
+					}
+				},
+				function (next) {
+					db.sortedSetsRemove([
+						'posts:flagged',
+						'posts:flags:count',
+						'uid:' + postData.uid + ':flag:pids'
+					], pid, next);
+				},
+				function (next) {
+					async.series([
+						function (next) {
+							db.getSortedSetRange('pid:' + pid + ':flag:uids', 0, -1, function (err, uids) {
+								if (err) {
+									return next(err);
+								}
+
+								async.each(uids, function (uid, next) {
+									var nid = 'post_flag:' + pid + ':uid:' + uid;
+									async.parallel([
+										async.apply(db.delete, 'notifications:' + nid),
+										async.apply(db.sortedSetRemove, 'notifications', 'post_flag:' + pid + ':uid:' + uid)
+									], next);
+								}, next);
+							});
+						},
+						async.apply(db.delete, 'pid:' + pid + ':flag:uids')
+					], next);
+				},
+				async.apply(db.deleteObjectField, 'post:' + pid, 'flags'),
+				async.apply(db.delete, 'pid:' + pid + ':flag:uid:reason'),
+				async.apply(db.deleteObjectFields, 'post:' + pid, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history'])
+			], next);
+		},
+		function (results, next) {
+			db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next);
+		}
+	], callback);
+};
+
+// Pretty sure we don't need this method...
+Flags.dismissAll = function (callback) {
+	db.getSortedSetRange('posts:flagged', 0, -1, function (err, pids) {
+		if (err) {
+			return callback(err);
+		}
+		async.eachSeries(pids, Flags.dismiss, callback);
+	});
+};
+
+Flags.dismissByUid = function (uid, callback) {
+	db.getSortedSetRange('uid:' + uid + ':flag:pids', 0, -1, function (err, pids) {
+		if (err) {
+			return callback(err);
+		}
+		async.eachSeries(pids, Flags.dismiss, callback);
+	});
+};
+
+Flags.get = function (set, cid, uid, start, stop, callback) {
+	async.waterfall([
+		function (next) {
+			if (Array.isArray(set)) {
+				db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next);
+			} else {
+				db.getSortedSetRevRange(set, start, -1, next);
+			}
+		},
+		function (pids, next) {
+			if (cid) {
+				Posts.filterPidsByCid(pids, cid, next);
+			} else {
+				process.nextTick(next, null, pids);
+			}
+		},
+		function (pids, next) {
+			getFlaggedPostsWithReasons(pids, uid, next);
+		},
+		function (posts, next) {
+			var count = posts.length;
+			var end = stop - start + 1;
+			next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count});
+		}
+	], callback);
+};
+
+function getFlaggedPostsWithReasons(pids, uid, callback) {
+	async.waterfall([
+		function (next) {
+			async.parallel({
+				uidsReasons: function (next) {
+					async.map(pids, function (pid, next) {
+						db.getSortedSetRange('pid:' + pid + ':flag:uid:reason', 0, -1, next);
+					}, next);
+				},
+				posts: function (next) {
+					Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next);
+				}
+			}, next);
+		},
+		function (results, next) {
+			async.map(results.uidsReasons, function (uidReasons, next) {
+				async.map(uidReasons, function (uidReason, next) {
+					var uid = uidReason.split(':')[0];
+					var reason = uidReason.substr(uidReason.indexOf(':') + 1);
+					user.getUserFields(uid, ['username', 'userslug', 'picture'], function (err, userData) {
+						next(err, {user: userData, reason: reason});
+					});
+				}, next);
+			}, function (err, reasons) {
+				if (err) {
+					return callback(err);
+				}
+
+				results.posts.forEach(function (post, index) {
+					if (post) {
+						post.flagReasons = reasons[index];
+					}
+				});
+
+				next(null, results.posts);
+			});
+		},
+		async.apply(Posts.expandFlagHistory),
+		function (posts, next) {
+			// Parse out flag data into its own object inside each post hash
+			async.map(posts, function (postObj, next) {
+				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];
+					}
+				}
+
+				if (postObj.flagData.assignee) {
+					user.getUserFields(parseInt(postObj.flagData.assignee, 10), ['username', 'picture'], function (err, userData) {
+						if (err) {
+							return next(err);
+						}
+
+						postObj.flagData.assigneeUser = userData;
+						next(null, postObj);
+					});
+				} else {
+					setImmediate(next.bind(null, null, postObj));
+				}
+			}, next);
+		}
+	], callback);
+}
+
+// New method signature (type, id, flagObj, callback) and name (.update())
+// uid used in history string, which should be rewritten too.
+Flags.update = 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) && flagObj[prop].length) {
+				changes.push(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));
+			}
+		}
+
+		changeset = changes.reduce(function (memo, prop) {
+			memo['flag:' + prop] = flagObj[prop];
+			return memo;
+		}, {});
+
+		// 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('[flags/update] Unable to deserialise post flag history, likely malformed data');
+			}
+		}
+
+		// Save flag data into post hash
+		if (changes.length) {
+			Posts.setPostFields(pid, changeset, callback);
+		} else {
+			setImmediate(callback);
+		}
+	});
+};
+
+// To be rewritten and deprecated
+Flags.expandFlagHistory = function (posts, callback) {
+	// Expand flag history
+	async.map(posts, function (post, next) {
+		var history;
+		try {
+			history = JSON.parse(post['flag:history'] || '[]');
+		} catch (e) {
+			winston.warn('[flags/get] Unable to deserialise post flag history, likely malformed data');
+			return 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);
+};
+
+module.exports = Flags;
\ No newline at end of file
diff --git a/src/posts.js b/src/posts.js
index 047917cb5f..b476b84414 100644
--- a/src/posts.js
+++ b/src/posts.js
@@ -21,7 +21,6 @@ var plugins = require('./plugins');
 	require('./posts/category')(Posts);
 	require('./posts/summary')(Posts);
 	require('./posts/recent')(Posts);
-	require('./posts/flags')(Posts);
 	require('./posts/tools')(Posts);
 	require('./posts/votes')(Posts);
 	require('./posts/bookmarks')(Posts);
diff --git a/src/posts/delete.js b/src/posts/delete.js
index 7a1d3d0cc8..ebf902aef2 100644
--- a/src/posts/delete.js
+++ b/src/posts/delete.js
@@ -8,6 +8,7 @@ var topics = require('../topics');
 var user = require('../user');
 var notifications = require('../notifications');
 var plugins = require('../plugins');
+var flags = require('../flags');
 
 module.exports = function (Posts) {
 
@@ -145,7 +146,7 @@ module.exports = function (Posts) {
 						db.sortedSetsRemove(['posts:pid', 'posts:flagged'], pid, next);
 					},
 					function (next) {
-						Posts.dismissFlag(pid, next);
+						flags.dismiss(pid, next);
 					}
 				], function (err) {
 					if (err) {
diff --git a/src/posts/flags.js b/src/posts/flags.js
deleted file mode 100644
index e81da20f95..0000000000
--- a/src/posts/flags.js
+++ /dev/null
@@ -1,417 +0,0 @@
-
-
-'use strict';
-
-var async = require('async');
-var winston = require('winston');
-var db = require('../database');
-var user = require('../user');
-var analytics = require('../analytics');
-
-module.exports = function (Posts) {
-
-	Posts.flag = function (post, uid, reason, callback) {
-		if (!parseInt(uid, 10) || !reason) {
-			return callback();
-		}
-
-		async.waterfall([
-			function (next) {
-				async.parallel({
-					hasFlagged: async.apply(Posts.isFlaggedByUser, post.pid, uid),
-					exists: async.apply(Posts.exists, post.pid)
-				}, next);
-			},
-			function (results, next) {
-				if (!results.exists) {
-					return next(new Error('[[error:no-post]]'));
-				}
-
-				if (results.hasFlagged) {
-					return next(new Error('[[error:already-flagged]]'));
-				}
-
-				var now = Date.now();
-				async.parallel([
-					function (next) {
-						db.sortedSetAdd('posts:flagged', now, post.pid, next);
-					},
-					function (next) {
-						db.sortedSetIncrBy('posts:flags:count', 1, post.pid, next);
-					},
-					function (next) {
-						db.incrObjectField('post:' + post.pid, 'flags', next);
-					},
-					function (next) {
-						db.sortedSetAdd('pid:' + post.pid + ':flag:uids', now, uid, next);
-					},
-					function (next) {
-						db.sortedSetAdd('pid:' + post.pid + ':flag:uid:reason', 0, uid + ':' + reason, next);
-					},
-					function (next) {
-						if (parseInt(post.uid, 10)) {
-							async.parallel([
-								async.apply(db.sortedSetIncrBy, 'users:flags', 1, post.uid),
-								async.apply(db.incrObjectField, 'user:' + post.uid, 'flags'),
-								async.apply(db.sortedSetAdd, 'uid:' + post.uid + ':flag:pids', now, post.pid)
-							], next);
-						} else {
-							next();
-						}
-					}
-				], next);
-			},
-			function (data, next) {
-				openNewFlag(post.pid, uid, next);
-			}
-		], function (err) {
-			if (err) {
-				return callback(err);
-			}
-			analytics.increment('flags');
-			callback();
-		});
-	};
-
-	function openNewFlag(pid, uid, callback) {
-		db.sortedSetScore('posts:flags:count', pid, function (err, count) {
-			if (err) {
-				return callback(err);
-			}
-			if (count === 1) {	// Only update state on new flag
-				Posts.updateFlagData(uid, pid, {
-					state: 'open'
-				}, callback);
-			} else {
-				callback();
-			}
-		});
-	}
-
-	Posts.isFlaggedByUser = function (pid, uid, callback) {
-		db.isSortedSetMember('pid:' + pid + ':flag:uids', uid, callback);
-	};
-
-	Posts.dismissFlag = function (pid, callback) {
-		async.waterfall([
-			function (next) {
-				db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next);
-			},
-			function (postData, next) {
-				if (!postData.pid) {
-					return callback();
-				}
-				async.parallel([
-					function (next) {
-						if (parseInt(postData.uid, 10)) {
-							if (parseInt(postData.flags, 10) > 0) {
-								async.parallel([
-									async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid),
-									async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags)
-								], next);
-							} else {
-								next();
-							}
-						} else {
-							next();
-						}
-					},
-					function (next) {
-						db.sortedSetsRemove([
-							'posts:flagged',
-							'posts:flags:count',
-							'uid:' + postData.uid + ':flag:pids'
-						], pid, next);
-					},
-					function (next) {
-						async.series([
-							function (next) {
-								db.getSortedSetRange('pid:' + pid + ':flag:uids', 0, -1, function (err, uids) {
-									if (err) {
-										return next(err);
-									}
-
-									async.each(uids, function (uid, next) {
-										var nid = 'post_flag:' + pid + ':uid:' + uid;
-										async.parallel([
-											async.apply(db.delete, 'notifications:' + nid),
-											async.apply(db.sortedSetRemove, 'notifications', 'post_flag:' + pid + ':uid:' + uid)
-										], next);
-									}, next);
-								});
-							},
-							async.apply(db.delete, 'pid:' + pid + ':flag:uids')
-						], next);
-					},
-					async.apply(db.deleteObjectField, 'post:' + pid, 'flags'),
-					async.apply(db.delete, 'pid:' + pid + ':flag:uid:reason'),
-					async.apply(db.deleteObjectFields, 'post:' + pid, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history'])
-				], next);
-			},
-			function (results, next) {
-				db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next);
-			}
-		], callback);
-	};
-
-	Posts.dismissAllFlags = function (callback) {
-		db.getSortedSetRange('posts:flagged', 0, -1, function (err, pids) {
-			if (err) {
-				return callback(err);
-			}
-			async.eachSeries(pids, Posts.dismissFlag, callback);
-		});
-	};
-
-	Posts.dismissUserFlags = function (uid, callback) {
-		db.getSortedSetRange('uid:' + uid + ':flag:pids', 0, -1, function (err, pids) {
-			if (err) {
-				return callback(err);
-			}
-			async.eachSeries(pids, Posts.dismissFlag, callback);
-		});
-	};
-
-	Posts.getFlags = function (set, cid, uid, start, stop, callback) {
-		async.waterfall([
-			function (next) {
-				if (Array.isArray(set)) {
-					db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next);
-				} else {
-					db.getSortedSetRevRange(set, start, -1, next);
-				}
-			},
-			function (pids, next) {
-				if (cid) {
-					Posts.filterPidsByCid(pids, cid, next);
-				} else {
-					process.nextTick(next, null, pids);
-				}
-			},
-			function (pids, next) {
-				getFlaggedPostsWithReasons(pids, uid, next);
-			},
-			function (posts, next) {
-				var count = posts.length;
-				var end = stop - start + 1;
-				next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count});
-			}
-		], callback);
-	};
-
-	function getFlaggedPostsWithReasons(pids, uid, callback) {
-		async.waterfall([
-			function (next) {
-				async.parallel({
-					uidsReasons: function (next) {
-						async.map(pids, function (pid, next) {
-							db.getSortedSetRange('pid:' + pid + ':flag:uid:reason', 0, -1, next);
-						}, next);
-					},
-					posts: function (next) {
-						Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next);
-					}
-				}, next);
-			},
-			function (results, next) {
-				async.map(results.uidsReasons, function (uidReasons, next) {
-					async.map(uidReasons, function (uidReason, next) {
-						var uid = uidReason.split(':')[0];
-						var reason = uidReason.substr(uidReason.indexOf(':') + 1);
-						user.getUserFields(uid, ['username', 'userslug', 'picture'], function (err, userData) {
-							next(err, {user: userData, reason: reason});
-						});
-					}, next);
-				}, function (err, reasons) {
-					if (err) {
-						return callback(err);
-					}
-
-					results.posts.forEach(function (post, index) {
-						if (post) {
-							post.flagReasons = reasons[index];
-						}
-					});
-
-					next(null, results.posts);
-				});
-			},
-			async.apply(Posts.expandFlagHistory),
-			function (posts, next) {
-				// Parse out flag data into its own object inside each post hash
-				async.map(posts, function (postObj, next) {
-					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];
-						}
-					}
-
-					if (postObj.flagData.assignee) {
-						user.getUserFields(parseInt(postObj.flagData.assignee, 10), ['username', 'picture'], function (err, userData) {
-							if (err) {
-								return next(err);
-							}
-
-							postObj.flagData.assigneeUser = userData;
-							next(null, postObj);
-						});
-					} else {
-						setImmediate(next.bind(null, null, postObj));
-					}
-				}, next);
-			}
-		], 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) && flagObj[prop].length) {
-					changes.push(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));
-				}
-			}
-
-			changeset = changes.reduce(function (memo, prop) {
-				memo['flag:' + prop] = flagObj[prop];
-				return memo;
-			}, {});
-
-			// 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
-			if (changes.length) {
-				Posts.setPostFields(pid, changeset, callback);
-			} else {
-				setImmediate(callback);
-			}
-		});
-	};
-
-	Posts.expandFlagHistory = function (posts, callback) {
-		// Expand flag history
-		async.map(posts, function (post, next) {
-			var history;
-			try {
-				history = JSON.parse(post['flag:history'] || '[]');
-			} catch (e) {
-				winston.warn('[posts/getFlags] Unable to deserialise post flag history, likely malformed data');
-				return 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 077b88bfc9..88b47058d1 100644
--- a/src/socket.io/posts/flag.js
+++ b/src/socket.io/posts/flag.js
@@ -12,6 +12,7 @@ var notifications = require('../../notifications');
 var plugins = require('../../plugins');
 var meta = require('../../meta');
 var utils = require('../../../public/src/utils');
+var flags = require('../../flags');
 
 module.exports = function (SocketPosts) {
 
@@ -64,7 +65,7 @@ module.exports = function (SocketPosts) {
 				flaggingUser = user.userData;
 				flaggingUser.uid = socket.uid;
 
-				posts.flag(post, socket.uid, data.reason, next);
+				flags.create('post', post.pid, socket.uid, data.reason, next);
 			},
 			function (next) {
 				async.parallel({
@@ -119,7 +120,7 @@ module.exports = function (SocketPosts) {
 				if (!isAdminOrGlobalModerator) {
 					return next(new Error('[[no-privileges]]'));
 				}
-				posts.dismissFlag(pid, next);
+				flags.dismiss(pid, next);
 			}
 		], callback);
 	};
@@ -133,7 +134,7 @@ module.exports = function (SocketPosts) {
 				if (!isAdminOrGlobalModerator) {
 					return next(new Error('[[no-privileges]]'));
 				}
-				posts.dismissAllFlags(next);
+				flags.dismissAll(next);
 			}
 		], callback);
 	};
@@ -165,7 +166,7 @@ module.exports = function (SocketPosts) {
 					return memo;
 				}, payload);
 
-				posts.updateFlagData(socket.uid, data.pid, payload, next);
+				flags.update(socket.uid, data.pid, payload, next);
 			}
 		], callback);
 	};
diff --git a/src/upgrade.js b/src/upgrade.js
index 79ffa6b5ee..2a605e84f9 100644
--- a/src/upgrade.js
+++ b/src/upgrade.js
@@ -462,8 +462,9 @@ Upgrade.upgrade = function (callback) {
 				updatesMade = true;
 				winston.info('[2016/04/29] Dismiss flags from deleted topics');
 
-				var posts = require('./posts'),
-					topics = require('./topics');
+				var posts = require('./posts');
+				var topics = require('./topics');
+				var flags = require('./flags');
 
 				var pids, tids;
 
@@ -486,7 +487,7 @@ Upgrade.upgrade = function (callback) {
 						}).filter(Boolean);
 
 						winston.info('[2016/04/29] ' + toDismiss.length + ' dismissable flags found');
-						async.each(toDismiss, posts.dismissFlag, next);
+						async.each(toDismiss, flags.dismiss, next);
 					}
 				], function (err) {
 					if (err) {
diff --git a/src/user/admin.js b/src/user/admin.js
index 8b5a6ebef4..5d2215980c 100644
--- a/src/user/admin.js
+++ b/src/user/admin.js
@@ -6,6 +6,7 @@ var db = require('../database');
 var posts = require('../posts');
 var plugins = require('../plugins');
 var winston = require('winston');
+var flags = require('../flags');
 
 module.exports = function (User) {
 
@@ -62,7 +63,7 @@ module.exports = function (User) {
 		}
 
 		async.eachSeries(uids, function (uid, next) {
-			posts.dismissUserFlags(uid, next);
+			flags.dismissByUid(uid, next);
 		}, callback);
 	};
 };

From 106502952a79ce18a7f0d1ce54ff036527ee955f Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 24 Nov 2016 11:56:57 -0500
Subject: [PATCH 02/48] fixed crash in flags page for now

---
 src/flags.js | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 1e6294aecb..89f42337d4 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -246,7 +246,7 @@ Flags.get = function (set, cid, uid, start, stop, callback) {
 		},
 		function (pids, next) {
 			if (cid) {
-				Posts.filterPidsByCid(pids, cid, next);
+				posts.filterPidsByCid(pids, cid, next);
 			} else {
 				process.nextTick(next, null, pids);
 			}
@@ -272,7 +272,7 @@ function getFlaggedPostsWithReasons(pids, uid, callback) {
 					}, next);
 				},
 				posts: function (next) {
-					Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next);
+					posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next);
 				}
 			}, next);
 		},
@@ -299,7 +299,7 @@ function getFlaggedPostsWithReasons(pids, uid, callback) {
 				next(null, results.posts);
 			});
 		},
-		async.apply(Posts.expandFlagHistory),
+		async.apply(Flags.expandFlagHistory),
 		function (posts, next) {
 			// Parse out flag data into its own object inside each post hash
 			async.map(posts, function (postObj, next) {
@@ -355,7 +355,7 @@ Flags.update = function (uid, pid, flagObj, callback) {
 	var changeset = {};
 	var prop;
 
-	Posts.getPostData(pid, function (err, postData) {
+	posts.getPostData(pid, function (err, postData) {
 		if (err) {
 			return callback(err);
 		}
@@ -417,7 +417,7 @@ Flags.update = function (uid, pid, flagObj, callback) {
 
 		// Save flag data into post hash
 		if (changes.length) {
-			Posts.setPostFields(pid, changeset, callback);
+			posts.setPostFields(pid, changeset, callback);
 		} else {
 			setImmediate(callback);
 		}

From 640df0379e3e3b818c9398da5a26b2585de550fd Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 25 Nov 2016 12:43:10 -0500
Subject: [PATCH 03/48] flag list page (#5232)

---
 public/language/en-GB/flags.json |   6 ++
 src/controllers/mods.js          |   9 ++-
 src/flags.js                     | 101 ++++++++++++++++++++++---------
 src/routes/index.js              |   2 +-
 4 files changed, 87 insertions(+), 31 deletions(-)
 create mode 100644 public/language/en-GB/flags.json

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
new file mode 100644
index 0000000000..de083196e7
--- /dev/null
+++ b/public/language/en-GB/flags.json
@@ -0,0 +1,6 @@
+{
+    "quick-filters": "Quick Filters",
+    "reporter": "Reporter",
+    "reported-at": "Reported At",
+    "no-flags": "Hooray! No flags found."
+}
\ No newline at end of file
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 0079412f87..2a1aad09dc 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -3,7 +3,8 @@
 var async = require('async');
 
 var user = require('../user');
-var adminFlagsController = require('./admin/flags');
+var flags = require('../flags');
+// var adminFlagsController = require('./admin/flags');
 
 var modsController = {};
 
@@ -20,7 +21,11 @@ modsController.flagged = function (req, res, next) {
 			res.locals.cids = results.moderatedCids;
 		}
 
-		adminFlagsController.get(req, res, next);
+		flags.list({}, function(err, flags) {
+			res.render('flags', {
+				flags: flags
+			});
+		});
 	});
 };
 
diff --git a/src/flags.js b/src/flags.js
index 89f42337d4..bdf1ac37bb 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -13,10 +13,71 @@ var utils = require('../public/src/utils');
 
 var Flags = {
 	_defaults: {
-		state: 'open'
+		state: 'open',
+		assignee: null
 	}
 };
 
+Flags.list = function (filters, callback) {
+	if (typeof filters === 'function' && !callback) {
+		callback = filters;
+		filters = {};
+	}
+
+	async.waterfall([
+		async.apply(db.getSortedSetRevRange.bind(db), 'flags:datetime', 0, 19),
+		function (flagIds, next) {
+			async.map(flagIds, function (flagId, next) {
+				async.waterfall([
+					async.apply(db.getObject, 'flag:' + flagId),
+					function (flagObj, next) {
+						user.getUserFields(flagObj.uid, ['username', 'picture'], function (err, userObj) {
+							next(err, Object.assign(flagObj, {
+								user: {
+									username: userObj.username,
+									picture: userObj.picture,
+									'icon:bgColor': userObj['icon:bgColor'],
+									'icon:text': userObj['icon:text']
+								}
+							}));
+						})
+					}
+				], function (err, flagObj) {
+					if (err) {
+						return next(err);
+					}
+
+					switch(flagObj.state) {
+						case 'open':
+							flagObj.labelClass = 'info';
+							break;
+						case 'wip':
+							flagObj.labelClass = 'warning';
+							break;
+						case 'resolved':
+							flagObj.labelClass = 'success';
+							break;
+						case 'rejected':
+							flagObj.labelClass = 'danger';
+							break;
+					}
+
+					next(null, Object.assign(flagObj, {
+						target_readable: flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1) + ' ' + flagObj.id,
+						datetimeISO: new Date(parseInt(flagObj.datetime, 10)).toISOString()
+					}));
+				});
+			},  next);
+		}
+	], function (err, flags) {
+		if (err) {
+			return callback(err);
+		}
+
+		return callback(null, flags);
+	});
+};
+
 Flags.create = function (type, id, uid, reason, callback) {
 	async.waterfall([
 		function (next) {
@@ -38,23 +99,22 @@ Flags.create = function (type, id, uid, reason, callback) {
 			var flagId = utils.generateUUID();
 
 			async.parallel([
-				async.apply(db.setObject.bind(db), 'flag:' + flagId, _.defaults({
-					description: reason
-				}), Flags._defaults),
-				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', now, flagId)
-			], function (err) {
-				if (err) {
-					return next(err);
-				}
-			});
+				async.apply(db.setObject.bind(db), 'flag:' + flagId, Object.assign({}, Flags._defaults, {
+					type: type,
+					id: id,
+					description: reason,
+					uid: uid,
+					datetime: Date.now()
+				})),
+				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId)
+			], next);
 		}
 	], function (err) {
 		if (err) {
 			return callback(err);
 		}
 
-		console.log('done', arguments);
-		process.exit();
+		callback();
 	});
 	// if (!parseInt(uid, 10) || !reason) {
 	// 	return callback();
@@ -107,7 +167,7 @@ Flags.create = function (type, id, uid, reason, callback) {
 	// 		], next);
 	// 	},
 	// 	function (data, next) {
-	// 		openNewFlag(post.pid, uid, next);
+	// 		openNewFlag(post.pid, uid, next);		// removed, used to just update flag to open state if new flag
 	// 	}
 	// ], function (err) {
 	// 	if (err) {
@@ -118,21 +178,6 @@ Flags.create = function (type, id, uid, reason, callback) {
 	// });
 };
 
-function openNewFlag(pid, uid, callback) {
-	db.sortedSetScore('posts:flags:count', pid, function (err, count) {
-		if (err) {
-			return callback(err);
-		}
-		if (count === 1) {	// Only update state on new flag
-			Flags.update(uid, pid, {
-				state: 'open'
-			}, callback);
-		} else {
-			callback();
-		}
-	});
-}
-
 Flags.exists = function (type, id, uid, callback) {
 	db.isObjectField('flagHash:flagId', [type, id, uid].join(':'), callback);
 };
diff --git a/src/routes/index.js b/src/routes/index.js
index f36ad1468a..0273160745 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -40,7 +40,7 @@ function mainRoutes(app, middleware, controllers) {
 }
 
 function modRoutes(app, middleware, controllers) {
-	setupPageRoute(app, '/posts/flags', middleware, [], controllers.mods.flagged);
+	setupPageRoute(app, '/flags', middleware, [], controllers.mods.flagged);
 }
 
 function globalModRoutes(app, middleware, controllers) {

From 98a104564b511155cbf24a78406905b07f96ae74 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 25 Nov 2016 14:17:51 -0500
Subject: [PATCH 04/48] some light refactoring, details API (#5232)

---
 public/language/en-GB/flags.json |  1 +
 src/controllers/mods.js          | 28 ++++++++--
 src/flags.js                     | 92 ++++++++++++++++++++------------
 src/routes/index.js              |  3 +-
 4 files changed, 86 insertions(+), 38 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index de083196e7..ad8456ebf6 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -1,5 +1,6 @@
 {
     "quick-filters": "Quick Filters",
+    "state": "State",
     "reporter": "Reporter",
     "reported-at": "Reported At",
     "no-flags": "Hooray! No flags found."
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 2a1aad09dc..f5a6e9b06d 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -6,15 +6,19 @@ var user = require('../user');
 var flags = require('../flags');
 // var adminFlagsController = require('./admin/flags');
 
-var modsController = {};
+var modsController = {
+	flags: {}
+};
 
-modsController.flagged = function (req, res, next) {
+modsController.flags.list = function (req, res, next) {
 	async.parallel({
 		isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid),
 		moderatedCids: async.apply(user.getModeratedCids, req.uid)
 	}, function (err, results) {
-		if (err || !(results.isAdminOrGlobalMod || !!results.moderatedCids.length)) {
+		if (err) {
 			return next(err);
+		} else if (!(results.isAdminOrGlobalMod || !!results.moderatedCids.length)) {
+			return next(new Error('[[error:no-privileges]]'));
 		}
 
 		if (!results.isAdminOrGlobalMod && results.moderatedCids.length) {
@@ -22,11 +26,27 @@ modsController.flagged = function (req, res, next) {
 		}
 
 		flags.list({}, function(err, flags) {
-			res.render('flags', {
+			res.render('flags/list', {
 				flags: flags
 			});
 		});
 	});
 };
 
+modsController.flags.detail = function (req, res, next) {
+	async.parallel({
+		isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid),
+		moderatedCids: async.apply(user.getModeratedCids, req.uid),
+		flagData: async.apply(flags.get, req.params.flagId)
+	}, function (err, results) {
+		if (err || !results.flagData) {
+			return next(err || new Error('[[error:invalid-data]]'));
+		} else if (!(results.isAdminOrGlobalMod || !!results.moderatedCids.length)) {
+			return next(new Error('[[error:no-privileges]]'));
+		}
+
+		res.render('flags/detail', results.flagData);
+	});
+};
+
 module.exports = modsController;
diff --git a/src/flags.js b/src/flags.js
index bdf1ac37bb..8734f811a8 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -18,6 +18,30 @@ var Flags = {
 	}
 };
 
+Flags.get = function (flagId, callback) {
+	async.waterfall([
+		async.apply(async.parallel, {
+			base: async.apply(db.getObject.bind(db), 'flag:' + flagId),
+			history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1),
+			notes: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':notes', 0, -1)
+		}),
+		function (data, next) {
+			user.getUserFields(data.base.uid, ['username', 'picture'], function (err, userObj) {
+				next(err, Object.assign(data.base, {
+					history: data.history,
+					notes: data.notes,
+					reporter: {
+						username: userObj.username,
+						picture: userObj.picture,
+						'icon:bgColor': userObj['icon:bgColor'],
+						'icon:text': userObj['icon:text']
+					}
+				}));
+			});
+		}
+	], callback);
+};
+
 Flags.list = function (filters, callback) {
 	if (typeof filters === 'function' && !callback) {
 		callback = filters;
@@ -33,7 +57,7 @@ Flags.list = function (filters, callback) {
 					function (flagObj, next) {
 						user.getUserFields(flagObj.uid, ['username', 'picture'], function (err, userObj) {
 							next(err, Object.assign(flagObj, {
-								user: {
+								reporter: {
 									username: userObj.username,
 									picture: userObj.picture,
 									'icon:bgColor': userObj['icon:bgColor'],
@@ -63,7 +87,7 @@ Flags.list = function (filters, callback) {
 					}
 
 					next(null, Object.assign(flagObj, {
-						target_readable: flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1) + ' ' + flagObj.id,
+						target_readable: flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1) + ' ' + flagObj.targetId,
 						datetimeISO: new Date(parseInt(flagObj.datetime, 10)).toISOString()
 					}));
 				});
@@ -95,18 +119,19 @@ Flags.create = function (type, id, uid, reason, callback) {
 				}
 			});
 		},
-		function (next) {
-			var flagId = utils.generateUUID();
-
+		async.apply(db.incrObjectField, 'global', 'nextFlagId'),
+		function (flagId, next) {
 			async.parallel([
 				async.apply(db.setObject.bind(db), 'flag:' + flagId, Object.assign({}, Flags._defaults, {
+					flagId: flagId,
 					type: type,
-					id: id,
+					targetId: id,
 					description: reason,
 					uid: uid,
 					datetime: Date.now()
 				})),
-				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId)
+				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId),
+				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId)
 			], next);
 		}
 	], function (err) {
@@ -280,32 +305,33 @@ Flags.dismissByUid = function (uid, callback) {
 	});
 };
 
-Flags.get = function (set, cid, uid, start, stop, callback) {
-	async.waterfall([
-		function (next) {
-			if (Array.isArray(set)) {
-				db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next);
-			} else {
-				db.getSortedSetRevRange(set, start, -1, next);
-			}
-		},
-		function (pids, next) {
-			if (cid) {
-				posts.filterPidsByCid(pids, cid, next);
-			} else {
-				process.nextTick(next, null, pids);
-			}
-		},
-		function (pids, next) {
-			getFlaggedPostsWithReasons(pids, uid, next);
-		},
-		function (posts, next) {
-			var count = posts.length;
-			var end = stop - start + 1;
-			next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count});
-		}
-	], callback);
-};
+// This is the old method to get list of flags, supercede by Flags.list();
+// Flags.get = function (set, cid, uid, start, stop, callback) {
+// 	async.waterfall([
+// 		function (next) {
+// 			if (Array.isArray(set)) {
+// 				db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next);
+// 			} else {
+// 				db.getSortedSetRevRange(set, start, -1, next);
+// 			}
+// 		},
+// 		function (pids, next) {
+// 			if (cid) {
+// 				posts.filterPidsByCid(pids, cid, next);
+// 			} else {
+// 				process.nextTick(next, null, pids);
+// 			}
+// 		},
+// 		function (pids, next) {
+// 			getFlaggedPostsWithReasons(pids, uid, next);
+// 		},
+// 		function (posts, next) {
+// 			var count = posts.length;
+// 			var end = stop - start + 1;
+// 			next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count});
+// 		}
+// 	], callback);
+// };
 
 function getFlaggedPostsWithReasons(pids, uid, callback) {
 	async.waterfall([
diff --git a/src/routes/index.js b/src/routes/index.js
index 0273160745..f71eac2c62 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -40,7 +40,8 @@ function mainRoutes(app, middleware, controllers) {
 }
 
 function modRoutes(app, middleware, controllers) {
-	setupPageRoute(app, '/flags', middleware, [], controllers.mods.flagged);
+	setupPageRoute(app, '/flags', middleware, [], controllers.mods.flags.list);
+	setupPageRoute(app, '/flags/:flagId', middleware, [], controllers.mods.flags.detail);
 }
 
 function globalModRoutes(app, middleware, controllers) {

From 9f7c4befea24d4af92bfb60fecc065125f6b5ffe Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 25 Nov 2016 15:09:52 -0500
Subject: [PATCH 05/48] omg tests (#5232), and .create returns flag data now

---
 src/controllers/mods.js |   6 ++-
 src/flags.js            |  23 +++++----
 test/flags.js           | 107 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 126 insertions(+), 10 deletions(-)
 create mode 100644 test/flags.js

diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index f5a6e9b06d..7ebb70d888 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -25,7 +25,11 @@ modsController.flags.list = function (req, res, next) {
 			res.locals.cids = results.moderatedCids;
 		}
 
-		flags.list({}, function(err, flags) {
+		flags.list({}, function (err, flags) {
+			if (err) {
+				return next(err);
+			}
+
 			res.render('flags/list', {
 				flags: flags
 			});
diff --git a/src/flags.js b/src/flags.js
index 8734f811a8..d9f6aa136f 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -64,7 +64,7 @@ Flags.list = function (filters, callback) {
 									'icon:text': userObj['icon:text']
 								}
 							}));
-						})
+						});
 					}
 				], function (err, flagObj) {
 					if (err) {
@@ -110,6 +110,10 @@ Flags.create = function (type, id, uid, reason, callback) {
 				async.apply(Flags.exists, type, id, uid),
 				async.apply(Flags.targetExists, type, id)
 			], function (err, checks) {
+				if (err) {
+					return next(err);
+				}
+
 				if (checks[0]) {
 					return next(new Error('[[error:already-flagged]]'));
 				} else if (!checks[1]) {
@@ -132,15 +136,16 @@ Flags.create = function (type, id, uid, reason, callback) {
 				})),
 				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId),
 				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId)
-			], next);
-		}
-	], function (err) {
-		if (err) {
-			return callback(err);
-		}
+			], function (err, data) {
+				if (err) {
+					return next(err);
+				}
 
-		callback();
-	});
+				next(null, flagId);
+			});
+		},
+		async.apply(Flags.get)
+	], callback);
 	// if (!parseInt(uid, 10) || !reason) {
 	// 	return callback();
 	// }
diff --git a/test/flags.js b/test/flags.js
new file mode 100644
index 0000000000..ed879fba49
--- /dev/null
+++ b/test/flags.js
@@ -0,0 +1,107 @@
+'use strict';
+/*globals require, before, after, describe, it*/
+
+var assert = require('assert');
+var async = require('async');
+
+var db = require('./mocks/databasemock');
+var Flags = require('../src/flags');
+var Categories = require('../src/categories');
+var Topics = require('../src/topics');
+var User = require('../src/user');
+
+describe('Flags', function () {
+	before(function (done) {
+		// Create some stuff to flag
+		async.waterfall([
+			async.apply(User.create, {username: 'testUser', password: 'abcdef', email: 'b@c.com'}),
+			function (uid, next) {
+				Categories.create({
+					name: 'test category'
+				}, function (err, category) {
+					if (err) {
+						return done(err);
+					}
+
+					Topics.post({
+						cid: category.cid,
+						uid: uid,
+						title: 'Topic to flag',
+						content: 'This is flaggable content'
+					}, next);
+				});
+			}
+		], done);
+	});
+
+	describe('.create()', function () {
+		it('should create a flag and return its data', function (done) {
+			Flags.create('post', 1, 1, 'Test flag', function (err, flagData) {
+				assert.ifError(err);
+				var compare = {
+					flagId: 1,
+					uid: 1,
+					targetId: 1,
+					type: 'post',
+					description: 'Test flag',
+					state: 'open'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(flagData[key]);
+						assert.strictEqual(flagData[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.get()', function () {
+		it('should retrieve and display a flag\'s data', function (done) {
+			Flags.get(1, function (err, flagData) {
+				assert.ifError(err);
+				var compare = {
+					flagId: 1,
+					uid: 1,
+					targetId: 1,
+					type: 'post',
+					description: 'Test flag',
+					state: 'open'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(flagData[key]);
+						assert.strictEqual(flagData[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.list()', function () {
+		it('should show a list of flags (with one item)', function (done) {
+			Flags.list({}, function (err, flags) {
+				assert.ifError(err);
+				assert.ok(Array.isArray(flags));
+				assert.equal(flags.length, 1);
+				
+				Flags.get(flags[0].flagId, function (err, flagData) {
+					assert.ifError(err);
+					assert.equal(flags[0].flagId, flagData.flagId);
+					assert.equal(flags[0].description, flagData.description);
+					done();
+				});
+			});
+		});
+	});
+
+	after(function (done) {
+		db.emptydb(done);
+	});
+});

From fceb5cc86b600087bff8b2adc983e70c3fbb725e Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Tue, 29 Nov 2016 22:10:51 -0500
Subject: [PATCH 06/48] more work on flags detail pages (#5232)

---
 public/language/en-GB/flags.json | 16 +++++++++-----
 public/language/en-GB/topic.json |  5 -----
 src/flags.js                     | 38 +++++++++++++++++++++++++++-----
 3 files changed, 44 insertions(+), 15 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index ad8456ebf6..bf22e68df6 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -1,7 +1,13 @@
 {
-    "quick-filters": "Quick Filters",
-    "state": "State",
-    "reporter": "Reporter",
-    "reported-at": "Reported At",
-    "no-flags": "Hooray! No flags found."
+	"quick-filters": "Quick Filters",
+	"state": "State",
+	"reporter": "Reporter",
+	"reported-at": "Reported At",
+	"no-flags": "Hooray! No flags found.",
+
+	"state": "State",
+	"state-open": "New/Open",
+	"state-wip": "Work in Progress",
+	"state-resolved": "Resolved",
+	"state-rejected": "Rejected"
 }
\ No newline at end of file
diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json
index 29a85c15cc..885afe5d62 100644
--- a/public/language/en-GB/topic.json
+++ b/public/language/en-GB/topic.json
@@ -41,11 +41,6 @@
 	"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",
diff --git a/src/flags.js b/src/flags.js
index d9f6aa136f..65ab029e2a 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -20,21 +20,30 @@ var Flags = {
 
 Flags.get = function (flagId, callback) {
 	async.waterfall([
+		// First stage
 		async.apply(async.parallel, {
 			base: async.apply(db.getObject.bind(db), 'flag:' + flagId),
 			history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1),
 			notes: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':notes', 0, -1)
 		}),
 		function (data, next) {
-			user.getUserFields(data.base.uid, ['username', 'picture'], function (err, userObj) {
+			// Second stage
+			async.parallel({
+				userObj: async.apply(user.getUserFields, data.base.uid, ['username', 'picture']),
+				targetObj: async.apply(Flags.getTarget, data.base.type, data.base.targetId, data.base.uid)
+			}, function (err, payload) {
+				// Final object return construction
 				next(err, Object.assign(data.base, {
+					datetimeISO: new Date(data.base.datetime).toISOString(),
+					target_readable: data.base.type.charAt(0).toUpperCase() + data.base.type.slice(1) + ' ' + data.base.targetId,
+					target: payload.targetObj,
 					history: data.history,
 					notes: data.notes,
 					reporter: {
-						username: userObj.username,
-						picture: userObj.picture,
-						'icon:bgColor': userObj['icon:bgColor'],
-						'icon:text': userObj['icon:text']
+						username: payload.userObj.username,
+						picture: payload.userObj.picture,
+						'icon:bgColor': payload.userObj['icon:bgColor'],
+						'icon:text': payload.userObj['icon:text']
 					}
 				}));
 			});
@@ -102,6 +111,25 @@ Flags.list = function (filters, callback) {
 	});
 };
 
+Flags.getTarget = function (type, id, uid, callback) {
+	switch (type) {
+		case 'post':
+			async.waterfall([
+				async.apply(posts.getPostsByPids, [id], uid),
+				function (posts, next) {
+					topics.addPostData(posts, uid, next);
+				}
+			], function (err, posts) {
+				callback(err, posts[0]);
+			});
+			break;
+		
+		case 'user':
+			user.getUsersData(id, callback);
+			break;
+	}
+};
+
 Flags.create = function (type, id, uid, reason, callback) {
 	async.waterfall([
 		function (next) {

From 77809b2b52002d62babde09b10067e93c845a1fe Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 30 Nov 2016 20:26:45 -0500
Subject: [PATCH 07/48] added avatar-xl size

---
 public/less/generics.less | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/public/less/generics.less b/public/less/generics.less
index 8533dd33b9..5b3c8eff4e 100644
--- a/public/less/generics.less
+++ b/public/less/generics.less
@@ -107,6 +107,12 @@
 	}
 
 	&.avatar-lg {
+		width: 64px;
+		height: 64px;
+		.user-icon-style(64px, 4rem);
+	}
+
+	&.avatar-xl {
 		width: 128px;
 		height: 128px;
 		.user-icon-style(128px, 7.5rem);

From c5c2d27180a5ab39edd87d3fd83a0b86a4ff85a3 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 30 Nov 2016 20:34:06 -0500
Subject: [PATCH 08/48] flag assignees, state, notes WIP, #5232

---
 public/language/en-GB/flags.json |  7 +++++-
 public/language/en-GB/topic.json |  4 ----
 src/controllers/mods.js          |  7 ++++--
 src/flags.js                     | 40 +++++++++++++++++++++++++++++++-
 src/user/data.js                 | 13 +++++++++++
 5 files changed, 63 insertions(+), 8 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index bf22e68df6..d266e2c4db 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -4,10 +4,15 @@
 	"reporter": "Reporter",
 	"reported-at": "Reported At",
 	"no-flags": "Hooray! No flags found.",
+	"assignee": "Assignee",
+	"update": "Update",
+	"notes": "Flag Notes",
+	"add-note": "Add Note",
 
 	"state": "State",
 	"state-open": "New/Open",
 	"state-wip": "Work in Progress",
 	"state-resolved": "Resolved",
-	"state-rejected": "Rejected"
+	"state-rejected": "Rejected",
+	"no-assignee": "Not Assigned"
 }
\ No newline at end of file
diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json
index 885afe5d62..ce7f35f816 100644
--- a/public/language/en-GB/topic.json
+++ b/public/language/en-GB/topic.json
@@ -37,12 +37,8 @@
 
 	"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_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",
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 7ebb70d888..012ffde3c6 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -41,7 +41,8 @@ modsController.flags.detail = function (req, res, next) {
 	async.parallel({
 		isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid),
 		moderatedCids: async.apply(user.getModeratedCids, req.uid),
-		flagData: async.apply(flags.get, req.params.flagId)
+		flagData: async.apply(flags.get, req.params.flagId),
+		assignees: async.apply(user.getAdminsandGlobalModsandModerators)
 	}, function (err, results) {
 		if (err || !results.flagData) {
 			return next(err || new Error('[[error:invalid-data]]'));
@@ -49,7 +50,9 @@ modsController.flags.detail = function (req, res, next) {
 			return next(new Error('[[error:no-privileges]]'));
 		}
 
-		res.render('flags/detail', results.flagData);
+		res.render('flags/detail', Object.assign(results.flagData, {
+			assignees: results.assignees
+		}));
 	});
 };
 
diff --git a/src/flags.js b/src/flags.js
index 65ab029e2a..b88dac6b2f 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -10,6 +10,7 @@ var analytics = require('./analytics');
 var topics = require('./topics');
 var posts = require('./posts');
 var utils = require('../public/src/utils');
+var _ = require('underscore');
 
 var Flags = {
 	_defaults: {
@@ -24,7 +25,7 @@ Flags.get = function (flagId, callback) {
 		async.apply(async.parallel, {
 			base: async.apply(db.getObject.bind(db), 'flag:' + flagId),
 			history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1),
-			notes: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':notes', 0, -1)
+			notes: async.apply(Flags.getNotes, flagId)
 		}),
 		function (data, next) {
 			// Second stage
@@ -130,6 +131,43 @@ Flags.getTarget = function (type, id, uid, callback) {
 	}
 };
 
+Flags.getNotes = function (flagId, callback) {
+	async.waterfall([
+		async.apply(db.getSortedSetRevRangeWithScores.bind(db), 'flag:' + flagId + ':notes', 0, -1),
+		function (notes, next) {
+			var uids = [];
+			var noteObj;
+			notes = notes.map(function (note) {
+				try {
+					noteObj = JSON.parse(note.value);
+					uids.push(noteObj[0]);
+					return {
+						uid: noteObj[0],
+						content: noteObj[1],
+						datetime: note.score,
+						datetimeISO: new Date(note.score).toISOString()
+					};
+				} catch (e) {
+					return next(e);
+				}
+			});
+			next(null, notes, uids);
+		},
+		function (notes, uids, next) {
+			user.getUsersData(uids, function (err, users) {
+				if (err) {
+					return next(err);
+				}
+
+				next(null, notes.map(function (note, idx) {
+					note.user = users[idx];
+					return note;
+				}));
+			});
+		}
+	], callback);
+};
+
 Flags.create = function (type, id, uid, reason, callback) {
 	async.waterfall([
 		function (next) {
diff --git a/src/user/data.js b/src/user/data.js
index cbaf066ded..1e10704c73 100644
--- a/src/user/data.js
+++ b/src/user/data.js
@@ -80,6 +80,15 @@ module.exports = function (User) {
 			return callback(null, []);
 		}
 
+		// Eliminate duplicates and build ref table
+		uids = uids.filter(function (uid, index) {
+			return index === uids.indexOf(uid);
+		});
+		var ref = uids.reduce(function (memo, cur, idx) {
+			memo[cur] = idx;
+			return memo;
+		}, {});
+
 		var keys = uids.map(function (uid) {
 			return 'user:' + uid;
 		});
@@ -89,6 +98,10 @@ module.exports = function (User) {
 				return callback(err);
 			}
 
+			users = uids.map(function (uid) {
+				return users[ref[uid]];
+			});
+
 			modifyUserData(users, [], callback);
 		});
 	};

From e9ff605a2024006bd7c8c451b057981cea58bb2f Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 1 Dec 2016 09:24:49 -0500
Subject: [PATCH 09/48] some more tests for #5232

---
 src/flags.js  |  4 +++-
 test/flags.js | 42 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 45 insertions(+), 1 deletion(-)

diff --git a/src/flags.js b/src/flags.js
index b88dac6b2f..8be774b467 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -126,7 +126,9 @@ Flags.getTarget = function (type, id, uid, callback) {
 			break;
 		
 		case 'user':
-			user.getUsersData(id, callback);
+			user.getUsersData([id], function (err, users) {
+				callback(err, users ? users[0] : undefined);
+			});
 			break;
 	}
 };
diff --git a/test/flags.js b/test/flags.js
index ed879fba49..c5e69aa973 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -101,6 +101,48 @@ describe('Flags', function () {
 		});
 	});
 
+	describe('.getTarget()', function() {
+		it('should return a post\'s data if queried with type "post"', function (done) {
+			Flags.getTarget('post', 1, 1, function (err, data) {
+				assert.ifError(err);
+				var compare = {
+					uid: 1,
+					pid: 1,
+					content: 'This is flaggable content'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(data[key]);
+						assert.strictEqual(data[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+
+		it('should return a user\'s data if queried with type "user"', function (done) {
+			Flags.getTarget('user', 1, 1, function (err, data) {
+				assert.ifError(err);
+				var compare = {
+					uid: 1,
+					username: 'testUser',
+					email: 'b@c.com'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(data[key]);
+						assert.strictEqual(data[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+	});;
+
 	after(function (done) {
 		db.emptydb(done);
 	});

From 888c120e08e7c725c820a302aac5d0e766254114 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 1 Dec 2016 09:42:24 -0500
Subject: [PATCH 10/48] removed some unneeded methods for #5232

---
 src/flags.js | 118 ---------------------------------------------------
 1 file changed, 118 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 8be774b467..99039aa175 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -292,11 +292,6 @@ Flags.targetExists = function (type, id, callback) {
 	}
 };
 
-/* new signature (type, id, uid, callback) */
-Flags.isFlaggedByUser = function (pid, uid, callback) {
-	db.isSortedSetMember('pid:' + pid + ':flag:uids', uid, callback);
-};
-
 Flags.dismiss = function (pid, callback) {
 	async.waterfall([
 		function (next) {
@@ -378,119 +373,6 @@ Flags.dismissByUid = function (uid, callback) {
 	});
 };
 
-// This is the old method to get list of flags, supercede by Flags.list();
-// Flags.get = function (set, cid, uid, start, stop, callback) {
-// 	async.waterfall([
-// 		function (next) {
-// 			if (Array.isArray(set)) {
-// 				db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next);
-// 			} else {
-// 				db.getSortedSetRevRange(set, start, -1, next);
-// 			}
-// 		},
-// 		function (pids, next) {
-// 			if (cid) {
-// 				posts.filterPidsByCid(pids, cid, next);
-// 			} else {
-// 				process.nextTick(next, null, pids);
-// 			}
-// 		},
-// 		function (pids, next) {
-// 			getFlaggedPostsWithReasons(pids, uid, next);
-// 		},
-// 		function (posts, next) {
-// 			var count = posts.length;
-// 			var end = stop - start + 1;
-// 			next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count});
-// 		}
-// 	], callback);
-// };
-
-function getFlaggedPostsWithReasons(pids, uid, callback) {
-	async.waterfall([
-		function (next) {
-			async.parallel({
-				uidsReasons: function (next) {
-					async.map(pids, function (pid, next) {
-						db.getSortedSetRange('pid:' + pid + ':flag:uid:reason', 0, -1, next);
-					}, next);
-				},
-				posts: function (next) {
-					posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next);
-				}
-			}, next);
-		},
-		function (results, next) {
-			async.map(results.uidsReasons, function (uidReasons, next) {
-				async.map(uidReasons, function (uidReason, next) {
-					var uid = uidReason.split(':')[0];
-					var reason = uidReason.substr(uidReason.indexOf(':') + 1);
-					user.getUserFields(uid, ['username', 'userslug', 'picture'], function (err, userData) {
-						next(err, {user: userData, reason: reason});
-					});
-				}, next);
-			}, function (err, reasons) {
-				if (err) {
-					return callback(err);
-				}
-
-				results.posts.forEach(function (post, index) {
-					if (post) {
-						post.flagReasons = reasons[index];
-					}
-				});
-
-				next(null, results.posts);
-			});
-		},
-		async.apply(Flags.expandFlagHistory),
-		function (posts, next) {
-			// Parse out flag data into its own object inside each post hash
-			async.map(posts, function (postObj, next) {
-				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];
-					}
-				}
-
-				if (postObj.flagData.assignee) {
-					user.getUserFields(parseInt(postObj.flagData.assignee, 10), ['username', 'picture'], function (err, userData) {
-						if (err) {
-							return next(err);
-						}
-
-						postObj.flagData.assigneeUser = userData;
-						next(null, postObj);
-					});
-				} else {
-					setImmediate(next.bind(null, null, postObj));
-				}
-			}, next);
-		}
-	], callback);
-}
-
 // New method signature (type, id, flagObj, callback) and name (.update())
 // uid used in history string, which should be rewritten too.
 Flags.update = function (uid, pid, flagObj, callback) {

From 709a7ff7f00df8fd70c5308337f9bd774d05f759 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 1 Dec 2016 11:04:37 -0500
Subject: [PATCH 11/48] fixed issue with getUsersData not actually returning
 the same number of elements as was passed-in

---
 src/user/data.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/user/data.js b/src/user/data.js
index 1e10704c73..2e5bfb2218 100644
--- a/src/user/data.js
+++ b/src/user/data.js
@@ -81,15 +81,15 @@ module.exports = function (User) {
 		}
 
 		// Eliminate duplicates and build ref table
-		uids = uids.filter(function (uid, index) {
+		var uniqueUids = uids.filter(function (uid, index) {
 			return index === uids.indexOf(uid);
 		});
-		var ref = uids.reduce(function (memo, cur, idx) {
+		var ref = uniqueUids.reduce(function (memo, cur, idx) {
 			memo[cur] = idx;
 			return memo;
 		}, {});
 
-		var keys = uids.map(function (uid) {
+		var keys = uniqueUids.map(function (uid) {
 			return 'user:' + uid;
 		});
 

From d9d60c20bd743abd0a027efef474916278e3d793 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 1 Dec 2016 11:42:06 -0500
Subject: [PATCH 12/48] flag updating and note appending, #5232

---
 public/language/en-GB/flags.json  |   4 +-
 public/src/client/flags/detail.js |  60 +++++++++++
 public/src/client/topic/flag.js   |   2 +-
 src/flags.js                      | 156 ++++++---------------------
 src/socket.io/flags.js            | 167 ++++++++++++++++++++++++++++
 src/socket.io/index.js            |   6 +-
 src/socket.io/posts.js            |   1 -
 src/socket.io/posts/flag.js       | 173 ------------------------------
 8 files changed, 270 insertions(+), 299 deletions(-)
 create mode 100644 public/src/client/flags/detail.js
 create mode 100644 src/socket.io/flags.js
 delete mode 100644 src/socket.io/posts/flag.js

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index d266e2c4db..0c1f32b88e 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -14,5 +14,7 @@
 	"state-wip": "Work in Progress",
 	"state-resolved": "Resolved",
 	"state-rejected": "Rejected",
-	"no-assignee": "Not Assigned"
+	"no-assignee": "Not Assigned",
+	"updated": "Flag Details Updated",
+	"note-added": "Note Added"
 }
\ No newline at end of file
diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js
new file mode 100644
index 0000000000..d23a1941b5
--- /dev/null
+++ b/public/src/client/flags/detail.js
@@ -0,0 +1,60 @@
+'use strict';
+
+/* globals define */
+
+define('forum/flags/detail', ['components'], function (components) {
+	var Flags = {};
+
+	Flags.init = function () {
+		// Update attributes
+		$('#state').val(ajaxify.data.state).removeAttr('disabled');
+		$('#assignee').val(ajaxify.data.assignee).removeAttr('disabled');
+
+		$('[data-action]').on('click', function () {
+			var action = this.getAttribute('data-action');
+
+			switch (action) {
+				case 'update':
+					socket.emit('flags.update', {
+						flagId: ajaxify.data.flagId,
+						data: $('#attributes').serializeArray()
+					}, function (err) {
+						if (err) {
+							return app.alertError(err.message);
+						} else {
+							app.alertSuccess('[[flags:updated]]');
+						}
+					});
+					break;
+				
+				case 'appendNote':
+					socket.emit('flags.appendNote', {
+						flagId: ajaxify.data.flagId,
+						note: document.getElementById('note').value
+					}, function (err, notes) {
+						if (err) {
+							return app.alertError(err.message);
+						} else {
+							app.alertSuccess('[[flags:note-added]]');
+							Flags.reloadNotes(notes);
+						}
+					});
+					break;
+			}
+		});
+	};
+
+	Flags.reloadNotes = function (notes) {
+		templates.parse('flags/detail', 'notes', {
+			notes: notes
+		}, function (html) {
+			var wrapperEl = components.get('flag/notes');
+			wrapperEl.empty();
+			wrapperEl.html(html);
+			wrapperEl.find('span.timeago').timeago();
+			document.getElementById('note').value = '';
+		});
+	};
+
+	return Flags;
+});
diff --git a/public/src/client/topic/flag.js b/public/src/client/topic/flag.js
index 78b1dd5d2a..046585ae68 100644
--- a/public/src/client/topic/flag.js
+++ b/public/src/client/topic/flag.js
@@ -48,7 +48,7 @@ define('forum/topic/flag', [], function () {
 		if (!pid || !reason) {
 			return;
 		}
-		socket.emit('posts.flag', {pid: pid, reason: reason}, function (err) {
+		socket.emit('flags.create', {pid: pid, reason: reason}, function (err) {
 			if (err) {
 				return app.alertError(err.message);
 			}
diff --git a/src/flags.js b/src/flags.js
index 99039aa175..323a2a1e07 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -373,138 +373,52 @@ Flags.dismissByUid = function (uid, callback) {
 	});
 };
 
-// New method signature (type, id, flagObj, callback) and name (.update())
-// uid used in history string, which should be rewritten too.
-Flags.update = function (uid, pid, flagObj, callback) {
+Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
-	var changes = [];
-	var changeset = {};
-	var prop;
+	var fields = ['state', 'assignee'];
 
-	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) && flagObj[prop].length) {
-				changes.push(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));
-			}
-		}
-
-		changeset = changes.reduce(function (memo, prop) {
-			memo['flag:' + prop] = flagObj[prop];
-			return memo;
-		}, {});
-
-		// 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()
-							});
+	async.waterfall([
+		async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields),
+		function (current, next) {
+			for(var prop in changeset) {
+				if (changeset.hasOwnProperty(prop)) {
+					if (current[prop] === changeset[prop]) {
+						delete changeset[prop];
 					}
-				});
+				}
+			}
 
-				changeset['flag:history'] = JSON.stringify(history);
-			} catch (e) {
-				winston.warn('[flags/update] Unable to deserialise post flag history, likely malformed data');
+			if (!Object.keys(changeset).length) {
+				// No changes
+				return next();
 			}
-		}
 
-		// Save flag data into post hash
-		if (changes.length) {
-			posts.setPostFields(pid, changeset, callback);
-		} else {
-			setImmediate(callback);
+			async.parallel([
+				// Save new object to db (upsert)
+				async.apply(db.setObject, 'flag:' + flagId, changeset),
+				// Append history
+				async.apply(Flags.appendHistory, flagId, uid, Object.keys(changeset))
+			], next);
 		}
-	});
+	], callback);
 };
 
-// To be rewritten and deprecated
-Flags.expandFlagHistory = function (posts, callback) {
-	// Expand flag history
-	async.map(posts, function (post, next) {
-		var history;
-		try {
-			history = JSON.parse(post['flag:history'] || '[]');
-		} catch (e) {
-			winston.warn('[flags/get] Unable to deserialise post flag history, likely malformed data');
-			return 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);
-						}
+Flags.appendHistory = function (flagId, uid, changeset, callback) {
+	return callback();
+};
 
-						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);
-			}
+Flags.appendNote = function (flagId, uid, note, callback) {
+	var payload;
+	try {
+		payload = JSON.stringify([uid, note]);
+	} catch (e) {
+		return callback(e);
+	}
 
-			post['flag:history'] = history;
-			next(null, post);
-		});
-	}, callback);
+	async.waterfall([
+		async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload),
+		async.apply(Flags.getNotes, flagId)
+	], callback);
 };
 
 module.exports = Flags;
\ No newline at end of file
diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js
new file mode 100644
index 0000000000..28db7551a6
--- /dev/null
+++ b/src/socket.io/flags.js
@@ -0,0 +1,167 @@
+'use strict';
+
+var async = require('async');
+var S = require('string');
+
+var user = require('../user');
+var groups = require('../groups');
+var posts = require('../posts');
+var topics = require('../topics');
+var privileges = require('../privileges');
+var notifications = require('../notifications');
+var plugins = require('../plugins');
+var meta = require('../meta');
+var utils = require('../../public/src/utils');
+var flags = require('../flags');
+
+var SocketFlags = {};
+
+SocketFlags.create = function (socket, data, callback) {
+	if (!socket.uid) {
+		return callback(new Error('[[error:not-logged-in]]'));
+	}
+
+	if (!data || !data.pid || !data.reason) {
+		return callback(new Error('[[error:invalid-data]]'));
+	}
+
+	var flaggingUser = {};
+	var post;
+
+	async.waterfall([
+		function (next) {
+			posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'deleted'], next);
+		},
+		function (postData, next) {
+			if (parseInt(postData.deleted, 10) === 1) {
+				return next(new Error('[[error:post-deleted]]'));
+			}
+
+			post = postData;
+			topics.getTopicFields(post.tid, ['title', 'cid'], next);
+		},
+		function (topicData, next) {
+			post.topic = topicData;
+
+			async.parallel({
+				isAdminOrMod: function (next) {
+					privileges.categories.isAdminOrMod(post.topic.cid, socket.uid, next);
+				},
+				userData: function (next) {
+					user.getUserFields(socket.uid, ['username', 'reputation', 'banned'], next);
+				}
+			}, next);
+		},
+		function (user, next) {
+			var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
+			if (!user.isAdminOrMod && parseInt(user.userData.reputation, 10) < minimumReputation) {
+				return next(new Error('[[error:not-enough-reputation-to-flag]]'));
+			}
+
+			if (parseInt(user.banned, 10) === 1) {
+				return next(new Error('[[error:user-banned]]'));
+			}
+
+			flaggingUser = user.userData;
+			flaggingUser.uid = socket.uid;
+
+			flags.create('post', post.pid, socket.uid, data.reason, next);
+		},
+		function (next) {
+			async.parallel({
+				post: function (next) {
+					posts.parsePost(post, next);
+				},
+				admins: function (next) {
+					groups.getMembers('administrators', 0, -1, next);
+				},
+				globalMods: function (next) {
+					groups.getMembers('Global Moderators', 0, -1, next);
+				},
+				moderators: function (next) {
+					groups.getMembers('cid:' + post.topic.cid + ':privileges:mods', 0, -1, next);
+				}
+			}, next);
+		},
+		function (results, next) {
+			var title = S(post.topic.title).decodeHTMLEntities().s;
+			var titleEscaped = title.replace(/%/g, '&#37;').replace(/,/g, '&#44;');
+
+			notifications.create({
+				bodyShort: '[[notifications:user_flagged_post_in, ' + flaggingUser.username + ', ' + titleEscaped + ']]',
+				bodyLong: post.content,
+				pid: data.pid,
+				path: '/post/' + data.pid,
+				nid: 'post_flag:' + data.pid + ':uid:' + socket.uid,
+				from: socket.uid,
+				mergeId: 'notifications:user_flagged_post_in|' + data.pid,
+				topicTitle: post.topic.title
+			}, function (err, notification) {
+				if (err || !notification) {
+					return next(err);
+				}
+
+				plugins.fireHook('action:post.flag', {post: post, reason: data.reason, flaggingUser: flaggingUser});
+				notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), next);
+			});
+		}
+	], callback);
+};
+
+SocketFlags.update = function (socket, data, callback) {
+	if (!data || !(data.flagId && data.data)) {
+		return callback(new Error('[[error:invalid-data]]'));
+	}
+
+	var payload = {};
+
+	async.waterfall([
+		function (next) {
+			async.parallel([
+				async.apply(user.isAdminOrGlobalMod, socket.uid),
+				async.apply(user.isModeratorOfAnyCategory, socket.uid)
+			], function (err, results) {
+				next(err, results[0] || results[1]);
+			});
+		},
+		function (allowed, next) {
+			if (!allowed) {
+				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);
+
+			flags.update(data.flagId, socket.uid, payload, next);
+		}
+	], callback);
+};
+
+SocketFlags.appendNote = function (socket, data, callback) {
+	if (!data || !(data.flagId && data.note)) {
+		return callback(new Error('[[error:invalid-data]]'));
+	}
+
+	async.waterfall([
+		function (next) {
+			async.parallel([
+				async.apply(user.isAdminOrGlobalMod, socket.uid),
+				async.apply(user.isModeratorOfAnyCategory, socket.uid)
+			], function (err, results) {
+				next(err, results[0] || results[1]);
+			});
+		},
+		function (allowed, next) {
+			if (!allowed) {
+				return next(new Error('[[no-privileges]]'));
+			}
+
+			flags.appendNote(data.flagId, socket.uid, data.note, next);
+		}
+	], callback);
+};
+
+module.exports = SocketFlags;
diff --git a/src/socket.io/index.js b/src/socket.io/index.js
index 38e73c1cc1..43157f19ad 100644
--- a/src/socket.io/index.js
+++ b/src/socket.io/index.js
@@ -123,8 +123,10 @@ var ratelimit = require('../middleware/ratelimit');
 	}
 
 	function requireModules() {
-		var modules = ['admin', 'categories', 'groups', 'meta', 'modules',
-			'notifications', 'plugins', 'posts', 'topics', 'user', 'blacklist'
+		var modules = [
+			'admin', 'categories', 'groups', 'meta', 'modules',
+			'notifications', 'plugins', 'posts', 'topics', 'user',
+			'blacklist', 'flags'
 		];
 
 		modules.forEach(function (module) {
diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js
index fe729a5c11..b5309dd3cf 100644
--- a/src/socket.io/posts.js
+++ b/src/socket.io/posts.js
@@ -20,7 +20,6 @@ require('./posts/move')(SocketPosts);
 require('./posts/votes')(SocketPosts);
 require('./posts/bookmarks')(SocketPosts);
 require('./posts/tools')(SocketPosts);
-require('./posts/flag')(SocketPosts);
 
 SocketPosts.reply = function (socket, data, callback) {
 	if (!data || !data.tid || !data.content) {
diff --git a/src/socket.io/posts/flag.js b/src/socket.io/posts/flag.js
deleted file mode 100644
index 88b47058d1..0000000000
--- a/src/socket.io/posts/flag.js
+++ /dev/null
@@ -1,173 +0,0 @@
-'use strict';
-
-var async = require('async');
-var S = require('string');
-
-var user = require('../../user');
-var groups = require('../../groups');
-var posts = require('../../posts');
-var topics = require('../../topics');
-var privileges = require('../../privileges');
-var notifications = require('../../notifications');
-var plugins = require('../../plugins');
-var meta = require('../../meta');
-var utils = require('../../../public/src/utils');
-var flags = require('../../flags');
-
-module.exports = function (SocketPosts) {
-
-	SocketPosts.flag = function (socket, data, callback) {
-		if (!socket.uid) {
-			return callback(new Error('[[error:not-logged-in]]'));
-		}
-
-		if (!data || !data.pid || !data.reason) {
-			return callback(new Error('[[error:invalid-data]]'));
-		}
-
-		var flaggingUser = {};
-		var post;
-
-		async.waterfall([
-			function (next) {
-				posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'deleted'], next);
-			},
-			function (postData, next) {
-				if (parseInt(postData.deleted, 10) === 1) {
-					return next(new Error('[[error:post-deleted]]'));
-				}
-
-				post = postData;
-				topics.getTopicFields(post.tid, ['title', 'cid'], next);
-			},
-			function (topicData, next) {
-				post.topic = topicData;
-
-				async.parallel({
-					isAdminOrMod: function (next) {
-						privileges.categories.isAdminOrMod(post.topic.cid, socket.uid, next);
-					},
-					userData: function (next) {
-						user.getUserFields(socket.uid, ['username', 'reputation', 'banned'], next);
-					}
-				}, next);
-			},
-			function (user, next) {
-				var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
-				if (!user.isAdminOrMod && parseInt(user.userData.reputation, 10) < minimumReputation) {
-					return next(new Error('[[error:not-enough-reputation-to-flag]]'));
-				}
-
-				if (parseInt(user.banned, 10) === 1) {
-					return next(new Error('[[error:user-banned]]'));
-				}
-
-				flaggingUser = user.userData;
-				flaggingUser.uid = socket.uid;
-
-				flags.create('post', post.pid, socket.uid, data.reason, next);
-			},
-			function (next) {
-				async.parallel({
-					post: function (next) {
-						posts.parsePost(post, next);
-					},
-					admins: function (next) {
-						groups.getMembers('administrators', 0, -1, next);
-					},
-					globalMods: function (next) {
-						groups.getMembers('Global Moderators', 0, -1, next);
-					},
-					moderators: function (next) {
-						groups.getMembers('cid:' + post.topic.cid + ':privileges:mods', 0, -1, next);
-					}
-				}, next);
-			},
-			function (results, next) {
-				var title = S(post.topic.title).decodeHTMLEntities().s;
-				var titleEscaped = title.replace(/%/g, '&#37;').replace(/,/g, '&#44;');
-
-				notifications.create({
-					bodyShort: '[[notifications:user_flagged_post_in, ' + flaggingUser.username + ', ' + titleEscaped + ']]',
-					bodyLong: post.content,
-					pid: data.pid,
-					path: '/post/' + data.pid,
-					nid: 'post_flag:' + data.pid + ':uid:' + socket.uid,
-					from: socket.uid,
-					mergeId: 'notifications:user_flagged_post_in|' + data.pid,
-					topicTitle: post.topic.title
-				}, function (err, notification) {
-					if (err || !notification) {
-						return next(err);
-					}
-
-					plugins.fireHook('action:post.flag', {post: post, reason: data.reason, flaggingUser: flaggingUser});
-					notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), next);
-				});
-			}
-		], callback);
-	};
-
-	SocketPosts.dismissFlag = function (socket, pid, callback) {
-		if (!pid || !socket.uid) {
-			return callback(new Error('[[error:invalid-data]]'));
-		}
-		async.waterfall([
-			function (next) {
-				user.isAdminOrGlobalMod(socket.uid, next);
-			},
-			function (isAdminOrGlobalModerator, next) {
-				if (!isAdminOrGlobalModerator) {
-					return next(new Error('[[no-privileges]]'));
-				}
-				flags.dismiss(pid, next);
-			}
-		], callback);
-	};
-
-	SocketPosts.dismissAllFlags = function (socket, data, callback) {
-		async.waterfall([
-			function (next) {
-				user.isAdminOrGlobalMod(socket.uid, next);
-			},
-			function (isAdminOrGlobalModerator, next) {
-				if (!isAdminOrGlobalModerator) {
-					return next(new Error('[[no-privileges]]'));
-				}
-				flags.dismissAll(next);
-			}
-		], callback);
-	};
-
-	SocketPosts.updateFlag = function (socket, data, callback) {
-		if (!data || !(data.pid && data.data)) {
-			return callback(new Error('[[error:invalid-data]]'));
-		}
-
-		var payload = {};
-
-		async.waterfall([
-			function (next) {
-				async.parallel([
-					async.apply(user.isAdminOrGlobalMod, socket.uid),
-					async.apply(user.isModeratorOfAnyCategory, socket.uid)
-				], function (err, results) {
-					next(err, results[0] || results[1]);
-				});
-			},
-			function (allowed, next) {
-				if (!allowed) {
-					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);
-
-				flags.update(socket.uid, data.pid, payload, next);
-			}
-		], callback);
-	};
-};

From f1d144f15e5461769e266cd69a116148df2eb021 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 1 Dec 2016 16:22:10 -0500
Subject: [PATCH 13/48] history appending, finished up notes, #5232

---
 public/language/en-GB/flags.json  |  3 ++
 public/src/client/flags/detail.js | 23 +++++++++---
 src/flags.js                      | 58 ++++++++++++++++++++++++++++---
 src/socket.io/flags.js            |  9 ++++-
 src/user/data.js                  | 17 +++++++--
 5 files changed, 98 insertions(+), 12 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 0c1f32b88e..7b9193680f 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -6,8 +6,11 @@
 	"no-flags": "Hooray! No flags found.",
 	"assignee": "Assignee",
 	"update": "Update",
+	"updated": "Updated",
 	"notes": "Flag Notes",
 	"add-note": "Add Note",
+	"history": "Flag History",
+	"back": "Back to Flags List",
 
 	"state": "State",
 	"state-open": "New/Open",
diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js
index d23a1941b5..aef21ec925 100644
--- a/public/src/client/flags/detail.js
+++ b/public/src/client/flags/detail.js
@@ -2,7 +2,7 @@
 
 /* globals define */
 
-define('forum/flags/detail', ['components'], function (components) {
+define('forum/flags/detail', ['components', 'translator'], function (components, translator) {
 	var Flags = {};
 
 	Flags.init = function () {
@@ -18,11 +18,12 @@ define('forum/flags/detail', ['components'], function (components) {
 					socket.emit('flags.update', {
 						flagId: ajaxify.data.flagId,
 						data: $('#attributes').serializeArray()
-					}, function (err) {
+					}, function (err, history) {
 						if (err) {
 							return app.alertError(err.message);
 						} else {
 							app.alertSuccess('[[flags:updated]]');
+							Flags.reloadHistory(history);
 						}
 					});
 					break;
@@ -31,12 +32,13 @@ define('forum/flags/detail', ['components'], function (components) {
 					socket.emit('flags.appendNote', {
 						flagId: ajaxify.data.flagId,
 						note: document.getElementById('note').value
-					}, function (err, notes) {
+					}, function (err, payload) {
 						if (err) {
 							return app.alertError(err.message);
 						} else {
 							app.alertSuccess('[[flags:note-added]]');
-							Flags.reloadNotes(notes);
+							Flags.reloadNotes(payload.notes);
+							Flags.reloadHistory(payload.history);
 						}
 					});
 					break;
@@ -56,5 +58,18 @@ define('forum/flags/detail', ['components'], function (components) {
 		});
 	};
 
+	Flags.reloadHistory = function (history) {
+		templates.parse('flags/detail', 'history', {
+			history: history
+		}, function (html) {
+			translator.translate(html, function (translated) {
+				var wrapperEl = components.get('flag/history');
+				wrapperEl.empty();
+				wrapperEl.html(translated);
+				wrapperEl.find('span.timeago').timeago();
+			});
+		});
+	};
+
 	return Flags;
 });
diff --git a/src/flags.js b/src/flags.js
index 323a2a1e07..3dc1e51f32 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -24,7 +24,7 @@ Flags.get = function (flagId, callback) {
 		// First stage
 		async.apply(async.parallel, {
 			base: async.apply(db.getObject.bind(db), 'flag:' + flagId),
-			history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1),
+			history: async.apply(Flags.getHistory, flagId),
 			notes: async.apply(Flags.getNotes, flagId)
 		}),
 		function (data, next) {
@@ -156,7 +156,7 @@ Flags.getNotes = function (flagId, callback) {
 			next(null, notes, uids);
 		},
 		function (notes, uids, next) {
-			user.getUsersData(uids, function (err, users) {
+			user.getUsersFields(uids, ['username', 'userslug', 'picture'], function (err, users) {
 				if (err) {
 					return next(err);
 				}
@@ -398,13 +398,61 @@ Flags.update = function (flagId, uid, changeset, callback) {
 				async.apply(db.setObject, 'flag:' + flagId, changeset),
 				// Append history
 				async.apply(Flags.appendHistory, flagId, uid, Object.keys(changeset))
-			], next);
+			], function (err, data) {
+				return next(err);
+			});
 		}
 	], callback);
 };
 
+Flags.getHistory = function (flagId, callback) {
+	var history;
+	var uids = [];
+	async.waterfall([
+		async.apply(db.getSortedSetRevRangeWithScores.bind(db), 'flag:' + flagId + ':history', 0, -1),
+		function (_history, next) {
+			history = _history.map(function (entry) {
+				try {
+					entry.value = JSON.parse(entry.value);
+				} catch (e) {
+					return callback(e);
+				}
+
+				uids.push(entry.value[0]);
+
+				return {
+					uid: entry.value[0],
+					fields: entry.value[1],
+					datetime: entry.score,
+					datetimeISO: new Date(entry.score).toISOString()
+				};
+			});
+
+			user.getUsersFields(uids, ['username', 'userslug', 'picture'], next);
+		}
+	], function (err, users) {
+		if (err) {
+			return callback(err);
+		}
+
+		history = history.map(function (event, idx) {
+			event.user = users[idx];
+			return event;
+		});
+
+		callback(null, history);
+	});
+};
+
 Flags.appendHistory = function (flagId, uid, changeset, callback) {
-	return callback();
+	var payload;
+	try {
+		payload = JSON.stringify([uid, changeset, Date.now()]);
+	} catch (e) {
+		return callback(e);
+	}
+
+	db.sortedSetAdd('flag:' + flagId + ':history', Date.now(), payload, callback);
 };
 
 Flags.appendNote = function (flagId, uid, note, callback) {
@@ -417,7 +465,7 @@ Flags.appendNote = function (flagId, uid, note, callback) {
 
 	async.waterfall([
 		async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload),
-		async.apply(Flags.getNotes, flagId)
+		async.apply(Flags.appendHistory, flagId, uid, ['notes'])
 	], callback);
 };
 
diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js
index 28db7551a6..8b66dd094c 100644
--- a/src/socket.io/flags.js
+++ b/src/socket.io/flags.js
@@ -136,7 +136,8 @@ SocketFlags.update = function (socket, data, callback) {
 			}, payload);
 
 			flags.update(data.flagId, socket.uid, payload, next);
-		}
+		},
+		async.apply(flags.getHistory, data.flagId)
 	], callback);
 };
 
@@ -160,6 +161,12 @@ SocketFlags.appendNote = function (socket, data, callback) {
 			}
 
 			flags.appendNote(data.flagId, socket.uid, data.note, next);
+		},
+		function (next) {
+			async.parallel({
+				"notes": async.apply(flags.getNotes, data.flagId),
+				"history": async.apply(flags.getHistory, data.flagId)
+			}, next);
 		}
 	], callback);
 };
diff --git a/src/user/data.js b/src/user/data.js
index 2e5bfb2218..5716208ae5 100644
--- a/src/user/data.js
+++ b/src/user/data.js
@@ -34,11 +34,20 @@ module.exports = function (User) {
 			}
 		}
 
-		if (!Array.isArray(uids) || !uids.length) {
+		// Eliminate duplicates and build ref table
+		var uniqueUids = uids.filter(function (uid, index) {
+			return index === uids.indexOf(uid);
+		});
+		var ref = uniqueUids.reduce(function (memo, cur, idx) {
+			memo[cur] = idx;
+			return memo;
+		}, {});
+
+		if (!Array.isArray(uniqueUids) || !uniqueUids.length) {
 			return callback(null, []);
 		}
 
-		var keys = uids.map(function (uid) {
+		var keys = uniqueUids.map(function (uid) {
 			return 'user:' + uid;
 		});
 
@@ -60,6 +69,10 @@ module.exports = function (User) {
 				return callback(err);
 			}
 
+			users = uids.map(function (uid) {
+				return users[ref[uid]];
+			});
+
 			modifyUserData(users, fieldsToRemove, callback);
 		});
 	};

From 22eeabc5c73050385eb45254eb2b2099a9781d98 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Thu, 1 Dec 2016 16:36:30 -0500
Subject: [PATCH 14/48] new strings for empty notes or history, #5232

---
 public/language/en-GB/flags.json | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 7b9193680f..cb1f513deb 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -7,10 +7,14 @@
 	"assignee": "Assignee",
 	"update": "Update",
 	"updated": "Updated",
+
 	"notes": "Flag Notes",
 	"add-note": "Add Note",
+	"no-notes": "No shared notes.",
+
 	"history": "Flag History",
 	"back": "Back to Flags List",
+	"no-history": "No flag history.",
 
 	"state": "State",
 	"state-open": "New/Open",

From 839a0efc0a056905e7a6b0a94a21af24e33315c2 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 2 Dec 2016 09:49:25 -0500
Subject: [PATCH 15/48] one more language string for #5232

---
 public/language/en-GB/flags.json | 1 +
 1 file changed, 1 insertion(+)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index cb1f513deb..5f989cd4d0 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -3,6 +3,7 @@
 	"state": "State",
 	"reporter": "Reporter",
 	"reported-at": "Reported At",
+	"description": "Description",
 	"no-flags": "Hooray! No flags found.",
 	"assignee": "Assignee",
 	"update": "Update",

From 20fa8ebf76bb702fc8277a577f0e239185dfca20 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 2 Dec 2016 11:24:12 -0500
Subject: [PATCH 16/48] simplified flags.get a tad

---
 src/flags.js | 9 ++-------
 1 file changed, 2 insertions(+), 7 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 3dc1e51f32..6022750f73 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -30,7 +30,7 @@ Flags.get = function (flagId, callback) {
 		function (data, next) {
 			// Second stage
 			async.parallel({
-				userObj: async.apply(user.getUserFields, data.base.uid, ['username', 'picture']),
+				userObj: async.apply(user.getUserFields, data.base.uid, ['username', 'userslug', 'picture']),
 				targetObj: async.apply(Flags.getTarget, data.base.type, data.base.targetId, data.base.uid)
 			}, function (err, payload) {
 				// Final object return construction
@@ -40,12 +40,7 @@ Flags.get = function (flagId, callback) {
 					target: payload.targetObj,
 					history: data.history,
 					notes: data.notes,
-					reporter: {
-						username: payload.userObj.username,
-						picture: payload.userObj.picture,
-						'icon:bgColor': payload.userObj['icon:bgColor'],
-						'icon:text': payload.userObj['icon:text']
-					}
+					reporter: payload.userObj
 				}));
 			});
 		}

From 0724bee6c65b5e2e0dcb71628f0ce573da62aaf7 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 2 Dec 2016 12:10:19 -0500
Subject: [PATCH 17/48] removed deprecated dismiss methods

---
 src/flags.js | 81 ----------------------------------------------------
 1 file changed, 81 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 6022750f73..54f6f0bd30 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -287,87 +287,6 @@ Flags.targetExists = function (type, id, callback) {
 	}
 };
 
-Flags.dismiss = function (pid, callback) {
-	async.waterfall([
-		function (next) {
-			db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next);
-		},
-		function (postData, next) {
-			if (!postData.pid) {
-				return callback();
-			}
-			async.parallel([
-				function (next) {
-					if (parseInt(postData.uid, 10)) {
-						if (parseInt(postData.flags, 10) > 0) {
-							async.parallel([
-								async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid),
-								async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags)
-							], next);
-						} else {
-							next();
-						}
-					} else {
-						next();
-					}
-				},
-				function (next) {
-					db.sortedSetsRemove([
-						'posts:flagged',
-						'posts:flags:count',
-						'uid:' + postData.uid + ':flag:pids'
-					], pid, next);
-				},
-				function (next) {
-					async.series([
-						function (next) {
-							db.getSortedSetRange('pid:' + pid + ':flag:uids', 0, -1, function (err, uids) {
-								if (err) {
-									return next(err);
-								}
-
-								async.each(uids, function (uid, next) {
-									var nid = 'post_flag:' + pid + ':uid:' + uid;
-									async.parallel([
-										async.apply(db.delete, 'notifications:' + nid),
-										async.apply(db.sortedSetRemove, 'notifications', 'post_flag:' + pid + ':uid:' + uid)
-									], next);
-								}, next);
-							});
-						},
-						async.apply(db.delete, 'pid:' + pid + ':flag:uids')
-					], next);
-				},
-				async.apply(db.deleteObjectField, 'post:' + pid, 'flags'),
-				async.apply(db.delete, 'pid:' + pid + ':flag:uid:reason'),
-				async.apply(db.deleteObjectFields, 'post:' + pid, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history'])
-			], next);
-		},
-		function (results, next) {
-			db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next);
-		}
-	], callback);
-};
-
-// Pretty sure we don't need this method...
-Flags.dismissAll = function (callback) {
-	db.getSortedSetRange('posts:flagged', 0, -1, function (err, pids) {
-		if (err) {
-			return callback(err);
-		}
-		async.eachSeries(pids, Flags.dismiss, callback);
-	});
-};
-
-Flags.dismissByUid = function (uid, callback) {
-	db.getSortedSetRange('uid:' + uid + ':flag:pids', 0, -1, function (err, pids) {
-		if (err) {
-			return callback(err);
-		}
-		async.eachSeries(pids, Flags.dismiss, callback);
-	});
-};
-
 Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];

From 169defd19476648c769a56fe2bbc6b062597f99b Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 2 Dec 2016 12:34:58 -0500
Subject: [PATCH 18/48] #5232, update flag history to save new value

---
 src/flags.js | 20 +++++++++++++++++---
 1 file changed, 17 insertions(+), 3 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 54f6f0bd30..893bba4442 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -290,14 +290,18 @@ Flags.targetExists = function (type, id, callback) {
 Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];
+	var history = [];
 
 	async.waterfall([
 		async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields),
 		function (current, next) {
-			for(var prop in changeset) {
+			for (var prop in changeset) {
 				if (changeset.hasOwnProperty(prop)) {
 					if (current[prop] === changeset[prop]) {
 						delete changeset[prop];
+					} else {
+						// Append to history payload
+						history.push(prop + ':' + changeset[prop]);
 					}
 				}
 			}
@@ -311,7 +315,7 @@ Flags.update = function (flagId, uid, changeset, callback) {
 				// Save new object to db (upsert)
 				async.apply(db.setObject, 'flag:' + flagId, changeset),
 				// Append history
-				async.apply(Flags.appendHistory, flagId, uid, Object.keys(changeset))
+				async.apply(Flags.appendHistory, flagId, uid, history)
 			], function (err, data) {
 				return next(err);
 			});
@@ -334,9 +338,18 @@ Flags.getHistory = function (flagId, callback) {
 
 				uids.push(entry.value[0]);
 
+				// Deserialise field object
+				var fields = entry.value[1].map(function (field) {
+					field = field.toString().split(':');
+					return {
+						"attribute": field[0],
+						"value": field[1] === undefined ? null : field[1]
+					};
+				});
+
 				return {
 					uid: entry.value[0],
-					fields: entry.value[1],
+					fields: fields,
 					datetime: entry.score,
 					datetimeISO: new Date(entry.score).toISOString()
 				};
@@ -349,6 +362,7 @@ Flags.getHistory = function (flagId, callback) {
 			return callback(err);
 		}
 
+		// Append user data to each history event
 		history = history.map(function (event, idx) {
 			event.user = users[idx];
 			return event;

From 9129597811ab10f2d5d8775ed1f8bc9bd1e7dae8 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 2 Dec 2016 12:51:39 -0500
Subject: [PATCH 19/48] #5232 some tweaks to flag history saving

---
 public/language/en-GB/flags.json |  1 -
 src/flags.js                     | 12 +++++++++++-
 2 files changed, 11 insertions(+), 2 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 5f989cd4d0..0cdc3fbfb9 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -23,6 +23,5 @@
 	"state-resolved": "Resolved",
 	"state-rejected": "Rejected",
 	"no-assignee": "Not Assigned",
-	"updated": "Flag Details Updated",
 	"note-added": "Note Added"
 }
\ No newline at end of file
diff --git a/src/flags.js b/src/flags.js
index 893bba4442..58b230af06 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -341,9 +341,19 @@ Flags.getHistory = function (flagId, callback) {
 				// Deserialise field object
 				var fields = entry.value[1].map(function (field) {
 					field = field.toString().split(':');
+
+					switch (field[0]) {
+						case 'state':
+							field[1] = field[1] === undefined ? null : '[[flags:state-' + field[1] + ']]';
+							break;
+
+						default:
+							field[1] = field[1] === undefined ? null : field[1];
+							break;
+					}
 					return {
 						"attribute": field[0],
-						"value": field[1] === undefined ? null : field[1]
+						"value": field[1]
 					};
 				});
 

From 753d4b0275e3927560d9f7c2c8217f6273d4715e Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 2 Dec 2016 15:28:28 -0500
Subject: [PATCH 20/48] wrapped up basic functionality of list and detail for
 flags, filter support. #5232

---
 public/language/en-GB/flags.json  |   7 ++
 public/language/en-GB/topic.json  |   6 --
 public/src/admin/manage/flags.js  | 172 ------------------------------
 public/src/client/flags/detail.js |   4 +-
 public/src/client/flags/list.js   |  28 +++++
 src/controllers/admin.js          |   1 -
 src/controllers/admin/flags.js    | 104 ------------------
 src/controllers/mods.js           |  12 ++-
 src/flags.js                      |  31 +++++-
 src/routes/admin.js               |   1 -
 src/socket.io/flags.js            |   2 +-
 11 files changed, 78 insertions(+), 290 deletions(-)
 delete mode 100644 public/src/admin/manage/flags.js
 create mode 100644 public/src/client/flags/list.js
 delete mode 100644 src/controllers/admin/flags.js

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 0cdc3fbfb9..cdc3c4069e 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -9,6 +9,13 @@
 	"update": "Update",
 	"updated": "Updated",
 
+	"filters": "Filter Options",
+	"filter-reporterId": "Reporter UID",
+	"filter-type": "Flag Type",
+	"filter-type-all": "All Content",
+	"filter-type-post": "Post",
+	"apply-filters": "Apply Filters",
+
 	"notes": "Flag Notes",
 	"add-note": "Add Note",
 	"no-notes": "No shared notes.",
diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json
index ce7f35f816..4ae208076e 100644
--- a/public/language/en-GB/topic.json
+++ b/public/language/en-GB/topic.json
@@ -37,12 +37,6 @@
 
 	"flag_title": "Flag this post for moderation",
 	"flag_success": "This post has been flagged for moderation.",
-	"flag_manage_history": "Action History",
-	"flag_manage_no_history": "No event history to report",
-	"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.",
 
diff --git a/public/src/admin/manage/flags.js b/public/src/admin/manage/flags.js
deleted file mode 100644
index fb8b32d602..0000000000
--- a/public/src/admin/manage/flags.js
+++ /dev/null
@@ -1,172 +0,0 @@
-"use strict";
-/*global define, socket, app, utils, bootbox, ajaxify*/
-
-define('admin/manage/flags', [
-	'autocomplete',
-	'Chart',
-	'components'
-], function (autocomplete, Chart, components) {
-
-	var	Flags = {};
-
-	Flags.init = function () {
-		$('.post-container .content img:not(.not-responsive)').addClass('img-responsive');
-
-		autocomplete.user($('#byUsername'));
-
-		handleDismiss();
-		handleDismissAll();
-		handleDelete();
-		handleGraphs();
-
-		updateFlagDetails(ajaxify.data.posts);
-
-		components.get('posts/flags').on('click', '[component="posts/flag/update"]', updateFlag);
-
-		// Open flag as indicated in location bar
-		if (window.location.hash.startsWith('#flag-pid-')) {
-			$(window.location.hash).collapse('toggle');
-		}
-	};
-
-	function handleDismiss() {
-		$('.flags').on('click', '.dismiss', function () {
-			var btn = $(this);
-			var pid = btn.parents('[data-pid]').attr('data-pid');
-
-			socket.emit('posts.dismissFlag', pid, function (err) {
-				done(err, btn);
-			});
- 		});
-	}
-
-	function handleDismissAll() {
-		$('#dismissAll').on('click', function () {
-			socket.emit('posts.dismissAllFlags', function (err) {
-				if (err) {
-					return app.alertError(err.message);
-				}
-
-				ajaxify.refresh();
-			});
-			return false;
-		});
-	}
-
-	function handleDelete() {
-		$('.flags').on('click', '.delete', function () {
-			var btn = $(this);
-			bootbox.confirm('Do you really want to delete this post?', function (confirm) {
-				if (!confirm) {
-					return;
-				}
-				var pid = btn.parents('[data-pid]').attr('data-pid');
-				var tid = btn.parents('[data-pid]').attr('data-tid');
-				socket.emit('posts.delete', {pid: pid, tid: tid}, function (err) {
-					done(err, btn);
-				});
-			});
-		});
-	}
-
-	function done(err, btn) {
-		if (err) {
-			return app.alertError(err.messaage);
-		}
-		btn.parents('[data-pid]').fadeOut(function () {
-			$(this).remove();
-			if (!$('.flags [data-pid]').length) {
-				$('.post-container').text('No flagged posts!');
-			}
-		});
-	}
-
-	function handleGraphs() {
-		var dailyCanvas = document.getElementById('flags:daily');
-		var dailyLabels = utils.getDaysArray().map(function (text, idx) {
-			return idx % 3 ? '' : text;
-		});
-
-		if (utils.isMobile()) {
-			Chart.defaults.global.tooltips.enabled = false;
-		}
-		var data = {
-			'flags:daily': {
-				labels: dailyLabels,
-				datasets: [
-					{
-						label: "",
-						backgroundColor: "rgba(151,187,205,0.2)",
-						borderColor: "rgba(151,187,205,1)",
-						pointBackgroundColor: "rgba(151,187,205,1)",
-						pointHoverBackgroundColor: "#fff",
-						pointBorderColor: "#fff",
-						pointHoverBorderColor: "rgba(151,187,205,1)",
-						data: ajaxify.data.analytics
-					}
-				]
-			}
-		};
-
-		dailyCanvas.width = $(dailyCanvas).parent().width();
-		new Chart(dailyCanvas.getContext('2d'), {
-			type: 'line',
-			data: data['flags:daily'],
-			options: {
-				responsive: true,
-				animation: false,
-				legend: {
-					display: false
-				},
-				scales: {
-					yAxes: [{
-						ticks: {
-							beginAtZero: true
-						}
-					}]
-				}
-			}
-		});
-	}
-
-	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/public/src/client/flags/detail.js b/public/src/client/flags/detail.js
index aef21ec925..e20f05dba9 100644
--- a/public/src/client/flags/detail.js
+++ b/public/src/client/flags/detail.js
@@ -2,7 +2,7 @@
 
 /* globals define */
 
-define('forum/flags/detail', ['components', 'translator'], function (components, translator) {
+define('forum/flags/detail', ['forum/flags/list', 'components', 'translator'], function (FlagsList, components, translator) {
 	var Flags = {};
 
 	Flags.init = function () {
@@ -44,6 +44,8 @@ define('forum/flags/detail', ['components', 'translator'], function (components,
 					break;
 			}
 		});
+
+		FlagsList.enableFilterForm();
 	};
 
 	Flags.reloadNotes = function (notes) {
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
new file mode 100644
index 0000000000..6111372150
--- /dev/null
+++ b/public/src/client/flags/list.js
@@ -0,0 +1,28 @@
+'use strict';
+
+/* globals define */
+
+define('forum/flags/list', ['components'], function (components) {
+	var Flags = {};
+
+	Flags.init = function () {
+		Flags.enableFilterForm();
+	};
+
+	Flags.enableFilterForm = function () {
+		var filtersEl = components.get('flags/filters');
+
+		filtersEl.find('button').on('click', function () {
+			var payload = filtersEl.serializeArray();
+			var qs = payload.map(function (filter) {
+				if (filter.value) {
+					return filter.name + '=' + filter.value;
+				}
+			}).filter(Boolean).join('&');
+
+			ajaxify.go('flags?' + qs);
+		})
+	};
+
+	return Flags;
+});
diff --git a/src/controllers/admin.js b/src/controllers/admin.js
index 7f622466cd..c58def03cf 100644
--- a/src/controllers/admin.js
+++ b/src/controllers/admin.js
@@ -4,7 +4,6 @@ var adminController = {
 	dashboard: require('./admin/dashboard'),
 	categories: require('./admin/categories'),
 	tags: require('./admin/tags'),
-	flags: require('./admin/flags'),
 	blacklist: require('./admin/blacklist'),
 	groups: require('./admin/groups'),
 	appearance: require('./admin/appearance'),
diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js
deleted file mode 100644
index 80c31ba60a..0000000000
--- a/src/controllers/admin/flags.js
+++ /dev/null
@@ -1,104 +0,0 @@
-"use strict";
-
-var async = require('async');
-var validator = require('validator');
-
-var posts = require('../../posts');
-var user = require('../../user');
-var flags = require('../../flags');
-var categories = require('../../categories');
-var analytics = require('../../analytics');
-var pagination = require('../../pagination');
-
-var flagsController = {};
-
-var itemsPerPage = 20;
-
-flagsController.get = function (req, res, next) {
-	var byUsername = req.query.byUsername || '';
-	var cid = req.query.cid || 0;
-	var sortBy = req.query.sortBy || 'count';
-	var page = parseInt(req.query.page, 10) || 1;
-
-	async.parallel({
-		categories: function (next) {
-			categories.buildForSelect(req.uid, next);
-		},
-		flagData: function (next) {
-			getFlagData(req, res, next);
-		},
-		analytics: function (next) {
-			analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30, next);
-		},
-		assignees: async.apply(user.getAdminsandGlobalModsandModerators)
-	}, 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) {
-			return {
-				uid: userObj.uid,
-				username: userObj.username
-			};
-		});
-
-		// If res.locals.cids is populated, then slim down the categories list
-		if (res.locals.cids) {
-			results.categories = results.categories.filter(function (category) {
-				return res.locals.cids.indexOf(String(category.cid)) !== -1;
-			});
-		}
-
-		var pageCount = Math.max(1, Math.ceil(results.flagData.count / itemsPerPage));
-
-		results.categories.forEach(function (category) {
-			category.selected = parseInt(category.cid, 10) === parseInt(cid, 10);
-		});
-
-		var data = {
-			posts: results.flagData.posts,
-			assignees: results.assignees,
-			analytics: results.analytics,
-			categories: results.categories,
-			byUsername: validator.escape(String(byUsername)),
-			sortByCount: sortBy === 'count',
-			sortByTime: sortBy === 'time',
-			pagination: pagination.create(page, pageCount, req.query),
-			title: '[[pages:flagged-posts]]'
-		};
-		res.render('admin/manage/flags', data);
-	});
-};
-
-function getFlagData(req, res, callback) {
-	var sortBy = req.query.sortBy || 'count';
-	var byUsername = req.query.byUsername || '';
-	var cid = req.query.cid || res.locals.cids || 0;
-	var page = parseInt(req.query.page, 10) || 1;
-	var start = (page - 1) * itemsPerPage;
-	var stop = start + itemsPerPage - 1;
-
-	var sets = [sortBy === 'count' ? 'posts:flags:count' : 'posts:flagged'];
-
-	async.waterfall([
-		function (next) {
-			if (byUsername) {
-				user.getUidByUsername(byUsername, next);
-			} else {
-				process.nextTick(next, null, 0);
-			}
-		},
-		function (uid, next) {
-			if (uid) {
-				sets.push('uid:' + uid + ':flag:pids');
-			}
-
-			flags.get(sets, cid, req.uid, start, stop, next);
-		}
-	], callback);
-}
-
-
-module.exports = flagsController;
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 012ffde3c6..759de90ef1 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -25,7 +25,17 @@ modsController.flags.list = function (req, res, next) {
 			res.locals.cids = results.moderatedCids;
 		}
 
-		flags.list({}, function (err, flags) {
+		// Parse query string params for filters
+		var valid = ['reporterId', 'type'];
+		var filters = valid.reduce(function (memo, cur) {
+			if (req.query.hasOwnProperty(cur)) {
+				memo[cur] = req.query[cur];
+			}
+
+			return memo;
+		}, {});
+
+		flags.list(filters, function (err, flags) {
 			if (err) {
 				return next(err);
 			}
diff --git a/src/flags.js b/src/flags.js
index 58b230af06..6744d8ad59 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -53,8 +53,31 @@ Flags.list = function (filters, callback) {
 		filters = {};
 	}
 
+	var sets = [];
+	if (Object.keys(filters).length > 0) {
+		for (var type in filters) {
+			switch (type) {
+				case 'type':
+					sets.push('flags:byType:' + filters[type]);
+					break;
+				
+				case 'reporterId':
+					sets.push('flags:byReporter:' + filters[type]);
+					break;
+			}
+		}
+
+	}
+	sets = sets.length ? sets : ['flags:datetime'];	// No filter default
+
 	async.waterfall([
-		async.apply(db.getSortedSetRevRange.bind(db), 'flags:datetime', 0, 19),
+		function (next) {
+			if (sets.length === 1) {
+				db.getSortedSetRevRange(sets[0], 0, 19, next);
+			} else {
+				db.getSortedSetRevIntersect({sets: sets, start: 0, stop: -1, aggregate: 'MAX'}, next);
+			}
+		},
 		function (flagIds, next) {
 			async.map(flagIds, function (flagId, next) {
 				async.waterfall([
@@ -197,8 +220,10 @@ Flags.create = function (type, id, uid, reason, callback) {
 					uid: uid,
 					datetime: Date.now()
 				})),
-				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId),
-				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId)
+				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId),	// by time, the default
+				async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, Date.now(), flagId),	// by reporter
+				async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, Date.now(), flagId),	// by flag type
+				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId)	// save hash for existence checking
 			], function (err, data) {
 				if (err) {
 					return next(err);
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 0611eede70..53b12fc4b8 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -56,7 +56,6 @@ function addRoutes(router, middleware, controllers) {
 	router.get('/manage/categories/:category_id/analytics', middlewares, controllers.admin.categories.getAnalytics);
 
 	router.get('/manage/tags', middlewares, controllers.admin.tags.get);
-	router.get('/manage/flags', middlewares, controllers.admin.flags.get);
 	router.get('/manage/ip-blacklist', middlewares, controllers.admin.blacklist.get);
 
 	router.get('/manage/users', middlewares, controllers.admin.users.sortByJoinDate);
diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js
index 8b66dd094c..e6fb0be116 100644
--- a/src/socket.io/flags.js
+++ b/src/socket.io/flags.js
@@ -67,7 +67,7 @@ SocketFlags.create = function (socket, data, callback) {
 
 			flags.create('post', post.pid, socket.uid, data.reason, next);
 		},
-		function (next) {
+		function (flagObj, next) {
 			async.parallel({
 				post: function (next) {
 					posts.parsePost(post, next);

From 8e1d441e20d70e61d12a13ec66c48c8c029f3470 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 5 Dec 2016 12:40:25 -0500
Subject: [PATCH 21/48] Added some quick filters, #5232

---
 public/language/en-GB/flags.json |  5 +++-
 public/src/client/flags/list.js  |  5 ++++
 src/controllers/mods.js          |  8 ++++---
 src/flags.js                     | 39 ++++++++++++++++++++++++--------
 4 files changed, 44 insertions(+), 13 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index cdc3c4069e..fbe32a812e 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -1,5 +1,4 @@
 {
-	"quick-filters": "Quick Filters",
 	"state": "State",
 	"reporter": "Reporter",
 	"reported-at": "Reported At",
@@ -9,11 +8,15 @@
 	"update": "Update",
 	"updated": "Updated",
 
+	"quick-filters": "Quick Filters",
+	"filter-active": "There are one or more filters active in this list of flags",
+	"filter-reset": "Remove Filters",
 	"filters": "Filter Options",
 	"filter-reporterId": "Reporter UID",
 	"filter-type": "Flag Type",
 	"filter-type-all": "All Content",
 	"filter-type-post": "Post",
+	"filter-quick-mine": "Assigned to me",
 	"apply-filters": "Apply Filters",
 
 	"notes": "Flag Notes",
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index 6111372150..2994e7fcf9 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -12,6 +12,11 @@ define('forum/flags/list', ['components'], function (components) {
 	Flags.enableFilterForm = function () {
 		var filtersEl = components.get('flags/filters');
 
+		// Parse ajaxify data to set form values to reflect current filters
+		for(var filter in ajaxify.data.filters) {
+			filtersEl.find('[name="' + filter + '"]').val(ajaxify.data.filters[filter]);
+		}
+
 		filtersEl.find('button').on('click', function () {
 			var payload = filtersEl.serializeArray();
 			var qs = payload.map(function (filter) {
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 759de90ef1..f15b1a6b1a 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -26,7 +26,7 @@ modsController.flags.list = function (req, res, next) {
 		}
 
 		// Parse query string params for filters
-		var valid = ['reporterId', 'type'];
+		var valid = ['reporterId', 'type', 'quick'];
 		var filters = valid.reduce(function (memo, cur) {
 			if (req.query.hasOwnProperty(cur)) {
 				memo[cur] = req.query[cur];
@@ -35,13 +35,15 @@ modsController.flags.list = function (req, res, next) {
 			return memo;
 		}, {});
 
-		flags.list(filters, function (err, flags) {
+		flags.list(filters, req.uid, function (err, flags) {
 			if (err) {
 				return next(err);
 			}
 
 			res.render('flags/list', {
-				flags: flags
+				flags: flags,
+				hasFilter: !!Object.keys(filters).length,
+				filters: filters
 			});
 		});
 	});
diff --git a/src/flags.js b/src/flags.js
index 6744d8ad59..492e1faa43 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -47,7 +47,7 @@ Flags.get = function (flagId, callback) {
 	], callback);
 };
 
-Flags.list = function (filters, callback) {
+Flags.list = function (filters, uid, callback) {
 	if (typeof filters === 'function' && !callback) {
 		callback = filters;
 		filters = {};
@@ -64,16 +64,23 @@ Flags.list = function (filters, callback) {
 				case 'reporterId':
 					sets.push('flags:byReporter:' + filters[type]);
 					break;
+				
+				case 'quick':
+					switch (filters.quick) {
+						case 'mine':
+							sets.push('flags:byAssignee:' + uid);
+							break;
+					}
+					break;
 			}
 		}
-
 	}
 	sets = sets.length ? sets : ['flags:datetime'];	// No filter default
 
 	async.waterfall([
 		function (next) {
 			if (sets.length === 1) {
-				db.getSortedSetRevRange(sets[0], 0, 19, next);
+				db.getSortedSetRevRange(sets[0], 0, -1, next);
 			} else {
 				db.getSortedSetRevIntersect({sets: sets, start: 0, stop: -1, aggregate: 'MAX'}, next);
 			}
@@ -316,6 +323,8 @@ Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];
 	var history = [];
+	var tasks = [];
+	var now = Date.now();
 
 	async.waterfall([
 		async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields),
@@ -325,6 +334,18 @@ Flags.update = function (flagId, uid, changeset, callback) {
 					if (current[prop] === changeset[prop]) {
 						delete changeset[prop];
 					} else {
+						// Add tasks as necessary
+						switch (prop) {
+							case 'state':
+								tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byState:' + changeset[prop], now, flagId));
+								tasks.push(async.apply(db.sortedSetRemove.bind(db), 'flags:byState:' + current[prop], flagId));
+								break;
+							
+							case 'assignee':
+								tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byAssignee:' + changeset[prop], now, flagId));
+								break;
+						}
+
 						// Append to history payload
 						history.push(prop + ':' + changeset[prop]);
 					}
@@ -336,12 +357,12 @@ Flags.update = function (flagId, uid, changeset, callback) {
 				return next();
 			}
 
-			async.parallel([
-				// Save new object to db (upsert)
-				async.apply(db.setObject, 'flag:' + flagId, changeset),
-				// Append history
-				async.apply(Flags.appendHistory, flagId, uid, history)
-			], function (err, data) {
+			// Save new object to db (upsert)
+			tasks.push(async.apply(db.setObject, 'flag:' + flagId, changeset));
+			// Append history
+			tasks.push(async.apply(Flags.appendHistory, flagId, uid, history))
+
+			async.parallel(tasks, function (err, data) {
 				return next(err);
 			});
 		}

From 88958049ebce7abc17a40ba5223d5edcbeabea92 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 5 Dec 2016 15:32:58 -0500
Subject: [PATCH 22/48] added some more filters, and appending event to history
 on flag creation

issue #5232
---
 public/language/en-GB/flags.json | 2 ++
 public/src/client/flags/list.js  | 4 ++--
 src/controllers/mods.js          | 2 +-
 src/flags.js                     | 9 +++++++++
 4 files changed, 14 insertions(+), 3 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index fbe32a812e..5d9799de50 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -16,6 +16,8 @@
 	"filter-type": "Flag Type",
 	"filter-type-all": "All Content",
 	"filter-type-post": "Post",
+	"filter-state": "State",
+	"filter-assignee": "Assignee UID",
 	"filter-quick-mine": "Assigned to me",
 	"apply-filters": "Apply Filters",
 
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index 2994e7fcf9..a5277f98df 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -13,7 +13,7 @@ define('forum/flags/list', ['components'], function (components) {
 		var filtersEl = components.get('flags/filters');
 
 		// Parse ajaxify data to set form values to reflect current filters
-		for(var filter in ajaxify.data.filters) {
+		for (var filter in ajaxify.data.filters) {
 			filtersEl.find('[name="' + filter + '"]').val(ajaxify.data.filters[filter]);
 		}
 
@@ -26,7 +26,7 @@ define('forum/flags/list', ['components'], function (components) {
 			}).filter(Boolean).join('&');
 
 			ajaxify.go('flags?' + qs);
-		})
+		});
 	};
 
 	return Flags;
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index f15b1a6b1a..512ad32222 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -26,7 +26,7 @@ modsController.flags.list = function (req, res, next) {
 		}
 
 		// Parse query string params for filters
-		var valid = ['reporterId', 'type', 'quick'];
+		var valid = ['assignee', 'state', 'reporterId', 'type', 'quick'];
 		var filters = valid.reduce(function (memo, cur) {
 			if (req.query.hasOwnProperty(cur)) {
 				memo[cur] = req.query[cur];
diff --git a/src/flags.js b/src/flags.js
index 492e1faa43..a8bca052d1 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -60,11 +60,19 @@ Flags.list = function (filters, uid, callback) {
 				case 'type':
 					sets.push('flags:byType:' + filters[type]);
 					break;
+
+				case 'state':
+					sets.push('flags:byState:' + filters[type]);
+					break;
 				
 				case 'reporterId':
 					sets.push('flags:byReporter:' + filters[type]);
 					break;
 				
+				case 'assignee':
+					sets.push('flags:byAssignee:' + filters[type]);
+					break;
+
 				case 'quick':
 					switch (filters.quick) {
 						case 'mine':
@@ -236,6 +244,7 @@ Flags.create = function (type, id, uid, reason, callback) {
 					return next(err);
 				}
 
+				Flags.appendHistory(flagId, uid, ['created']);
 				next(null, flagId);
 			});
 		},

From cd3002e812a1c41aea190d72b5941f3d6634505f Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 5 Dec 2016 15:52:01 -0500
Subject: [PATCH 23/48] removed user flag reset method and associated socket
 call from ACP

---
 public/src/admin/manage/users.js | 9 ---------
 src/socket.io/admin/user.js      | 8 --------
 src/views/admin/manage/users.tpl | 1 -
 3 files changed, 18 deletions(-)

diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js
index b8fb8d5de6..2f344e37c7 100644
--- a/public/src/admin/manage/users.js
+++ b/public/src/admin/manage/users.js
@@ -128,15 +128,6 @@ define('admin/manage/users', ['translator'], function (translator) {
 			socket.emit('admin.user.resetLockouts', uids, done('Lockout(s) reset!'));
 		});
 
-		$('.reset-flags').on('click', function () {
-			var uids = getSelectedUids();
-			if (!uids.length) {
-				return;
-			}
-
-			socket.emit('admin.user.resetFlags', uids, done('Flags(s) reset!'));
-		});
-
 		$('.admin-user').on('click', function () {
 			var uids = getSelectedUids();
 			if (!uids.length) {
diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js
index 4a77c224a7..272a13f7f7 100644
--- a/src/socket.io/admin/user.js
+++ b/src/socket.io/admin/user.js
@@ -68,14 +68,6 @@ User.resetLockouts = function (socket, uids, callback) {
 	async.each(uids, user.auth.resetLockout, callback);
 };
 
-User.resetFlags = function (socket, uids, callback) {
-	if (!Array.isArray(uids)) {
-		return callback(new Error('[[error:invalid-data]]'));
-	}
-
-	user.resetFlags(uids, callback);
-};
-
 User.validateEmail = function (socket, uids, callback) {
 	if (!Array.isArray(uids)) {
 		return callback(new Error('[[error:invalid-data]]'));
diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl
index de0cd15e62..b43d66af8a 100644
--- a/src/views/admin/manage/users.tpl
+++ b/src/views/admin/manage/users.tpl
@@ -19,7 +19,6 @@
 							<li><a href="#" class="ban-user-temporary"><i class="fa fa-fw fa-clock-o"></i> Ban User(s) Temporarily</a></li>
 							<li><a href="#" class="unban-user"><i class="fa fa-fw fa-comment-o"></i> Unban User(s)</a></li>
 							<li><a href="#" class="reset-lockout"><i class="fa fa-fw fa-unlock"></i> Reset Lockout</a></li>
-							<li><a href="#" class="reset-flags"><i class="fa fa-fw fa-flag"></i> Reset Flags</a></li>
 							<li class="divider"></li>
 							<li><a href="#" class="delete-user"><i class="fa fa-fw fa-trash-o"></i> Delete User(s)</a></li>
 							<li><a href="#" class="delete-user-and-content"><i class="fa fa-fw fa-trash-o"></i> Delete User(s) and Content</a></li>

From 9f9051026b89b41d6028e8c0b59f6fa9a6e7336e Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Tue, 6 Dec 2016 16:11:56 -0500
Subject: [PATCH 24/48] more work on #5232

---
 public/language/en-GB/flags.json |   8 ++
 public/src/client/flags/list.js  |   7 ++
 public/src/client/topic/flag.js  |   2 +-
 src/controllers/mods.js          |   2 +-
 src/flags.js                     | 143 +++++++++++++++++++++++++++----
 src/socket.io/flags.js           |  85 ++----------------
 6 files changed, 153 insertions(+), 94 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 5d9799de50..6b4e96f4fb 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -13,6 +13,7 @@
 	"filter-reset": "Remove Filters",
 	"filters": "Filter Options",
 	"filter-reporterId": "Reporter UID",
+	"filter-targetUid": "Flagged UID",
 	"filter-type": "Flag Type",
 	"filter-type-all": "All Content",
 	"filter-type-post": "Post",
@@ -21,6 +22,13 @@
 	"filter-quick-mine": "Assigned to me",
 	"apply-filters": "Apply Filters",
 
+	"quick-links": "Quick Links",
+	"flagged-user": "Flagged User",
+	"reporter": "Reporting User",
+	"view-profile": "View Profile",
+	"start-new-chat": "Start New Chat",
+	"go-to-target": "View Flag Target",
+
 	"notes": "Flag Notes",
 	"add-note": "Add Note",
 	"no-notes": "No shared notes.",
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index a5277f98df..cbdaf94f24 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -7,6 +7,7 @@ define('forum/flags/list', ['components'], function (components) {
 
 	Flags.init = function () {
 		Flags.enableFilterForm();
+		Flags.enableChatButtons();
 	};
 
 	Flags.enableFilterForm = function () {
@@ -29,5 +30,11 @@ define('forum/flags/list', ['components'], function (components) {
 		});
 	};
 
+	Flags.enableChatButtons = function () {
+		$('[data-chat]').on('click', function () {
+			app.newChat(this.getAttribute('data-chat'));
+		});
+	};
+
 	return Flags;
 });
diff --git a/public/src/client/topic/flag.js b/public/src/client/topic/flag.js
index 046585ae68..6b3440da54 100644
--- a/public/src/client/topic/flag.js
+++ b/public/src/client/topic/flag.js
@@ -48,7 +48,7 @@ define('forum/topic/flag', [], function () {
 		if (!pid || !reason) {
 			return;
 		}
-		socket.emit('flags.create', {pid: pid, reason: reason}, function (err) {
+		socket.emit('flags.create', {type: 'post', id: pid, reason: reason}, function (err) {
 			if (err) {
 				return app.alertError(err.message);
 			}
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 512ad32222..788294a01e 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -26,7 +26,7 @@ modsController.flags.list = function (req, res, next) {
 		}
 
 		// Parse query string params for filters
-		var valid = ['assignee', 'state', 'reporterId', 'type', 'quick'];
+		var valid = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'quick'];
 		var filters = valid.reduce(function (memo, cur) {
 			if (req.query.hasOwnProperty(cur)) {
 				memo[cur] = req.query[cur];
diff --git a/src/flags.js b/src/flags.js
index a8bca052d1..45aae2502e 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -1,23 +1,22 @@
-
-
 'use strict';
 
 var async = require('async');
 var winston = require('winston');
 var db = require('./database');
 var user = require('./user');
+var groups = require('./groups');
+var meta = require('./meta');
+var notifications = require('./notifications');
 var analytics = require('./analytics');
 var topics = require('./topics');
 var posts = require('./posts');
+var privileges = require('./privileges');
+var plugins = require('./plugins');
 var utils = require('../public/src/utils');
 var _ = require('underscore');
+var S = require('string');
 
-var Flags = {
-	_defaults: {
-		state: 'open',
-		assignee: null
-	}
-};
+var Flags = {};
 
 Flags.get = function (flagId, callback) {
 	async.waterfall([
@@ -72,6 +71,10 @@ Flags.list = function (filters, uid, callback) {
 				case 'assignee':
 					sets.push('flags:byAssignee:' + filters[type]);
 					break;
+				
+				case 'targetUid':
+					sets.push('flags:byTargetUid:' + filters[type]);
+					break;
 
 				case 'quick':
 					switch (filters.quick) {
@@ -145,6 +148,43 @@ Flags.list = function (filters, uid, callback) {
 	});
 };
 
+Flags.validate = function (payload, callback) {
+	async.parallel({
+		targetExists: async.apply(Flags.targetExists, payload.type, payload.id),
+		target: async.apply(Flags.getTarget, payload.type, payload.id, payload.uid),
+		reporter: async.apply(user.getUserData, payload.uid)
+	}, function (err, data) {
+		if (err) {
+			return callback(err);
+		}
+
+		if (data.target.deleted) {
+			return callback(new Error('[[error:post-deleted]]'));
+		} else if (data.reporter.banned) {
+			return callback(new Error('[[error:user-banned]]'));
+		}
+
+		switch (payload.type) {
+			case 'post':
+				async.parallel({
+					privileges: async.apply(privileges.posts.get, [payload.id], payload.uid)
+				}, function (err, subdata) {
+					if (err) {
+						return callback(err);
+					}
+
+					var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
+					if (!subdata.privileges[0].isAdminOrMod && parseInt(data.reporter.reputation, 10) < minimumReputation) {
+						return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
+					}
+
+					callback();
+				});
+				break;
+		} 
+	});
+};
+
 Flags.getTarget = function (type, id, uid, callback) {
 	switch (type) {
 		case 'post':
@@ -204,17 +244,22 @@ Flags.getNotes = function (flagId, callback) {
 };
 
 Flags.create = function (type, id, uid, reason, callback) {
+	var targetUid;
+
 	async.waterfall([
 		function (next) {
 			// Sanity checks
 			async.parallel([
 				async.apply(Flags.exists, type, id, uid),
-				async.apply(Flags.targetExists, type, id)
+				async.apply(Flags.targetExists, type, id),
+				async.apply(Flags.getTargetUid, type, id)
 			], function (err, checks) {
 				if (err) {
 					return next(err);
 				}
 
+				targetUid = checks[2] || null;
+
 				if (checks[0]) {
 					return next(new Error('[[error:already-flagged]]'));
 				} else if (!checks[1]) {
@@ -226,25 +271,31 @@ Flags.create = function (type, id, uid, reason, callback) {
 		},
 		async.apply(db.incrObjectField, 'global', 'nextFlagId'),
 		function (flagId, next) {
-			async.parallel([
-				async.apply(db.setObject.bind(db), 'flag:' + flagId, Object.assign({}, Flags._defaults, {
+			var tasks = [
+				async.apply(db.setObject.bind(db), 'flag:' + flagId, {
 					flagId: flagId,
 					type: type,
 					targetId: id,
 					description: reason,
 					uid: uid,
 					datetime: Date.now()
-				})),
+				}),
 				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId),	// by time, the default
 				async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, Date.now(), flagId),	// by reporter
 				async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, Date.now(), flagId),	// by flag type
 				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId)	// save hash for existence checking
-			], function (err, data) {
+			];
+
+			if (targetUid) {
+				tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, Date.now(), flagId));	// by target uid
+			}
+		
+			async.parallel(tasks, function (err, data) {
 				if (err) {
 					return next(err);
 				}
 
-				Flags.appendHistory(flagId, uid, ['created']);
+				Flags.update(flagId, uid, { "state": "open" });
 				next(null, flagId);
 			});
 		},
@@ -318,7 +369,7 @@ Flags.exists = function (type, id, uid, callback) {
 
 Flags.targetExists = function (type, id, callback) {
 	switch (type) {
-		case 'topic':
+		case 'topic':	// just an example...
 			topics.exists(id, callback);
 			break;
 		
@@ -328,6 +379,14 @@ Flags.targetExists = function (type, id, callback) {
 	}
 };
 
+Flags.getTargetUid = function (type, id, callback) {
+	switch (type) {
+		case 'post':
+			posts.getPostField(id, 'uid', callback);
+			break;
+	}
+};
+
 Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];
@@ -369,7 +428,7 @@ Flags.update = function (flagId, uid, changeset, callback) {
 			// Save new object to db (upsert)
 			tasks.push(async.apply(db.setObject, 'flag:' + flagId, changeset));
 			// Append history
-			tasks.push(async.apply(Flags.appendHistory, flagId, uid, history))
+			tasks.push(async.apply(Flags.appendHistory, flagId, uid, history));
 
 			async.parallel(tasks, function (err, data) {
 				return next(err);
@@ -462,4 +521,56 @@ Flags.appendNote = function (flagId, uid, note, callback) {
 	], callback);
 };
 
+Flags.notify = function (flagObj, uid, callback) {
+	// Notify administrators, mods, and other associated people
+	switch (flagObj.type) {
+		case 'post':
+			async.parallel({
+				post: function (next) {
+					async.waterfall([
+						async.apply(posts.getPostData, flagObj.targetId),
+						async.apply(posts.parsePost)
+					], next);
+				},
+				title: async.apply(topics.getTitleByPid, flagObj.targetId),
+				admins: async.apply(groups.getMembers, 'administrators', 0, -1),
+				globalMods: async.apply(groups.getMembers, 'Global Moderators', 0, -1),
+				moderators: function (next) {
+					async.waterfall([
+						async.apply(posts.getCidByPid, flagObj.targetId),
+						function (cid, next) {
+							groups.getMembers('cid:' + cid + ':privileges:mods', 0, -1, next);
+						}
+					], next);
+				}
+			}, function (err, results) {
+				if (err) {
+					return callback(err);
+				}
+
+				var title = S(results.title).decodeHTMLEntities().s;
+				var titleEscaped = title.replace(/%/g, '&#37;').replace(/,/g, '&#44;');
+
+				notifications.create({
+					bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]',
+					bodyLong: results.post.content,
+					pid: flagObj.targetId,
+					path: '/post/' + flagObj.targetId,
+					nid: 'flag:post:' + flagObj.targetId + ':uid:' + uid,
+					from: uid,
+					mergeId: 'notifications:user_flagged_post_in|' + flagObj.targetId,
+					topicTitle: results.title
+				}, function (err, notification) {
+					if (err || !notification) {
+						return callback(err);
+					}
+
+					plugins.fireHook('action:post.flag', {post: results.post, reason: flagObj.description, flaggingUser: flagObj.reporter});
+					notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), callback);
+				});
+			});
+			break;
+	}
+};
+
 module.exports = Flags;
\ No newline at end of file
diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js
index e6fb0be116..ce148faa87 100644
--- a/src/socket.io/flags.js
+++ b/src/socket.io/flags.js
@@ -21,89 +21,22 @@ SocketFlags.create = function (socket, data, callback) {
 		return callback(new Error('[[error:not-logged-in]]'));
 	}
 
-	if (!data || !data.pid || !data.reason) {
+	if (!data || !data.type || !data.id || !data.reason) {
 		return callback(new Error('[[error:invalid-data]]'));
 	}
 
-	var flaggingUser = {};
-	var post;
-
 	async.waterfall([
+		async.apply(flags.validate, {
+			uid: socket.uid,
+			type: data.type,
+			id: data.id
+		}),
 		function (next) {
-			posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'deleted'], next);
-		},
-		function (postData, next) {
-			if (parseInt(postData.deleted, 10) === 1) {
-				return next(new Error('[[error:post-deleted]]'));
-			}
-
-			post = postData;
-			topics.getTopicFields(post.tid, ['title', 'cid'], next);
-		},
-		function (topicData, next) {
-			post.topic = topicData;
-
-			async.parallel({
-				isAdminOrMod: function (next) {
-					privileges.categories.isAdminOrMod(post.topic.cid, socket.uid, next);
-				},
-				userData: function (next) {
-					user.getUserFields(socket.uid, ['username', 'reputation', 'banned'], next);
-				}
-			}, next);
-		},
-		function (user, next) {
-			var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
-			if (!user.isAdminOrMod && parseInt(user.userData.reputation, 10) < minimumReputation) {
-				return next(new Error('[[error:not-enough-reputation-to-flag]]'));
-			}
-
-			if (parseInt(user.banned, 10) === 1) {
-				return next(new Error('[[error:user-banned]]'));
-			}
-
-			flaggingUser = user.userData;
-			flaggingUser.uid = socket.uid;
-
-			flags.create('post', post.pid, socket.uid, data.reason, next);
+			// If we got here, then no errors occurred
+			flags.create(data.type, data.id, socket.uid, data.reason, next);
 		},
 		function (flagObj, next) {
-			async.parallel({
-				post: function (next) {
-					posts.parsePost(post, next);
-				},
-				admins: function (next) {
-					groups.getMembers('administrators', 0, -1, next);
-				},
-				globalMods: function (next) {
-					groups.getMembers('Global Moderators', 0, -1, next);
-				},
-				moderators: function (next) {
-					groups.getMembers('cid:' + post.topic.cid + ':privileges:mods', 0, -1, next);
-				}
-			}, next);
-		},
-		function (results, next) {
-			var title = S(post.topic.title).decodeHTMLEntities().s;
-			var titleEscaped = title.replace(/%/g, '&#37;').replace(/,/g, '&#44;');
-
-			notifications.create({
-				bodyShort: '[[notifications:user_flagged_post_in, ' + flaggingUser.username + ', ' + titleEscaped + ']]',
-				bodyLong: post.content,
-				pid: data.pid,
-				path: '/post/' + data.pid,
-				nid: 'post_flag:' + data.pid + ':uid:' + socket.uid,
-				from: socket.uid,
-				mergeId: 'notifications:user_flagged_post_in|' + data.pid,
-				topicTitle: post.topic.title
-			}, function (err, notification) {
-				if (err || !notification) {
-					return next(err);
-				}
-
-				plugins.fireHook('action:post.flag', {post: post, reason: data.reason, flaggingUser: flaggingUser});
-				notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), next);
-			});
+			flags.notify(flagObj, socket.uid, next);
 		}
 	], callback);
 };

From 57fcb92bbcc14f265e238348e82d18e5ea357431 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Tue, 6 Dec 2016 20:28:54 -0500
Subject: [PATCH 25/48] added a smattering of tests for #5232

---
 test/flags.js | 188 ++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 183 insertions(+), 5 deletions(-)

diff --git a/test/flags.js b/test/flags.js
index c5e69aa973..ee735afd60 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -8,7 +8,9 @@ var db = require('./mocks/databasemock');
 var Flags = require('../src/flags');
 var Categories = require('../src/categories');
 var Topics = require('../src/topics');
+var Posts = require('../src/posts');
 var User = require('../src/user');
+var Meta = require('../src/meta');
 
 describe('Flags', function () {
 	before(function (done) {
@@ -43,8 +45,7 @@ describe('Flags', function () {
 					uid: 1,
 					targetId: 1,
 					type: 'post',
-					description: 'Test flag',
-					state: 'open'
+					description: 'Test flag'
 				};
 
 				for(var key in compare) {
@@ -86,7 +87,7 @@ describe('Flags', function () {
 
 	describe('.list()', function () {
 		it('should show a list of flags (with one item)', function (done) {
-			Flags.list({}, function (err, flags) {
+			Flags.list({}, 1, function (err, flags) {
 				assert.ifError(err);
 				assert.ok(Array.isArray(flags));
 				assert.equal(flags.length, 1);
@@ -101,7 +102,49 @@ describe('Flags', function () {
 		});
 	});
 
-	describe('.getTarget()', function() {
+	describe('.update()', function () {
+		it('should alter a flag\'s various attributes and persist them to the database', function (done) {
+			Flags.update(1, 1, {
+				"state": "wip",
+				"assignee": 1
+			}, function (err) {
+				assert.ifError(err);
+				db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) {
+					if (err) {
+						throw err;
+					}
+
+					assert.strictEqual('wip', data.state);
+					assert.strictEqual(1, data.assignee);
+					done();
+				});
+			});
+		});
+
+		it('should persist to the flag\'s history', function (done) {
+			Flags.getHistory(1, function (err, history) {
+				if (err) {
+					throw err;
+				}
+
+				history.forEach(function (change) {
+					switch (change.attribute) {
+						case 'state':
+							assert.strictEqual('[[flags:state-wip]]', change.value);
+							break;
+						
+						case 'assignee':
+							assert.strictEqual(1, change.value);
+							break;
+					}
+				});
+
+				done();
+			});
+		});
+	});
+
+	describe('.getTarget()', function () {
 		it('should return a post\'s data if queried with type "post"', function (done) {
 			Flags.getTarget('post', 1, 1, function (err, data) {
 				assert.ifError(err);
@@ -141,7 +184,142 @@ describe('Flags', function () {
 				done();
 			});
 		});
-	});;
+	});
+
+	describe('.validate()', function () {
+		it('should error out if type is post and post is deleted', function (done) {
+			Posts.delete(1, 1, function (err) {
+				if (err) {
+					throw err;
+				}
+
+				Flags.validate({
+					type: 'post',
+					id: 1,
+					uid: 1
+				}, function (err) {
+					assert.ok(err);
+					assert.strictEqual('[[error:post-deleted]]', err.message);
+					Posts.restore(1, 1, done);
+				});
+			});
+		});
+
+		it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) {
+			Meta.configs.set('privileges:flag', '50', function (err) {
+				assert.ifError(err);
+
+				Flags.validate({
+					type: 'post',
+					id: 1,
+					uid: 1
+				}, function (err) {
+					assert.ok(err);
+					assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
+					Meta.configs.set('privileges:flag', 0, done);
+				});
+			});
+		});
+	});
+
+	describe('.appendNote()', function () {
+		it('should add a note to a flag', function (done) {
+			Flags.appendNote(1, 1, 'this is my note', function (err) {
+				assert.ifError(err);
+				
+				db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
+					if (err) {
+						throw err;
+					}
+
+					assert.strictEqual('[1,"this is my note"]', notes[0]);
+					done();
+				});
+			});
+		});
+
+		it('should be a JSON string', function (done) {
+			db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
+				if (err) {
+					throw err;
+				}
+
+				try {
+					JSON.parse(notes[0]);
+				} catch (e) {
+					assert.ifError(e);
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.getNotes()', function () {
+		before(function (done) {
+			// Add a second note
+			Flags.appendNote(1, 1, 'this is the second note', done);
+		});
+
+		it('return should match a predefined spec', function (done) {
+			Flags.getNotes(1, function (err, notes) {
+				assert.ifError(err);
+				var compare = {
+					uid: 1,
+					content: 'this is my note'
+				};
+				
+				var data = notes[1];
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(data[key]);
+						assert.strictEqual(data[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+
+		it('should retrieve a list of notes, from newest to oldest', function (done) {
+			Flags.getNotes(1, function (err, notes) {
+				assert.ifError(err);
+				assert(notes[0].datetime > notes[1].datetime);
+				assert.strictEqual('this is the second note', notes[0].content);
+				done();
+			});
+		});
+	});
+
+	describe('.appendHistory()', function () {
+		var entries;
+		before(function (done) {
+			db.sortedSetCard('flag:1:history', function (err, count) {
+				entries = count;
+				done(err);
+			});
+		});
+
+		it('should add a new entry into a flag\'s history', function (done) {
+			Flags.appendHistory(1, 1, ['state:rejected'], function (err) {
+				assert.ifError(err);
+
+				Flags.getHistory(1, function (err, history) {
+					assert.strictEqual(entries + 1, history.length);
+					done();
+				});
+			});
+		});
+	});
+
+	describe('.getHistory()', function () {
+		it('should retrieve a flag\'s history', function (done) {
+			Flags.getHistory(1, function (err, history) {
+				assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
+				done();
+			});
+		});
+	});
 
 	after(function (done) {
 		db.emptydb(done);

From 5dd892bd010e126b2c8f13a47460144c3a77bdcf Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 7 Dec 2016 12:07:22 -0500
Subject: [PATCH 26/48] a bunch of changes here... allowing user profiles to be
 flagged, #5232

---
 public/language/en-GB/flags.json              | 13 +++-
 public/language/en-GB/notifications.json      |  3 +
 public/language/en-GB/topic.json              |  9 +--
 public/language/en-GB/user.json               |  1 +
 public/src/client/account/header.js           | 10 +++
 public/src/client/flags/list.js               |  2 +
 public/src/client/topic/postTools.js          |  9 ++-
 .../topic/flag.js => modules/flags.js}        | 25 +++---
 src/controllers/mods.js                       |  6 +-
 src/flags.js                                  | 76 +++++++++++++++++--
 src/meta/js.js                                |  4 +-
 src/notifications.js                          |  2 +
 test/flags.js                                 | 12 ++-
 13 files changed, 133 insertions(+), 39 deletions(-)
 rename public/src/{client/topic/flag.js => modules/flags.js} (62%)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 6b4e96f4fb..a9272897ca 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -29,6 +29,9 @@
 	"start-new-chat": "Start New Chat",
 	"go-to-target": "View Flag Target",
 
+	"user-view": "View Profile",
+	"user-edit": "Edit Profile",
+
 	"notes": "Flag Notes",
 	"add-note": "Add Note",
 	"no-notes": "No shared notes.",
@@ -43,5 +46,13 @@
 	"state-resolved": "Resolved",
 	"state-rejected": "Rejected",
 	"no-assignee": "Not Assigned",
-	"note-added": "Note Added"
+	"note-added": "Note Added",
+
+	"modal-title": "Report Inappropriate Content",
+	"modal-body": "Please specify your reason for flagging %1 %2 for review. Alternatively, use one of the quick report buttons if applicable.",
+	"modal-reason-spam": "Spam",
+	"modal-reason-offensive": "Offensive",
+	"modal-reason-custom": "Reason for reporting this content...",
+	"modal-submit": "Submit Report",
+	"modal-submit-success": "Content has been flagged for moderation."
 }
\ No newline at end of file
diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json
index 5a2ed58908..0838ca17eb 100644
--- a/public/language/en-GB/notifications.json
+++ b/public/language/en-GB/notifications.json
@@ -21,6 +21,9 @@
 	"user_flagged_post_in": "<strong>%1</strong> flagged a post in <strong>%2</strong>",
 	"user_flagged_post_in_dual": "<strong>%1</strong> and <strong>%2</strong> flagged a post in <strong>%3</strong>",
 	"user_flagged_post_in_multiple": "<strong>%1</strong> and %2 others flagged a post in <strong>%3</strong>",
+	"user_flagged_user": "<strong>%1</strong> flagged a user profile (%2)",
+	"user_flagged_user_dual": "<strong>%1</strong> and <strong>%2</strong> flagged a user profile (%3)",
+	"user_flagged_user_multiple": "<strong>%1</strong> and %2 others flagged a user profile (%3)",
 	"user_posted_to" : "<strong>%1</strong> has posted a reply to: <strong>%2</strong>",
 	"user_posted_to_dual" : "<strong>%1</strong> and <strong>%2</strong> have posted replies to: <strong>%3</strong>",
 	"user_posted_to_multiple" : "<strong>%1</strong> and %2 others have posted replies to: <strong>%3</strong>",
diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json
index 4ae208076e..5571292dda 100644
--- a/public/language/en-GB/topic.json
+++ b/public/language/en-GB/topic.json
@@ -28,7 +28,6 @@
 	"link": "Link",
 	"share": "Share",
 	"tools": "Tools",
-	"flag": "Flag",
 	"locked": "Locked",
 	"pinned": "Pinned",
 	"moved": "Moved",
@@ -36,7 +35,6 @@
 	"bookmark_instructions" : "Click here to return to the last read post in this thread.",
 
 	"flag_title": "Flag this post for moderation",
-	"flag_success": "This post has been flagged for moderation.",
 
 	"deleted_message": "This topic has been deleted. Only users with topic management privileges can see it.",
 
@@ -138,10 +136,5 @@
 	"stale.create": "Create a new topic",
 	"stale.reply_anyway": "Reply to this topic anyway",
 
-	"link_back": "Re: [%1](%2)\n\n",
-
-	"spam": "Spam",
-	"offensive": "Offensive",
-	"custom-flag-reason": "Enter a flagging reason"
-
+	"link_back": "Re: [%1](%2)\n\n"
 }
diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json
index f0cb35f615..0725f208a3 100644
--- a/public/language/en-GB/user.json
+++ b/public/language/en-GB/user.json
@@ -35,6 +35,7 @@
 	"chat": "Chat",
 	"chat_with": "Continue chat with %1",
 	"new_chat_with": "Start new chat with %1",
+	"flag-profile": "Flag Profile",
 	"follow": "Follow",
 	"unfollow": "Unfollow",
 	"more": "More",
diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js
index d225e2cae1..b53cbb2be4 100644
--- a/public/src/client/account/header.js
+++ b/public/src/client/account/header.js
@@ -49,6 +49,7 @@ define('forum/account/header', [
 		components.get('account/ban').on('click', banAccount);
 		components.get('account/unban').on('click', unbanAccount);
 		components.get('account/delete').on('click', deleteAccount);
+		components.get('account/flag').on('click', flagAccount);
 	};
 
 	function hidePrivateLinks() {
@@ -167,6 +168,15 @@ define('forum/account/header', [
 		});
 	}
 
+	function flagAccount() {
+		require(['flags'], function (flags) {
+			flags.showFlagModal({
+				type: 'user',
+				id: ajaxify.data.uid
+			});
+		});
+	}
+
 	function removeCover() {
 		socket.emit('user.removeCover', {
 			uid: ajaxify.data.uid
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index cbdaf94f24..bf8b4bc1a8 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -23,6 +23,8 @@ define('forum/flags/list', ['components'], function (components) {
 			var qs = payload.map(function (filter) {
 				if (filter.value) {
 					return filter.name + '=' + filter.value;
+				} else {
+					return;
 				}
 			}).filter(Boolean).join('&');
 
diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js
index faa0567878..5618e692db 100644
--- a/public/src/client/topic/postTools.js
+++ b/public/src/client/topic/postTools.js
@@ -167,10 +167,11 @@ define('forum/topic/postTools', ['share', 'navigator', 'components', 'translator
 
 		postContainer.on('click', '[component="post/flag"]', function () {
 			var pid = getData($(this), 'data-pid');
-			var username = getData($(this), 'data-username');
-			var userslug = getData($(this), 'data-userslug');
-			require(['forum/topic/flag'], function (flag) {
-				flag.showFlagModal(pid, username, userslug);
+			require(['flags'], function (flags) {
+				flags.showFlagModal({
+					type: 'post',
+					id: pid
+				});
 			});
 		});
 
diff --git a/public/src/client/topic/flag.js b/public/src/modules/flags.js
similarity index 62%
rename from public/src/client/topic/flag.js
rename to public/src/modules/flags.js
index 6b3440da54..cc9fd5103a 100644
--- a/public/src/client/topic/flag.js
+++ b/public/src/modules/flags.js
@@ -2,18 +2,13 @@
 
 /* globals define, app, socket, templates */
 
-define('forum/topic/flag', [], function () {
-
+define('flags', [], function () {
 	var Flag = {},
 		flagModal,
 		flagCommit;
 
-	Flag.showFlagModal = function (pid, username, userslug) {
-		parseModal({
-			pid: pid,
-			username: username,
-			userslug: userslug
-		}, function (html) {
+	Flag.showFlagModal = function (data) {
+		parseModal(data, function (html) {
 			flagModal = $(html);
 
 			flagModal.on('hidden.bs.modal', function () {
@@ -23,11 +18,11 @@ define('forum/topic/flag', [], function () {
 			flagCommit = flagModal.find('#flag-post-commit');
 
 			flagModal.on('click', '.flag-reason', function () {
-				flagPost(pid, $(this).text());
+				createFlag(data.type, data.id, $(this).text());
 			});
 
 			flagCommit.on('click', function () {
-				flagPost(pid, flagModal.find('#flag-reason-custom').val());
+				createFlag(data.type, data.id, flagModal.find('#flag-reason-custom').val());
 			});
 
 			flagModal.modal('show');
@@ -37,24 +32,24 @@ define('forum/topic/flag', [], function () {
 	};
 
 	function parseModal(tplData, callback) {
-		templates.parse('partials/modals/flag_post_modal', tplData, function (html) {
+		templates.parse('partials/modals/flag_modal', tplData, function (html) {
 			require(['translator'], function (translator) {
 				translator.translate(html, callback);
 			});
 		});
 	}
 
-	function flagPost(pid, reason) {
-		if (!pid || !reason) {
+	function createFlag(type, id, reason) {
+		if (!type || !id || !reason) {
 			return;
 		}
-		socket.emit('flags.create', {type: 'post', id: pid, reason: reason}, function (err) {
+		socket.emit('flags.create', {type: type, id: id, reason: reason}, function (err) {
 			if (err) {
 				return app.alertError(err.message);
 			}
 
 			flagModal.modal('hide');
-			app.alertSuccess('[[topic:flag_success]]');
+			app.alertSuccess('[[flags:modal-submit-success]]');
 		});
 	}
 
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 788294a01e..656605f277 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -63,7 +63,11 @@ modsController.flags.detail = function (req, res, next) {
 		}
 
 		res.render('flags/detail', Object.assign(results.flagData, {
-			assignees: results.assignees
+			assignees: results.assignees,
+			type_bool: ['post', 'user'].reduce(function (memo, cur) {
+				memo[cur] = results.flagData.type === cur;
+				return memo;
+			}, {})
 		}));
 	});
 };
diff --git a/src/flags.js b/src/flags.js
index 45aae2502e..9f121f81bf 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -167,20 +167,44 @@ Flags.validate = function (payload, callback) {
 		switch (payload.type) {
 			case 'post':
 				async.parallel({
-					privileges: async.apply(privileges.posts.get, [payload.id], payload.uid)
+					editable: async.apply(privileges.posts.canEdit, payload.id, payload.uid)
 				}, function (err, subdata) {
 					if (err) {
 						return callback(err);
 					}
 
 					var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
-					if (!subdata.privileges[0].isAdminOrMod && parseInt(data.reporter.reputation, 10) < minimumReputation) {
+					// Check if reporter meets rep threshold (or can edit the target post, in which case threshold does not apply)
+					if (!subdata.editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) {
 						return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
 					}
 
 					callback();
 				});
 				break;
+			
+			case 'user':
+				async.parallel({
+					editable: async.apply(privileges.users.canEdit, payload.uid, payload.id)
+				}, function (err, subdata) {
+					if (err) {
+						return callback(err);
+					}
+
+
+					var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
+					// Check if reporter meets rep threshold (or can edit the target user, in which case threshold does not apply)
+					if (!subdata.editable && parseInt(data.reporter.reputation, 10) < minimumReputation) {
+						return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
+					}
+
+					callback();
+				});
+				break;
+			
+			default:
+				callback(new Error('[[error:invalid-data]]'));
+				break;
 		} 
 	});
 };
@@ -369,13 +393,17 @@ Flags.exists = function (type, id, uid, callback) {
 
 Flags.targetExists = function (type, id, callback) {
 	switch (type) {
-		case 'topic':	// just an example...
-			topics.exists(id, callback);
-			break;
-		
 		case 'post':
 			posts.exists(id, callback);
 			break;
+		
+		case 'user':
+			user.exists(id, callback);
+			break;
+
+		default:
+			callback(new Error('[[error:invalid-data]]'));
+			break;
 	}
 };
 
@@ -384,6 +412,10 @@ Flags.getTargetUid = function (type, id, callback) {
 		case 'post':
 			posts.getPostField(id, 'uid', callback);
 			break;
+		
+		case 'user':
+			setImmediate(callback, null, id);
+			break; 
 	}
 };
 
@@ -553,7 +585,7 @@ Flags.notify = function (flagObj, uid, callback) {
 
 				notifications.create({
 					bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]',
-					bodyLong: results.post.content,
+					bodyLong: flagObj.description,
 					pid: flagObj.targetId,
 					path: '/post/' + flagObj.targetId,
 					nid: 'flag:post:' + flagObj.targetId + ':uid:' + uid,
@@ -570,6 +602,36 @@ Flags.notify = function (flagObj, uid, callback) {
 				});
 			});
 			break;
+
+		case 'user':
+			async.parallel({
+				admins: async.apply(groups.getMembers, 'administrators', 0, -1),
+				globalMods: async.apply(groups.getMembers, 'Global Moderators', 0, -1),
+			}, function (err, results) {
+				if (err) {
+					return callback(err);
+				}
+
+				notifications.create({
+					bodyShort: '[[notifications:user_flagged_user, ' + flagObj.reporter.username + ', ' + flagObj.target.username + ']]',
+					bodyLong: flagObj.description,
+					path: '/uid/' + flagObj.targetId,
+					nid: 'flag:user:' + flagObj.targetId + ':uid:' + uid,
+					from: uid,
+					mergeId: 'notifications:user_flagged_user|' + flagObj.targetId
+				}, function (err, notification) {
+					if (err || !notification) {
+						return callback(err);
+					}
+
+					notifications.push(notification, results.admins.concat(results.globalMods), callback);
+				});
+			});
+			break;
+		
+		default:
+			callback(new Error('[[error:invalid-data]]'));
+			break;
 	}
 };
 
diff --git a/src/meta/js.js b/src/meta/js.js
index 626fa0ecd8..947550f37e 100644
--- a/src/meta/js.js
+++ b/src/meta/js.js
@@ -49,7 +49,6 @@ module.exports = function (Meta) {
 				'public/src/client/unread.js',
 				'public/src/client/topic.js',
 				'public/src/client/topic/events.js',
-				'public/src/client/topic/flag.js',
 				'public/src/client/topic/fork.js',
 				'public/src/client/topic/move.js',
 				'public/src/client/topic/posts.js',
@@ -72,7 +71,8 @@ module.exports = function (Meta) {
 				'public/src/modules/taskbar.js',
 				'public/src/modules/helpers.js',
 				'public/src/modules/sounds.js',
-				'public/src/modules/string.js'
+				'public/src/modules/string.js',
+				'public/src/modules/flags.js'
 			],
 
 			// modules listed below are routed through express (/src/modules) so they can be defined anonymously
diff --git a/src/notifications.js b/src/notifications.js
index b99700be01..0fb1e1ace3 100644
--- a/src/notifications.js
+++ b/src/notifications.js
@@ -415,6 +415,7 @@ var utils = require('../public/src/utils');
 				'notifications:user_started_following_you',
 				'notifications:user_posted_to',
 				'notifications:user_flagged_post_in',
+				'notifications:user_flagged_user',
 				'new_register'
 			],
 			isolated, differentiators, differentiator, modifyIndex, set;
@@ -462,6 +463,7 @@ var utils = require('../public/src/utils');
 					case 'notifications:user_started_following_you':
 					case 'notifications:user_posted_to':
 					case 'notifications:user_flagged_post_in':
+					case 'notifications:user_flagged_user':
 						var usernames = set.map(function (notifObj) {
 							return notifObj && notifObj.user && notifObj.user.username;
 						}).filter(function (username, idx, array) {
diff --git a/test/flags.js b/test/flags.js
index ee735afd60..14bb1e65c6 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -32,6 +32,11 @@ describe('Flags', function () {
 						content: 'This is flaggable content'
 					}, next);
 				});
+			},
+			function (topicData, next) {
+				User.create({
+					username: 'testUser2', password: 'abcdef', email: 'c@d.com'
+				}, next);
 			}
 		], done);
 	});
@@ -212,7 +217,7 @@ describe('Flags', function () {
 				Flags.validate({
 					type: 'post',
 					id: 1,
-					uid: 1
+					uid: 2
 				}, function (err) {
 					assert.ok(err);
 					assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
@@ -305,6 +310,10 @@ describe('Flags', function () {
 				assert.ifError(err);
 
 				Flags.getHistory(1, function (err, history) {
+					if (err) {
+						throw err;
+					}
+
 					assert.strictEqual(entries + 1, history.length);
 					done();
 				});
@@ -315,6 +324,7 @@ describe('Flags', function () {
 	describe('.getHistory()', function () {
 		it('should retrieve a flag\'s history', function (done) {
 			Flags.getHistory(1, function (err, history) {
+				assert.ifError(err);
 				assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
 				done();
 			});

From a5fb4825b4c92a481e7da04246cb307b56f40bd0 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 7 Dec 2016 13:06:55 -0500
Subject: [PATCH 27/48] deprecating old hook for #5232

---
 public/language/en-GB/pages.json | 2 +-
 src/flags.js                     | 7 ++++++-
 src/plugins/hooks.js             | 3 ++-
 3 files changed, 9 insertions(+), 3 deletions(-)

diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json
index 801b28edea..104e2249f2 100644
--- a/public/language/en-GB/pages.json
+++ b/public/language/en-GB/pages.json
@@ -6,7 +6,7 @@
 	"popular-month": "Popular topics this month",
 	"popular-alltime": "All time popular topics",
 	"recent": "Recent Topics",
-	"flagged-posts": "Flagged Posts",
+	"flagged-content": "Flagged Content",
 	"ip-blacklist": "IP Blacklist",
 
 	"users/online": "Online Users",
diff --git a/src/flags.js b/src/flags.js
index 9f121f81bf..bfbe80992a 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -597,7 +597,9 @@ Flags.notify = function (flagObj, uid, callback) {
 						return callback(err);
 					}
 
-					plugins.fireHook('action:post.flag', {post: results.post, reason: flagObj.description, flaggingUser: flagObj.reporter});
+					plugins.fireHook('action:flag.create', {
+						flag: flagObj
+					});
 					notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), callback);
 				});
 			});
@@ -624,6 +626,9 @@ Flags.notify = function (flagObj, uid, callback) {
 						return callback(err);
 					}
 
+					plugins.fireHook('action:flag.create', {
+						flag: flagObj
+					});
 					notifications.push(notification, results.admins.concat(results.globalMods), callback);
 				});
 			});
diff --git a/src/plugins/hooks.js b/src/plugins/hooks.js
index b8e5af0e46..b5a32ba1b7 100644
--- a/src/plugins/hooks.js
+++ b/src/plugins/hooks.js
@@ -7,7 +7,8 @@ module.exports = function (Plugins) {
 	Plugins.deprecatedHooks = {
 		'filter:user.custom_fields': null,	// remove in v1.1.0
 		'filter:post.save': 'filter:post.create',
-		'filter:user.profileLinks': 'filter:user.profileMenu'
+		'filter:user.profileLinks': 'filter:user.profileMenu',
+		'action:post.flag': 'action:flag.create'
 	};
 	/*
 		`data` is an object consisting of (* is required):

From e40eb75f8f66a04811d64943426e1ff18c3f453c Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 7 Dec 2016 15:13:40 -0500
Subject: [PATCH 28/48] change history saving to append an object not a
 serialised array, #5232

---
 src/flags.js | 41 ++++++++++++++++-------------------------
 1 file changed, 16 insertions(+), 25 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index bfbe80992a..1655361b0e 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -422,7 +422,6 @@ Flags.getTargetUid = function (type, id, callback) {
 Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];
-	var history = [];
 	var tasks = [];
 	var now = Date.now();
 
@@ -445,9 +444,6 @@ Flags.update = function (flagId, uid, changeset, callback) {
 								tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byAssignee:' + changeset[prop], now, flagId));
 								break;
 						}
-
-						// Append to history payload
-						history.push(prop + ':' + changeset[prop]);
 					}
 				}
 			}
@@ -460,7 +456,7 @@ Flags.update = function (flagId, uid, changeset, callback) {
 			// Save new object to db (upsert)
 			tasks.push(async.apply(db.setObject, 'flag:' + flagId, changeset));
 			// Append history
-			tasks.push(async.apply(Flags.appendHistory, flagId, uid, history));
+			tasks.push(async.apply(Flags.appendHistory, flagId, uid, changeset));
 
 			async.parallel(tasks, function (err, data) {
 				return next(err);
@@ -484,28 +480,21 @@ Flags.getHistory = function (flagId, callback) {
 
 				uids.push(entry.value[0]);
 
-				// Deserialise field object
-				var fields = entry.value[1].map(function (field) {
-					field = field.toString().split(':');
-
-					switch (field[0]) {
-						case 'state':
-							field[1] = field[1] === undefined ? null : '[[flags:state-' + field[1] + ']]';
-							break;
-
-						default:
-							field[1] = field[1] === undefined ? null : field[1];
-							break;
-					}
-					return {
-						"attribute": field[0],
-						"value": field[1]
-					};
-				});
+				// Deserialise changeset
+				var changeset = entry.value[1];
+				if (changeset.hasOwnProperty('state')) {
+					changeset.state = changeset.state === undefined ? '' : '[[flags:state-' + changeset.state + ']]';
+				}
+				if (changeset.hasOwnProperty('assignee')) {
+					changeset.assignee = changeset.assignee || '';
+				}
+				if (changeset.hasOwnProperty('notes')) {
+					changeset.notes = changeset.notes || '';
+				}
 
 				return {
 					uid: entry.value[0],
-					fields: fields,
+					fields: changeset,
 					datetime: entry.score,
 					datetimeISO: new Date(entry.score).toISOString()
 				};
@@ -549,7 +538,9 @@ Flags.appendNote = function (flagId, uid, note, callback) {
 
 	async.waterfall([
 		async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload),
-		async.apply(Flags.appendHistory, flagId, uid, ['notes'])
+		async.apply(Flags.appendHistory, flagId, uid, {
+			notes: null
+		})
 	], callback);
 };
 

From 6533fa066d219c05ad295a884d526e8fabf586ce Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 7 Dec 2016 15:42:47 -0500
Subject: [PATCH 29/48] removed unneeded fixes #5232

---
 src/flags.js | 6 ------
 1 file changed, 6 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 1655361b0e..aafae593e0 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -485,12 +485,6 @@ Flags.getHistory = function (flagId, callback) {
 				if (changeset.hasOwnProperty('state')) {
 					changeset.state = changeset.state === undefined ? '' : '[[flags:state-' + changeset.state + ']]';
 				}
-				if (changeset.hasOwnProperty('assignee')) {
-					changeset.assignee = changeset.assignee || '';
-				}
-				if (changeset.hasOwnProperty('notes')) {
-					changeset.notes = changeset.notes || '';
-				}
 
 				return {
 					uid: entry.value[0],

From 31996f9377a8142ec9be92164905f182ab0bcc45 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 7 Dec 2016 15:51:05 -0500
Subject: [PATCH 30/48] added page titles

---
 public/language/en-GB/pages.json | 3 +++
 src/controllers/mods.js          | 6 ++++--
 2 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json
index 104e2249f2..5efa686fc3 100644
--- a/public/language/en-GB/pages.json
+++ b/public/language/en-GB/pages.json
@@ -32,6 +32,9 @@
 	"chats": "Chats",
 	"chat": "Chatting with %1",
 
+	"flags": "Flags",
+	"flag-details": "Flag %1 Details",
+
 	"account/edit": "Editing \"%1\"",
 	"account/edit/password": "Editing password of \"%1\"",
 	"account/edit/username": "Editing username of \"%1\"",
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 656605f277..62f7b6dd07 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -43,7 +43,8 @@ modsController.flags.list = function (req, res, next) {
 			res.render('flags/list', {
 				flags: flags,
 				hasFilter: !!Object.keys(filters).length,
-				filters: filters
+				filters: filters,
+				title: '[[pages:flags]]'
 			});
 		});
 	});
@@ -67,7 +68,8 @@ modsController.flags.detail = function (req, res, next) {
 			type_bool: ['post', 'user'].reduce(function (memo, cur) {
 				memo[cur] = results.flagData.type === cur;
 				return memo;
-			}, {})
+			}, {}),
+			title: '[[pages:flag-details, ' + req.params.flagId + ']]'
 		}));
 	});
 };

From ebc9abd7730bac3366dd4599b5a365c58a7c5697 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 9 Dec 2016 14:33:59 -0500
Subject: [PATCH 31/48] upgrade script and graphs for #5232

---
 public/src/client/flags/list.js  |  51 +++++++-
 src/controllers/mods.js          |   9 +-
 src/flags.js                     |  49 +++++---
 src/upgrade.js                   |  87 +++++++++++++-
 src/views/admin/manage/flags.tpl | 196 -------------------------------
 5 files changed, 177 insertions(+), 215 deletions(-)
 delete mode 100644 src/views/admin/manage/flags.tpl

diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index bf8b4bc1a8..7647e1db6d 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -2,12 +2,13 @@
 
 /* globals define */
 
-define('forum/flags/list', ['components'], function (components) {
+define('forum/flags/list', ['components', 'Chart'], function (components, Chart) {
 	var Flags = {};
 
 	Flags.init = function () {
 		Flags.enableFilterForm();
 		Flags.enableChatButtons();
+		Flags.handleGraphs();
 	};
 
 	Flags.enableFilterForm = function () {
@@ -38,5 +39,53 @@ define('forum/flags/list', ['components'], function (components) {
 		});
 	};
 
+	Flags.handleGraphs = function () {
+		var dailyCanvas = document.getElementById('flags:daily');
+		var dailyLabels = utils.getDaysArray().map(function (text, idx) {
+			return idx % 3 ? '' : text;
+		});
+
+		if (utils.isMobile()) {
+			Chart.defaults.global.tooltips.enabled = false;
+		}
+		var data = {
+			'flags:daily': {
+				labels: dailyLabels,
+				datasets: [
+					{
+						label: "",
+						backgroundColor: "rgba(151,187,205,0.2)",
+						borderColor: "rgba(151,187,205,1)",
+						pointBackgroundColor: "rgba(151,187,205,1)",
+						pointHoverBackgroundColor: "#fff",
+						pointBorderColor: "#fff",
+						pointHoverBorderColor: "rgba(151,187,205,1)",
+						data: ajaxify.data.analytics
+					}
+				]
+			}
+		};
+
+		dailyCanvas.width = $(dailyCanvas).parent().width();
+		new Chart(dailyCanvas.getContext('2d'), {
+			type: 'line',
+			data: data['flags:daily'],
+			options: {
+				responsive: true,
+				animation: false,
+				legend: {
+					display: false
+				},
+				scales: {
+					yAxes: [{
+						ticks: {
+							beginAtZero: true
+						}
+					}]
+				}
+			}
+		});
+	};
+
 	return Flags;
 });
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 62f7b6dd07..39820e60a6 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -4,6 +4,7 @@ var async = require('async');
 
 var user = require('../user');
 var flags = require('../flags');
+var analytics = require('../analytics');
 // var adminFlagsController = require('./admin/flags');
 
 var modsController = {
@@ -35,13 +36,17 @@ modsController.flags.list = function (req, res, next) {
 			return memo;
 		}, {});
 
-		flags.list(filters, req.uid, function (err, flags) {
+		async.parallel({
+			flags: async.apply(flags.list, filters, req.uid),
+			analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30)
+		}, function (err, data) {
 			if (err) {
 				return next(err);
 			}
 
 			res.render('flags/list', {
-				flags: flags,
+				flags: data.flags,
+				analytics: data.analytics,
 				hasFilter: !!Object.keys(filters).length,
 				filters: filters,
 				title: '[[pages:flags]]'
diff --git a/src/flags.js b/src/flags.js
index aafae593e0..7c56ba4fb5 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -267,8 +267,16 @@ Flags.getNotes = function (flagId, callback) {
 	], callback);
 };
 
-Flags.create = function (type, id, uid, reason, callback) {
+Flags.create = function (type, id, uid, reason, timestamp, callback) {
 	var targetUid;
+	var doHistoryAppend = false;
+
+	// timestamp is optional
+	if (typeof timestamp === 'function' && !callback) {
+		callback = timestamp;
+		timestamp = Date.now();
+		doHistoryAppend = true;
+	}
 
 	async.waterfall([
 		function (next) {
@@ -302,16 +310,17 @@ Flags.create = function (type, id, uid, reason, callback) {
 					targetId: id,
 					description: reason,
 					uid: uid,
-					datetime: Date.now()
+					datetime: timestamp
 				}),
-				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId),	// by time, the default
-				async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, Date.now(), flagId),	// by reporter
-				async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, Date.now(), flagId),	// by flag type
-				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId)	// save hash for existence checking
+				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', timestamp, flagId),	// by time, the default
+				async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, timestamp, flagId),	// by reporter
+				async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, timestamp, flagId),	// by flag type
+				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId),	// save hash for existence checking
+				async.apply(analytics.increment, 'flags')	// some fancy analytics
 			];
 
 			if (targetUid) {
-				tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, Date.now(), flagId));	// by target uid
+				tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, timestamp, flagId));	// by target uid
 			}
 		
 			async.parallel(tasks, function (err, data) {
@@ -319,7 +328,10 @@ Flags.create = function (type, id, uid, reason, callback) {
 					return next(err);
 				}
 
-				Flags.update(flagId, uid, { "state": "open" });
+				if (doHistoryAppend) {
+					Flags.update(flagId, uid, { "state": "open" });
+				}
+
 				next(null, flagId);
 			});
 		},
@@ -423,7 +435,7 @@ Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];
 	var tasks = [];
-	var now = Date.now();
+	var now = changeset.datetime || Date.now();
 
 	async.waterfall([
 		async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields),
@@ -513,16 +525,24 @@ Flags.getHistory = function (flagId, callback) {
 
 Flags.appendHistory = function (flagId, uid, changeset, callback) {
 	var payload;
+	var datetime = changeset.datetime || Date.now();
+	delete changeset.datetime;
+
 	try {
-		payload = JSON.stringify([uid, changeset, Date.now()]);
+		payload = JSON.stringify([uid, changeset, datetime]);
 	} catch (e) {
 		return callback(e);
 	}
 
-	db.sortedSetAdd('flag:' + flagId + ':history', Date.now(), payload, callback);
+	db.sortedSetAdd('flag:' + flagId + ':history', datetime, payload, callback);
 };
 
-Flags.appendNote = function (flagId, uid, note, callback) {
+Flags.appendNote = function (flagId, uid, note, datetime, callback) {
+	if (typeof datetime === 'function' && !callback) {
+		callback = datetime;
+		datetime = Date.now();
+	}
+
 	var payload;
 	try {
 		payload = JSON.stringify([uid, note]);
@@ -531,9 +551,10 @@ Flags.appendNote = function (flagId, uid, note, callback) {
 	}
 
 	async.waterfall([
-		async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload),
+		async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', datetime, payload),
 		async.apply(Flags.appendHistory, flagId, uid, {
-			notes: null
+			notes: null,
+			datetime: datetime
 		})
 	], callback);
 };
diff --git a/src/upgrade.js b/src/upgrade.js
index 2a605e84f9..29b806d4a7 100644
--- a/src/upgrade.js
+++ b/src/upgrade.js
@@ -12,7 +12,7 @@ var db = require('./database'),
 	schemaDate, thisSchemaDate,
 
 	// IMPORTANT: REMEMBER TO UPDATE VALUE OF latestSchema
-	latestSchema = Date.UTC(2016, 10, 22);
+	latestSchema = Date.UTC(2016, 11, 7);
 
 Upgrade.check = function (callback) {
 	db.get('schemaDate', function (err, value) {
@@ -1023,7 +1023,7 @@ Upgrade.upgrade = function (callback) {
 
 			if (schemaDate < thisSchemaDate) {
 				updatesMade = true;
-				winston.info('[2016/11/25] Creating sorted sets for pinned topcis');
+				winston.info('[2016/11/25] Creating sorted sets for pinned topics');
 
 				var topics = require('./topics');
 				var batch = require('./batch');
@@ -1060,6 +1060,89 @@ Upgrade.upgrade = function (callback) {
 				next();
 			}
 		},
+		function (next) {
+			thisSchemaDate = Date.UTC(2016, 11, 7);
+
+			if (schemaDate < thisSchemaDate) {
+				updatesMade = true;
+				winston.info('[2016/12/07] Migrating flags to new schema (#5232)');
+
+				var batch = require('./batch');
+				var posts = require('./posts');
+				var flags = require('./flags');
+				var migrated = 0;
+
+				batch.processSortedSet('posts:pid', function (ids, next) {
+					posts.getPostsByPids(ids, 1, function (err, posts) {
+						if (err) {
+							return next(err);
+						}
+
+						posts = posts.filter(function (post) {
+							return post.hasOwnProperty('flags');
+						});
+						
+						async.each(posts, function (post, next) {
+							async.parallel({
+								uids: async.apply(db.getSortedSetRangeWithScores, 'pid:' + post.pid + ':flag:uids', 0, -1),
+								reasons: async.apply(db.getSortedSetRange, 'pid:' + post.pid + ':flag:uid:reason', 0, -1)
+							}, function (err, data) {
+								if (err) {
+									return next(err);
+								}
+
+								// Just take the first entry
+								var datetime = data.uids[0].score;
+								var reason = data.reasons[0].split(':')[1];
+								var flagObj;
+
+								async.waterfall([
+									async.apply(flags.create, 'post', post.pid, data.uids[0].value, reason, datetime),
+									function (_flagObj, next) {
+										flagObj = _flagObj;
+										if (post['flag:state'] || post['flag:assignee']) {
+											flags.update(flagObj.flagId, 1, {
+												state: post['flag:state'],
+												assignee: post['flag:assignee'],
+												datetime: datetime
+											}, next);
+										} else {
+											setImmediate(next);
+										}
+									},
+									function (next) {
+										if (post.hasOwnProperty('flag:notes') && post['flag:notes'].length) {
+											try {
+												var history = JSON.parse(post['flag:history']);
+												history = history.filter(function (event) {
+													return event.type === 'notes';
+												})[0];
+
+												flags.appendNote(flagObj.flagId, history.uid, post['flag:notes'], history.timestamp, next);
+											} catch (e) {
+												next(e);
+											}
+										} else {
+											setImmediate(next);
+										}
+									}
+								], next);
+							});
+						}, next);
+					});
+				}, function (err) {
+					if (err) {
+						return next(err);
+					}
+
+					winston.info('[2016/12/07] Migrating flags to new schema (#5232) - done');
+					Upgrade.update(thisSchemaDate, next);
+				});
+			} else {
+				winston.info('[2016/12/07] Migrating flags to new schema (#5232) - skipped!');
+				next();
+			}
+		}
 		// Add new schema updates here
 		// IMPORTANT: REMEMBER TO UPDATE VALUE OF latestSchema IN LINE 24!!!
 	], function (err) {
diff --git a/src/views/admin/manage/flags.tpl b/src/views/admin/manage/flags.tpl
deleted file mode 100644
index a14348ba42..0000000000
--- a/src/views/admin/manage/flags.tpl
+++ /dev/null
@@ -1,196 +0,0 @@
-<div class="flags">
-
-	<div class="col-lg-12">
-
-		<div class="text-center">
-			<div class="panel panel-default">
-				<div class="panel-body">
-					<div><canvas id="flags:daily" height="250"></canvas></div>
-					<p>
-
-					</p>
-				</div>
-				<div class="panel-footer"><small>Daily flags</small></div>
-			</div>
-		</div>
-
-		<form id="flag-search" method="GET" action="flags">
-			<div class="form-group">
-				<div>
-					<div>
-						<label>Flags by user</label>
-						<input type="text" class="form-control" id="byUsername" placeholder="Search flagged posts by username" name="byUsername" value="{byUsername}">
-					</div>
-				</div>
-			</div>
-
-			<div class="form-group">
-				<div>
-					<div>
-						<label>Category</label>
-						<select class="form-control" id="category-selector" name="cid">
-							<option value="">[[unread:all_categories]]</option>
-							<!-- BEGIN categories -->
-							<option value="{categories.cid}" <!-- IF categories.selected -->selected<!-- ENDIF categories.selected -->>{categories.text}</option>
-							<!-- END categories -->
-						</select>
-					</div>
-				</div>
-			</div>
-
-			<div class="form-group">
-				<label>Sort By</label>
-				<div>
-					<div>
-						<select id="flag-sort-by" class="form-control" name="sortBy">
-							<option value="count" <!-- IF sortByCount -->selected<!-- ENDIF sortByCount -->>Most Flags</option>
-							<option value="time" <!-- IF sortByTime -->selected<!-- ENDIF sortByTime -->>Most Recent</option>
-						</select>
-					</div>
-				</div>
-			</div>
-
-			<button type="submit" class="btn btn-primary">Search</button>
-			<button class="btn btn-primary" id="dismissAll">Dismiss All</button>
-		</form>
-
-		<hr/>
-
-		<div data-next="{next}">
-
-			<div component="posts/flags" class="panel-group post-container" id="accordion" role="tablist" aria-multiselectable="true" data-next="{next}">
-				<!-- IF !posts.length -->
-				<div class="alert alert-success">
-					No flagged posts!
-				</div>
-				<!-- ENDIF !posts.length -->
-
-				<!-- BEGIN posts -->
-				<div class="panel panel-default" component="posts/flag" data-pid="{../pid}">
-					<div class="panel-heading" role="tab">
-						<h4 class="panel-title">
-							<a role="button" data-toggle="collapse" data-parent="#accordion" href="#flag-pid-{posts.pid}" aria-expanded="true" aria-controls="flag-pid-{posts.pid}">
-								<!-- IF ../flagData.assignee -->
-								<div class="pull-right">
-									<!-- IF ../flagData.assigneeUser.picture -->
-									<img class="avatar avatar-xs" title="{../flagData.assigneeUser.username}" src="{../flagData.assigneeUser.picture}">
-									<!-- ELSE -->
-									<div class="avatar avatar-xs" title="{../flagData.assigneeUser.username}" style="background-color: {../flagData.assigneeUser.icon:bgColor};">{../flagData.assigneeUser.icon:text}</div>
-									<!-- ENDIF ../flagData.assigneeUser.picture -->
-								</div>
-								<!-- ENDIF ../flagData.assignee -->
-								<span class="label <!-- IF ../flagData.labelClass -->label-{../flagData.labelClass}<!-- ELSE -->label-info<!-- ENDIF ../flagData.labelClass -->">[[topic:flag_manage_state_<!-- IF ../flagData.state -->{../flagData.state}<!-- ELSE -->open<!-- ENDIF ../flagData.state -->]]</span>
-								&nbsp;[[topic:flag_manage_title, {posts.category.name}]]
-								<small><span class="timeago" title="{posts.timestampISO}"></span></small>
-							</a>
-						</h4>
-					</div>
-					<div id="flag-pid-{posts.pid}" class="panel-collapse collapse" role="tabpanel">
-						<div class="panel-body">
-							<div class="row" data-pid="{posts.pid}" data-tid="{posts.topic.tid}">
-								<div class="col-sm-8">
-									<div class="well flag-post-body">
-										<a href="{config.relative_path}/user/{../user.userslug}">
-											<!-- IF ../user.picture -->
-											<img title="{posts.user.username}" src="{../user.picture}">
-											<!-- ELSE -->
-											<div title="{posts.user.username}" class="user-icon" style="background-color: {../user.icon:bgColor};">{../user.icon:text}</div>
-											<!-- ENDIF ../user.picture -->
-										</a>
-
-										<a href="{config.relative_path}/user/{../user.userslug}">
-											<strong><span>{../user.username}</span></strong>
-										</a>
-										<div class="content">
-											<p>{posts.content}</p>
-										</div>
-										<small>
-											<span class="pull-right">
-												Posted in <a href="{config.relative_path}/category/{posts.category.slug}" target="_blank"><i class="fa {posts.category.icon}"></i> {posts.category.name}</a>, <span class="timeago" title="{posts.timestampISO}"></span> &bull;
-												<a href="{config.relative_path}/post/{posts.pid}" target="_blank">Read More</a>
-											</span>
-										</small>
-									</div>
-								</div>
-								<div class="col-sm-4">
-									<i class="fa fa-flag"></i> This post has been flagged {posts.flags} time(s):
-									<blockquote class="flag-reporters">
-										<ul>
-											<!-- BEGIN posts.flagReasons -->
-											<li>
-												<a target="_blank" href="{config.relative_path}/user/{../user.userslug}">
-													<!-- IF ../user.picture -->
-													<img src="{../user.picture}" />
-													<!-- ELSE -->
-													<div class="user-icon" style="background-color: {../user.icon:bgColor};">{../user.icon:text}</div>
-													<!-- ENDIF ../user.picture -->
-													{../user.username}
-												</a>: "{posts.flagReasons.reason}"
-											</li>
-											<!-- END posts.flagReasons -->
-										</ul>
-									</blockquote>
-									<div class="btn-group">
-										<button class="btn btn-sm btn-success dismiss">Dismiss this Flag</button>
-										<button class="btn btn-sm btn-danger delete">Delete the Post</button>
-									</div>
-								</div>
-							</div>
-							<hr />
-							<div class="row">
-								<div class="col-sm-6">
-									<form role="form">
-										<div class="form-group">
-											<label for="{posts.pid}-assignee">[[topic:flag_manage_assignee]]</label>
-											<select class="form-control" id="{posts.pid}-assignee" name="assignee">
-												<!-- BEGIN assignees -->
-												<option value="{assignees.uid}">{assignees.username}</option>
-												<!-- END assignees -->
-											</select>
-										</div>
-										<div class="form-group">
-											<label for="{posts.pid}-state">[[topic:flag_manage_state]]</label>
-											<select class="form-control" id="{posts.pid}-state" name="state">
-												<option value="open">[[topic:flag_manage_state_open]]</option>
-												<option value="wip">[[topic:flag_manage_state_wip]]</option>
-												<option value="resolved">[[topic:flag_manage_state_resolved]]</option>
-												<option value="rejected">[[topic:flag_manage_state_rejected]]</option>
-											</select>
-										</div>
-										<div class="form-group">
-											<label for="{posts.pid}-notes">[[topic:flag_manage_notes]]</label>
-											<textarea class="form-control" id="{posts.pid}-notes" name="notes"></textarea>
-										</div>
-										<button type="button" component="posts/flag/update" class="btn btn-sm btn-primary btn-block">[[topic:flag_manage_update]]</button>
-									</form>
-								</div>
-								<div class="col-sm-6">
-									<h5>[[topic:flag_manage_history]]</h5>
-									<!-- IF !posts.flagData.history.length -->
-									<div class="alert alert-info">[[topic:flag_manage_no_history]]</div>
-									<!-- ELSE -->
-									<ul class="list-group" component="posts/flag/history">
-										<!-- BEGIN posts.flagData.history -->
-										<li class="list-group-item">
-											<div class="pull-right"><small><span class="timeago" title="{posts.flagData.history.timestampISO}"></span></small></div>
-											<!-- IF ../user.picture -->
-											<img class="avatar avatar-sm avatar-rounded" src="{../user.picture}" title="{../user.username}" />
-											<!-- ELSE -->
-											<div class="avatar avatar-sm avatar-rounded" style="background-color: {../user.icon:bgColor};" title="{../user.username}">{../user.icon:text}</div>
-											<!-- ENDIF ../user.picture -->
-											[[topic:flag_manage_history_{posts.flagData.history.type}, {posts.flagData.history.label}]]
-										</li>
-										<!-- END posts.flagData.history -->
-									</ul>
-									<!-- ENDIF !posts.flagData.history.length -->
-								</div>
-							</div>
-						</div>
-					</div>
-				</div>
-				<!-- END posts -->
-				<!-- IMPORT partials/paginator.tpl -->
-			</div>
-		</div>
-	</div>
-</div>

From 9ada35cfb982ca332e9c097e06588bb0e986046f Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 9 Dec 2016 14:39:31 -0500
Subject: [PATCH 32/48] allowing Analytics.increment to have a callback

---
 src/analytics.js | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/analytics.js b/src/analytics.js
index 6b248057da..9151bd6ce7 100644
--- a/src/analytics.js
+++ b/src/analytics.js
@@ -20,13 +20,17 @@ new cronJob('*/10 * * * *', function () {
 	Analytics.writeData();
 }, null, true);
 
-Analytics.increment = function (keys) {
+Analytics.increment = function (keys, callback) {
 	keys = Array.isArray(keys) ? keys : [keys];
 
 	keys.forEach(function (key) {
 		counters[key] = counters[key] || 0;
 		++counters[key];
 	});
+
+	if (typeof callback === 'function') {
+		callback();
+	}
 };
 
 Analytics.pageView = function (payload) {

From 47530423066c778983d7d00caa0073a1fb72d5c9 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 9 Dec 2016 14:53:49 -0500
Subject: [PATCH 33/48] lowered analytics disk writes to every ten seconds,
 because why every 10 minutes? :shipit:

---
 src/analytics.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/analytics.js b/src/analytics.js
index 9151bd6ce7..c6cfbeba7e 100644
--- a/src/analytics.js
+++ b/src/analytics.js
@@ -16,7 +16,7 @@ var uniquevisitors = 0;
 
 var isCategory = /^(?:\/api)?\/category\/(\d+)/;
 
-new cronJob('*/10 * * * *', function () {
+new cronJob('*/10 * * * * *', function () {
 	Analytics.writeData();
 }, null, true);
 

From aaec71bc0ca1d3613a07f6906b8d951ae4e60fab Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Fri, 9 Dec 2016 14:59:23 -0500
Subject: [PATCH 34/48] added stepSize to flags chart, #5232

---
 public/src/client/flags/list.js | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index 7647e1db6d..255c7176f1 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -79,7 +79,8 @@ define('forum/flags/list', ['components', 'Chart'], function (components, Chart)
 				scales: {
 					yAxes: [{
 						ticks: {
-							beginAtZero: true
+							beginAtZero: true,
+							stepSize: 1
 						}
 					}]
 				}

From 01969970995810602d26ff25ebc9ea8e38760a3e Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Tue, 13 Dec 2016 09:36:46 -0500
Subject: [PATCH 35/48] fixing issues as found by @barisusakli

re: #5232 and #5282
---
 src/flags.js | 89 +++++++---------------------------------------------
 1 file changed, 11 insertions(+), 78 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 7c56ba4fb5..1fb2986d2b 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -139,13 +139,7 @@ Flags.list = function (filters, uid, callback) {
 				});
 			},  next);
 		}
-	], function (err, flags) {
-		if (err) {
-			return callback(err);
-		}
-
-		return callback(null, flags);
-	});
+	], callback);
 };
 
 Flags.validate = function (payload, callback) {
@@ -166,16 +160,14 @@ Flags.validate = function (payload, callback) {
 
 		switch (payload.type) {
 			case 'post':
-				async.parallel({
-					editable: async.apply(privileges.posts.canEdit, payload.id, payload.uid)
-				}, function (err, subdata) {
+				privileges.posts.canEdit(payload.id, payload.uid, function (err, editable) {
 					if (err) {
 						return callback(err);
 					}
 
 					var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
 					// Check if reporter meets rep threshold (or can edit the target post, in which case threshold does not apply)
-					if (!subdata.editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) {
+					if (!editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) {
 						return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
 					}
 
@@ -184,17 +176,14 @@ Flags.validate = function (payload, callback) {
 				break;
 			
 			case 'user':
-				async.parallel({
-					editable: async.apply(privileges.users.canEdit, payload.uid, payload.id)
-				}, function (err, subdata) {
+				privileges.users.canEdit(payload.uid, payload.id, function (err, editable) {
 					if (err) {
 						return callback(err);
 					}
 
-
 					var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1;
 					// Check if reporter meets rep threshold (or can edit the target user, in which case threshold does not apply)
-					if (!subdata.editable && parseInt(data.reporter.reputation, 10) < minimumReputation) {
+					if (!editable && parseInt(data.reporter.reputation, 10) < minimumReputation) {
 						return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
 					}
 
@@ -227,6 +216,10 @@ Flags.getTarget = function (type, id, uid, callback) {
 				callback(err, users ? users[0] : undefined);
 			});
 			break;
+		
+		default:
+			 callback(new Error('[[error:invalid-data]]'));
+			 break;
 	}
 };
 
@@ -315,7 +308,7 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) {
 				async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', timestamp, flagId),	// by time, the default
 				async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, timestamp, flagId),	// by reporter
 				async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, timestamp, flagId),	// by flag type
-				async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId),	// save hash for existence checking
+				async.apply(db.sortedSetAdd.bind(db), 'flags:hash', flagId, [type, id, uid].join(':')),	// save zset for duplicate checking
 				async.apply(analytics.increment, 'flags')	// some fancy analytics
 			];
 
@@ -337,70 +330,10 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) {
 		},
 		async.apply(Flags.get)
 	], callback);
-	// if (!parseInt(uid, 10) || !reason) {
-	// 	return callback();
-	// }
-
-	// async.waterfall([
-	// 	function (next) {
-	// 		async.parallel({
-	// 			hasFlagged: async.apply(Flags.isFlaggedByUser, post.pid, uid),
-	// 			exists: async.apply(Posts.exists, post.pid)
-	// 		}, next);
-	// 	},
-	// 	function (results, next) {
-	// 		if (!results.exists) {
-	// 			return next(new Error('[[error:no-post]]'));
-	// 		}
-
-	// 		if (results.hasFlagged) {
-	// 			return next(new Error('[[error:already-flagged]]'));
-	// 		}
-
-	// 		var now = Date.now();
-	// 		async.parallel([
-	// 			function (next) {
-	// 				db.sortedSetAdd('posts:flagged', now, post.pid, next);
-	// 			},
-	// 			function (next) {
-	// 				db.sortedSetIncrBy('posts:flags:count', 1, post.pid, next);
-	// 			},
-	// 			function (next) {
-	// 				db.incrObjectField('post:' + post.pid, 'flags', next);
-	// 			},
-	// 			function (next) {
-	// 				db.sortedSetAdd('pid:' + post.pid + ':flag:uids', now, uid, next);
-	// 			},
-	// 			function (next) {
-	// 				db.sortedSetAdd('pid:' + post.pid + ':flag:uid:reason', 0, uid + ':' + reason, next);
-	// 			},
-	// 			function (next) {
-	// 				if (parseInt(post.uid, 10)) {
-	// 					async.parallel([
-	// 						async.apply(db.sortedSetIncrBy, 'users:flags', 1, post.uid),
-	// 						async.apply(db.incrObjectField, 'user:' + post.uid, 'flags'),
-	// 						async.apply(db.sortedSetAdd, 'uid:' + post.uid + ':flag:pids', now, post.pid)
-	// 					], next);
-	// 				} else {
-	// 					next();
-	// 				}
-	// 			}
-	// 		], next);
-	// 	},
-	// 	function (data, next) {
-	// 		openNewFlag(post.pid, uid, next);		// removed, used to just update flag to open state if new flag
-	// 	}
-	// ], function (err) {
-	// 	if (err) {
-	// 		return callback(err);
-	// 	}
-	// 	analytics.increment('flags');
-	// 	callback();
-	// });
 };
 
 Flags.exists = function (type, id, uid, callback) {
-	db.isObjectField('flagHash:flagId', [type, id, uid].join(':'), callback);
+	db.isSortedSetMember('flags:hash', [type, id, uid].join(':'), callback);
 };
 
 Flags.targetExists = function (type, id, callback) {

From 831c2064a0435eb07c5bb3d010753dc0416e6166 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Tue, 13 Dec 2016 12:11:51 -0500
Subject: [PATCH 36/48] For #5232, added tests and returning flag data on
 socket flag creation

---
 src/flags.js           |   4 +
 src/socket.io/flags.js |  12 +-
 test/flags.js          | 612 +++++++++++++++++++++++------------------
 3 files changed, 358 insertions(+), 270 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 1fb2986d2b..1ae5a84ed8 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -494,6 +494,10 @@ Flags.appendNote = function (flagId, uid, note, datetime, callback) {
 
 Flags.notify = function (flagObj, uid, callback) {
 	// Notify administrators, mods, and other associated people
+	if (!callback) {
+		callback = function () {};
+	}
+
 	switch (flagObj.type) {
 		case 'post':
 			async.parallel({
diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js
index ce148faa87..c25bd662fa 100644
--- a/src/socket.io/flags.js
+++ b/src/socket.io/flags.js
@@ -34,11 +34,15 @@ SocketFlags.create = function (socket, data, callback) {
 		function (next) {
 			// If we got here, then no errors occurred
 			flags.create(data.type, data.id, socket.uid, data.reason, next);
-		},
-		function (flagObj, next) {
-			flags.notify(flagObj, socket.uid, next);
 		}
-	], callback);
+	], function (err, flagObj) {
+		if (err) {
+			return callback(err);
+		}
+
+		flags.notify(flagObj, socket.uid);
+		callback(null, flagObj);
+	});
 };
 
 SocketFlags.update = function (socket, data, callback) {
diff --git a/test/flags.js b/test/flags.js
index 14bb1e65c6..8d359c6930 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -10,6 +10,7 @@ var Categories = require('../src/categories');
 var Topics = require('../src/topics');
 var Posts = require('../src/posts');
 var User = require('../src/user');
+var Groups = require('../src/groups');
 var Meta = require('../src/meta');
 
 describe('Flags', function () {
@@ -37,300 +38,379 @@ describe('Flags', function () {
 				User.create({
 					username: 'testUser2', password: 'abcdef', email: 'c@d.com'
 				}, next);
+			},
+			function (uid, next) {
+				Groups.join('administrators', uid, next);
 			}
 		], done);
 	});
 
-	describe('.create()', function () {
-		it('should create a flag and return its data', function (done) {
-			Flags.create('post', 1, 1, 'Test flag', function (err, flagData) {
-				assert.ifError(err);
-				var compare = {
-					flagId: 1,
-					uid: 1,
-					targetId: 1,
-					type: 'post',
-					description: 'Test flag'
-				};
-
-				for(var key in compare) {
-					if (compare.hasOwnProperty(key)) {
-						assert.ok(flagData[key]);
-						assert.strictEqual(flagData[key], compare[key]);
-					}
-				}
-
-				done();
-			});
-		});
-	});
-
-	describe('.get()', function () {
-		it('should retrieve and display a flag\'s data', function (done) {
-			Flags.get(1, function (err, flagData) {
-				assert.ifError(err);
-				var compare = {
-					flagId: 1,
-					uid: 1,
-					targetId: 1,
-					type: 'post',
-					description: 'Test flag',
-					state: 'open'
-				};
-
-				for(var key in compare) {
-					if (compare.hasOwnProperty(key)) {
-						assert.ok(flagData[key]);
-						assert.strictEqual(flagData[key], compare[key]);
-					}
-				}
-
-				done();
-			});
-		});
-	});
-
-	describe('.list()', function () {
-		it('should show a list of flags (with one item)', function (done) {
-			Flags.list({}, 1, function (err, flags) {
-				assert.ifError(err);
-				assert.ok(Array.isArray(flags));
-				assert.equal(flags.length, 1);
+	// describe('.create()', function () {
+	// 	it('should create a flag and return its data', function (done) {
+	// 		Flags.create('post', 1, 1, 'Test flag', function (err, flagData) {
+	// 			assert.ifError(err);
+	// 			var compare = {
+	// 				flagId: 1,
+	// 				uid: 1,
+	// 				targetId: 1,
+	// 				type: 'post',
+	// 				description: 'Test flag'
+	// 			};
+
+	// 			for(var key in compare) {
+	// 				if (compare.hasOwnProperty(key)) {
+	// 					assert.ok(flagData[key]);
+	// 					assert.strictEqual(flagData[key], compare[key]);
+	// 				}
+	// 			}
+
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	// describe('.get()', function () {
+	// 	it('should retrieve and display a flag\'s data', function (done) {
+	// 		Flags.get(1, function (err, flagData) {
+	// 			assert.ifError(err);
+	// 			var compare = {
+	// 				flagId: 1,
+	// 				uid: 1,
+	// 				targetId: 1,
+	// 				type: 'post',
+	// 				description: 'Test flag',
+	// 				state: 'open'
+	// 			};
+
+	// 			for(var key in compare) {
+	// 				if (compare.hasOwnProperty(key)) {
+	// 					assert.ok(flagData[key]);
+	// 					assert.strictEqual(flagData[key], compare[key]);
+	// 				}
+	// 			}
+
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	// describe('.list()', function () {
+	// 	it('should show a list of flags (with one item)', function (done) {
+	// 		Flags.list({}, 1, function (err, flags) {
+	// 			assert.ifError(err);
+	// 			assert.ok(Array.isArray(flags));
+	// 			assert.equal(flags.length, 1);
 				
-				Flags.get(flags[0].flagId, function (err, flagData) {
-					assert.ifError(err);
-					assert.equal(flags[0].flagId, flagData.flagId);
-					assert.equal(flags[0].description, flagData.description);
-					done();
-				});
-			});
-		});
-	});
-
-	describe('.update()', function () {
-		it('should alter a flag\'s various attributes and persist them to the database', function (done) {
-			Flags.update(1, 1, {
-				"state": "wip",
-				"assignee": 1
-			}, function (err) {
-				assert.ifError(err);
-				db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) {
-					if (err) {
-						throw err;
-					}
-
-					assert.strictEqual('wip', data.state);
-					assert.strictEqual(1, data.assignee);
-					done();
-				});
-			});
-		});
-
-		it('should persist to the flag\'s history', function (done) {
-			Flags.getHistory(1, function (err, history) {
-				if (err) {
-					throw err;
-				}
-
-				history.forEach(function (change) {
-					switch (change.attribute) {
-						case 'state':
-							assert.strictEqual('[[flags:state-wip]]', change.value);
-							break;
+	// 			Flags.get(flags[0].flagId, function (err, flagData) {
+	// 				assert.ifError(err);
+	// 				assert.equal(flags[0].flagId, flagData.flagId);
+	// 				assert.equal(flags[0].description, flagData.description);
+	// 				done();
+	// 			});
+	// 		});
+	// 	});
+	// });
+
+	// describe('.update()', function () {
+	// 	it('should alter a flag\'s various attributes and persist them to the database', function (done) {
+	// 		Flags.update(1, 1, {
+	// 			"state": "wip",
+	// 			"assignee": 1
+	// 		}, function (err) {
+	// 			assert.ifError(err);
+	// 			db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) {
+	// 				if (err) {
+	// 					throw err;
+	// 				}
+
+	// 				assert.strictEqual('wip', data.state);
+	// 				assert.strictEqual(1, data.assignee);
+	// 				done();
+	// 			});
+	// 		});
+	// 	});
+
+	// 	it('should persist to the flag\'s history', function (done) {
+	// 		Flags.getHistory(1, function (err, history) {
+	// 			if (err) {
+	// 				throw err;
+	// 			}
+
+	// 			history.forEach(function (change) {
+	// 				switch (change.attribute) {
+	// 					case 'state':
+	// 						assert.strictEqual('[[flags:state-wip]]', change.value);
+	// 						break;
 						
-						case 'assignee':
-							assert.strictEqual(1, change.value);
-							break;
-					}
-				});
-
-				done();
-			});
-		});
-	});
-
-	describe('.getTarget()', function () {
-		it('should return a post\'s data if queried with type "post"', function (done) {
-			Flags.getTarget('post', 1, 1, function (err, data) {
-				assert.ifError(err);
-				var compare = {
-					uid: 1,
-					pid: 1,
-					content: 'This is flaggable content'
-				};
-
-				for(var key in compare) {
-					if (compare.hasOwnProperty(key)) {
-						assert.ok(data[key]);
-						assert.strictEqual(data[key], compare[key]);
-					}
-				}
-
-				done();
-			});
-		});
+	// 					case 'assignee':
+	// 						assert.strictEqual(1, change.value);
+	// 						break;
+	// 				}
+	// 			});
+
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	// describe('.getTarget()', function () {
+	// 	it('should return a post\'s data if queried with type "post"', function (done) {
+	// 		Flags.getTarget('post', 1, 1, function (err, data) {
+	// 			assert.ifError(err);
+	// 			var compare = {
+	// 				uid: 1,
+	// 				pid: 1,
+	// 				content: 'This is flaggable content'
+	// 			};
+
+	// 			for(var key in compare) {
+	// 				if (compare.hasOwnProperty(key)) {
+	// 					assert.ok(data[key]);
+	// 					assert.strictEqual(data[key], compare[key]);
+	// 				}
+	// 			}
+
+	// 			done();
+	// 		});
+	// 	});
+
+	// 	it('should return a user\'s data if queried with type "user"', function (done) {
+	// 		Flags.getTarget('user', 1, 1, function (err, data) {
+	// 			assert.ifError(err);
+	// 			var compare = {
+	// 				uid: 1,
+	// 				username: 'testUser',
+	// 				email: 'b@c.com'
+	// 			};
+
+	// 			for(var key in compare) {
+	// 				if (compare.hasOwnProperty(key)) {
+	// 					assert.ok(data[key]);
+	// 					assert.strictEqual(data[key], compare[key]);
+	// 				}
+	// 			}
+
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	// describe('.validate()', function () {
+	// 	it('should error out if type is post and post is deleted', function (done) {
+	// 		Posts.delete(1, 1, function (err) {
+	// 			if (err) {
+	// 				throw err;
+	// 			}
+
+	// 			Flags.validate({
+	// 				type: 'post',
+	// 				id: 1,
+	// 				uid: 1
+	// 			}, function (err) {
+	// 				assert.ok(err);
+	// 				assert.strictEqual('[[error:post-deleted]]', err.message);
+	// 				Posts.restore(1, 1, done);
+	// 			});
+	// 		});
+	// 	});
+
+	// 	it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) {
+	// 		Meta.configs.set('privileges:flag', '50', function (err) {
+	// 			assert.ifError(err);
+
+	// 			Flags.validate({
+	// 				type: 'post',
+	// 				id: 1,
+	// 				uid: 2
+	// 			}, function (err) {
+	// 				assert.ok(err);
+	// 				assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
+	// 				Meta.configs.set('privileges:flag', 0, done);
+	// 			});
+	// 		});
+	// 	});
+	// });
+
+	// describe('.appendNote()', function () {
+	// 	it('should add a note to a flag', function (done) {
+	// 		Flags.appendNote(1, 1, 'this is my note', function (err) {
+	// 			assert.ifError(err);
+				
+	// 			db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
+	// 				if (err) {
+	// 					throw err;
+	// 				}
+
+	// 				assert.strictEqual('[1,"this is my note"]', notes[0]);
+	// 				done();
+	// 			});
+	// 		});
+	// 	});
+
+	// 	it('should be a JSON string', function (done) {
+	// 		db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
+	// 			if (err) {
+	// 				throw err;
+	// 			}
+
+	// 			try {
+	// 				JSON.parse(notes[0]);
+	// 			} catch (e) {
+	// 				assert.ifError(e);
+	// 			}
+
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	// describe('.getNotes()', function () {
+	// 	before(function (done) {
+	// 		// Add a second note
+	// 		Flags.appendNote(1, 1, 'this is the second note', done);
+	// 	});
+
+	// 	it('return should match a predefined spec', function (done) {
+	// 		Flags.getNotes(1, function (err, notes) {
+	// 			assert.ifError(err);
+	// 			var compare = {
+	// 				uid: 1,
+	// 				content: 'this is my note'
+	// 			};
+				
+	// 			var data = notes[1];
+	// 			for(var key in compare) {
+	// 				if (compare.hasOwnProperty(key)) {
+	// 					assert.ok(data[key]);
+	// 					assert.strictEqual(data[key], compare[key]);
+	// 				}
+	// 			}
+
+	// 			done();
+	// 		});
+	// 	});
+
+	// 	it('should retrieve a list of notes, from newest to oldest', function (done) {
+	// 		Flags.getNotes(1, function (err, notes) {
+	// 			assert.ifError(err);
+	// 			assert(notes[0].datetime > notes[1].datetime);
+	// 			assert.strictEqual('this is the second note', notes[0].content);
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	// describe('.appendHistory()', function () {
+	// 	var entries;
+	// 	before(function (done) {
+	// 		db.sortedSetCard('flag:1:history', function (err, count) {
+	// 			entries = count;
+	// 			done(err);
+	// 		});
+	// 	});
+
+	// 	it('should add a new entry into a flag\'s history', function (done) {
+	// 		Flags.appendHistory(1, 1, ['state:rejected'], function (err) {
+	// 			assert.ifError(err);
+
+	// 			Flags.getHistory(1, function (err, history) {
+	// 				if (err) {
+	// 					throw err;
+	// 				}
+
+	// 				assert.strictEqual(entries + 1, history.length);
+	// 				done();
+	// 			});
+	// 		});
+	// 	});
+	// });
+
+	// describe('.getHistory()', function () {
+	// 	it('should retrieve a flag\'s history', function (done) {
+	// 		Flags.getHistory(1, function (err, history) {
+	// 			assert.ifError(err);
+	// 			assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
+	// 			done();
+	// 		});
+	// 	});
+	// });
+
+	describe('(websockets)', function () {
+		var SocketFlags = require('../src/socket.io/flags.js');
+		var tid, pid, flag;
 
-		it('should return a user\'s data if queried with type "user"', function (done) {
-			Flags.getTarget('user', 1, 1, function (err, data) {
-				assert.ifError(err);
-				var compare = {
-					uid: 1,
-					username: 'testUser',
-					email: 'b@c.com'
-				};
-
-				for(var key in compare) {
-					if (compare.hasOwnProperty(key)) {
-						assert.ok(data[key]);
-						assert.strictEqual(data[key], compare[key]);
-					}
-				}
+		before(function (done) {
+			Topics.post({
+				cid: 1,
+				uid: 1,
+				title: 'Another topic',
+				content: 'This is flaggable content'
+			}, function (err, topic) {
+				tid = topic.postData.tid;
+				pid = topic.postData.pid;
 
-				done();
+				done(err);
 			});
 		});
-	});
-
-	describe('.validate()', function () {
-		it('should error out if type is post and post is deleted', function (done) {
-			Posts.delete(1, 1, function (err) {
-				if (err) {
-					throw err;
-				}
 
-				Flags.validate({
+		describe('.create()', function () {
+			it('should create a flag with no errors', function (done) {
+				SocketFlags.create({ uid: 2 }, {
 					type: 'post',
-					id: 1,
-					uid: 1
-				}, function (err) {
-					assert.ok(err);
-					assert.strictEqual('[[error:post-deleted]]', err.message);
-					Posts.restore(1, 1, done);
-				});
-			});
-		});
-
-		it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) {
-			Meta.configs.set('privileges:flag', '50', function (err) {
-				assert.ifError(err);
+					id: pid,
+					reason: 'foobar'
+				}, function (err, flagObj) {
+					flag = flagObj;
+					assert.ifError(err);
 
-				Flags.validate({
-					type: 'post',
-					id: 1,
-					uid: 2
-				}, function (err) {
-					assert.ok(err);
-					assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
-					Meta.configs.set('privileges:flag', 0, done);
+					Flags.exists('post', pid, 1, function (err, exists) {
+						assert.ifError(err);
+						assert(true);
+						done();
+					});
 				});
 			});
 		});
-	});
-
-	describe('.appendNote()', function () {
-		it('should add a note to a flag', function (done) {
-			Flags.appendNote(1, 1, 'this is my note', function (err) {
-				assert.ifError(err);
-				
-				db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
-					if (err) {
-						throw err;
-					}
 
-					assert.strictEqual('[1,"this is my note"]', notes[0]);
+		describe('.update()', function () {
+			it('should update a flag\'s properties', function (done) {
+				SocketFlags.update({ uid: 2 }, {
+					flagId: flag.flagId,
+					data: [{
+						name: 'state',
+						value: 'wip'
+					}]
+				}, function (err, history) {
+					assert.ifError(err);
+					assert(Array.isArray(history));
+					assert(history[0].fields.hasOwnProperty('state'));
+					assert.strictEqual('[[flags:state-wip]]', history[0].fields.state);
 					done();
 				});
 			});
 		});
 
-		it('should be a JSON string', function (done) {
-			db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
-				if (err) {
-					throw err;
-				}
-
-				try {
-					JSON.parse(notes[0]);
-				} catch (e) {
-					assert.ifError(e);
-				}
-
-				done();
-			});
-		});
-	});
-
-	describe('.getNotes()', function () {
-		before(function (done) {
-			// Add a second note
-			Flags.appendNote(1, 1, 'this is the second note', done);
-		});
-
-		it('return should match a predefined spec', function (done) {
-			Flags.getNotes(1, function (err, notes) {
-				assert.ifError(err);
-				var compare = {
-					uid: 1,
-					content: 'this is my note'
-				};
-				
-				var data = notes[1];
-				for(var key in compare) {
-					if (compare.hasOwnProperty(key)) {
-						assert.ok(data[key]);
-						assert.strictEqual(data[key], compare[key]);
-					}
-				}
-
-				done();
-			});
-		});
-
-		it('should retrieve a list of notes, from newest to oldest', function (done) {
-			Flags.getNotes(1, function (err, notes) {
-				assert.ifError(err);
-				assert(notes[0].datetime > notes[1].datetime);
-				assert.strictEqual('this is the second note', notes[0].content);
-				done();
-			});
-		});
-	});
-
-	describe('.appendHistory()', function () {
-		var entries;
-		before(function (done) {
-			db.sortedSetCard('flag:1:history', function (err, count) {
-				entries = count;
-				done(err);
-			});
-		});
-
-		it('should add a new entry into a flag\'s history', function (done) {
-			Flags.appendHistory(1, 1, ['state:rejected'], function (err) {
-				assert.ifError(err);
-
-				Flags.getHistory(1, function (err, history) {
-					if (err) {
-						throw err;
-					}
-
-					assert.strictEqual(entries + 1, history.length);
+		describe('.appendNote()', function () {
+			it('should append a note to the flag', function (done) {
+				SocketFlags.appendNote({ uid: 2 }, {
+					flagId: flag.flagId,
+					note: 'lorem ipsum dolor sit amet'
+				}, function (err, data) {
+					assert(data.hasOwnProperty('notes'));
+					assert(Array.isArray(data.notes));
+					assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content);
+					assert.strictEqual(2, data.notes[0].uid);
+
+					assert(data.hasOwnProperty('history'));
+					assert(Array.isArray(data.history));
+					assert.strictEqual(1, Object.keys(data.history[0].fields).length);
+					assert(data.history[0].fields.hasOwnProperty('notes'));
 					done();
 				});
 			});
 		});
 	});
 
-	describe('.getHistory()', function () {
-		it('should retrieve a flag\'s history', function (done) {
-			Flags.getHistory(1, function (err, history) {
-				assert.ifError(err);
-				assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
-				done();
-			});
-		});
-	});
-
 	after(function (done) {
 		db.emptydb(done);
 	});

From 380ebf67ee84f34907d1f59d8d8985bdaa66ea6b Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Tue, 13 Dec 2016 14:24:09 -0500
Subject: [PATCH 37/48] oops, uncommenting all the other tests for flags

---
 test/flags.js | 570 +++++++++++++++++++++++++-------------------------
 1 file changed, 285 insertions(+), 285 deletions(-)

diff --git a/test/flags.js b/test/flags.js
index 8d359c6930..7b0252086a 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -45,295 +45,295 @@ describe('Flags', function () {
 		], done);
 	});
 
-	// describe('.create()', function () {
-	// 	it('should create a flag and return its data', function (done) {
-	// 		Flags.create('post', 1, 1, 'Test flag', function (err, flagData) {
-	// 			assert.ifError(err);
-	// 			var compare = {
-	// 				flagId: 1,
-	// 				uid: 1,
-	// 				targetId: 1,
-	// 				type: 'post',
-	// 				description: 'Test flag'
-	// 			};
-
-	// 			for(var key in compare) {
-	// 				if (compare.hasOwnProperty(key)) {
-	// 					assert.ok(flagData[key]);
-	// 					assert.strictEqual(flagData[key], compare[key]);
-	// 				}
-	// 			}
-
-	// 			done();
-	// 		});
-	// 	});
-	// });
-
-	// describe('.get()', function () {
-	// 	it('should retrieve and display a flag\'s data', function (done) {
-	// 		Flags.get(1, function (err, flagData) {
-	// 			assert.ifError(err);
-	// 			var compare = {
-	// 				flagId: 1,
-	// 				uid: 1,
-	// 				targetId: 1,
-	// 				type: 'post',
-	// 				description: 'Test flag',
-	// 				state: 'open'
-	// 			};
-
-	// 			for(var key in compare) {
-	// 				if (compare.hasOwnProperty(key)) {
-	// 					assert.ok(flagData[key]);
-	// 					assert.strictEqual(flagData[key], compare[key]);
-	// 				}
-	// 			}
-
-	// 			done();
-	// 		});
-	// 	});
-	// });
-
-	// describe('.list()', function () {
-	// 	it('should show a list of flags (with one item)', function (done) {
-	// 		Flags.list({}, 1, function (err, flags) {
-	// 			assert.ifError(err);
-	// 			assert.ok(Array.isArray(flags));
-	// 			assert.equal(flags.length, 1);
+	describe('.create()', function () {
+		it('should create a flag and return its data', function (done) {
+			Flags.create('post', 1, 1, 'Test flag', function (err, flagData) {
+				assert.ifError(err);
+				var compare = {
+					flagId: 1,
+					uid: 1,
+					targetId: 1,
+					type: 'post',
+					description: 'Test flag'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(flagData[key]);
+						assert.strictEqual(flagData[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.get()', function () {
+		it('should retrieve and display a flag\'s data', function (done) {
+			Flags.get(1, function (err, flagData) {
+				assert.ifError(err);
+				var compare = {
+					flagId: 1,
+					uid: 1,
+					targetId: 1,
+					type: 'post',
+					description: 'Test flag',
+					state: 'open'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(flagData[key]);
+						assert.strictEqual(flagData[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.list()', function () {
+		it('should show a list of flags (with one item)', function (done) {
+			Flags.list({}, 1, function (err, flags) {
+				assert.ifError(err);
+				assert.ok(Array.isArray(flags));
+				assert.equal(flags.length, 1);
 				
-	// 			Flags.get(flags[0].flagId, function (err, flagData) {
-	// 				assert.ifError(err);
-	// 				assert.equal(flags[0].flagId, flagData.flagId);
-	// 				assert.equal(flags[0].description, flagData.description);
-	// 				done();
-	// 			});
-	// 		});
-	// 	});
-	// });
-
-	// describe('.update()', function () {
-	// 	it('should alter a flag\'s various attributes and persist them to the database', function (done) {
-	// 		Flags.update(1, 1, {
-	// 			"state": "wip",
-	// 			"assignee": 1
-	// 		}, function (err) {
-	// 			assert.ifError(err);
-	// 			db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) {
-	// 				if (err) {
-	// 					throw err;
-	// 				}
-
-	// 				assert.strictEqual('wip', data.state);
-	// 				assert.strictEqual(1, data.assignee);
-	// 				done();
-	// 			});
-	// 		});
-	// 	});
-
-	// 	it('should persist to the flag\'s history', function (done) {
-	// 		Flags.getHistory(1, function (err, history) {
-	// 			if (err) {
-	// 				throw err;
-	// 			}
-
-	// 			history.forEach(function (change) {
-	// 				switch (change.attribute) {
-	// 					case 'state':
-	// 						assert.strictEqual('[[flags:state-wip]]', change.value);
-	// 						break;
+				Flags.get(flags[0].flagId, function (err, flagData) {
+					assert.ifError(err);
+					assert.equal(flags[0].flagId, flagData.flagId);
+					assert.equal(flags[0].description, flagData.description);
+					done();
+				});
+			});
+		});
+	});
+
+	describe('.update()', function () {
+		it('should alter a flag\'s various attributes and persist them to the database', function (done) {
+			Flags.update(1, 1, {
+				"state": "wip",
+				"assignee": 1
+			}, function (err) {
+				assert.ifError(err);
+				db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) {
+					if (err) {
+						throw err;
+					}
+
+					assert.strictEqual('wip', data.state);
+					assert.strictEqual(1, data.assignee);
+					done();
+				});
+			});
+		});
+
+		it('should persist to the flag\'s history', function (done) {
+			Flags.getHistory(1, function (err, history) {
+				if (err) {
+					throw err;
+				}
+
+				history.forEach(function (change) {
+					switch (change.attribute) {
+						case 'state':
+							assert.strictEqual('[[flags:state-wip]]', change.value);
+							break;
 						
-	// 					case 'assignee':
-	// 						assert.strictEqual(1, change.value);
-	// 						break;
-	// 				}
-	// 			});
-
-	// 			done();
-	// 		});
-	// 	});
-	// });
-
-	// describe('.getTarget()', function () {
-	// 	it('should return a post\'s data if queried with type "post"', function (done) {
-	// 		Flags.getTarget('post', 1, 1, function (err, data) {
-	// 			assert.ifError(err);
-	// 			var compare = {
-	// 				uid: 1,
-	// 				pid: 1,
-	// 				content: 'This is flaggable content'
-	// 			};
-
-	// 			for(var key in compare) {
-	// 				if (compare.hasOwnProperty(key)) {
-	// 					assert.ok(data[key]);
-	// 					assert.strictEqual(data[key], compare[key]);
-	// 				}
-	// 			}
-
-	// 			done();
-	// 		});
-	// 	});
-
-	// 	it('should return a user\'s data if queried with type "user"', function (done) {
-	// 		Flags.getTarget('user', 1, 1, function (err, data) {
-	// 			assert.ifError(err);
-	// 			var compare = {
-	// 				uid: 1,
-	// 				username: 'testUser',
-	// 				email: 'b@c.com'
-	// 			};
-
-	// 			for(var key in compare) {
-	// 				if (compare.hasOwnProperty(key)) {
-	// 					assert.ok(data[key]);
-	// 					assert.strictEqual(data[key], compare[key]);
-	// 				}
-	// 			}
-
-	// 			done();
-	// 		});
-	// 	});
-	// });
-
-	// describe('.validate()', function () {
-	// 	it('should error out if type is post and post is deleted', function (done) {
-	// 		Posts.delete(1, 1, function (err) {
-	// 			if (err) {
-	// 				throw err;
-	// 			}
-
-	// 			Flags.validate({
-	// 				type: 'post',
-	// 				id: 1,
-	// 				uid: 1
-	// 			}, function (err) {
-	// 				assert.ok(err);
-	// 				assert.strictEqual('[[error:post-deleted]]', err.message);
-	// 				Posts.restore(1, 1, done);
-	// 			});
-	// 		});
-	// 	});
-
-	// 	it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) {
-	// 		Meta.configs.set('privileges:flag', '50', function (err) {
-	// 			assert.ifError(err);
-
-	// 			Flags.validate({
-	// 				type: 'post',
-	// 				id: 1,
-	// 				uid: 2
-	// 			}, function (err) {
-	// 				assert.ok(err);
-	// 				assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
-	// 				Meta.configs.set('privileges:flag', 0, done);
-	// 			});
-	// 		});
-	// 	});
-	// });
-
-	// describe('.appendNote()', function () {
-	// 	it('should add a note to a flag', function (done) {
-	// 		Flags.appendNote(1, 1, 'this is my note', function (err) {
-	// 			assert.ifError(err);
+						case 'assignee':
+							assert.strictEqual(1, change.value);
+							break;
+					}
+				});
+
+				done();
+			});
+		});
+	});
+
+	describe('.getTarget()', function () {
+		it('should return a post\'s data if queried with type "post"', function (done) {
+			Flags.getTarget('post', 1, 1, function (err, data) {
+				assert.ifError(err);
+				var compare = {
+					uid: 1,
+					pid: 1,
+					content: 'This is flaggable content'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(data[key]);
+						assert.strictEqual(data[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+
+		it('should return a user\'s data if queried with type "user"', function (done) {
+			Flags.getTarget('user', 1, 1, function (err, data) {
+				assert.ifError(err);
+				var compare = {
+					uid: 1,
+					username: 'testUser',
+					email: 'b@c.com'
+				};
+
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(data[key]);
+						assert.strictEqual(data[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.validate()', function () {
+		it('should error out if type is post and post is deleted', function (done) {
+			Posts.delete(1, 1, function (err) {
+				if (err) {
+					throw err;
+				}
+
+				Flags.validate({
+					type: 'post',
+					id: 1,
+					uid: 1
+				}, function (err) {
+					assert.ok(err);
+					assert.strictEqual('[[error:post-deleted]]', err.message);
+					Posts.restore(1, 1, done);
+				});
+			});
+		});
+
+		it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) {
+			Meta.configs.set('privileges:flag', '50', function (err) {
+				assert.ifError(err);
+
+				Flags.validate({
+					type: 'post',
+					id: 1,
+					uid: 2
+				}, function (err) {
+					assert.ok(err);
+					assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
+					Meta.configs.set('privileges:flag', 0, done);
+				});
+			});
+		});
+	});
+
+	describe('.appendNote()', function () {
+		it('should add a note to a flag', function (done) {
+			Flags.appendNote(1, 1, 'this is my note', function (err) {
+				assert.ifError(err);
 				
-	// 			db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
-	// 				if (err) {
-	// 					throw err;
-	// 				}
-
-	// 				assert.strictEqual('[1,"this is my note"]', notes[0]);
-	// 				done();
-	// 			});
-	// 		});
-	// 	});
-
-	// 	it('should be a JSON string', function (done) {
-	// 		db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
-	// 			if (err) {
-	// 				throw err;
-	// 			}
-
-	// 			try {
-	// 				JSON.parse(notes[0]);
-	// 			} catch (e) {
-	// 				assert.ifError(e);
-	// 			}
-
-	// 			done();
-	// 		});
-	// 	});
-	// });
-
-	// describe('.getNotes()', function () {
-	// 	before(function (done) {
-	// 		// Add a second note
-	// 		Flags.appendNote(1, 1, 'this is the second note', done);
-	// 	});
-
-	// 	it('return should match a predefined spec', function (done) {
-	// 		Flags.getNotes(1, function (err, notes) {
-	// 			assert.ifError(err);
-	// 			var compare = {
-	// 				uid: 1,
-	// 				content: 'this is my note'
-	// 			};
+				db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
+					if (err) {
+						throw err;
+					}
+
+					assert.strictEqual('[1,"this is my note"]', notes[0]);
+					done();
+				});
+			});
+		});
+
+		it('should be a JSON string', function (done) {
+			db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) {
+				if (err) {
+					throw err;
+				}
+
+				try {
+					JSON.parse(notes[0]);
+				} catch (e) {
+					assert.ifError(e);
+				}
+
+				done();
+			});
+		});
+	});
+
+	describe('.getNotes()', function () {
+		before(function (done) {
+			// Add a second note
+			Flags.appendNote(1, 1, 'this is the second note', done);
+		});
+
+		it('return should match a predefined spec', function (done) {
+			Flags.getNotes(1, function (err, notes) {
+				assert.ifError(err);
+				var compare = {
+					uid: 1,
+					content: 'this is my note'
+				};
 				
-	// 			var data = notes[1];
-	// 			for(var key in compare) {
-	// 				if (compare.hasOwnProperty(key)) {
-	// 					assert.ok(data[key]);
-	// 					assert.strictEqual(data[key], compare[key]);
-	// 				}
-	// 			}
-
-	// 			done();
-	// 		});
-	// 	});
-
-	// 	it('should retrieve a list of notes, from newest to oldest', function (done) {
-	// 		Flags.getNotes(1, function (err, notes) {
-	// 			assert.ifError(err);
-	// 			assert(notes[0].datetime > notes[1].datetime);
-	// 			assert.strictEqual('this is the second note', notes[0].content);
-	// 			done();
-	// 		});
-	// 	});
-	// });
-
-	// describe('.appendHistory()', function () {
-	// 	var entries;
-	// 	before(function (done) {
-	// 		db.sortedSetCard('flag:1:history', function (err, count) {
-	// 			entries = count;
-	// 			done(err);
-	// 		});
-	// 	});
-
-	// 	it('should add a new entry into a flag\'s history', function (done) {
-	// 		Flags.appendHistory(1, 1, ['state:rejected'], function (err) {
-	// 			assert.ifError(err);
-
-	// 			Flags.getHistory(1, function (err, history) {
-	// 				if (err) {
-	// 					throw err;
-	// 				}
-
-	// 				assert.strictEqual(entries + 1, history.length);
-	// 				done();
-	// 			});
-	// 		});
-	// 	});
-	// });
-
-	// describe('.getHistory()', function () {
-	// 	it('should retrieve a flag\'s history', function (done) {
-	// 		Flags.getHistory(1, function (err, history) {
-	// 			assert.ifError(err);
-	// 			assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
-	// 			done();
-	// 		});
-	// 	});
-	// });
+				var data = notes[1];
+				for(var key in compare) {
+					if (compare.hasOwnProperty(key)) {
+						assert.ok(data[key]);
+						assert.strictEqual(data[key], compare[key]);
+					}
+				}
+
+				done();
+			});
+		});
+
+		it('should retrieve a list of notes, from newest to oldest', function (done) {
+			Flags.getNotes(1, function (err, notes) {
+				assert.ifError(err);
+				assert(notes[0].datetime > notes[1].datetime);
+				assert.strictEqual('this is the second note', notes[0].content);
+				done();
+			});
+		});
+	});
+
+	describe('.appendHistory()', function () {
+		var entries;
+		before(function (done) {
+			db.sortedSetCard('flag:1:history', function (err, count) {
+				entries = count;
+				done(err);
+			});
+		});
+
+		it('should add a new entry into a flag\'s history', function (done) {
+			Flags.appendHistory(1, 1, ['state:rejected'], function (err) {
+				assert.ifError(err);
+
+				Flags.getHistory(1, function (err, history) {
+					if (err) {
+						throw err;
+					}
+
+					assert.strictEqual(entries + 1, history.length);
+					done();
+				});
+			});
+		});
+	});
+
+	describe('.getHistory()', function () {
+		it('should retrieve a flag\'s history', function (done) {
+			Flags.getHistory(1, function (err, history) {
+				assert.ifError(err);
+				assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
+				done();
+			});
+		});
+	});
 
 	describe('(websockets)', function () {
 		var SocketFlags = require('../src/socket.io/flags.js');

From e6768ab57286749821eec15cb69e39641139577a Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 14 Dec 2016 10:03:39 -0500
Subject: [PATCH 38/48] some more fixes to flags, simplifying qs manipulation
 in flags search

re: #5232
---
 public/language/en-GB/flags.json |  1 +
 public/src/client/flags/list.js  | 14 ++++----------
 src/controllers/mods.js          |  1 -
 3 files changed, 5 insertions(+), 11 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index a9272897ca..f1fd71bf27 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -41,6 +41,7 @@
 	"no-history": "No flag history.",
 
 	"state": "State",
+	"state-all": "All states",
 	"state-open": "New/Open",
 	"state-wip": "Work in Progress",
 	"state-resolved": "Resolved",
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index 255c7176f1..12cc19093f 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -20,16 +20,10 @@ define('forum/flags/list', ['components', 'Chart'], function (components, Chart)
 		}
 
 		filtersEl.find('button').on('click', function () {
-			var payload = filtersEl.serializeArray();
-			var qs = payload.map(function (filter) {
-				if (filter.value) {
-					return filter.name + '=' + filter.value;
-				} else {
-					return;
-				}
-			}).filter(Boolean).join('&');
-
-			ajaxify.go('flags?' + qs);
+			var payload = filtersEl.serializeArray().filter(function (item) {
+				return !!item.value;
+			});
+			ajaxify.go('flags?' + $.param(payload));
 		});
 	};
 
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 39820e60a6..666f449316 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -5,7 +5,6 @@ var async = require('async');
 var user = require('../user');
 var flags = require('../flags');
 var analytics = require('../analytics');
-// var adminFlagsController = require('./admin/flags');
 
 var modsController = {
 	flags: {}

From ebffc4460011178f2745a27f78d2aeaca6559d9a Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 14 Dec 2016 15:00:41 -0500
Subject: [PATCH 39/48] fix tests, #5232

---
 src/flags.js  |  2 +-
 test/flags.js | 22 +++++++++++++++++++---
 2 files changed, 20 insertions(+), 4 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 1ae5a84ed8..995e880801 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -47,7 +47,7 @@ Flags.get = function (flagId, callback) {
 };
 
 Flags.list = function (filters, uid, callback) {
-	if (typeof filters === 'function' && !callback) {
+	if (typeof filters === 'function' && !uid && !callback) {
 		callback = filters;
 		filters = {};
 	}
diff --git a/test/flags.js b/test/flags.js
index 7b0252086a..607b807db3 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -41,6 +41,11 @@ describe('Flags', function () {
 			},
 			function (uid, next) {
 				Groups.join('administrators', uid, next);
+			},
+			function (next) {
+				User.create({
+					username: 'unprivileged', password: 'abcdef', email: 'd@e.com'
+				}, next);
 			}
 		], done);
 	});
@@ -109,6 +114,15 @@ describe('Flags', function () {
 				});
 			});
 		});
+
+		it('should return a filtered list of flags if said filters are passed in', function (done) {
+			Flags.list({
+				state: 'open'
+			}, 1, function (err, flags) {
+				assert.ifError(err);
+				done();
+			});
+		});
 	});
 
 	describe('.update()', function () {
@@ -221,7 +235,7 @@ describe('Flags', function () {
 				Flags.validate({
 					type: 'post',
 					id: 1,
-					uid: 2
+					uid: 3
 				}, function (err) {
 					assert.ok(err);
 					assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message);
@@ -310,7 +324,9 @@ describe('Flags', function () {
 		});
 
 		it('should add a new entry into a flag\'s history', function (done) {
-			Flags.appendHistory(1, 1, ['state:rejected'], function (err) {
+			Flags.appendHistory(1, 1, {
+				state: 'rejected'
+			}, function (err) {
 				assert.ifError(err);
 
 				Flags.getHistory(1, function (err, history) {
@@ -329,7 +345,7 @@ describe('Flags', function () {
 		it('should retrieve a flag\'s history', function (done) {
 			Flags.getHistory(1, function (err, history) {
 				assert.ifError(err);
-				assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]');
+				assert.strictEqual(history[0].fields.state, '[[flags:state-rejected]]');
 				done();
 			});
 		});

From 0927d54c9877f8b024abd167121602b0e4aff2a5 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Wed, 14 Dec 2016 15:53:57 -0500
Subject: [PATCH 40/48] ability to filter flags by cid, #5232, more tests

---
 public/language/en-GB/flags.json |  2 ++
 src/controllers/mods.js          | 13 ++++++--
 src/flags.js                     | 32 +++++++++++++++---
 test/flags.js                    | 57 ++++++++++++++++++++++++++++----
 4 files changed, 92 insertions(+), 12 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index f1fd71bf27..2a1bf919f8 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -19,7 +19,9 @@
 	"filter-type-post": "Post",
 	"filter-state": "State",
 	"filter-assignee": "Assignee UID",
+	"filter-cid": "Category",
 	"filter-quick-mine": "Assigned to me",
+	"filter-cid-all": "All categories",
 	"apply-filters": "Apply Filters",
 
 	"quick-links": "Quick Links",
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 666f449316..cae9ade1ed 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -3,6 +3,7 @@
 var async = require('async');
 
 var user = require('../user');
+var categories = require('../categories');
 var flags = require('../flags');
 var analytics = require('../analytics');
 
@@ -26,7 +27,7 @@ modsController.flags.list = function (req, res, next) {
 		}
 
 		// Parse query string params for filters
-		var valid = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'quick'];
+		var valid = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick'];
 		var filters = valid.reduce(function (memo, cur) {
 			if (req.query.hasOwnProperty(cur)) {
 				memo[cur] = req.query[cur];
@@ -37,15 +38,23 @@ modsController.flags.list = function (req, res, next) {
 
 		async.parallel({
 			flags: async.apply(flags.list, filters, req.uid),
-			analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30)
+			analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30),
+			categories: async.apply(categories.buildForSelect, req.uid)
 		}, function (err, data) {
 			if (err) {
 				return next(err);
 			}
 
+			// Minimal returned set for templates.js
+			data.categories = data.categories.reduce(function (memo, cur) {
+				memo[cur.cid] = cur.name;
+				return memo;
+			}, {});
+
 			res.render('flags/list', {
 				flags: data.flags,
 				analytics: data.analytics,
+				categories: data.categories,
 				hasFilter: !!Object.keys(filters).length,
 				filters: filters,
 				title: '[[pages:flags]]'
diff --git a/src/flags.js b/src/flags.js
index 995e880801..81b97ee408 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -71,11 +71,15 @@ Flags.list = function (filters, uid, callback) {
 				case 'assignee':
 					sets.push('flags:byAssignee:' + filters[type]);
 					break;
-				
+
 				case 'targetUid':
 					sets.push('flags:byTargetUid:' + filters[type]);
 					break;
 
+				case 'cid':
+					sets.push('flags:byCid:' + filters[type]);
+					break;
+
 				case 'quick':
 					switch (filters.quick) {
 						case 'mine':
@@ -262,6 +266,7 @@ Flags.getNotes = function (flagId, callback) {
 
 Flags.create = function (type, id, uid, reason, timestamp, callback) {
 	var targetUid;
+	var targetCid;
 	var doHistoryAppend = false;
 
 	// timestamp is optional
@@ -273,17 +278,21 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) {
 
 	async.waterfall([
 		function (next) {
-			// Sanity checks
 			async.parallel([
+				// Sanity checks
 				async.apply(Flags.exists, type, id, uid),
 				async.apply(Flags.targetExists, type, id),
-				async.apply(Flags.getTargetUid, type, id)
+
+				// Extra data for zset insertion
+				async.apply(Flags.getTargetUid, type, id),
+				async.apply(Flags.getTargetCid, type, id)
 			], function (err, checks) {
 				if (err) {
 					return next(err);
 				}
 
 				targetUid = checks[2] || null;
+				targetCid = checks[3] || null;
 
 				if (checks[0]) {
 					return next(new Error('[[error:already-flagged]]'));
@@ -315,6 +324,9 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) {
 			if (targetUid) {
 				tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, timestamp, flagId));	// by target uid
 			}
+			if (targetCid) {
+				tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byCid:' + targetCid, timestamp, flagId));	// by target uid
+			}
 		
 			async.parallel(tasks, function (err, data) {
 				if (err) {
@@ -358,12 +370,24 @@ Flags.getTargetUid = function (type, id, callback) {
 			posts.getPostField(id, 'uid', callback);
 			break;
 		
-		case 'user':
+		default:
 			setImmediate(callback, null, id);
 			break; 
 	}
 };
 
+Flags.getTargetCid = function (type, id, callback) {
+	switch (type) {
+		case 'post':
+			posts.getCidByPid(id, callback);
+			break;
+
+		default:
+			setImmediate(callback, null, id);
+			break;
+	}
+};
+
 Flags.update = function (flagId, uid, changeset, callback) {
 	// Retrieve existing flag data to compare for history-saving purposes
 	var fields = ['state', 'assignee'];
diff --git a/test/flags.js b/test/flags.js
index 607b807db3..c46ac0bcef 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -72,6 +72,14 @@ describe('Flags', function () {
 				done();
 			});
 		});
+
+		it('should add the flag to the byCid zset for category 1 if it is of type post', function (done) {
+			db.isSortedSetMember('flags:byCid:' + 1, 1, function (err, isMember) {
+				assert.ifError(err);
+				assert.ok(isMember);
+				done();
+			});
+		});
 	});
 
 	describe('.get()', function () {
@@ -115,12 +123,49 @@ describe('Flags', function () {
 			});
 		});
 
-		it('should return a filtered list of flags if said filters are passed in', function (done) {
-			Flags.list({
-				state: 'open'
-			}, 1, function (err, flags) {
-				assert.ifError(err);
-				done();
+		describe('(with filters)', function () {
+			it('should return a filtered list of flags if said filters are passed in', function (done) {
+				Flags.list({
+					state: 'open'
+				}, 1, function (err, flags) {
+					assert.ifError(err);
+					assert.ok(Array.isArray(flags));
+					assert.strictEqual(1, flags[0].flagId);
+					done();
+				});
+			});
+
+			it('should return no flags if a filter with no matching flags is used', function (done) {
+				Flags.list({
+					state: 'rejected'
+				}, 1, function (err, flags) {
+					assert.ifError(err);
+					assert.ok(Array.isArray(flags));
+					assert.strictEqual(0, flags.length);
+					done();
+				});
+			});
+
+			it('should return a flag when filtered by cid 1', function (done) {
+				Flags.list({
+					cid: 1
+				}, 1, function (err, flags) {
+					assert.ifError(err);
+					assert.ok(Array.isArray(flags));
+					assert.strictEqual(1, flags.length);
+					done();
+				});
+			});
+
+			it('shouldn\'t return a flag when filtered by cid 2', function (done) {
+				Flags.list({
+					cid: 2
+				}, 1, function (err, flags) {
+					assert.ifError(err);
+					assert.ok(Array.isArray(flags));
+					assert.strictEqual(0, flags.length);
+					done();
+				});
 			});
 		});
 	});

From 7b471b76db69c3fc5eb0ea5fca17b177e07f8c93 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 09:50:46 -0500
Subject: [PATCH 41/48] fixing tests for #5232

---
 test/flags.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/flags.js b/test/flags.js
index c46ac0bcef..cc8a0425e3 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -457,6 +457,7 @@ describe('Flags', function () {
 					flagId: flag.flagId,
 					note: 'lorem ipsum dolor sit amet'
 				}, function (err, data) {
+					assert.ifError(err);
 					assert(data.hasOwnProperty('notes'));
 					assert(Array.isArray(data.notes));
 					assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content);

From ad633aad4583546bad9302ca1cbef9ad052c131e Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 11:16:03 -0500
Subject: [PATCH 42/48] additional tests and proper handling for purged flag
 targets, #5232

---
 public/language/en-GB/flags.json |  1 +
 src/controllers/mods.js          |  9 +++--
 src/flags.js                     | 60 +++++++++++++++++++-------------
 src/posts/delete.js              |  3 --
 src/upgrade.js                   | 47 -------------------------
 src/user/admin.js                | 10 ------
 test/flags.js                    | 44 +++++++++++++++++++++++
 7 files changed, 87 insertions(+), 87 deletions(-)

diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json
index 2a1bf919f8..66b9acc92a 100644
--- a/public/language/en-GB/flags.json
+++ b/public/language/en-GB/flags.json
@@ -7,6 +7,7 @@
 	"assignee": "Assignee",
 	"update": "Update",
 	"updated": "Updated",
+	"target-purged": "The content this flag referred to has been purged and is no longer available.",
 
 	"quick-filters": "Quick Filters",
 	"filter-active": "There are one or more filters active in this list of flags",
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index cae9ade1ed..242d68d708 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -78,8 +78,13 @@ modsController.flags.detail = function (req, res, next) {
 
 		res.render('flags/detail', Object.assign(results.flagData, {
 			assignees: results.assignees,
-			type_bool: ['post', 'user'].reduce(function (memo, cur) {
-				memo[cur] = results.flagData.type === cur;
+			type_bool: ['post', 'user', 'empty'].reduce(function (memo, cur) {
+				if (cur !== 'empty') {
+					memo[cur] = results.flagData.type === cur && !!Object.keys(results.flagData.target).length;
+				} else {
+					memo[cur] = !Object.keys(results.flagData.target).length;
+				}
+
 				return memo;
 			}, {}),
 			title: '[[pages:flag-details, ' + req.params.flagId + ']]'
diff --git a/src/flags.js b/src/flags.js
index 81b97ee408..61b7a1746d 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -202,31 +202,6 @@ Flags.validate = function (payload, callback) {
 	});
 };
 
-Flags.getTarget = function (type, id, uid, callback) {
-	switch (type) {
-		case 'post':
-			async.waterfall([
-				async.apply(posts.getPostsByPids, [id], uid),
-				function (posts, next) {
-					topics.addPostData(posts, uid, next);
-				}
-			], function (err, posts) {
-				callback(err, posts[0]);
-			});
-			break;
-		
-		case 'user':
-			user.getUsersData([id], function (err, users) {
-				callback(err, users ? users[0] : undefined);
-			});
-			break;
-		
-		default:
-			 callback(new Error('[[error:invalid-data]]'));
-			 break;
-	}
-};
-
 Flags.getNotes = function (flagId, callback) {
 	async.waterfall([
 		async.apply(db.getSortedSetRevRangeWithScores.bind(db), 'flag:' + flagId + ':notes', 0, -1),
@@ -348,6 +323,41 @@ Flags.exists = function (type, id, uid, callback) {
 	db.isSortedSetMember('flags:hash', [type, id, uid].join(':'), callback);
 };
 
+Flags.getTarget = function (type, id, uid, callback) {
+	async.waterfall([
+		async.apply(Flags.targetExists, type, id),
+		function (exists, next) {
+			if (exists) {
+				switch (type) {
+					case 'post':
+						async.waterfall([
+							async.apply(posts.getPostsByPids, [id], uid),
+							function (posts, next) {
+								topics.addPostData(posts, uid, next);
+							}
+						], function (err, posts) {
+							next(err, posts[0]);
+						});
+						break;
+
+					case 'user':
+						user.getUsersData([id], function (err, users) {
+							next(err, users ? users[0] : undefined);
+						});
+						break;
+
+					default:
+						next(new Error('[[error:invalid-data]]'));
+						break;
+				}
+			} else {
+				// Target used to exist (otherwise flag creation'd fail), but no longer
+				next(null, {});
+			}
+		}
+	], callback);
+};
+
 Flags.targetExists = function (type, id, callback) {
 	switch (type) {
 		case 'post':
diff --git a/src/posts/delete.js b/src/posts/delete.js
index ebf902aef2..32ee6b6f41 100644
--- a/src/posts/delete.js
+++ b/src/posts/delete.js
@@ -144,9 +144,6 @@ module.exports = function (Posts) {
 					},
 					function (next) {
 						db.sortedSetsRemove(['posts:pid', 'posts:flagged'], pid, next);
-					},
-					function (next) {
-						flags.dismiss(pid, next);
 					}
 				], function (err) {
 					if (err) {
diff --git a/src/upgrade.js b/src/upgrade.js
index 29b806d4a7..1815d1e560 100644
--- a/src/upgrade.js
+++ b/src/upgrade.js
@@ -455,53 +455,6 @@ Upgrade.upgrade = function (callback) {
 				next();
 			}
 		},
-		function (next) {
-			thisSchemaDate = Date.UTC(2016, 3, 29);
-
-			if (schemaDate < thisSchemaDate) {
-				updatesMade = true;
-				winston.info('[2016/04/29] Dismiss flags from deleted topics');
-
-				var posts = require('./posts');
-				var topics = require('./topics');
-				var flags = require('./flags');
-
-				var pids, tids;
-
-				async.waterfall([
-					async.apply(db.getSortedSetRange, 'posts:flagged', 0, -1),
-					function (_pids, next) {
-						pids = _pids;
-						posts.getPostsFields(pids, ['tid'], next);
-					},
-					function (_tids, next) {
-						tids = _tids.map(function (a) {
-							return a.tid;
-						});
-
-						topics.getTopicsFields(tids, ['deleted'], next);
-					},
-					function (state, next) {
-						var toDismiss = state.map(function (a, idx) {
-							return parseInt(a.deleted, 10) === 1 ? pids[idx] : null;
-						}).filter(Boolean);
-
-						winston.info('[2016/04/29] ' + toDismiss.length + ' dismissable flags found');
-						async.each(toDismiss, flags.dismiss, next);
-					}
-				], function (err) {
-					if (err) {
-						return next(err);
-					}
-
-					winston.info('[2016/04/29] Dismiss flags from deleted topics done');
-					Upgrade.update(thisSchemaDate, next);
-				});
-			} else {
-				winston.info('[2016/04/29] Dismiss flags from deleted topics skipped!');
-				next();
-			}
-		},
 		function (next) {
 			thisSchemaDate = Date.UTC(2016, 4, 28);
 
diff --git a/src/user/admin.js b/src/user/admin.js
index 5d2215980c..4f7ecf66fb 100644
--- a/src/user/admin.js
+++ b/src/user/admin.js
@@ -56,14 +56,4 @@ module.exports = function (User) {
 			}
 		], callback);
 	};
-
-	User.resetFlags = function (uids, callback) {
-		if (!Array.isArray(uids) || !uids.length) {
-			return callback();
-		}
-
-		async.eachSeries(uids, function (uid, next) {
-			flags.dismissByUid(uid, next);
-		}, callback);
-	};
 };
diff --git a/test/flags.js b/test/flags.js
index cc8a0425e3..58250fb27f 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -82,6 +82,42 @@ describe('Flags', function () {
 		});
 	});
 
+	describe('.exists()', function () {
+		it('should return Boolean True if a flag matching the flag hash already exists', function (done) {
+			Flags.exists('post', 1, 1, function (err, exists) {
+				assert.ifError(err);
+				assert.strictEqual(true, exists);
+				done();
+			});
+		});
+
+		it('should return Boolean False if a flag matching the flag hash does not already exists', function (done) {
+			Flags.exists('post', 1, 2, function (err, exists) {
+				assert.ifError(err);
+				assert.strictEqual(false, exists);
+				done();
+			});
+		});
+	});
+
+	describe('.targetExists()', function () {
+		it('should return Boolean True if the targeted element exists', function (done) {
+			Flags.targetExists('post', 1, function (err, exists) {
+				assert.ifError(err);
+				assert.strictEqual(true, exists);
+				done();
+			});
+		});
+
+		it('should return Boolean False if the targeted element does not exist', function (done) {
+			Flags.targetExists('post', 15, function (err, exists) {
+				assert.ifError(err);
+				assert.strictEqual(false, exists);
+				done();
+			});
+		});
+	});
+
 	describe('.get()', function () {
 		it('should retrieve and display a flag\'s data', function (done) {
 			Flags.get(1, function (err, flagData) {
@@ -252,6 +288,14 @@ describe('Flags', function () {
 				done();
 			});
 		});
+
+		it('should return a plain object with no properties if the target no longer exists', function (done) {
+			Flags.getTarget('user', 15, 1, function (err, data) {
+				assert.ifError(err);
+				assert.strictEqual(0, Object.keys(data).length);
+				done();
+			});
+		});
 	});
 
 	describe('.validate()', function () {

From 47c9c936936f50cc0fa4494d5ae2164f5f219fee Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 11:54:52 -0500
Subject: [PATCH 43/48] removed old flagging tests

---
 test/posts.js | 215 --------------------------------------------------
 1 file changed, 215 deletions(-)

diff --git a/test/posts.js b/test/posts.js
index 1ddbd83399..090edd3ca1 100644
--- a/test/posts.js
+++ b/test/posts.js
@@ -418,221 +418,6 @@ describe('Post\'s', function () {
 		});
 	});
 
-	describe('flagging a post', function () {
-		var meta = require('../src/meta');
-		var socketPosts = require('../src/socket.io/posts');
-		it('should fail to flag a post due to low reputation', function (done) {
-			meta.config['privileges:flag'] = 10;
-			flagPost(function (err) {
-				assert.equal(err.message, '[[error:not-enough-reputation-to-flag]]');
-				done();
-			});
-		});
-
-		it('should flag a post', function (done) {
-			meta.config['privileges:flag'] = -1;
-			flagPost(function (err) {
-				assert.ifError(err);
-				done();
-			});
-		});
-
-		it('should return nothing without a uid or a reason', function (done) {
-			socketPosts.flag({uid: 0}, {pid: postData.pid, reason: 'reason'}, function (err) {
-				assert.equal(err.message, '[[error:not-logged-in]]');
-				socketPosts.flag({uid: voteeUid}, {}, function (err) {
-					assert.equal(err.message, '[[error:invalid-data]]');
-					done();
-				});
-			});
-		});
-
-		it('should return an error without an existing post', function (done) {
-			socketPosts.flag({uid: voteeUid}, {pid: 12312312, reason: 'reason'}, function (err) {
-				assert.equal(err.message, '[[error:no-post]]');
-				done();
-			});
-		});
-
-		it('should return an error if the flag already exists', function (done) {
-			socketPosts.flag({uid: voteeUid}, {pid: postData.pid, reason: 'reason'}, function (err) {
-				assert.equal(err.message, '[[error:already-flagged]]');
-				done();
-			});
-		});
-	});
-
-	function flagPost(next) {
-		var socketPosts = require('../src/socket.io/posts');
-		socketPosts.flag({uid: voteeUid}, {pid: postData.pid, reason: 'reason'}, next);
-	}
-
-	describe('get flag data', function () {
-		it('should see the flagged post', function (done) {
-			posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) {
-				assert.ifError(err);
-				assert(hasFlagged);
-				done();
-			});
-		});
-
-		it('should return the flagged post data', function (done) {
-			posts.getFlags('posts:flagged', cid, voteeUid, 0, -1, function (err, flagData) {
-				assert.ifError(err);
-				assert(flagData.posts);
-				assert(flagData.count);
-				assert.equal(flagData.count, 1);
-				assert.equal(flagData.posts.length, 1);
-				assert(flagData.posts[0].flagReasons);
-				assert.equal(flagData.posts[0].flagReasons.length, 1);
-				assert.strictEqual(flagData.posts[0].flagReasons[0].reason, 'reason');
-				assert(flagData.posts[0].flagData);
-				assert.strictEqual(flagData.posts[0].flagData.state, 'open');
-				done();
-			});
-		});
-	});
-
-	describe('updating a flag', function () {
-		var socketPosts = require('../src/socket.io/posts');
-
-		it('should update a flag', function (done) {
-			async.waterfall([
-				function (next) {
-					socketPosts.updateFlag({uid: globalModUid}, {
-						pid: postData.pid,
-						data: [
-							{name: 'assignee', value: `${globalModUid}`},
-							{name: 'notes', value: 'notes'}
-						]
-					}, function (err) {
-						assert.ifError(err);
-						posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) {
-							assert.ifError(err);
-							assert(flagData.posts);
-							assert.equal(flagData.posts.length, 1);
-							assert.deepEqual({
-								assignee: flagData.posts[0].flagData.assignee,
-								notes: flagData.posts[0].flagData.notes,
-								state: flagData.posts[0].flagData.state,
-								labelClass: flagData.posts[0].flagData.labelClass
-							}, {
-								assignee: `${globalModUid}`,
-								notes: 'notes',
-								state: 'open',
-								labelClass: 'info'
-							});
-							next();
-						});
-					});
-				}, function (next) {
-					posts.updateFlagData(globalModUid, postData.pid, {
-						state: 'rejected'
-					}, function (err) {
-						assert.ifError(err);
-						posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) {
-							assert.ifError(err);
-							assert(flagData.posts);
-							assert.equal(flagData.posts.length, 1);
-							assert.deepEqual({
-								state: flagData.posts[0].flagData.state,
-								labelClass: flagData.posts[0].flagData.labelClass
-							}, {
-								state: 'rejected',
-								labelClass: 'danger'
-							});
-							next();
-						});
-					});
-				}, function (next) {
-					posts.updateFlagData(globalModUid, postData.pid, {
-						state: 'wip'
-					}, function (err) {
-						assert.ifError(err);
-						posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) {
-							assert.ifError(err);
-							assert(flagData.posts);
-							assert.equal(flagData.posts.length, 1);
-							assert.deepEqual({
-								state: flagData.posts[0].flagData.state,
-								labelClass: flagData.posts[0].flagData.labelClass
-							}, {
-								state: 'wip',
-								labelClass: 'warning'
-							});
-							next();
-						});
-					});
-				}, function (next) {
-					posts.updateFlagData(globalModUid, postData.pid, {
-						state: 'resolved'
-					}, function (err) {
-						assert.ifError(err);
-						posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) {
-							assert.ifError(err);
-							assert(flagData.posts);
-							assert.equal(flagData.posts.length, 1);
-							assert.deepEqual({
-								state: flagData.posts[0].flagData.state,
-								labelClass: flagData.posts[0].flagData.labelClass
-							}, {
-								state: 'resolved',
-								labelClass: 'success'
-							});
-							next();
-						});
-					});
-				}
-			], done);
-		});
-	});
-
-	describe('dismissing a flag', function () {
-		var socketPosts = require('../src/socket.io/posts');
-
-		it('should dismiss a flag', function (done) {
-			socketPosts.dismissFlag({uid: globalModUid}, postData.pid, function (err) {
-				assert.ifError(err);
-				posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) {
-					assert.ifError(err);
-					assert(!hasFlagged);
-					flagPost(function (err) {
-						assert.ifError(err);
-						done();
-					});
-				});
-			});
-		});
-
-		it('should dismiss all of a user\'s flags', function (done) {
-			posts.dismissUserFlags(voteeUid, function (err) {
-				assert.ifError(err);
-				posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) {
-					assert.ifError(err);
-					assert(!hasFlagged);
-					flagPost(function (err) {
-						assert.ifError(err);
-						done();
-					});
-				});
-			});
-		});
-
-		it('should dismiss all flags', function (done) {
-			socketPosts.dismissAllFlags({uid: globalModUid}, {}, function (err) {
-				assert.ifError(err);
-				posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) {
-					assert.ifError(err);
-					assert(!hasFlagged);
-					flagPost(function (err) {
-						assert.ifError(err);
-						done();
-					});
-				});
-			});
-		});
-	});
-
 	describe('getPostSummaryByPids', function () {
 		it('should return empty array for empty pids', function (done) {
 			posts.getPostSummaryByPids([], 0, {}, function (err, data) {

From 5e52cfdf86ed709d122fbaf73bd6379c7745f308 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 12:01:00 -0500
Subject: [PATCH 44/48] removed one more old flag test for #5232

---
 test/socket.io.js | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/test/socket.io.js b/test/socket.io.js
index ddb136d3b0..3928dc64c4 100644
--- a/test/socket.io.js
+++ b/test/socket.io.js
@@ -245,16 +245,6 @@ describe('socket.io', function () {
 		});
 	});
 
-	it('should reset flags', function (done) {
-		var socketAdmin = require('../src/socket.io/admin');
-		socketAdmin.user.resetFlags({uid: adminUid}, [regularUid], function (err) {
-			assert.ifError(err);
-			done();
-		});
-	});
-
-
-
 	describe('validation emails', function () {
 		var socketAdmin = require('../src/socket.io/admin');
 		var meta = require('../src/meta');

From 2ea63f3d4259407045d9d586a4bbf8b4343a23fb Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 12:13:36 -0500
Subject: [PATCH 45/48] how much fun is it to fix tests when stack traces don't
 work? lots. :shipit:

---
 src/flags.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index 61b7a1746d..d3735671b2 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -34,7 +34,7 @@ Flags.get = function (flagId, callback) {
 			}, function (err, payload) {
 				// Final object return construction
 				next(err, Object.assign(data.base, {
-					datetimeISO: new Date(data.base.datetime).toISOString(),
+					datetimeISO: new Date(parseInt(data.base.datetime, 10)).toISOString(),
 					target_readable: data.base.type.charAt(0).toUpperCase() + data.base.type.slice(1) + ' ' + data.base.targetId,
 					target: payload.targetObj,
 					history: data.history,
@@ -216,7 +216,7 @@ Flags.getNotes = function (flagId, callback) {
 						uid: noteObj[0],
 						content: noteObj[1],
 						datetime: note.score,
-						datetimeISO: new Date(note.score).toISOString()
+						datetimeISO: new Date(parseInt(note.score, 10)).toISOString()
 					};
 				} catch (e) {
 					return next(e);
@@ -469,7 +469,7 @@ Flags.getHistory = function (flagId, callback) {
 					uid: entry.value[0],
 					fields: changeset,
 					datetime: entry.score,
-					datetimeISO: new Date(entry.score).toISOString()
+					datetimeISO: new Date(parseInt(entry.score, 10)).toISOString()
 				};
 			});
 

From 07ac7dce841d2d9ed96229dd7df02bf21fa20145 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 12:31:55 -0500
Subject: [PATCH 46/48] minor tweaks to test to be redis-compatible

---
 test/flags.js | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/test/flags.js b/test/flags.js
index 58250fb27f..dfa6f1a773 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -65,7 +65,7 @@ describe('Flags', function () {
 				for(var key in compare) {
 					if (compare.hasOwnProperty(key)) {
 						assert.ok(flagData[key]);
-						assert.strictEqual(flagData[key], compare[key]);
+						assert.equal(flagData[key], compare[key]);
 					}
 				}
 
@@ -134,7 +134,7 @@ describe('Flags', function () {
 				for(var key in compare) {
 					if (compare.hasOwnProperty(key)) {
 						assert.ok(flagData[key]);
-						assert.strictEqual(flagData[key], compare[key]);
+						assert.equal(flagData[key], compare[key]);
 					}
 				}
 
@@ -166,7 +166,7 @@ describe('Flags', function () {
 				}, 1, function (err, flags) {
 					assert.ifError(err);
 					assert.ok(Array.isArray(flags));
-					assert.strictEqual(1, flags[0].flagId);
+					assert.strictEqual(1, parseInt(flags[0].flagId, 10));
 					done();
 				});
 			});
@@ -219,7 +219,8 @@ describe('Flags', function () {
 					}
 
 					assert.strictEqual('wip', data.state);
-					assert.strictEqual(1, data.assignee);
+					assert.ok(!isNaN(parseInt(data.assignee, 10)));
+					assert.strictEqual(1, parseInt(data.assignee, 10));
 					done();
 				});
 			});
@@ -261,7 +262,7 @@ describe('Flags', function () {
 				for(var key in compare) {
 					if (compare.hasOwnProperty(key)) {
 						assert.ok(data[key]);
-						assert.strictEqual(data[key], compare[key]);
+						assert.equal(data[key], compare[key]);
 					}
 				}
 
@@ -281,7 +282,7 @@ describe('Flags', function () {
 				for(var key in compare) {
 					if (compare.hasOwnProperty(key)) {
 						assert.ok(data[key]);
-						assert.strictEqual(data[key], compare[key]);
+						assert.equal(data[key], compare[key]);
 					}
 				}
 
@@ -480,7 +481,7 @@ describe('Flags', function () {
 		describe('.update()', function () {
 			it('should update a flag\'s properties', function (done) {
 				SocketFlags.update({ uid: 2 }, {
-					flagId: flag.flagId,
+					flagId: 2,
 					data: [{
 						name: 'state',
 						value: 'wip'
@@ -498,7 +499,7 @@ describe('Flags', function () {
 		describe('.appendNote()', function () {
 			it('should append a note to the flag', function (done) {
 				SocketFlags.appendNote({ uid: 2 }, {
-					flagId: flag.flagId,
+					flagId: 2,
 					note: 'lorem ipsum dolor sit amet'
 				}, function (err, data) {
 					assert.ifError(err);

From babafde7268c2d334457a12d23022af24ef5d6ab Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 12:40:33 -0500
Subject: [PATCH 47/48] once more for good measure!

---
 src/flags.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/flags.js b/src/flags.js
index d3735671b2..7825f5603d 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -156,9 +156,9 @@ Flags.validate = function (payload, callback) {
 			return callback(err);
 		}
 
-		if (data.target.deleted) {
+		if (parseInt(data.target.deleted, 10)) {
 			return callback(new Error('[[error:post-deleted]]'));
-		} else if (data.reporter.banned) {
+		} else if (parseInt(data.reporter.banned, 10)) {
 			return callback(new Error('[[error:user-banned]]'));
 		}
 

From 283ae564f2771459592e8a36a46e09e4bcafc1d1 Mon Sep 17 00:00:00 2001
From: Julian Lam <julian@nodebb.org>
Date: Mon, 19 Dec 2016 12:52:47 -0500
Subject: [PATCH 48/48] removing incorrect parseInt

---
 src/flags.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/flags.js b/src/flags.js
index 7825f5603d..ecc5a84e3e 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -156,7 +156,7 @@ Flags.validate = function (payload, callback) {
 			return callback(err);
 		}
 
-		if (parseInt(data.target.deleted, 10)) {
+		if (data.target.deleted) {
 			return callback(new Error('[[error:post-deleted]]'));
 		} else if (parseInt(data.reporter.banned, 10)) {
 			return callback(new Error('[[error:user-banned]]'));