feat: global sitewide activity (neat)

This commit is contained in:
zkldi
2022-12-13 01:22:17 +00:00
parent 79c97b0025
commit a5fcde5fa6
9 changed files with 169 additions and 4 deletions
@@ -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 }) {
<Route exact path="/dashboard/profiles">
<UserGameStatsInfo user={user} />
</Route>
<Route exact path="/dashboard/global-activity">
<Activity url="/activity" />
</Route>
</Switch>
</div>
);
+4 -1
View File
@@ -69,7 +69,10 @@ export default function DashboardRoutes() {
<Switch>
{/* this is the easiest (read: least sucky) way of handling */}
{/* these routes */}
<Route exact path={["/dashboard", "/dashboard/profiles"]}>
<Route
exact
path={["/dashboard", "/dashboard/profiles", "/dashboard/global-activity"]}
>
<DashboardPage />
</Route>
@@ -10,6 +10,9 @@ export function DashboardHeader() {
<NavItem key="profiles" to="/dashboard/profiles">
Your Profiles
</NavItem>,
<NavItem key="profiles" to="/dashboard/global-activity">
Global Activity
</NavItem>,
];
return (
+90 -2
View File
@@ -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<SessionDocument>;
recentlyHighlightedScores: Array<ScoreDocument>;
songs: Array<SongDocument>;
charts: Array<ChartDocument>;
achievedClasses: Array<ClassAchievementDocument>;
users: Array<UserDocument>;
goals: Array<GoalDocument>;
goalSubs: Array<GoalSubscriptionDocument>;
quests: Array<QuestDocument>;
questSubs: Array<QuestSubscriptionDocument>;
}> {
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<Record<IDStrings, Awaited<ReturnType<typeof GetRecentActivity>>>> = {};
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<SessionDocument> }]
>;
// 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.
@@ -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;
@@ -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);
@@ -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) => {
+15
View File
@@ -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!<br />
This is a DEBUG endpoint and only exists in local dev.<br />
<a href='/api/v1/status'>Click here to go to <code>/api/v1/status</code> endpoint.</a>`
);
});
}
export default router;
+1 -1
View File
@@ -242,7 +242,7 @@ export async function GetRecentlyAchievedQuests(
baseQuery: Omit<FilterQuery<QuestSubscriptionDocument>, "achieved">,
limit = 100
) {
const query = {
const query: FilterQuery<QuestSubscriptionDocument> = {
...baseQuery,
wasInstantlyAchieved: false,
achieved: true,