= ({ siteID, lastUpdated }) => {
+ const [today, loading] = useImmediateFetch(
+ TodayMetricsFetch,
+ { siteID },
+ lastUpdated
+ );
+ const [total, totalLoading] = useImmediateFetch(
+ TotalMetricsFetch,
+ { siteID },
+ lastUpdated
+ );
+
+ return (
+
+
+ Today's activity
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ ? (today.comments.rejected / today.comments.total) * 100
+ : 0
+ ).toFixed(2)} %`
+ : "-.-- %"
+ }
+ >
+
+ Rejection rate
+
+
+ 0
+ ? (total.comments.rejected / total.comments.total) * 100
+ : 0
+ ).toFixed(2)} %`
+ : "-.-- %"
+ }
+ >
+
+ All time average
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New community members
+
+
+
+ Total members
+
+
+
+
+ Banned members
+
+
+
+ Total banned members
+
+
+
+
+
+ );
+};
+
+export default TodayTotals;
diff --git a/src/core/client/admin/routes/Dashboard/sections/TopStories.tsx b/src/core/client/admin/routes/Dashboard/sections/TopStories.tsx
new file mode 100644
index 000000000..210fc1af7
--- /dev/null
+++ b/src/core/client/admin/routes/Dashboard/sections/TopStories.tsx
@@ -0,0 +1,90 @@
+import { Localized } from "@fluent/react/compat";
+import { Link } from "found";
+import React, { FunctionComponent } from "react";
+
+import NotAvailable from "coral-admin/components/NotAvailable";
+import { TodayStoriesMetricsJSON } from "coral-common/rest/dashboard/types";
+import { getModerationLink } from "coral-framework/helpers";
+import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ TextLink,
+} from "coral-ui/components/v2";
+
+import { DashboardBox, DashboardComponentHeading, Loader } from "../components";
+import createDashboardFetch from "../createDashboardFetch";
+
+const TodayStoriesMetrics = createDashboardFetch(
+ "topStoriesFetch",
+ "/dashboard/today/stories"
+);
+
+interface Props {
+ siteID: string;
+ lastUpdated: string;
+}
+
+const TopStories: FunctionComponent = ({ siteID, lastUpdated }) => {
+ const [today, loading] = useImmediateFetch(
+ TodayStoriesMetrics,
+ { siteID },
+ lastUpdated
+ );
+ return (
+
+
+
+ Today's most commented stories
+
+
+
+
+
+
+
+
+
+
+ {loading && (
+
+
+
+
+
+ )}
+ {today && today.results.length < 1 && (
+
+
+
+ )}
+ {today &&
+ today.results.map((result) => (
+
+
+
+ {result.story.title ? result.story.title : }
+
+
+ {result.count}
+
+ ))}
+
+
+
+ );
+};
+
+export default TopStories;
diff --git a/src/core/client/admin/routes/Dashboard/sections/index.ts b/src/core/client/admin/routes/Dashboard/sections/index.ts
new file mode 100644
index 000000000..cb6683e64
--- /dev/null
+++ b/src/core/client/admin/routes/Dashboard/sections/index.ts
@@ -0,0 +1,4 @@
+export { default as Today } from "./Today";
+export { default as SignupActivity } from "./SignupActivity";
+export { default as CommentActivity } from "./CommentActivity";
+export { default as TopStories } from "./TopStories";
diff --git a/src/core/client/framework/lib/relay/fetch.tsx b/src/core/client/framework/lib/relay/fetch.tsx
index de2356e47..e7d0d0602 100644
--- a/src/core/client/framework/lib/relay/fetch.tsx
+++ b/src/core/client/framework/lib/relay/fetch.tsx
@@ -1,4 +1,5 @@
-import React, { useCallback } from "react";
+import { isUndefined } from "lodash";
+import React, { useCallback, useEffect, useState } from "react";
import { fetchQuery as relayFetchQuery } from "react-relay";
import {
compose,
@@ -70,6 +71,34 @@ export function useFetch(
);
}
+export function useImmediateFetch(
+ fetch: Fetch>,
+ variables: V,
+ refetch?: string
+): [R | null, boolean] {
+ const fetcher = useFetch(fetch);
+ const [state, setState] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ async function doTheFetch() {
+ setState(null);
+ setLoading(true);
+ const value = await fetcher(variables);
+
+ // TODO: Maybe we don't need this timeout?
+ setTimeout(() => {
+ setState(value);
+ setLoading(false);
+ }, 100 + 50 * Math.random());
+ }
+
+ doTheFetch();
+ }, Object.values(variables).concat(isUndefined(refetch) ? [] : [refetch]));
+
+ return [state, loading];
+}
+
/**
* withFetch creates a HOC that injects the fetch as
* a property.
diff --git a/src/core/client/ui/components/v2/Dropdown/Button.tsx b/src/core/client/ui/components/v2/Dropdown/Button.tsx
index bec4dd3ba..8349f590e 100644
--- a/src/core/client/ui/components/v2/Dropdown/Button.tsx
+++ b/src/core/client/ui/components/v2/Dropdown/Button.tsx
@@ -1,18 +1,18 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
+import { Flex } from "coral-ui/components";
+import Icon from "coral-ui/components/Icon";
+import BaseButton, { BaseButtonProps } from "coral-ui/components/v2/BaseButton";
import { withStyles } from "coral-ui/hocs";
-import BaseButton, { BaseButtonProps } from "coral-ui/components/BaseButton";
-import Icon from "coral-ui/components/Icon";
-
-import { Flex } from "coral-ui/components";
import styles from "./Button.css";
interface Props extends Omit {
children: React.ReactNode;
icon?: React.ReactNode;
href?: string;
+ to?: string;
className?: string;
onClick?: React.EventHandler;
classes: typeof styles;
diff --git a/src/core/common/rest/dashboard/types.ts b/src/core/common/rest/dashboard/types.ts
new file mode 100644
index 000000000..cb2c6ef95
--- /dev/null
+++ b/src/core/common/rest/dashboard/types.ts
@@ -0,0 +1,26 @@
+export interface TodayMetricsJSON {
+ users: {
+ total: number;
+ bans: number;
+ };
+ comments: {
+ total: number;
+ rejected: number;
+ staff: number;
+ };
+}
+
+export interface TimeSeriesMetricsJSON {
+ series: Array<{ count: number; timestamp: string }>;
+ average?: number;
+}
+
+export interface TodayStoriesMetricsJSON {
+ results: Array<{
+ count: number;
+ story: {
+ id: string;
+ title?: string;
+ };
+ }>;
+}
diff --git a/src/core/server/app/handlers/api/dashboard/index.ts b/src/core/server/app/handlers/api/dashboard/index.ts
new file mode 100644
index 000000000..bc02342f8
--- /dev/null
+++ b/src/core/server/app/handlers/api/dashboard/index.ts
@@ -0,0 +1,189 @@
+import {
+ TimeSeriesMetricsJSON,
+ TodayMetricsJSON,
+ TodayStoriesMetricsJSON,
+} from "coral-common/rest/dashboard/types";
+import { AppOptions } from "coral-server/app";
+import { calculateTotalCommentCount } from "coral-server/models/comment";
+import {
+ retrieveAllTimeStaffCommentMetrics,
+ retrieveAverageCommentsMetric,
+ retrieveHourlyCommentMetrics,
+ retrieveTodayCommentMetrics,
+ retrieveTodayTopStoryMetrics,
+} from "coral-server/models/comment/metrics";
+import { retrieveSite } from "coral-server/models/site";
+import { retrieveManyStories } from "coral-server/models/story";
+import {
+ retrieveAllTimeUserMetrics,
+ retrieveDailyUserMetrics,
+ retrieveTodayUserMetrics,
+} from "coral-server/models/user/metrics";
+import { Request, RequestHandler } from "coral-server/types/express";
+
+function getMetricsOptions(req: Request) {
+ // Get the current Tenant on the request.
+ const { id: tenantID } = req.coral?.tenant!;
+
+ const now = req.coral?.now!;
+
+ // To set a fixed date for the date, uncomment the line below.
+ // const now = DateTime.utc(2020, 5, 5, 12, 30).toJSDate();
+
+ // To allow for date overrides (to load metrics for another time then now),
+ // uncomment the lines below.
+ // const { date = req.coral?.now!.toISOString() } = req.query;
+ // const now = new Date(date);
+
+ // Get the Site ID and timezone for this set of metrics.
+ const { siteID, tz } = req.query;
+ if (!tz) {
+ throw new Error("tz was not provided");
+ }
+
+ return { tenantID, siteID, tz, now };
+}
+
+export const todayMetricsHandler = ({
+ mongo,
+}: AppOptions): RequestHandler => async (req, res, next) => {
+ try {
+ const { tenantID, siteID, tz, now } = getMetricsOptions(req);
+ if (!siteID) {
+ throw new Error("siteID was not provided");
+ }
+
+ const [users, comments] = await Promise.all([
+ retrieveTodayUserMetrics(mongo, tenantID, tz, now),
+ retrieveTodayCommentMetrics(mongo, tenantID, siteID, tz, now),
+ ]);
+
+ const result: TodayMetricsJSON = {
+ users,
+ comments,
+ };
+
+ return res.json(result);
+ } catch (err) {
+ return next(err);
+ }
+};
+
+export const totalMetricsHandler = ({
+ mongo,
+}: AppOptions): RequestHandler => async (req, res, next) => {
+ try {
+ const { tenantID, siteID } = getMetricsOptions(req);
+ if (!siteID) {
+ throw new Error("siteID was not provided");
+ }
+
+ const site = await retrieveSite(mongo, tenantID, siteID);
+ if (!site) {
+ throw new Error("site specified was not found");
+ }
+
+ const [users, staff] = await Promise.all([
+ retrieveAllTimeUserMetrics(mongo, tenantID),
+ retrieveAllTimeStaffCommentMetrics(mongo, tenantID, siteID),
+ ]);
+
+ const result: TodayMetricsJSON = {
+ users,
+ comments: {
+ total: calculateTotalCommentCount(site.commentCounts.status),
+ rejected: site.commentCounts.status.REJECTED,
+ staff,
+ },
+ };
+
+ return res.json(result);
+ } catch (err) {
+ return next(err);
+ }
+};
+
+export const hourlyCommentsMetricsHandler = ({
+ mongo,
+}: AppOptions): RequestHandler => async (req, res, next) => {
+ try {
+ const { tenantID, siteID, tz, now } = getMetricsOptions(req);
+ if (!siteID) {
+ throw new Error("siteID was not provided");
+ }
+
+ const [series, average] = await Promise.all([
+ retrieveHourlyCommentMetrics(mongo, tenantID, siteID, tz, now),
+ retrieveAverageCommentsMetric(mongo, tenantID, siteID, tz, now),
+ ]);
+
+ const result: TimeSeriesMetricsJSON = {
+ series,
+ average,
+ };
+
+ return res.json(result);
+ } catch (err) {
+ return next(err);
+ }
+};
+
+export const dailyUsersMetricsHandler = ({
+ mongo,
+}: AppOptions): RequestHandler => async (req, res, next) => {
+ try {
+ const { tenantID, tz, now } = getMetricsOptions(req);
+
+ const result: TimeSeriesMetricsJSON = {
+ series: await retrieveDailyUserMetrics(mongo, tenantID, tz, now),
+ };
+
+ return res.json(result);
+ } catch (err) {
+ return next(err);
+ }
+};
+
+export const todayStoriesMetricsHandler = ({
+ mongo,
+}: AppOptions): RequestHandler => async (req, res, next) => {
+ try {
+ const { tenantID, siteID, tz, now } = getMetricsOptions(req);
+ if (!siteID) {
+ throw new Error("siteID was not provided");
+ }
+
+ const results = await retrieveTodayTopStoryMetrics(
+ mongo,
+ tenantID,
+ siteID,
+ tz,
+ now
+ );
+
+ // Fetch all the stories for each count.
+ const stories = await retrieveManyStories(
+ mongo,
+ tenantID,
+ results.map(({ _id }) => _id)
+ );
+
+ // Ensure that all entries are not null.
+ if (!stories.every((story) => story) || results.length !== stories.length) {
+ throw new Error("some stories with comments were not found");
+ }
+
+ const result: TodayStoriesMetricsJSON = {
+ results: results.map(({ count }, idx) => {
+ // We verified above that there were no null values.
+ const story = stories[idx]!;
+
+ return { count, story: { id: story.id, title: story.metadata?.title } };
+ }),
+ };
+
+ return res.json(result);
+ } catch (err) {
+ return next(err);
+ }
+};
diff --git a/src/core/server/app/handlers/api/index.ts b/src/core/server/app/handlers/api/index.ts
index 95ff1823a..652642c03 100644
--- a/src/core/server/app/handlers/api/index.ts
+++ b/src/core/server/app/handlers/api/index.ts
@@ -4,5 +4,6 @@ export * from "./graphql";
export * from "./health";
export * from "./install";
export * from "./version";
+export * from "./dashboard";
export * from "./user";
export * from "./story";
diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts
index 218637806..773209b19 100644
--- a/src/core/server/app/middleware/passport/strategies/jwt.ts
+++ b/src/core/server/app/middleware/passport/strategies/jwt.ts
@@ -21,7 +21,7 @@ import { SSOToken, SSOVerifier } from "./verifiers/sso";
export type JWTStrategyOptions = Pick<
AppOptions,
- "signingConfig" | "mongo" | "redis" | "tenantCache"
+ "signingConfig" | "mongo" | "redis" | "tenantCache" | "mongo"
>;
/**
diff --git a/src/core/server/app/middleware/passport/strategies/oauth2.ts b/src/core/server/app/middleware/passport/strategies/oauth2.ts
index 0131e014d..e75552e0f 100644
--- a/src/core/server/app/middleware/passport/strategies/oauth2.ts
+++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts
@@ -1,5 +1,6 @@
import { Db } from "mongodb";
-import { Strategy as BaseStrategy, StrategyCreated } from "passport";
+import { Profile, Strategy as BaseStrategy, StrategyCreated } from "passport";
+import { VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { Config } from "coral-server/config";
@@ -12,8 +13,6 @@ import {
TenantCacheAdapter,
} from "coral-server/services/tenant/cache";
import { Request } from "coral-server/types/express";
-import { Profile } from "passport";
-import { VerifyCallback } from "passport-oauth2";
interface OAuth2Integration {
enabled: boolean;
diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts b/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts
index 9827acdf8..6edd8db62 100644
--- a/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts
+++ b/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts
@@ -16,10 +16,7 @@ import {
export { OIDCIDToken } from "../oidc";
-export type OIDCVerifierOptions = Pick<
- AppOptions,
- "mongo" | "redis" | "tenantCache"
->;
+export type OIDCVerifierOptions = Pick;
export class OIDCVerifier implements Verifier {
private mongo: Db;
diff --git a/src/core/server/app/middleware/role.ts b/src/core/server/app/middleware/role.ts
new file mode 100644
index 000000000..ceb36f595
--- /dev/null
+++ b/src/core/server/app/middleware/role.ts
@@ -0,0 +1,21 @@
+import { UserForbiddenError } from "coral-server/errors";
+import { RequestHandler } from "coral-server/types/express";
+
+import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
+
+export const roleMiddleware = (roles: GQLUSER_ROLE[]): RequestHandler => (
+ req,
+ res,
+ next
+) => {
+ if (!req.user || !roles.includes(req.user.role)) {
+ return next(
+ new UserForbiddenError(
+ "user does not have sufficient privileges",
+ req.originalUrl,
+ req.method
+ )
+ );
+ }
+ return next();
+};
diff --git a/src/core/server/app/middleware/userLimiter.ts b/src/core/server/app/middleware/userLimiter.ts
new file mode 100644
index 000000000..d4fb9a0e8
--- /dev/null
+++ b/src/core/server/app/middleware/userLimiter.ts
@@ -0,0 +1,30 @@
+import { Redis } from "ioredis";
+
+import { RequestLimiter } from "coral-server/app/request/limiter";
+import { Config } from "coral-server/config";
+import { RequestHandler } from "coral-server/types/express";
+
+export interface MiddlewareOptions {
+ redis: Redis;
+ config: Config;
+}
+
+export const userLimiterMiddleware = ({
+ redis,
+ config,
+}: MiddlewareOptions): RequestHandler => {
+ const limiter = new RequestLimiter({
+ redis,
+ ttl: "1m",
+ max: 5,
+ prefix: "userID",
+ config,
+ });
+
+ return async (req, res, next) => {
+ limiter
+ .test(req, req.user!.id)
+ .then(() => next())
+ .catch((err) => next(err));
+ };
+};
diff --git a/src/core/server/app/router/api/dashboard.ts b/src/core/server/app/router/api/dashboard.ts
new file mode 100644
index 000000000..0adc10daa
--- /dev/null
+++ b/src/core/server/app/router/api/dashboard.ts
@@ -0,0 +1,25 @@
+import { AppOptions } from "coral-server/app";
+import {
+ dailyUsersMetricsHandler,
+ hourlyCommentsMetricsHandler,
+ todayMetricsHandler,
+ todayStoriesMetricsHandler,
+ totalMetricsHandler,
+} from "coral-server/app/handlers";
+import { userLimiterMiddleware } from "coral-server/app/middleware/userLimiter";
+
+import { createAPIRouter } from "./helpers";
+
+export function createDashboardRouter(app: AppOptions) {
+ const router = createAPIRouter({ cache: "30s" });
+
+ router.use(userLimiterMiddleware(app));
+
+ router.get("/today", todayMetricsHandler(app));
+ router.get("/total", totalMetricsHandler(app));
+ router.get("/hourly/comments", hourlyCommentsMetricsHandler(app));
+ router.get("/daily/users", dailyUsersMetricsHandler(app));
+ router.get("/today/stories", todayStoriesMetricsHandler(app));
+
+ return router;
+}
diff --git a/src/core/server/app/router/api/index.ts b/src/core/server/app/router/api/index.ts
index cc138c48d..3e3d43837 100644
--- a/src/core/server/app/router/api/index.ts
+++ b/src/core/server/app/router/api/index.ts
@@ -6,13 +6,17 @@ import { graphQLHandler } from "coral-server/app/handlers";
import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql";
import { jsonMiddleware } from "coral-server/app/middleware/json";
+import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn";
import { errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { authenticate } from "coral-server/app/middleware/passport";
+import { roleMiddleware } from "coral-server/app/middleware/role";
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
+import { STAFF_ROLES } from "coral-server/models/user/constants";
import { createNewAccountRouter } from "./account";
import { createNewAuthRouter } from "./auth";
+import { createDashboardRouter } from "./dashboard";
import { createNewInstallRouter } from "./install";
import { createStoryRouter } from "./story";
import { createNewUserRouter } from "./user";
@@ -56,6 +60,14 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
graphQLHandler(app)
);
+ router.use(
+ "/dashboard",
+ authenticate(options.passport),
+ loggedInMiddleware,
+ roleMiddleware(STAFF_ROLES),
+ createDashboardRouter(app)
+ );
+
// General API error handler.
router.use(notFoundMiddleware);
router.use(errorLogger);
diff --git a/src/core/server/helpers/metrics.ts b/src/core/server/helpers/metrics.ts
new file mode 100644
index 000000000..9734f4303
--- /dev/null
+++ b/src/core/server/helpers/metrics.ts
@@ -0,0 +1,89 @@
+import { DateTime } from "luxon";
+
+export interface TimeRange {
+ readonly start: DateTime;
+ readonly end: DateTime;
+ readonly hours: number;
+}
+
+export type TimeUnit = "day" | "hour";
+
+const TIME_UNIT_FORMAT: Record = {
+ day: { js: "yyyy-LL-dd", mongo: "%Y-%m-%d" },
+ hour: { js: "yyyy-LL-dd HH:00", mongo: "%Y-%m-%d %H:00" },
+};
+
+const TIME_UNIT_HOURS: Record = {
+ day: 24,
+ hour: 1,
+};
+
+const TIME_UNIT_MAX: Record = {
+ day: 7,
+ hour: 24,
+};
+
+export function getTimeRange(
+ unit: TimeUnit,
+ zone: string,
+ now: Date,
+ interval = TIME_UNIT_MAX[unit]
+): TimeRange {
+ // Convert the date to the specified zone.
+ const end = DateTime.fromJSDate(now).setZone(zone);
+
+ return {
+ start: end.startOf(unit).minus({ [unit]: interval - 1 }),
+ end,
+ hours: interval * TIME_UNIT_HOURS[unit],
+ };
+}
+
+export function getMongoFormat(unit: TimeUnit): string {
+ return TIME_UNIT_FORMAT[unit].mongo;
+}
+
+export interface Result {
+ _id: string;
+ count: number;
+}
+
+export interface Point {
+ count: number;
+ timestamp: string;
+}
+
+export function formatTimeRangeSeries(
+ unit: TimeUnit,
+ start: DateTime,
+ results: Result[]
+) {
+ if (results.length > TIME_UNIT_MAX[unit]) {
+ throw new Error(
+ `invalid number of items, expected ${TIME_UNIT_MAX[unit]}, got ${results.length}`
+ );
+ }
+
+ const series: Point[] = [];
+
+ let date = start;
+ for (let i = 0; i < TIME_UNIT_MAX[unit]; i++) {
+ const search = date.toFormat(TIME_UNIT_FORMAT[unit].js);
+ const result = results.find(({ _id }) => _id === search);
+
+ // Add the result (or zero if it doesn't exist) to the series.
+ series.push({
+ count: result ? result.count : 0,
+ timestamp: date.toJSDate().toISOString(),
+ });
+
+ // Increment the date by the specified unit.
+ date = date.plus({ [unit]: 1 });
+ }
+
+ return series;
+}
+
+export function getCount>(results: T) {
+ return results.length === 1 ? results[0].count : 0;
+}
diff --git a/src/core/server/models/comment/metrics.ts b/src/core/server/models/comment/metrics.ts
new file mode 100644
index 000000000..79a6b0885
--- /dev/null
+++ b/src/core/server/models/comment/metrics.ts
@@ -0,0 +1,175 @@
+import { DateTime } from "luxon";
+import { Db } from "mongodb";
+
+import {
+ formatTimeRangeSeries,
+ getCount,
+ getMongoFormat,
+ getTimeRange,
+ Result,
+} from "coral-server/helpers/metrics";
+import { comments as collection } from "coral-server/services/mongodb/collections";
+
+import {
+ GQLCOMMENT_STATUS,
+ GQLTAG,
+} from "coral-server/graph/schema/__generated__/types";
+
+export async function retrieveHourlyCommentMetrics(
+ mongo: Db,
+ tenantID: string,
+ siteID: string,
+ timezone: string,
+ now: Date
+) {
+ const { start, end } = getTimeRange("hour", timezone, now);
+
+ // Return the last 24 hours (in hour documents).
+ const results = await collection(mongo)
+ .aggregate([
+ { $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
+ {
+ $group: {
+ _id: {
+ $dateToString: {
+ date: "$createdAt",
+ format: getMongoFormat("hour"),
+ timezone,
+ },
+ },
+ count: { $sum: 1 },
+ },
+ },
+ { $sort: { _id: 1 } },
+ ])
+ .toArray();
+
+ return formatTimeRangeSeries("hour", start, results);
+}
+
+export async function retrieveTodayCommentMetrics(
+ mongo: Db,
+ tenantID: string,
+ siteID: string,
+ timezone: string,
+ now: Date
+) {
+ const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
+ const end = DateTime.fromJSDate(now);
+
+ const status = await collection<{ _id: GQLCOMMENT_STATUS; count: number }>(
+ mongo
+ )
+ .aggregate([
+ {
+ $match: {
+ tenantID,
+ siteID,
+ createdAt: { $gte: start, $lte: end },
+ },
+ },
+ {
+ $group: {
+ _id: "$status",
+ count: { $sum: 1 },
+ },
+ },
+ ])
+ .toArray();
+
+ const rejected = status.find((doc) => doc._id === GQLCOMMENT_STATUS.REJECTED);
+ const total = status.reduce((acc, doc) => acc + doc.count, 0);
+
+ const staff = await collection<{ count: number }>(mongo)
+ .aggregate([
+ {
+ $match: {
+ tenantID,
+ siteID,
+ createdAt: { $gte: start, $lte: end },
+ "tags.type": GQLTAG.STAFF,
+ },
+ },
+ { $count: "count" },
+ ])
+ .toArray();
+
+ return {
+ total,
+ rejected: rejected ? rejected.count : 0,
+ staff: getCount(staff),
+ };
+}
+
+export async function retrieveAllTimeStaffCommentMetrics(
+ mongo: Db,
+ tenantID: string,
+ siteID: string
+) {
+ // Get the referenced tenant, site, and staff comments.
+ const staff = await collection<{ count: number }>(mongo)
+ .aggregate([
+ {
+ $match: {
+ tenantID,
+ siteID,
+ "tags.type": GQLTAG.STAFF,
+ },
+ },
+ { $count: "count" },
+ ])
+ .toArray();
+
+ return getCount(staff);
+}
+
+export async function retrieveAverageCommentsMetric(
+ mongo: Db,
+ tenantID: string,
+ siteID: string,
+ timezone: string,
+ now: Date
+) {
+ const { start, hours, end } = getTimeRange("hour", timezone, now, 72);
+
+ // Return the last 24 hours (in hour documents).
+ const results = await collection<{ count: number }>(mongo)
+ .aggregate([
+ { $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
+ { $count: "count" },
+ ])
+ .toArray();
+
+ const total = getCount(results);
+
+ return Math.floor(total / hours);
+}
+
+export async function retrieveTodayTopStoryMetrics(
+ mongo: Db,
+ tenantID: string,
+ siteID: string,
+ timezone: string,
+ now: Date
+) {
+ const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
+ const end = DateTime.fromJSDate(now);
+
+ // Return the last 24 hours worth of comments.
+ const results = await collection(mongo)
+ .aggregate([
+ { $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
+ {
+ $group: {
+ _id: "$storyID",
+ count: { $sum: 1 },
+ },
+ },
+ { $sort: { count: -1 } },
+ // TODO: 17 was for visual treatment, feel free to change this!
+ { $limit: 17 },
+ ])
+ .toArray();
+
+ return results;
+}
diff --git a/src/core/server/models/site/helpers.ts b/src/core/server/models/site/helpers.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/core/server/models/site/index.ts b/src/core/server/models/site/index.ts
index b4d472c16..caa35bb69 100644
--- a/src/core/server/models/site/index.ts
+++ b/src/core/server/models/site/index.ts
@@ -121,6 +121,10 @@ async function retrieveConnection(
return resolveConnection(query, input, (_, index) => index + skip + 1);
}
+export async function countTenantSites(mongo: Db, tenantID: string) {
+ return collection(mongo).find({ tenantID }).count();
+}
+
export async function retrieveSiteConnection(
mongo: Db,
tenantID: string,
diff --git a/src/core/server/models/user/metrics.ts b/src/core/server/models/user/metrics.ts
new file mode 100644
index 000000000..526d7e178
--- /dev/null
+++ b/src/core/server/models/user/metrics.ts
@@ -0,0 +1,96 @@
+import { DateTime } from "luxon";
+import { Db } from "mongodb";
+
+import {
+ formatTimeRangeSeries,
+ getCount,
+ getMongoFormat,
+ getTimeRange,
+ Result,
+} from "coral-server/helpers/metrics";
+import { users as collection } from "coral-server/services/mongodb/collections";
+
+export async function retrieveTodayUserMetrics(
+ mongo: Db,
+ tenantID: string,
+ timezone: string,
+ now: Date
+) {
+ const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
+ const end = DateTime.fromJSDate(now);
+
+ const [total, bans] = await Promise.all([
+ collection<{ count: number }>(mongo)
+ .aggregate([
+ { $match: { tenantID, createdAt: { $gte: start, $lte: end } } },
+ { $count: "count" },
+ ])
+ .toArray(),
+ collection<{ count: number }>(mongo)
+ .aggregate([
+ {
+ $match: {
+ tenantID,
+ "status.ban.active": true,
+ "status.ban.history.createdAt": { $gte: start, $lte: end },
+ },
+ },
+ { $count: "count" },
+ ])
+ .toArray(),
+ ]);
+
+ return {
+ total: getCount(total),
+ bans: getCount(bans),
+ };
+}
+
+export async function retrieveAllTimeUserMetrics(mongo: Db, tenantID: string) {
+ const [bans, total] = await Promise.all([
+ collection<{ count: number }>(mongo)
+ .aggregate([
+ { $match: { tenantID, "status.ban.active": true } },
+ { $count: "count" },
+ ])
+ .toArray(),
+ collection<{ count: number }>(mongo)
+ .aggregate([{ $match: { tenantID } }, { $count: "count" }])
+ .toArray(),
+ ]);
+
+ return {
+ total: getCount(total),
+ bans: getCount(bans),
+ };
+}
+
+export async function retrieveDailyUserMetrics(
+ mongo: Db,
+ tenantID: string,
+ timezone: string,
+ now: Date
+) {
+ const { start, end } = getTimeRange("day", timezone, now);
+
+ // Return the last 7 days (in day documents).
+ const results = await collection(mongo)
+ .aggregate([
+ { $match: { tenantID, createdAt: { $gte: start, $lte: end } } },
+ {
+ $group: {
+ _id: {
+ $dateToString: {
+ date: "$createdAt",
+ format: getMongoFormat("day"),
+ timezone,
+ },
+ },
+ count: { $sum: 1 },
+ },
+ },
+ ])
+ .toArray();
+
+ return formatTimeRangeSeries("day", start, results);
+}
diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts
index 048cb3e6e..2f7442e5a 100644
--- a/src/core/server/stacks/createComment.ts
+++ b/src/core/server/stacks/createComment.ts
@@ -1,7 +1,6 @@
import { Db } from "mongodb";
import { ERROR_TYPES } from "coral-common/errors";
-
import { Config } from "coral-server/config";
import {
CommentNotFoundError,
diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl
index 325bcc81a..eca90d005 100644
--- a/src/locales/en-US/admin.ftl
+++ b/src/locales/en-US/admin.ftl
@@ -29,6 +29,7 @@ navigation-moderate = Moderate
navigation-community = Community
navigation-stories = Stories
navigation-configure = Configure
+navigation-dashboard = Dashboard
## User Menu
userMenu-signOut = Sign Out
@@ -1254,3 +1255,28 @@ hotkeysModal-shortcuts-ban = Ban comment author
hotkeysModal-shortcuts-zen = Toggle single-comment view
authcheck-network-error = A network error occurred. Please refresh the page.
+
+dashboard-heading-last-updated = Last updated:
+
+dashboard-today-heading = Today's activity
+dashboard-today-new-comments = New comments
+dashboard-alltime-new-comments = All time total
+dashboard-today-rejections = Rejection rate
+dashboard-alltime-rejections = All time average
+dashboard-today-staff-comments = Staff comments
+dashboard-alltime-staff-comments = All time total
+dashboard-today-signups = New community members
+dashboard-alltime-signups = Total members
+dashboard-today-bans = Banned members
+dashboard-alltime-bans = Total banned members
+
+dashboard-top-stories-today-heading = Today's most commented stories
+dashboard-top-stories-table-header-story = Story
+dashboard-top-stories-table-header-comments = Comments
+dashboard-top-stories-no-comments = No comments today
+
+dashboard-commenters-activity-heading = New community members this week
+
+dashboard-comment-activity-heading = Hourly comment activity
+dashboard-comment-activity-tooltip-comments = Comments
+dashboard-comment-activity-legend = All-time average