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]
}
################################################################################