diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 52440593d..621494db0 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -1,12 +1,91 @@ +const debug = require('debug')('talk:graph:mutators:comment'); const errors = require('../../errors'); +const ActionModel = require('../../models/action'); const AssetsService = require('../../services/assets'); const ActionsService = require('../../services/actions'); const CommentsService = require('../../services/comments'); +const KarmaService = require('../../services/karma'); const linkify = require('linkify-it')(); const Wordlist = require('../../services/wordlist'); +/** + * adjustKarma will adjust the affected user's karma depending on the moderators + * action. + */ +const adjustKarma = (Comments, id, status) => async () => { + try { + + // Use the dataloader to get the comment that was just moderated and + // get the flag user's id's so we can adjust their karma too. + let [ + comment, + flagUserIDs + ] = await Promise.all([ + + // Load the comment that was just made/updated by the setCommentStatus + // operation. + Comments.get.load(id), + + // Find all the flag actions that were referenced by this comment + // at this point in time. + ActionModel.find({ + item_id: id, + item_type: 'COMMENTS', + action_type: 'FLAG' + }).then((actions) => { + + // This is to ensure that this is always an array. + if (!actions) { + return []; + } + + return actions.map(({user_id}) => user_id); + }) + ]); + + debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`); + + switch (status) { + case 'REJECTED': + + // Reduce the user's karma. + debug(`CommentUser[${comment.author_id}] had their karma reduced`); + + // Decrease the flag user's karma, the moderator disagreed with this + // action. + debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`); + await Promise.all([ + KarmaService.modifyUser(comment.author_id, -1, 'comment'), + KarmaService.modifyUser(flagUserIDs, 1, 'flag', true) + ]); + + break; + + case 'ACCEPTED': + + // Increase the user's karma. + debug(`CommentUser[${comment.author_id}] had their karma increased`); + + // Increase the flag user's karma, the moderator agreed with this + // action. + debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`); + await Promise.all([ + KarmaService.modifyUser(comment.author_id, 1, 'comment'), + KarmaService.modifyUser(flagUserIDs, -1, 'flag', true) + ]); + + break; + + } + + return; + } catch (e) { + console.error(e); + } +}; + /** * Creates a new comment. * @param {Object} user the user performing the request @@ -86,6 +165,7 @@ const filterNewComment = (context, {body, asset_id}) => { * @return {Promise} resolves to the comment's status */ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => { + let {user} = context; // Check to see if the body is too short, if it is, then complain about it! if (body.length < 2) { @@ -123,6 +203,22 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, return 'REJECTED'; } + if (user && user.metadata) { + + // If the user is not a reliable commenter (passed the unreliability + // threshold by having too many rejected comments) then we can change the + // status of the comment to `PREMOD`, therefore pushing the user's comments + // away from the public eye until a moderator can manage them. This of + // course can only be applied if the comment's current status is `NONE`, + // we don't want to interfere if the comment was rejected. + if (KarmaService.isReliable('comment', user.metadata.trust) === false) { + + // Update the response from the comment creation to add the PREMOD so that + // that user's UI will reflect the fact that their comment is in pre-mod. + return 'PREMOD'; + } + } + return moderation === 'PRE' ? 'PREMOD' : 'NONE'; }; @@ -179,7 +275,6 @@ const createPublicComment = async (context, commentInput) => { * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment */ - const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { let comment = await CommentsService.pushStatus(id, status, user ? user.id : null); @@ -196,6 +291,10 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { Comments.countByAssetID.clear(comment.asset_id); + // postSetCommentStatus will use the arguments from the mutation and + // adjust the affected user's karma in the next tick. + process.nextTick(adjustKarma(Comments, id, status)); + return comment; }; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index edc1f3594..e7c0db20c 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -1,3 +1,5 @@ +const KarmaService = require('../../services/karma'); + const User = { action_summaries({id}, _, {loaders: {Actions}}) { return Actions.getSummariesByItemID.load(id); @@ -59,6 +61,13 @@ const User = { } return null; + }, + + // Extract the reliability from the user metadata if they have permission. + reliable(user, _, {user: requestingUser}) { + if (requestingUser && requestingUser.hasRoles('ADMIN')) { + return KarmaService.model(user); + } } }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 015306e63..d4f035da6 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -5,6 +5,22 @@ # Date represented as an ISO8601 string. scalar Date +################################################################################ +## Reliability +################################################################################ + +# Reliability defines how a given user should be considered reliable for their +# comment or flag activity. +type Reliability { + + # flagger will be `true` when the flagger is reliable, `false` if not, or + # `null` if the reliability cannot be determined. + flagger: Boolean + + # commenter will be `true` when the commenter is reliable, `false` if not, or + # `null` if the reliability cannot be determined. + commenter: Boolean +} ################################################################################ ## Users @@ -62,6 +78,11 @@ type User { # returns all comments based on a query. comments(query: CommentsQuery): [Comment!] + # reliable is the reference to a given user's Reliability. If the requesting + # user does not have permission to access the reliability, null will be + # returned. + reliable: Reliability + # returns user status status: USER_STATUS } diff --git a/services/karma.js b/services/karma.js new file mode 100644 index 000000000..ea932c86a --- /dev/null +++ b/services/karma.js @@ -0,0 +1,155 @@ +const debug = require('debug')('talk:trust'); +const UserModel = require('../models/user'); + +/** + * This will create an object with the property name of the action type as the + * key and an object as it's value. This will contain a RELIABLE, and UNRELIABLE + * property with the number of karma points associated with their particular + * state. + * + * If only the RELIABLE variable is provided, then it will also be used as the + * UNRELIABLE variable. + * + * The form of the environment variable is: + * + * :,;:,;... + * + * The default used is: + * + * comment:1,1;flag:-1,-1 + */ +const parseThresholds = (thresholds) => thresholds + .split(';') + .filter((threshold) => threshold && threshold.length > 0) + .reduce((acc, threshold) => { + const thresholds = threshold.split(':'); + if (thresholds.length < 2) { + return acc; + } + + let [name, values] = thresholds; + let [RELIABLE, UNRELIABLE] = values.split(',').map((value) => parseInt(value)); + + if (!(name in acc)) { + acc[name] = {}; + } + + if (isNaN(UNRELIABLE) && !isNaN(RELIABLE)) { + acc[name].RELIABLE = RELIABLE; + acc[name].UNRELIABLE = RELIABLE; + } else { + if (!isNaN(UNRELIABLE)) { + acc[name].UNRELIABLE = UNRELIABLE; + } + + if (!isNaN(RELIABLE)) { + acc[name].RELIABLE = RELIABLE; + } + } + + return acc; + }, { + comment: { + RELIABLE: -1, + UNRELIABLE: -1 + }, + flag: { + RELIABLE: -1, + UNRELIABLE: -1 + } + }); + +const THRESHOLDS = parseThresholds(process.env.TRUST_THRESHOLDS || ''); + +debug('using thresholds: ', THRESHOLDS); + +/** + * KarmaModel represents the checkable properties of a user and wrapps the + * KarmaService function `isReliable` to work flexibly with the graph. + */ +class KarmaModel { + constructor(model) { + this.model = model; + } + + get flagger() { + return KarmaService.isReliable('flag', this.model); + } + + get commenter() { + return KarmaService.isReliable('comment', this.model); + } +} + +/** + * KarmaService provides interfaces for editing a user's karma. + */ +class KarmaService { + + /** + * Model returns a KarmaModel based on the passed in user. + */ + static model(user) { + if (user === null || !user.metadata || !user.metadata.trust) { + return new KarmaModel({}); + } + + return new KarmaModel(user.metadata.trust); + } + + /** + * Inspects the reliability of a property and returns it if known. + * @param {String} name - name of the property + * @param {Object} trust - object possibly containing the propertys + */ + static isReliable(name, trust) { + if (trust && trust[name]) { + if (trust[name].karma > THRESHOLDS[name].RELIABLE) { + return true; + } else if (trust[name].karma < THRESHOLDS[name].UNRELIABLE) { + return false; + } + } else if (THRESHOLDS[name].RELIABLE < 0) { + return true; + } else if (THRESHOLDS[name].UNRELIABLE > 0) { + return false; + } + + return null; + } + + /** + * modifyUserKarma updates the user to adjust their karma, for either the `type` + * of 'comment' or 'flag'. If `multi` is true, then it assumes that `id` is an + * array of id's. + */ + static async modifyUser(id, direction = 1, type = 'comment', multi = false) { + const key = `metadata.trust.${type}.karma`; + + let update = { + $inc: { + [key]: direction + } + }; + + if (multi) { + + // If it was in multi-mode but there was no user's to adjust, bail. + if (id.length <= 0) { + return; + } + + return UserModel.update({ + id: { + $in: id + } + }, update, { + multi: true + }); + } + + return UserModel.update({id}, update); + } +} + +module.exports = KarmaService;