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,