From a5fcde5fa69ef03b0d7e6bd7d6b3605a416f6604 Mon Sep 17 00:00:00 2001
From: zkldi <20380519+zkldi@users.noreply.github.com>
Date: Tue, 13 Dec 2022 01:22:17 +0000
Subject: [PATCH] feat: global sitewide activity (neat)
---
.../src/app/pages/dashboard/DashboardPage.tsx | 4 +
client/src/app/routes/DashboardRoutes.tsx | 5 +-
.../components/dashboard/DashboardHeader.tsx | 3 +
server/src/lib/activity/activity.ts | 92 ++++++++++++++++++-
.../server/router/api/v1/activity/router.ts | 47 ++++++++++
server/src/server/router/api/v1/router.ts | 2 +
.../_userID/games/_game/_playtype/router.ts | 3 +
server/src/server/router/router.ts | 15 +++
server/src/utils/db.ts | 2 +-
9 files changed, 169 insertions(+), 4 deletions(-)
create mode 100644 server/src/server/router/api/v1/activity/router.ts
diff --git a/client/src/app/pages/dashboard/DashboardPage.tsx b/client/src/app/pages/dashboard/DashboardPage.tsx
index 22537f571..dad775721 100644
--- a/client/src/app/pages/dashboard/DashboardPage.tsx
+++ b/client/src/app/pages/dashboard/DashboardPage.tsx
@@ -25,6 +25,7 @@ import { UGSWithRankingData, UserRecentSummary } from "types/api-returns";
import { DashboardHeader } from "components/dashboard/DashboardHeader";
import DashboardActivity from "components/dashboard/DashboardActivity";
import GoalLink from "components/util/GoalLink";
+import Activity from "components/activity/Activity";
import { FolderInfoComponent } from "./users/games/_game/_playtype/folders/FolderSelectPage";
import { GameStatContainer } from "./users/UserGamesPage";
@@ -67,6 +68,9 @@ function DashboardLoggedIn({ user }: { user: UserDocument }) {
+
+
+
);
diff --git a/client/src/app/routes/DashboardRoutes.tsx b/client/src/app/routes/DashboardRoutes.tsx
index 396e9596d..49600a133 100644
--- a/client/src/app/routes/DashboardRoutes.tsx
+++ b/client/src/app/routes/DashboardRoutes.tsx
@@ -69,7 +69,10 @@ export default function DashboardRoutes() {
{/* this is the easiest (read: least sucky) way of handling */}
{/* these routes */}
-
+
diff --git a/client/src/components/dashboard/DashboardHeader.tsx b/client/src/components/dashboard/DashboardHeader.tsx
index 00822365e..146edf8c3 100644
--- a/client/src/components/dashboard/DashboardHeader.tsx
+++ b/client/src/components/dashboard/DashboardHeader.tsx
@@ -10,6 +10,9 @@ export function DashboardHeader() {
Your Profiles
,
+
+ Global Activity
+ ,
];
return (
diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts
index a48b62989..b2f24f5ca 100644
--- a/server/src/lib/activity/activity.ts
+++ b/server/src/lib/activity/activity.ts
@@ -9,7 +9,21 @@ import { GetGPT } from "utils/req-tachi-data";
import { GetUsersWithIDs } from "utils/user";
import type { Request, Response } from "express-serve-static-core";
import type { FilterQuery } from "mongodb";
-import type { ClassAchievementDocument, Game, ScoreDocument, SessionDocument } from "tachi-common";
+import type {
+ ChartDocument,
+ ClassAchievementDocument,
+ Game,
+ GoalDocument,
+ GoalSubscriptionDocument,
+ IDStrings,
+ Playtype,
+ QuestDocument,
+ QuestSubscriptionDocument,
+ ScoreDocument,
+ SessionDocument,
+ SongDocument,
+ UserDocument,
+} from "tachi-common";
export type ActivityConstraint = FilterQuery<
ClassAchievementDocument & ScoreDocument & SessionDocument
@@ -43,7 +57,18 @@ export async function GetRecentActivity(
query: ActivityConstraint,
sessions = 30,
startFrom: number | null = null
-) {
+): Promise<{
+ recentSessions: Array;
+ recentlyHighlightedScores: Array;
+ songs: Array;
+ charts: Array;
+ achievedClasses: Array;
+ users: Array;
+ goals: Array;
+ goalSubs: Array;
+ quests: Array;
+ questSubs: Array;
+}> {
const baseQuery = query;
const initialSessionQuery = {
@@ -135,6 +160,69 @@ export async function GetRecentActivity(
};
}
+/**
+ * @see {GetRecentActivity}, but for multiple games. Works pretty much as expected.
+ *
+ * @param gpts - An array of Game+Playtype combos to fetch from.
+ */
+export async function GetRecentActivityForMultipleGames(
+ gpts: Array<{ game: Game; playtype: Playtype; query: ActivityConstraint }>,
+ sessions = 30,
+ startFrom: number | null = null
+) {
+ // { "iidx:SP": {recentSessions: ..., ...} }
+ const data: Partial>>> = {};
+
+ await Promise.all(
+ gpts.map(async ({ game, playtype }) => {
+ const activity = await GetRecentActivity(
+ game,
+ {
+ game,
+ playtype,
+ },
+ sessions,
+ startFrom
+ );
+
+ data[`${game}:${playtype}` as IDStrings] = activity;
+ })
+ );
+
+ // depressingly, we have to discard most of this data for sorting reasons
+ // because not all sessions are guaranteed to be in the same order
+ // it's possible for, say, a really old jubeat session to push the "oldest session"
+ // very far back, resulting in us skipping over data.
+
+ const flatPointer = Object.entries(data) as Array<
+ [IDStrings, { recentSessions: Array }]
+ >;
+
+ // sort all games data to find the Nth session (where we should set our cutoff).
+ const sessionTimes = flatPointer
+ .flatMap((e) => e[1].recentSessions.map((e) => e.timeEnded))
+ .sort((a, b) => b - a);
+
+ const stop = sessionTimes[sessions] ?? -Infinity;
+
+ for (const value of Object.values(data)) {
+ // remove all data that happened before the stop point.
+ value.achievedClasses = value.achievedClasses.filter((e) => e.timeAchieved > stop);
+ value.goalSubs = value.goalSubs.filter(
+ (e) => e.timeAchieved !== null && e.timeAchieved > stop
+ );
+ value.questSubs = value.questSubs.filter(
+ (e) => e.timeAchieved !== null && e.timeAchieved > stop
+ );
+ value.recentSessions = value.recentSessions.filter((e) => e.timeEnded > stop);
+ value.recentlyHighlightedScores = value.recentlyHighlightedScores.filter(
+ (e) => e.timeAchieved !== null && e.timeAchieved > stop
+ );
+ }
+
+ return data;
+}
+
/**
* Utility for creating an express handler for activity-related endpoints. These endpoints
* are all *remarkably* similar, but with slightly different initial constraints.
diff --git a/server/src/server/router/api/v1/activity/router.ts b/server/src/server/router/api/v1/activity/router.ts
new file mode 100644
index 000000000..19b4c2062
--- /dev/null
+++ b/server/src/server/router/api/v1/activity/router.ts
@@ -0,0 +1,47 @@
+import { Router } from "express";
+import { GetRecentActivityForMultipleGames } from "lib/activity/activity";
+import { GetGameConfig } from "tachi-common";
+import { allSupportedGames } from "tachi-common/config/static-config";
+
+const router: Router = Router({ mergeParams: true });
+
+/**
+ * Retrieve *all* activity across every game on the site.
+ *
+ * @param session - See CreateActivityRouteHandler
+ * @param startTime - See CreateActivityRouteHandler
+ *
+ * @name GET /api/v1/activity
+ */
+router.get("/", async (req, res) => {
+ const qStartTime = req.query.startTime as string | undefined;
+
+ const startTime = qStartTime ? Number(qStartTime) : null;
+
+ if (Number.isNaN(startTime)) {
+ return res.status(400).json({
+ success: false,
+ description: `Invalid startTime, got a non number.`,
+ });
+ }
+
+ const gpts = [];
+
+ for (const game of allSupportedGames) {
+ const playtypes = GetGameConfig(game).validPlaytypes;
+
+ for (const playtype of playtypes) {
+ gpts.push({ game, playtype, query: {} });
+ }
+ }
+
+ const data = await GetRecentActivityForMultipleGames(gpts, undefined, startTime);
+
+ return res.status(200).json({
+ success: true,
+ description: `Returned global activity.`,
+ body: data,
+ });
+});
+
+export default router;
diff --git a/server/src/server/router/api/v1/router.ts b/server/src/server/router/api/v1/router.ts
index 5e78aeeab..6f5a89b25 100644
--- a/server/src/server/router/api/v1/router.ts
+++ b/server/src/server/router/api/v1/router.ts
@@ -1,3 +1,4 @@
+import activityRouter from "./activity/router";
import adminRouter from "./admin/router";
import authRouter from "./auth/router";
import clientsRouter from "./clients/router";
@@ -25,6 +26,7 @@ router.use("/auth", authRouter);
router.use(NormalRateLimitMiddleware);
router.use("/admin", adminRouter);
+router.use("/activity", activityRouter);
router.use("/status", statusRouter);
router.use("/import", importRouter);
router.use("/imports", importsRouter);
diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
index 3d5faa5a4..ff1f9f153 100644
--- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
@@ -315,6 +315,9 @@ router.get("/leaderboard-adjacent", async (req, res) => {
/**
* Retrieve activity for this user.
*
+ * @param session - See CreateActivityRouteHandler
+ * @param startTime - See CreateActivityRouteHandler
+ *
* @name GET /api/v1/users/:userID/games/:game/:playtype/activity
*/
router.get("/activity", (req, res) => {
diff --git a/server/src/server/router/router.ts b/server/src/server/router/router.ts
index 9ab821801..805149d67 100644
--- a/server/src/server/router/router.ts
+++ b/server/src/server/router/router.ts
@@ -3,6 +3,7 @@ import irRouter from "./ir/router";
import { RejectIfBanned, SetRequestPermissions } from "../middleware/auth";
import { NormalRateLimitMiddleware } from "../middleware/rate-limiter";
import { Router } from "express";
+import { Environment } from "lib/setup/config";
import { UpdateLastSeen } from "server/middleware/update-last-seen";
const router: Router = Router({ mergeParams: true });
@@ -18,4 +19,18 @@ router.use(UpdateLastSeen);
router.use("/api/v1", apiRouterV1);
+// if in localdev, add a debug endpoint to tell users when they got a successful fetch
+// on the root endpoint.
+// That is to say, if a user is hitting 127.0.0.1:8080/
+// instead of "cannot GET /", they should get a nice message.
+if (Environment.nodeEnv === "dev") {
+ router.get("/", (req, res) => {
+ return res.send(
+ `Server is live and running. All is good!
+ This is a DEBUG endpoint and only exists in local dev.
+ Click here to go to /api/v1/status endpoint.`
+ );
+ });
+}
+
export default router;
diff --git a/server/src/utils/db.ts b/server/src/utils/db.ts
index ec9388a3d..3b7fed644 100644
--- a/server/src/utils/db.ts
+++ b/server/src/utils/db.ts
@@ -242,7 +242,7 @@ export async function GetRecentlyAchievedQuests(
baseQuery: Omit, "achieved">,
limit = 100
) {
- const query = {
+ const query: FilterQuery = {
...baseQuery,
wasInstantlyAchieved: false,
achieved: true,