diff --git a/app.js b/app.js index f5243f81a..9aca8b0ef 100644 --- a/app.js +++ b/app.js @@ -69,9 +69,11 @@ app.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions)); if (app.get('env') !== 'production') { // Interactive graphiql interface. - app.use('/api/v1/graph/iql', apollo.graphiqlExpress({ - endpointURL: '/api/v1/graph/ql' - })); + app.use('/api/v1/graph/iql', (req, res) => { + res.render('graphiql', { + endpointURL: '/api/v1/graph/ql' + }); + }); // GraphQL documention. app.get('/admin/docs', (req, res) => { diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 9c9d5a571..c11342c01 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -18,3 +18,6 @@ export const hideShortcutsNote = () => { return {type: actions.HIDE_SHORTCUTS_NOTE}; }; + +export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId}); +export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL}); diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index 14672146e..1d09b0e1a 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -3,3 +3,5 @@ export const SINGLE_VIEW = 'SINGLE_VIEW'; export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG'; export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG'; export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE'; +export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL'; +export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL'; diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 3d1c1dd7a..e6062674d 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -10,7 +10,15 @@ import {banUser, setCommentStatus} from '../../graphql/mutations'; import {fetchSettings} from 'actions/settings'; import {updateAssets} from 'actions/assets'; -import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog, hideShortcutsNote} from 'actions/moderation'; +import { + toggleModal, + singleView, + showBanUserDialog, + hideBanUserDialog, + hideShortcutsNote, + viewUserDetail, + hideUserDetail +} from 'actions/moderation'; import {Spinner} from 'coral-ui'; import BanUserDialog from '../../components/BanUserDialog'; @@ -19,6 +27,7 @@ import ModerationMenu from './components/ModerationMenu'; import ModerationHeader from './components/ModerationHeader'; import NotFoundAsset from './components/NotFoundAsset'; import ModerationKeysModal from '../../components/ModerationKeysModal'; +import UserDetail from './UserDetail'; class ModerationContainer extends Component { state = { @@ -111,7 +120,7 @@ class ModerationContainer extends Component { } render () { - const {data, moderation, settings, assets, onClose, ...props} = this.props; + const {data, moderation, settings, assets, onClose, viewUserDetail, hideUserDetail, ...props} = this.props; const providedAssetId = this.props.params.id; const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path; @@ -181,6 +190,8 @@ class ModerationContainer extends Component { assetId={providedAssetId} sort={this.state.sort} commentCount={activeTabCount} + viewUserDetail={viewUserDetail} + hideUserDetail={hideUserDetail} /> - + {moderation.userDetailId && ( + + )} ); } @@ -214,6 +230,8 @@ const mapDispatchToProps = (dispatch) => ({ singleView: () => dispatch(singleView()), updateAssets: (assets) => dispatch(updateAssets(assets)), fetchSettings: () => dispatch(fetchSettings()), + viewUserDetail: (id) => dispatch(viewUserDetail(id)), + hideUserDetail: () => dispatch(hideUserDetail()), showBanUserDialog: (user, commentId, commentStatus, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, commentStatus, showRejectedNote)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), hideShortcutsNote: () => dispatch(hideShortcutsNote()), diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index c4bf0f85c..f6291c071 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -12,6 +12,7 @@ const lang = new I18n(translations); class ModerationQueue extends React.Component { static propTypes = { + viewUserDetail: PropTypes.func.isRequired, bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, currentAsset: PropTypes.object, @@ -33,7 +34,17 @@ class ModerationQueue extends React.Component { } render () { - const {comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props} = this.props; + const { + comments, + selectedIndex, + commentCount, + singleView, + loadMore, + activeTab, + sort, + viewUserDetail, + ...props + } = this.props; return (
@@ -49,6 +60,7 @@ class ModerationQueue extends React.Component { selected={i === selectedIndex} suspectWords={props.suspectWords} bannedWords={props.bannedWords} + viewUserDetail={viewUserDetail} actions={actionsMap[status]} showBanUserDialog={props.showBanUserDialog} acceptComment={props.acceptComment} diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.css b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css new file mode 100644 index 000000000..eb3f2e4c7 --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css @@ -0,0 +1,32 @@ +.copyButton { + float: right; + top: -10px; +} + +.memberSince { + clear: both; +} + +.small { + color: #aaa; +} + +.stats { + display: flex; + + .stat { + margin: 0 4px 12px; + } + + .stat:last-child { + margin-right: 0; + } + + p { + margin: 0; + } + + .stat p:first-child { + font-weight: bold; + } +} diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js new file mode 100644 index 000000000..200b8537d --- /dev/null +++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js @@ -0,0 +1,74 @@ +import React, {PropTypes} from 'react'; +import {Button, Drawer} from 'coral-ui'; +import styles from './UserDetail.css'; +import {compose} from 'react-apollo'; +import {getUserDetail} from 'coral-admin/src/graphql/queries'; +import Slot from 'coral-framework/components/Slot'; + +class UserDetail extends React.Component { + static propTypes = { + id: PropTypes.string.isRequired, + hideUserDetail: PropTypes.func.isRequired + } + + copyPermalink () { + this.profile.select(); + try { + document.execCommand('copy'); + } catch (e) { + + /* nothing */ + } + } + + render () { + const {data, hideUserDetail} = this.props; + + if (!('user' in data)) { + return null; + } + + const {user, totalComments, rejectedComments} = data; + const localProfile = user.profiles.find((p) => p.provider === 'local'); + let profile; + if (localProfile) { + profile = localProfile.id; + } + + let rejectedPercent = rejectedComments / totalComments; + if (rejectedPercent === Infinity || isNaN(rejectedPercent)) { + + // if totalComments is 0, you're dividing by zero, which is naughty + rejectedPercent = 0; + } + + return ( + +

{user.username}

+ + {profile &&

this.profile = ref} contentEditable="true">{profile}

} + +

Member since {new Date(user.created_at).toLocaleString()}

+
+

+ Account summary +
Data represents the last six months of activity +

+
+
+

Total Comments

+

{totalComments}

+
+
+

Reject Rate

+

{`${(rejectedPercent).toFixed(1)}%`}

+
+
+
+ ); + } +} + +export default compose( + getUserDetail +)(UserDetail); diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index 0f31210c0..2909f7644 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -22,6 +22,7 @@ const lang = new I18n(translations); const Comment = ({ actions = [], comment, + viewUserDetail, suspectWords, bannedWords, ...props @@ -56,7 +57,7 @@ const Comment = ({
- + viewUserDetail(comment.user.id)}> {comment.user.name} @@ -154,6 +155,7 @@ const Comment = ({ }; Comment.propTypes = { + viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -165,8 +167,9 @@ Comment.propTypes = { actions: PropTypes.array, created_at: PropTypes.string.isRequired, user: PropTypes.shape({ + id: PropTypes.string, status: PropTypes.string - }), + }).isRequired, asset: PropTypes.shape({ title: PropTypes.string, url: PropTypes.string, diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css index 966a37526..52800fe43 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css +++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css @@ -424,6 +424,17 @@ span { top: 7px; } +.username { + color: blue; + text-decoration: underline; + padding: 5px; + cursor: pointer; + + &:hover { + background-color: rgba(255, 0, 0, .1); + } +} + .external { font-size: .7em; text-decoration: none; diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index f26c8464e..ae94d70c3 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql'; import MOD_QUEUE_LOAD_MORE from './loadMore.graphql'; import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql'; import METRICS from './metricsQuery.graphql'; +import USER_DETAIL from './userDetail.graphql'; import GET_QUEUE_COUNTS from './getQueueCounts.graphql'; export const modQueueQuery = graphql(MOD_QUEUE_QUERY, { @@ -95,6 +96,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => { }); }; +export const getUserDetail = graphql(USER_DETAIL, { + options: ({id}) => { + return { + variables: {author_id: id} + }; + } +}); + export const getQueueCounts = graphql(GET_QUEUE_COUNTS, { options: ({params: {id = null}}) => { return { diff --git a/client/coral-admin/src/graphql/queries/userDetail.graphql b/client/coral-admin/src/graphql/queries/userDetail.graphql new file mode 100644 index 000000000..c375b80ff --- /dev/null +++ b/client/coral-admin/src/graphql/queries/userDetail.graphql @@ -0,0 +1,13 @@ +query UserDetail ($author_id: ID!) { + user(id: $author_id) { + id + username + created_at + profiles { + id + provider + } + } + totalComments: commentCount(query: {author_id: $author_id}) + rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]}) +} diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 8f51cea6b..b0ff99617 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -7,6 +7,7 @@ const initialState = Map({ user: Map({}), commentId: null, commentStatus: null, + userDetailId: null, banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show' }); @@ -38,6 +39,10 @@ export default function moderation (state = initialState, action) { case actions.HIDE_SHORTCUTS_NOTE: return state .set('shortcutsNoteVisible', 'hide'); + case actions.VIEW_USER_DETAIL: + return state.set('userDetailId', action.userId); + case actions.HIDE_USER_DETAIL: + return state.set('userDetailId', null); default : return state; } diff --git a/client/coral-ui/components/Drawer.css b/client/coral-ui/components/Drawer.css new file mode 100644 index 000000000..d22512a3a --- /dev/null +++ b/client/coral-ui/components/Drawer.css @@ -0,0 +1,33 @@ +.drawer { + max-width: 700px; + min-width: 400px; + padding: 20px; + position: fixed; + top: 0; + right: 0; + bottom: 0; + background-color: white; + transition: transform 500ms ease-in-out; + box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15); + z-index: 10000; +} + +.closeButton { + position: absolute; + width: 40px; + height: 40px; + left: -40px; + background-color: white; + border-radius: 4px 0 0 4px; + box-sizing: border-box; + font-size: 32px; + top: 60px; + box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15); + text-align: center; + padding-top: 10px; + cursor: pointer; + + &:hover { + color: #ccc; + } +} diff --git a/client/coral-ui/components/Drawer.js b/client/coral-ui/components/Drawer.js new file mode 100644 index 000000000..d896203a8 --- /dev/null +++ b/client/coral-ui/components/Drawer.js @@ -0,0 +1,19 @@ +import React, {PropTypes} from 'react'; +import styles from './Drawer.css'; +import onClickOutside from 'react-onclickoutside'; + +const Drawer = ({children, handleClickOutside}) => { + return ( +
+
×
+ {children} +
+ ); +}; + +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: