Merge branch 'flagging-refactor' into develop
commit
76bcc1c327
@ -0,0 +1,62 @@
|
||||
{
|
||||
"state": "State",
|
||||
"reporter": "Reporter",
|
||||
"reported-at": "Reported At",
|
||||
"description": "Description",
|
||||
"no-flags": "Hooray! No flags found.",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"flagged-user": "Flagged User",
|
||||
"reporter": "Reporting User",
|
||||
"view-profile": "View Profile",
|
||||
"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.",
|
||||
|
||||
"history": "Flag History",
|
||||
"back": "Back to Flags List",
|
||||
"no-history": "No flag history.",
|
||||
|
||||
"state": "State",
|
||||
"state-all": "All states",
|
||||
"state-open": "New/Open",
|
||||
"state-wip": "Work in Progress",
|
||||
"state-resolved": "Resolved",
|
||||
"state-rejected": "Rejected",
|
||||
"no-assignee": "Not Assigned",
|
||||
"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."
|
||||
}
|
@ -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;
|
||||
});
|
@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
/* globals define */
|
||||
|
||||
define('forum/flags/detail', ['forum/flags/list', 'components', 'translator'], function (FlagsList, components, translator) {
|
||||
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, history) {
|
||||
if (err) {
|
||||
return app.alertError(err.message);
|
||||
} else {
|
||||
app.alertSuccess('[[flags:updated]]');
|
||||
Flags.reloadHistory(history);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'appendNote':
|
||||
socket.emit('flags.appendNote', {
|
||||
flagId: ajaxify.data.flagId,
|
||||
note: document.getElementById('note').value
|
||||
}, function (err, payload) {
|
||||
if (err) {
|
||||
return app.alertError(err.message);
|
||||
} else {
|
||||
app.alertSuccess('[[flags:note-added]]');
|
||||
Flags.reloadNotes(payload.notes);
|
||||
Flags.reloadHistory(payload.history);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
FlagsList.enableFilterForm();
|
||||
};
|
||||
|
||||
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 = '';
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
/* globals define */
|
||||
|
||||
define('forum/flags/list', ['components', 'Chart'], function (components, Chart) {
|
||||
var Flags = {};
|
||||
|
||||
Flags.init = function () {
|
||||
Flags.enableFilterForm();
|
||||
Flags.enableChatButtons();
|
||||
Flags.handleGraphs();
|
||||
};
|
||||
|
||||
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().filter(function (item) {
|
||||
return !!item.value;
|
||||
});
|
||||
ajaxify.go('flags?' + $.param(payload));
|
||||
});
|
||||
};
|
||||
|
||||
Flags.enableChatButtons = function () {
|
||||
$('[data-chat]').on('click', function () {
|
||||
app.newChat(this.getAttribute('data-chat'));
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
stepSize: 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return Flags;
|
||||
});
|
@ -1,103 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
var async = require('async');
|
||||
var validator = require('validator');
|
||||
|
||||
var posts = require('../../posts');
|
||||
var user = require('../../user');
|
||||
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');
|
||||
}
|
||||
|
||||
posts.getFlags(sets, cid, req.uid, start, stop, next);
|
||||
}
|
||||
], callback);
|
||||
}
|
||||
|
||||
|
||||
module.exports = flagsController;
|
@ -0,0 +1,620 @@
|
||||
'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 = {};
|
||||
|
||||
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(Flags.getHistory, flagId),
|
||||
notes: async.apply(Flags.getNotes, flagId)
|
||||
}),
|
||||
function (data, next) {
|
||||
// Second stage
|
||||
async.parallel({
|
||||
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
|
||||
next(err, Object.assign(data.base, {
|
||||
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,
|
||||
notes: data.notes,
|
||||
reporter: payload.userObj
|
||||
}));
|
||||
});
|
||||
}
|
||||
], callback);
|
||||
};
|
||||
|
||||
Flags.list = function (filters, uid, callback) {
|
||||
if (typeof filters === 'function' && !uid && !callback) {
|
||||
callback = filters;
|
||||
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 '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 'targetUid':
|
||||
sets.push('flags:byTargetUid:' + filters[type]);
|
||||
break;
|
||||
|
||||
case 'cid':
|
||||
sets.push('flags:byCid:' + 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, -1, next);
|
||||
} else {
|
||||
db.getSortedSetRevIntersect({sets: sets, start: 0, stop: -1, aggregate: 'MAX'}, next);
|
||||
}
|
||||
},
|
||||
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, {
|
||||
reporter: {
|
||||
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.targetId,
|
||||
datetimeISO: new Date(parseInt(flagObj.datetime, 10)).toISOString()
|
||||
}));
|
||||
});
|
||||
}, next);
|
||||
}
|
||||
], 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 (parseInt(data.reporter.banned, 10)) {
|
||||
return callback(new Error('[[error:user-banned]]'));
|
||||
}
|
||||
|
||||
switch (payload.type) {
|
||||
case 'post':
|
||||
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 (!editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) {
|
||||
return callback(new Error('[[error:not-enough-reputation-to-flag]]'));
|
||||
}
|
||||
|
||||
callback();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
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 (!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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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(parseInt(note.score, 10)).toISOString()
|
||||
};
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
next(null, notes, uids);
|
||||
},
|
||||
function (notes, uids, next) {
|
||||
user.getUsersFields(uids, ['username', 'userslug', 'picture'], 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, timestamp, callback) {
|
||||
var targetUid;
|
||||
var targetCid;
|
||||
var doHistoryAppend = false;
|
||||
|
||||
// timestamp is optional
|
||||
if (typeof timestamp === 'function' && !callback) {
|
||||
callback = timestamp;
|
||||
timestamp = Date.now();
|
||||
doHistoryAppend = true;
|
||||
}
|
||||
|
||||
async.waterfall([
|
||||
function (next) {
|
||||
async.parallel([
|
||||
// Sanity checks
|
||||
async.apply(Flags.exists, type, id, uid),
|
||||
async.apply(Flags.targetExists, 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]]'));
|
||||
} else if (!checks[1]) {
|
||||
return next(new Error('[[error:invalid-data]]'));
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
async.apply(db.incrObjectField, 'global', 'nextFlagId'),
|
||||
function (flagId, next) {
|
||||
var tasks = [
|
||||
async.apply(db.setObject.bind(db), 'flag:' + flagId, {
|
||||
flagId: flagId,
|
||||
type: type,
|
||||
targetId: id,
|
||||
description: reason,
|
||||
uid: uid,
|
||||
datetime: timestamp
|
||||
}),
|
||||
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.sortedSetAdd.bind(db), 'flags:hash', flagId, [type, id, uid].join(':')), // save zset for duplicate checking
|
||||
async.apply(analytics.increment, 'flags') // some fancy analytics
|
||||
];
|
||||
|
||||
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) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (doHistoryAppend) {
|
||||
Flags.update(flagId, uid, { "state": "open" });
|
||||
}
|
||||
|
||||
next(null, flagId);
|
||||
});
|
||||
},
|
||||
async.apply(Flags.get)
|
||||
], callback);
|
||||
};
|
||||
|
||||
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':
|
||||
posts.exists(id, callback);
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
user.exists(id, callback);
|
||||
break;
|
||||
|
||||
default:
|
||||
callback(new Error('[[error:invalid-data]]'));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
Flags.getTargetUid = function (type, id, callback) {
|
||||
switch (type) {
|
||||
case 'post':
|
||||
posts.getPostField(id, 'uid', callback);
|
||||
break;
|
||||
|
||||
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'];
|
||||
var tasks = [];
|
||||
var now = changeset.datetime || 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];
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(changeset).length) {
|
||||
// No changes
|
||||
return next();
|
||||
}
|
||||
|
||||
// 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, changeset));
|
||||
|
||||
async.parallel(tasks, 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]);
|
||||
|
||||
// Deserialise changeset
|
||||
var changeset = entry.value[1];
|
||||
if (changeset.hasOwnProperty('state')) {
|
||||
changeset.state = changeset.state === undefined ? '' : '[[flags:state-' + changeset.state + ']]';
|
||||
}
|
||||
|
||||
return {
|
||||
uid: entry.value[0],
|
||||
fields: changeset,
|
||||
datetime: entry.score,
|
||||
datetimeISO: new Date(parseInt(entry.score, 10)).toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
user.getUsersFields(uids, ['username', 'userslug', 'picture'], next);
|
||||
}
|
||||
], function (err, users) {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// Append user data to each history event
|
||||
history = history.map(function (event, idx) {
|
||||
event.user = users[idx];
|
||||
return event;
|
||||
});
|
||||
|
||||
callback(null, history);
|
||||
});
|
||||
};
|
||||
|
||||
Flags.appendHistory = function (flagId, uid, changeset, callback) {
|
||||
var payload;
|
||||
var datetime = changeset.datetime || Date.now();
|
||||
delete changeset.datetime;
|
||||
|
||||
try {
|
||||
payload = JSON.stringify([uid, changeset, datetime]);
|
||||
} catch (e) {
|
||||
return callback(e);
|
||||
}
|
||||
|
||||
db.sortedSetAdd('flag:' + flagId + ':history', datetime, payload, 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]);
|
||||
} catch (e) {
|
||||
return callback(e);
|
||||
}
|
||||
|
||||
async.waterfall([
|
||||
async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', datetime, payload),
|
||||
async.apply(Flags.appendHistory, flagId, uid, {
|
||||
notes: null,
|
||||
datetime: 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({
|
||||
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, '%').replace(/,/g, ',');
|
||||
|
||||
notifications.create({
|
||||
bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]',
|
||||
bodyLong: flagObj.description,
|
||||
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:flag.create', {
|
||||
flag: flagObj
|
||||
});
|
||||
notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), 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);
|
||||
}
|
||||
|
||||
plugins.fireHook('action:flag.create', {
|
||||
flag: flagObj
|
||||
});
|
||||
notifications.push(notification, results.admins.concat(results.globalMods), callback);
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
callback(new Error('[[error:invalid-data]]'));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Flags;
|
@ -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);
|
||||
};
|
||||
};
|
@ -0,0 +1,111 @@
|
||||
'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.type || !data.id || !data.reason) {
|
||||
return callback(new Error('[[error:invalid-data]]'));
|
||||
}
|
||||
|
||||
async.waterfall([
|
||||
async.apply(flags.validate, {
|
||||
uid: socket.uid,
|
||||
type: data.type,
|
||||
id: data.id
|
||||
}),
|
||||
function (next) {
|
||||
// If we got here, then no errors occurred
|
||||
flags.create(data.type, data.id, socket.uid, data.reason, next);
|
||||
}
|
||||
], function (err, flagObj) {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
flags.notify(flagObj, socket.uid);
|
||||
callback(null, flagObj);
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
async.apply(flags.getHistory, data.flagId)
|
||||
], 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);
|
||||
},
|
||||
function (next) {
|
||||
async.parallel({
|
||||
"notes": async.apply(flags.getNotes, data.flagId),
|
||||
"history": async.apply(flags.getHistory, data.flagId)
|
||||
}, next);
|
||||
}
|
||||
], callback);
|
||||
};
|
||||
|
||||
module.exports = SocketFlags;
|
@ -1,172 +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');
|
||||
|
||||
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;
|
||||
|
||||
posts.flag(post, 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, '%').replace(/,/g, ',');
|
||||
|
||||
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]]'));
|
||||
}
|
||||
posts.dismissFlag(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]]'));
|
||||
}
|
||||
posts.dismissAllFlags(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);
|
||||
|
||||
posts.updateFlagData(socket.uid, data.pid, payload, next);
|
||||
}
|
||||
], callback);
|
||||
};
|
||||
};
|
@ -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>
|
||||
[[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> •
|
||||
<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>
|
@ -0,0 +1,524 @@
|
||||
'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 Posts = require('../src/posts');
|
||||
var User = require('../src/user');
|
||||
var Groups = require('../src/groups');
|
||||
var Meta = require('../src/meta');
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
function (topicData, next) {
|
||||
User.create({
|
||||
username: 'testUser2', password: 'abcdef', email: 'c@d.com'
|
||||
}, next);
|
||||
},
|
||||
function (uid, next) {
|
||||
Groups.join('administrators', uid, next);
|
||||
},
|
||||
function (next) {
|
||||
User.create({
|
||||
username: 'unprivileged', password: 'abcdef', email: 'd@e.com'
|
||||
}, 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.equal(flagData[key], compare[key]);
|
||||
}
|
||||
}
|
||||
|
||||
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('.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) {
|
||||
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.equal(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('(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, parseInt(flags[0].flagId, 10));
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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.ok(!isNaN(parseInt(data.assignee, 10)));
|
||||
assert.strictEqual(1, parseInt(data.assignee, 10));
|
||||
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.equal(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.equal(data[key], compare[key]);
|
||||
}
|
||||
}
|
||||
|
||||
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 () {
|
||||
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: 3
|
||||
}, 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.state, '[[flags:state-rejected]]');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('(websockets)', function () {
|
||||
var SocketFlags = require('../src/socket.io/flags.js');
|
||||
var tid, pid, flag;
|
||||
|
||||
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(err);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.create()', function () {
|
||||
it('should create a flag with no errors', function (done) {
|
||||
SocketFlags.create({ uid: 2 }, {
|
||||
type: 'post',
|
||||
id: pid,
|
||||
reason: 'foobar'
|
||||
}, function (err, flagObj) {
|
||||
flag = flagObj;
|
||||
assert.ifError(err);
|
||||
|
||||
Flags.exists('post', pid, 1, function (err, exists) {
|
||||
assert.ifError(err);
|
||||
assert(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.update()', function () {
|
||||
it('should update a flag\'s properties', function (done) {
|
||||
SocketFlags.update({ uid: 2 }, {
|
||||
flagId: 2,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.appendNote()', function () {
|
||||
it('should append a note to the flag', function (done) {
|
||||
SocketFlags.appendNote({ uid: 2 }, {
|
||||
flagId: 2,
|
||||
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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
after(function (done) {
|
||||
db.emptydb(done);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue