diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index b28c4582b..d96bacfe0 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -4,22 +4,22 @@ import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-ro import Streams from 'containers/Streams/Streams'; import Configure from 'containers/Configure/Configure'; import LayoutContainer from 'containers/LayoutContainer'; -import CommentStream from 'containers/CommentStream/CommentStream'; import InstallContainer from 'containers/Install/InstallContainer'; import CommunityContainer from 'containers/Community/CommunityContainer'; import ModerationLayout from 'containers/ModerationQueue/ModerationLayout'; import ModerationContainer from 'containers/ModerationQueue/ModerationContainer'; +import Dashboard from 'containers/Dashboard/Dashboard'; const routes = (
- + {/* Moderation Routes */} diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js deleted file mode 100644 index 14f33bf36..000000000 --- a/client/coral-admin/src/actions/comments.js +++ /dev/null @@ -1,103 +0,0 @@ -import coralApi from '../../../coral-framework/helpers/response'; -import * as commentTypes from '../constants/comments'; -import * as actionTypes from '../constants/actions'; - -function addUsersCommentsActions (dispatch, {comments, users, actions}) { - dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users}); - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments}); - dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions}); -} - -// Get comments to fill each of the three lists on the mod queue -export const fetchModerationQueueComments = () => { - return dispatch => { - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); - - return Promise.all([ - coralApi('/queue/comments/premod'), - coralApi('/queue/users/flagged'), - coralApi('/queue/comments/rejected'), - coralApi('/queue/comments/flagged') - ]) - .then(([premodComments, pendingUsers, rejected, flagged]) => { - - /* Combine seperate calls into a single object */ - flagged.comments.forEach(comment => comment.flagged = true); - return { - comments: [...premodComments.comments, ...rejected.comments, ...flagged.comments], - users: [...premodComments.users, ...pendingUsers.users, ...rejected.users, ...flagged.users], - actions: [...premodComments.actions, ...pendingUsers.actions, ...rejected.actions, ...flagged.actions] - }; - }) - .then(addUsersCommentsActions.bind(this, dispatch)); - }; -}; - -export const fetchPremodQueue = () => { - return dispatch => { - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); - - return coralApi('/queue/comments/premod') - .then(addUsersCommentsActions.bind(this, dispatch)); - }; -}; - -export const fetchPendingUsersQueue = () => { - return dispatch => { - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); - - return coralApi('/queue/users/flagged') - .then(addUsersCommentsActions.bind(this, dispatch)); - }; -}; - -export const fetchRejectedQueue = () => { - return dispatch => { - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); - - return coralApi('/queue/comments/rejected') - .then(addUsersCommentsActions.bind(this, dispatch)); - }; -}; - -export const fetchFlaggedQueue = () => { - return dispatch => { - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); - - return coralApi('/queue/comments/flagged') - .then(results => { - results.comments.forEach(comment => comment.flagged = true); - return results; - }) - .then(addUsersCommentsActions.bind(this, dispatch)); - }; -}; - -// Create a new comment -export const createComment = (name, body) => { - return (dispatch) => { - const formData = {body, name}; - return coralApi('/comments', {method: 'POST', body: formData}) - .then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res})) - .catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error})); - }; -}; - -/** - * Action disptacher related to comments - */ - -// Update a comment. Now to update a comment we need to send back the whole object -export const updateStatus = (status, comment) => { - return dispatch => { - dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_REQUEST, id: comment.id, status}); - return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}}) - .then(res => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_SUCCESS, res})) - .catch(error => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_FAILURE, error})); - }; -}; - -export const flagComment = id => (dispatch, getState) => { - dispatch({type: commentTypes.COMMENT_FLAG, id}); - dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)}); -}; diff --git a/client/coral-admin/src/components/BanUserDialog.css b/client/coral-admin/src/components/BanUserDialog.css index 054c343dd..a46b9da32 100644 --- a/client/coral-admin/src/components/BanUserDialog.css +++ b/client/coral-admin/src/components/BanUserDialog.css @@ -22,17 +22,17 @@ } } -.formField { +.textField { margin-top: 15px; } -.formField label { +.textField label { font-size: 1.08em; font-weight: bold; margin-bottom: 5px; } -.formField input { +.textField input { width: 100%; display: block; border: none; diff --git a/client/coral-admin/src/components/FlagWidget.css b/client/coral-admin/src/components/FlagWidget.css new file mode 100644 index 000000000..f40da403d --- /dev/null +++ b/client/coral-admin/src/components/FlagWidget.css @@ -0,0 +1,34 @@ +.heading { + margin: 0; + font-size: 1.5rem; + font-weight: bold; +} + +.widgetTable { + width: 100%; + border-collapse: collapse; + box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2); +} + +.widgetTable thead th { + border-bottom: 1px solid #f47e6b; + padding: 10px; + text-align: left; +} + +.widgetTable tbody tr { + border-bottom: 1px solid lightgrey; +} + +.widgetTable tbody tr:last-child { + border-bottom: none; +} + +.widgetTable tbody td { + padding: 10px; +} + +.lede { + font-size: 0.9em; + color: grey; +} diff --git a/client/coral-admin/src/components/FlagWidget.js b/client/coral-admin/src/components/FlagWidget.js new file mode 100644 index 000000000..8116d4ff9 --- /dev/null +++ b/client/coral-admin/src/components/FlagWidget.js @@ -0,0 +1,66 @@ +import React, {PropTypes} from 'react'; +import {Link} from 'react-router'; +import styles from './FlagWidget.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations'; + +const lang = new I18n(translations); + +const FlagWidget = ({assets}) => { + + return ( + + + + {/* empty on purpose */} + + + + + + + + { + assets.length + ? assets.map((asset, index) => { + const flagCount = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary').actionCount; + const likeCount = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary').actionCount; + return ( + + + + + + + + ); + }) + : + } + +
{lang.t('streams.article')}{lang.t('modqueue.flagged')}{lang.t('modqueue.likes')}{lang.t('dashboard.comment_count')}
{index + 1}. + {asset.title} +

{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}

+
{likeCount}{flagCount}{asset.commentCount}
{lang.t('dashboard.no_flags')}
+ ); +}; + +FlagWidget.propTypes = { + assets: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + title: PropTypes.string, + url: PropTypes.string, + commentCount: PropTypes.number, + action_summaries: PropTypes.arrayOf( + PropTypes.shape({ + __typename: PropTypes.string.isRequired, + actionCount: PropTypes.number.isRequired, + actionableItemCount: PropTypes.number.isRequired + }) + ) + }) + ).isRequired +}; + +export default FlagWidget; diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index e92e3c84f..6eacaba9e 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -13,21 +13,33 @@ export default ({handleLogout, restricted = false}) => ( !restricted ?
- - {lang.t('configure.moderate')} - - + + {lang.t('configure.moderate')} + + {lang.t('configure.streams')} - - {lang.t('configure.community')} + + {lang.t('configure.community')} - - {lang.t('configure.configure')} + + {lang.t('configure.configure')} + + + {lang.t('configure.dashboard')}
diff --git a/client/coral-admin/src/containers/CommentStream/CommentStream.css b/client/coral-admin/src/containers/CommentStream/CommentStream.css deleted file mode 100644 index 9247183e5..000000000 --- a/client/coral-admin/src/containers/CommentStream/CommentStream.css +++ /dev/null @@ -1,13 +0,0 @@ - -@custom-media --big-viewport (min-width: 780px); - -.container { - max-width: 860px; - margin: 0 auto; -} - -@media (--big-viewport) { - .tab { - flex: none; - } -} diff --git a/client/coral-admin/src/containers/CommentStream/CommentStream.js b/client/coral-admin/src/containers/CommentStream/CommentStream.js deleted file mode 100644 index e2fac3702..000000000 --- a/client/coral-admin/src/containers/CommentStream/CommentStream.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import styles from './CommentStream.css'; -import {Snackbar} from 'react-mdl'; -import {connect} from 'react-redux'; -import {createComment, flagComment} from 'actions/comments'; -import ModerationList from 'components/ModerationList'; -import CommentBox from 'components/CommentBox'; - -/** - * Renders a comment stream using a ModerationList component - * and adds a box for adding a new comment - */ - -class CommentStream extends React.Component { - constructor (props) { - super(props); - this.state = {snackbar: false, snackbarMsg: ''}; - this.onSubmit = this.onSubmit.bind(this); - this.onClickAction = this.onClickAction.bind(this); - } - - // Fetch the comments before mounting - componentWillMount () { - this.props.dispatch({type: 'COMMENT_STREAM_FETCH'}); - } - - // Submit the new comment - onSubmit (comment) { - this.props.dispatch(createComment(comment.name, comment.body)); - } - - // The only action for now is flagging - onClickAction (action, id) { - if (action === 'flag') { - this.props.dispatch(flagComment(id)); - clearTimeout(this._snackTimeout); - this.setState({snackbar: true, snackbarMsg: 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.'}); - this._snackTimeout = setTimeout(() => this.setState({snackbar: false}), 30000); - } - } - - // Render the comment box along with the ModerationList - render ({comments, users}, {snackbar, snackbarMsg}) { - return ( -
- - - {snackbarMsg} -
- ); - } -} - -const mapStateToProps = state => ({ - comments: state.comments.toJS(), - users: state.users.toJS() -}); - -export default connect(mapStateToProps)(CommentStream); diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.css b/client/coral-admin/src/containers/Dashboard/Dashboard.css new file mode 100644 index 000000000..be2cd9f7a --- /dev/null +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.css @@ -0,0 +1,23 @@ +.Dashboard { + display: flex; + padding: 5px; +} + +.widget { + margin-top: 10px; + flex: 1; + box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2); + margin-right: 10px; + padding: 15px; + +} + +.widget:last-child { + margin-right: 0; +} + +.heading { + margin: 0; + font-size: 1.5rem; + font-weight: bold; +} diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.js b/client/coral-admin/src/containers/Dashboard/Dashboard.js new file mode 100644 index 000000000..6baa16af1 --- /dev/null +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.js @@ -0,0 +1,38 @@ +import React from 'react'; +import {compose} from 'react-apollo'; +import {mostFlags} from 'coral-admin/src/graphql/queries'; +import {Spinner} from 'coral-ui'; +import styles from './Dashboard.css'; +import FlagWidget from '../../components/FlagWidget'; + +class Dashboard extends React.Component { + render () { + + const {data} = this.props; + const {metrics: assets} = data; + + if (data.loading) { + return ; + } + + if (data.error) { + return
{data.error}
; + } + + return ( +
+
+

Top Ten Articles with the most flagged comments

+ +
+
+

Top ten comments with the most likes

+
+
+ ); + } +} + +export default compose( + mostFlags +)(Dashboard); diff --git a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js index 0f85bbad4..ae23d0048 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js +++ b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js @@ -1,6 +1,6 @@ import React from 'react'; import styles from './style.css'; -import {FormField, Button} from 'coral-ui'; +import {TextField, Button} from 'coral-ui'; const AddOrganizationName = props => { const {handleSettingsChange, handleSettingsSubmit, install} = props; @@ -12,8 +12,8 @@ const AddOrganizationName = props => {

- { const {handleUserChange, handleUserSubmit, install} = props; @@ -8,8 +8,8 @@ const InitialStep = props => {
- { noValidate /> - { errorMsg={install.errors.username} /> - { errorMsg={install.errors.password} /> - { const {nextStep} = props; @@ -14,19 +14,19 @@ const InviteTeamMembers = props => {
- - -
+
this.emailInput = input} diff --git a/client/coral-sign-in/components/SignInContent.js b/client/coral-sign-in/components/SignInContent.js index eebaf960b..1f339979b 100644 --- a/client/coral-sign-in/components/SignInContent.js +++ b/client/coral-sign-in/components/SignInContent.js @@ -1,6 +1,6 @@ import React, {PropTypes} from 'react'; import Alert from './Alert'; -import {Button, FormField, Spinner, Success} from 'coral-ui'; +import {Button, TextField, Spinner, Success} from 'coral-ui'; import styles from './styles.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; @@ -32,7 +32,7 @@ const SignInContent = ({ auth.emailVerificationFailure ?

{lang.t('signIn.requestNewVerifyEmail')}

-
- -
- - - { errors.password && Password must be at least 8 characters. } - ( -
+const TextField = ({className, showErrors = false, errorMsg, label, ...props}) => ( +
@@ -15,7 +15,7 @@ const FormField = ({className, showErrors = false, errorMsg, label, ...props}) =
); -FormField.propTypes = { +TextField.propTypes = { label: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, @@ -23,4 +23,4 @@ FormField.propTypes = { type: PropTypes.string }; -export default FormField; +export default TextField; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index beb2396f5..255259c32 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -13,7 +13,7 @@ export {default as Icon} from './components/Icon'; export {default as List} from './components/List'; export {default as Item} from './components/Item'; export {default as Card} from './components/Card'; -export {default as FormField} from './components/FormField'; +export {default as TextField} from './components/TextField'; export {default as Success} from './components/Success'; export {default as Pager} from './components/Pager'; export {default as Wizard} from './components/Wizard'; diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index a07a18a3f..ba8fb3751 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -46,6 +46,15 @@ const findOrCreateAssetByURL = (context, asset_url) => { }); }; +const getAssetsForMetrics = ({loaders: {Actions, Comments}}) => { + return Actions.getByTypes({action_type: 'FLAG', item_type: 'COMMENT'}) + .then((actions) => { // ALL ACTIONS :O + const ids = actions.map(({item_id}) => item_id); + + return Comments.getByQuery({ids}); + }); +}; + /** * Creates a set of loaders based on a GraphQL context. * @param {Object} context the context of the GraphQL request @@ -59,6 +68,7 @@ module.exports = (context) => ({ getByURL: (url) => findOrCreateAssetByURL(context, url), getByID: new DataLoader((ids) => genAssetsByID(context, ids)), + getForMetrics: () => getAssetsForMetrics(context), getAll: new util.SingletonResolver(() => AssetModel.find({})) } }); diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 23f7eb29d..24b66f4bd 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -18,7 +18,7 @@ const getCountsByAssetID = (context, asset_ids) => { $in: asset_ids }, status: { - $in: [null, 'ACCEPTED'] + $in: ['NONE', 'ACCEPTED'] }, parent_id: null } @@ -51,7 +51,7 @@ const getCountsByParentID = (context, parent_ids) => { $in: parent_ids }, status: { - $in: [null, 'ACCEPTED'] + $in: ['NONE', 'ACCEPTED'] } } }, @@ -88,7 +88,7 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_ } else { comments = comments.where({ status: { - $in: [null, 'ACCEPTED'] + $in: ['NONE', 'ACCEPTED'] } }); } diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 536e40fa9..5b1894b65 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const Actions = require('./actions'); const Assets = require('./assets'); const Comments = require('./comments'); +const Metrics = require('./metrics'); const Settings = require('./settings'); const Users = require('./users'); @@ -18,6 +19,7 @@ module.exports = (context) => { Actions, Assets, Comments, + Metrics, Settings, Users ].map((loaders) => { diff --git a/graph/loaders/metrics.js b/graph/loaders/metrics.js new file mode 100644 index 000000000..a842165c0 --- /dev/null +++ b/graph/loaders/metrics.js @@ -0,0 +1,155 @@ +const _ = require('lodash'); +const DataLoader = require('dataloader'); +const {objectCacheKeyFn} = require('./util'); + +const CommentModel = require('../../models/comment'); +const ActionModel = require('../../models/action'); + +const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => { + + let commentMetrics = {}; + let assetMetrics = []; + + return Metrics.getRecentActions.load({from, to}) + .then((actionSummaries) => { + + commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => { + if (!(item_id in acc)) { + acc[item_id] = []; + } + + acc[item_id].push({action_type, count}); + + return acc; + }, {}); + + // Collect just the comment id's. + let commentIDs = _.uniq(actionSummaries.map((as) => as.item_id)); + + // Find those comments. + return Metrics.getSpecificComments.loadMany(commentIDs); + }) + .then((comments) => { + + let commentResults = _.groupBy(comments, 'asset_id'); + + assetMetrics = Object.keys(commentResults).map((asset_id) => { + let ids = commentResults[asset_id].map((comment) => comment.id); + let summaries = _.groupBy(_.flatten(ids.map((id) => commentMetrics[id])), 'action_type'); + + let action_summaries = Object.keys(summaries).map((action_type) => ({ + action_type, + actionCount: summaries[action_type].reduce((acc, {count}) => acc + count, 0), + actionableItemCount: summaries[action_type].length + })); + + return {action_summaries, id: asset_id}; + }); + + // Sort these metrics by the predefined sort order. This will ensure that + // if the action summary does not exist on the object, that it is less + // prefered over the one that does have it. + assetMetrics.sort((a, b) => { + let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sort)); + let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sort)); + + // If either a or b don't have this action type, then one of them will + // automatically win. + if (aActionSummary == null || bActionSummary == null) { + if (bActionSummary != null) { + return 1; + } + + if (aActionSummary != null) { + return -1; + } + + return 0; + } + + // Both of them had an actionCount, hence we can determine that we could + // compare the actual values directly. + return bActionSummary.actionCount - aActionSummary.actionCount; + }); + + // Only keep the top `limit`. + assetMetrics = assetMetrics.slice(0, limit); + + // Determine the assets that we need to return. + return Assets.getByID.loadMany(assetMetrics.map((asset) => asset.id)); + }) + .then((assets) => { + + // Join up the assets that are returned by their id. + let groupedAssets = _.groupBy(assets, 'id'); + + // Return from the sorted asset metrics and return their assetes. + return assetMetrics.map(({id, action_summaries}) => { + if (id in groupedAssets) { + let asset = groupedAssets[id][0]; + + // Add the action summaries to the asset. + asset.action_summaries = action_summaries; + + return asset; + } + + return null; + }).filter((asset) => asset != null); + }); +}; + +const getRecentActions = (context, {from, to}) => { + return ActionModel.aggregate([ + + // Find all actions that were created in the time range. + {$match: { + item_type: 'COMMENTS', + created_at: { + $gt: from, + $lt: to + } + }}, + + // Count all those items. + {$group: { + _id: { + item_id: '$item_id', + action_type: '$action_type' + }, + count: { + $sum: 1 + } + }}, + + // Project the count to a better field. + {$project: { + item_id: '$_id.item_id', + action_type: '$_id.action_type', + count: '$count' + }} + ]); +}; + +const getSpecificComments = (context, ids) => { + return CommentModel.find({ + id: { + $in: ids + } + }) + .select({ + id: 1, + asset_id: 1 + }); +}; + +module.exports = (context) => ({ + Metrics: { + getSpecificComments: new DataLoader((ids) => getSpecificComments(context, ids)), + getRecentActions: new DataLoader(([{from, to}]) => getRecentActions(context, {from, to}).then((as) => [as]), { + batch: false, + cacheKeyFn: objectCacheKeyFn('from', 'to') + }), + get: ({from, to, sort, limit}) => getMetrics(context, {from, to, sort, limit}) + } +}); diff --git a/graph/loaders/util.js b/graph/loaders/util.js index 4640d8245..ab6f8a1f3 100644 --- a/graph/loaders/util.js +++ b/graph/loaders/util.js @@ -130,10 +130,20 @@ const objectCacheKeyFn = (...paths) => (obj) => { return paths.map((path) => obj[path]).join(':'); }; +/** + * Maps an object's paths to a string that can be used as a cache key. + * @param {Array} paths paths on the object to be used to generate the cache + * key + */ +const arrayCacheKeyFn = (arr) => { + return arr.sort().join(':'); +}; + module.exports = { singleJoinBy, arrayJoinBy, objectCacheKeyFn, + arrayCacheKeyFn, SingletonResolver, SharedCacheDataLoader }; diff --git a/graph/mutators/action.js b/graph/mutators/action.js index 3499719e1..a572a641c 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -45,10 +45,6 @@ const deleteAction = ({user}, {id}) => { }; module.exports = (context) => { - - // TODO: refactor to something that'll return an error in the event an attempt - // is made to mutate state while not logged in. There's got to be a better way - // to do this. if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) { return { Action: { diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 98891953c..5007a3039 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -11,10 +11,10 @@ const Wordlist = require('../../services/wordlist'); * @param {String} body body of the comment * @param {String} asset_id asset for the comment * @param {String} parent_id optional parent of the comment - * @param {String} [status=null] the status of the new comment + * @param {String} [status='NONE'] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = null) => { +const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => { return CommentsService.publicCreate({ body, asset_id, @@ -105,7 +105,7 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => { if (charCountEnable && body.length > charCount) { return 'REJECTED'; } - return moderation === 'PRE' ? 'PREMOD' : null; + return moderation === 'PRE' ? 'PREMOD' : 'NONE'; }); } @@ -169,16 +169,26 @@ const createPublicComment = (context, commentInput) => { * @param {String} status the new status of the comment */ -const setCommentStatus = ({comment}, {id, status}) => { - return CommentsService.setStatus(id, status) - .then(res => res); +const setCommentStatus = ({loaders: {Comments}}, {id, status}) => { + return CommentsService + .setStatus(id, status) + .then((comment) => { + + // If the loaders are present, clear the caches for these values because we + // just added a new comment, hence the counts should be updated. + if (Comments && Comments.countByAssetID && Comments.countByParentID) { + if (comment.parent_id != null) { + Comments.countByParentID.clear(comment.parent_id); + } else { + Comments.countByAssetID.clear(comment.asset_id); + } + } + + return comment; + }); }; module.exports = (context) => { - - // TODO: refactor to something that'll return an error in the event an attempt - // is made to mutate state while not logged in. There's got to be a better way - // to do this. let mutators = { Comment: { create: () => Promise.reject(errors.ErrNotAuthorized), diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 2c43f11be..3f87c1fb5 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -7,10 +7,6 @@ const setUserStatus = ({user}, {id, status}) => { }; module.exports = (context) => { - - // TODO: refactor to something that'll return an error in the event an attempt - // is made to mutate state while not logged in. There's got to be a better way - // to do this. if (context.user && context.user.can('mutation:setUserStatus')) { return { User: { diff --git a/graph/resolvers/asset_action_summary.js b/graph/resolvers/asset_action_summary.js new file mode 100644 index 000000000..c4a3cef01 --- /dev/null +++ b/graph/resolvers/asset_action_summary.js @@ -0,0 +1,12 @@ +const AssetActionSummary = { + __resolveType({action_type}) { + switch (action_type) { + case 'FLAG': + return 'FlagAssetActionSummary'; + case 'LIKE': + return 'LikeAssetActionSummary'; + } + } +}; + +module.exports = AssetActionSummary; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 84dd10fdc..65461dc76 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -1,5 +1,6 @@ const ActionSummary = require('./action_summary'); const Action = require('./action'); +const AssetActionSummary = require('./asset_action_summary'); const Asset = require('./asset'); const Comment = require('./comment'); const Date = require('./date'); @@ -17,6 +18,7 @@ const ValidationUserError = require('./validation_user_error'); module.exports = { ActionSummary, Action, + AssetActionSummary, Asset, Comment, Date, diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index ec3ac5b08..eb66274dd 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -39,6 +39,14 @@ const RootQuery = { return Comments.getByQuery(query); }, + metrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics}}) { + if (user == null || !user.hasRoles('ADMIN')) { + return null; + } + + return Metrics.get({from, to, sort, limit}); + }, + // This returns the current user, ensure that if we aren't logged in, we // return null. me(_, args, {user}) { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index b1c4824e8..cabb50985 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -67,6 +67,10 @@ type Tag { # The statuses that a comment may have. enum COMMENT_STATUS { + # The comment is not PREMOD, but was not applied a moderation status by a + # moderator. + NONE + # The comment has been accepted by a moderator. ACCEPTED @@ -93,7 +97,7 @@ enum ACTION_TYPE { input CommentsQuery { # current status of a comment. - statuses: [COMMENT_STATUS] + statuses: [COMMENT_STATUS!] # asset that a comment is on. asset_id: ID @@ -152,7 +156,7 @@ type Comment { asset: Asset # The current status of a comment. - status: COMMENT_STATUS + status: COMMENT_STATUS! # The time when the comment was created created_at: Date! @@ -188,6 +192,36 @@ interface ActionSummary { current_user: Action } +# A summary of actions for a specific action type on an Asset. +interface AssetActionSummary { + + # Number of actions associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the actions. + actionableItemCount: Int +} + +# A summary of counts related to all the Flags on an Asset. +type FlagAssetActionSummary implements AssetActionSummary { + + # Number of flags associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the flags. + actionableItemCount: Int +} + +# A summary of counts related to all the Likes on an Asset. +type LikeAssetActionSummary implements AssetActionSummary { + + # Number of likes associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the likes. + actionableItemCount: Int +} + # LikeAction is used by users who "like" a specific entity. type LikeAction implements Action { @@ -274,6 +308,8 @@ type Settings { infoBoxEnable: Boolean infoBoxContent: String + questionBoxEnable: Boolean + questionBoxContent: String closeTimeout: Int closedMessage: String charCountEnable: Boolean @@ -313,8 +349,15 @@ type Asset { # The date that the asset was closed at. closedAt: Date + # Summary of all Actions against all entities associated with the Asset. + # (likes, flags, etc.) + action_summaries: [AssetActionSummary] + # The date that the asset was created. created_at: Date + + # The author(s) of the asset. + author: String } ################################################################################ @@ -349,7 +392,7 @@ type ValidationUserError implements UserError { } ################################################################################ -## Queries +## Queries; ################################################################################ # Establishes the ordering of the content by their created_at time stamp. @@ -386,6 +429,10 @@ type RootQuery { # The currently logged in user based on the request. me: User + + # Metrics related to user actions are saturated into the assets returned. The + # sort will affect if it will allow + metrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset] } ################################################################################ diff --git a/models/comment.js b/models/comment.js index ac44d904e..c88041c8a 100644 --- a/models/comment.js +++ b/models/comment.js @@ -6,7 +6,7 @@ const STATUSES = [ 'ACCEPTED', 'REJECTED', 'PREMOD', - null + 'NONE' ]; /** @@ -66,7 +66,11 @@ const CommentSchema = new Schema({ asset_id: String, author_id: String, status_history: [StatusSchema], - status: {type: String, default: null}, + status: { + type: String, + enum: STATUSES, + default: 'NONE' + }, tags: [TagSchema], parent_id: String }, { diff --git a/models/setting.js b/models/setting.js index 0987b141d..993384ff1 100644 --- a/models/setting.js +++ b/models/setting.js @@ -32,6 +32,14 @@ const SettingSchema = new Schema({ type: String, default: '' }, + questionBoxEnable: { + type: Boolean, + default: false + }, + questionBoxContent: { + type: String, + default: '' + }, organizationName: { type: String }, diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 96f3911d4..e77ca0c30 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -52,7 +52,7 @@ router.get('/', (req, res, next) => { if (user_id) { query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN')); } else if (status) { - query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? null : status)); + query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? 'NONE' : status)); } else if (action_type) { query = CommentsService .findIdsByActionType(action_type) diff --git a/services/comments.js b/services/comments.js index bcc534c51..ad9ce18db 100644 --- a/services/comments.js +++ b/services/comments.js @@ -11,6 +11,7 @@ const STATUSES = [ 'ACCEPTED', 'REJECTED', 'PREMOD', + 'NONE', ]; module.exports = class CommentsService { @@ -31,7 +32,7 @@ module.exports = class CommentsService { body, asset_id, parent_id, - status = null, + status = 'NONE', author_id } = comment; @@ -146,7 +147,7 @@ module.exports = class CommentsService { * @param {String} status status of the comment to search for * @return {Promise} resovles to comment array */ - static findByStatus(status = null) { + static findByStatus(status = 'NONE') { return CommentModel.find({status}); } @@ -155,7 +156,7 @@ module.exports = class CommentsService { * @param {String} asset_id * @return {Promise} */ - static moderationQueue(status = null, asset_id = null) { + static moderationQueue(status = 'NONE', asset_id = null) { // Fetch the comments with statuses. let comments = CommentModel.find({status}); @@ -272,7 +273,7 @@ module.exports = class CommentsService { return Promise.reject(new Error(`status ${status} is not supported`)); } - return CommentModel.update({id}, { + return CommentModel.findOneAndUpdate({id}, { $set: {status} }); } diff --git a/test/services/comments.js b/test/services/comments.js index 4c3e0eab9..0bab40bfa 100644 --- a/test/services/comments.js +++ b/test/services/comments.js @@ -131,7 +131,7 @@ describe('services.CommentsService', () => { expect(c2).to.not.be.null; expect(c2.id).to.be.uuid; - expect(c2.status).to.be.null; + expect(c2.status).to.be.equal('NONE'); expect(c3).to.not.be.null; expect(c3.id).to.be.uuid; @@ -225,7 +225,7 @@ describe('services.CommentsService', () => { return CommentsService.findById(comment_id) .then((c) => { - expect(c.status).to.be.null; + expect(c.status).to.be.equal('NONE'); return CommentsService.pushStatus(comment_id, 'REJECTED', '123'); })