mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 12:58:11 +03:00
feat: global sitewide activity (neat)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user