diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index bd72bbb8c..d96bacfe0 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -9,6 +9,7 @@ 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 = (
@@ -18,6 +19,7 @@ const routes = ( + {/* Moderation Routes */} 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/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/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index fc59f8f84..5a429756e 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -1,6 +1,24 @@ import {graphql} from 'react-apollo'; + +import MOST_FLAGS from './mostFlags.graphql'; import MOD_QUEUE_QUERY from './modQueueQuery.graphql'; +export const mostFlags = graphql(MOST_FLAGS, { + options: () => { + + // currently hard-coded per Greg's advice + const fiveMinutesAgo = new Date(); + fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 305); + return { + variables: { + sort: 'FLAG', + from: fiveMinutesAgo.toISOString(), + to: new Date().toISOString() + } + }; + } +}); + export const modQueueQuery = graphql(MOD_QUEUE_QUERY, { options: ({params: {id = ''}}) => { return { diff --git a/client/coral-admin/src/graphql/queries/mostFlags.graphql b/client/coral-admin/src/graphql/queries/mostFlags.graphql new file mode 100644 index 000000000..182ea0c8b --- /dev/null +++ b/client/coral-admin/src/graphql/queries/mostFlags.graphql @@ -0,0 +1,14 @@ +query Metrics ($from: Date!, $to: Date!, $sort: ACTION_TYPE!) { + metrics(from: $from, to: $to, sort: $sort) { + id + title + url + commentCount + author + created_at + action_summaries { + actionCount + actionableItemCount + } + } +} diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index fc507400e..5b15564d7 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -16,6 +16,7 @@ "loading": "Loading results" }, "modqueue": { + "likes": "likes", "all": "all", "premod": "pre-mod", "rejected": "rejected", @@ -50,6 +51,7 @@ "configure": { "custom-css-url": "Custom CSS URL", "custom-css-url-desc": "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external.", + "dashboard": "Dashboard", "enable-pre-moderation": "Enable pre-moderation", "enable-pre-moderation-text": "Moderators must approve any comment before it is published.", "require-email-verification": "Require Email Verification", @@ -106,6 +108,10 @@ "email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.", "write_message": "Write a message" }, + "dashboard": { + "no_flags": "There have been no flags in the last 5 minutes! Hooray!", + "comment_count": "Comments" + }, "streams": { "search": "Search", "filter-streams": "Filter Streams", @@ -140,6 +146,7 @@ "loading": "Cargando resultados" }, "modqueue": { + "likes": "gustos", "premod": "pre-mod", "rejected": "rechazado", "flagged": "marcado", @@ -161,6 +168,7 @@ "configure": { "custom-css-url": "URL CSS a medida", "custom-css-url-desc": "URL de una hoja de estilo que va a sobrescribir los estilos por defecto de Embed Stream. Puede ser interna o externa.", + "dashboard": "Panel", "enable-pre-moderation": "Habilitar pre-moderación", "enable-pre-moderation-text": "Los moderadores deben aprobar cada comentario antes de que sea publicado.", "require-email-verification": "Necesita confirmación de correo", @@ -207,6 +215,10 @@ "cancel": "Cancelar", "yes_ban_user": "Si, Suspendan el usuario" }, + "dashbord": { + "no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!", + "comment_count": "Comentarios" + }, "streams": { "search": "", "filter-streams": "", diff --git a/client/coral-docs/src/services/fetcher.js b/client/coral-docs/src/services/fetcher.js index e4d6f091d..aec448762 100644 --- a/client/coral-docs/src/services/fetcher.js +++ b/client/coral-docs/src/services/fetcher.js @@ -1,5 +1,5 @@ export default function fetcher(query) { - return fetch(`${window.location.host}/api/v1/graph/ql`, { + return fetch(`${window.location.origin}/api/v1/graph/ql`, { method: 'POST', headers: { Accept: 'application/json', 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/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/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 966598d7c..cabb50985 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -192,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 { @@ -319,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 } ################################################################################ @@ -355,7 +392,7 @@ type ValidationUserError implements UserError { } ################################################################################ -## Queries +## Queries; ################################################################################ # Establishes the ordering of the content by their created_at time stamp. @@ -392,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] } ################################################################################