+ );
+};
+
+Drawer.propTypes = {
+ active: PropTypes.bool,
+ handleClickOutside: PropTypes.func.isRequired
+};
+
+export default onClickOutside(Drawer);
diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js
index 148da0383..3e9456727 100644
--- a/client/coral-ui/index.js
+++ b/client/coral-ui/index.js
@@ -23,3 +23,4 @@ export {default as Select} from './components/Select';
export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
+export {default as Drawer} from './components/Drawer';
diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js
index ccee16ca7..96f0e8f73 100644
--- a/graph/loaders/comments.js
+++ b/graph/loaders/comments.js
@@ -120,7 +120,7 @@ const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgn
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
-
+
return CommentModel.where(query).count();
};
@@ -191,7 +191,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) =>
* @return {Promise} resolves to the counts of the comments from the
* query
*/
-const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) => {
+const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => {
let query = CommentModel.find();
if (ids) {
@@ -210,6 +210,10 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) =
query = query.where({parent_id});
}
+ if (author_id) {
+ query = query.where({author_id});
+ }
+
return CommentModel
.find(query)
.count();
diff --git a/graph/loaders/users.js b/graph/loaders/users.js
index 145b0dfdb..c4850eaf0 100644
--- a/graph/loaders/users.js
+++ b/graph/loaders/users.js
@@ -15,7 +15,7 @@ const genUserByIDs = (context, ids) => UsersService
* @param {Object} context graph context
* @param {Object} query query terms to apply to the users query
*/
-const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
+const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) => {
let users = UserModel.find();
@@ -27,6 +27,14 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
});
}
+ if (statuses != null) {
+ users = users.where({
+ status: {
+ $in: statuses
+ }
+ });
+ }
+
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
users = users.where({
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/root_query.js b/graph/resolvers/root_query.js
index ad4ed4b0d..8c1552b5f 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -19,34 +19,32 @@ const RootQuery = {
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
- async comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) {
- let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored};
+ async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
+ let {action_type} = query;
if (user != null && user.hasRoles('ADMIN') && action_type) {
- let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
-
- // Perform the query using the available resolver.
- return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored});
+ query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
return Comments.getByQuery(query);
},
+
comment(_, {id}, {loaders: {Comments}}) {
return Comments.get.load(id);
},
- async commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) {
+
+ async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
- if (action_type) {
- let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
+ const {action_type} = query;
- // Perform the query using the available resolver.
- return Comments.getCountByQuery({ids, statuses, asset_id, parent_id});
+ if (action_type) {
+ query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
- return Comments.getCountByQuery({statuses, asset_id, parent_id});
+ return Comments.getCountByQuery(query);
},
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
@@ -79,21 +77,27 @@ const RootQuery = {
return user;
},
- // This endpoint is used for loading the user moderation queues (users whose username has been flagged),
- // so hide it in the event that we aren't an admin.
- async users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
-
+ // this returns an arbitrary user
+ user(_, {id}, {user, loaders: {Users}}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
- const query = {limit, cursor, sort};
+ return Users.getByID.load(id);
+ },
+
+ // This endpoint is used for loading the user moderation queues (users whose username has been flagged),
+ // so hide it in the event that we aren't an admin.
+ async users(_, {query}, {user, loaders: {Users, Actions}}) {
+ if (user == null || !user.hasRoles('ADMIN')) {
+ return null;
+ }
+
+ const {action_type} = query;
if (action_type) {
- let ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
-
- // Perform the query using the available resolver.
- return Users.getByQuery({ids, limit, cursor, sort}).find({status: 'PENDING'});
+ query.ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
+ query.statuses = ['PENDING'];
}
return Users.getByQuery(query);
diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js
index 9b25c812f..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);
@@ -10,6 +12,13 @@ const User = {
}
},
+ created_at({roles, created_at}, _, {user}) {
+ if (user && user.hasRoles('ADMIN')) {
+ return created_at;
+ }
+
+ return null;
+ },
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
@@ -20,6 +29,15 @@ const User = {
return null;
},
+ profiles({profiles}, _, {user}) {
+
+ // if the user is not an admin, do not return the profiles
+ if (user && user.hasRoles('ADMIN')) {
+ return profiles;
+ }
+
+ return null;
+ },
ignoredUsers({id}, args, {user, loaders: {Users}}) {
// Only allow a logged in user that is either the current user or is a staff
@@ -43,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/subscriptions.js b/graph/subscriptions.js
index 369b5c058..2eb676cc4 100644
--- a/graph/subscriptions.js
+++ b/graph/subscriptions.js
@@ -1,6 +1,7 @@
const {SubscriptionManager} = require('graphql-subscriptions');
const {SubscriptionServer} = require('subscriptions-transport-ws');
const _ = require('lodash');
+const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('./pubsub');
const schema = require('./schema');
@@ -9,24 +10,22 @@ const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
-// Core setup functions
-let setupFunctions = {
- commentAdded: (options, args) => ({
- commentAdded: {
- filter: (comment) => comment.asset_id === args.asset_id
- },
- }),
-};
-
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
* as provide new ones.
*/
-setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => {
+const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => {
+ debug(`added plugin '${plugin.name}'`);
return _.merge(acc, setupFunctions);
-}, setupFunctions);
+}, {
+ commentAdded: (options, args) => ({
+ commentAdded: {
+ filter: (comment) => comment.asset_id === args.asset_id
+ },
+ }),
+});
/**
* This creates a new subscription manager.
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index f51258eb1..b11889865 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
@@ -20,6 +36,14 @@ enum USER_ROLES {
MODERATOR
}
+type UserProfile {
+ # the id is an identifier for the user profile (email, facebook id, etc)
+ id: String!
+
+ # name of the provider attached to the authentication mode
+ provider: String!
+}
+
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
@@ -30,6 +54,9 @@ type User {
# Username of a user.
username: String!
+ # creation date of user
+ created_at: String!
+
# Action summaries against the user.
action_summaries: [ActionSummary!]!
@@ -39,6 +66,9 @@ type User {
# the current roles of the user.
roles: [USER_ROLES!]
+ # the current profiles of the user.
+ profiles: [UserProfile]
+
# determines whether the user can edit their username
canEditName: Boolean
@@ -48,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
}
@@ -159,6 +194,10 @@ input CommentCountQuery {
# type.
action_type: ACTION_TYPE
+ # author_id allows the querying of comment counts based on the author of the
+ # comments.
+ author_id: ID
+
# Filter by a specific tag name.
tag: [String]
}
@@ -549,6 +588,9 @@ type RootQuery {
# Users returned based on a query.
users(query: UsersQuery): [User]
+ # a single User by id
+ user(id: ID!): User
+
# Asset metrics related to user actions are saturated into the assets
# returned. Parameters `from` and `to` are related to the action created_at field.
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
@@ -741,7 +783,7 @@ type EditCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occured.
- errors: [UserError]
+ errors: [UserError]
}
# All mutations for the application are defined on this object.
diff --git a/package.json b/package.json
index 917445550..fe7552414 100644
--- a/package.json
+++ b/package.json
@@ -176,7 +176,7 @@
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
- "react-onclickoutside": "^5.7.1",
+ "react-onclickoutside": "^5.11.1",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.14.0",
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;
diff --git a/views/graphiql.ejs b/views/graphiql.ejs
new file mode 100644
index 000000000..5f1759c0d
--- /dev/null
+++ b/views/graphiql.ejs
@@ -0,0 +1,119 @@
+
+
+
+
+
+ GraphiQL
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 20d7b20da..ef1afebfc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6797,7 +6797,7 @@ react-mdl@^1.7.1, react-mdl@^1.7.2:
lodash.isequal "^4.4.0"
prop-types "^15.5.0"
-react-onclickoutside@^5.7.1:
+react-onclickoutside@^5.11.1:
version "5.11.1"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623"
dependencies: