diff --git a/client/src/app/pages/dashboard/users/UserImportsPage.tsx b/client/src/app/pages/dashboard/users/UserImportsPage.tsx new file mode 100644 index 000000000..8fafcbd93 --- /dev/null +++ b/client/src/app/pages/dashboard/users/UserImportsPage.tsx @@ -0,0 +1,124 @@ +import useSetSubheader from "components/layout/header/useSetSubheader"; +import FailedImportsTable from "components/tables/imports/FailedImportsTable"; +import ImportsTable from "components/tables/imports/ImportsTable"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import ImportViewerOptions from "components/util/import/ImportViewerOptions"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import SelectLinkButton from "components/util/SelectLinkButton"; +import React, { useMemo, useState } from "react"; +import { Route, Switch } from "react-router-dom"; +import { ImportDocument, ImportTrackerFailed, PublicUserDocument } from "tachi-common"; +import { FailedImportDataset, ImportDataset } from "types/tables"; + +export default function UserImportsPage({ reqUser }: { reqUser: PublicUserDocument }) { + useSetSubheader( + ["Users", reqUser.username, "Imports"], + [reqUser], + `${reqUser.username}'s Imports` + ); + + // honestly yanked straight from ImportAnalysers.tsx. + const [userIntent, setUserIntent] = useState(null); + const [importType, setImportType] = useState(null); + + const params = useMemo(() => { + const p = new URLSearchParams(); + + if (userIntent) { + p.set("userIntent", userIntent); + } + + if (importType) { + p.set("importType", importType); + } + + return p; + }, [userIntent, importType]); + + return ( + <> +
+ + Recent Imports + + + Recent Failed Imports + +
+ + + + + + + + + + +
hi
+ +
+
+ + ); +} + +function ViewRecentImports({ + params, + reqUser, +}: { + params: URLSearchParams; + reqUser: PublicUserDocument; +}) { + const { data, error } = useApiQuery>( + `/users/${reqUser.id}/imports?${params.toString()}` + ); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const dataset: ImportDataset = data.map((e) => ({ + __related: { + user: reqUser, + }, + ...e, + })); + + return ; +} + +function ViewRecentFailedImports({ + params, + reqUser, +}: { + params: URLSearchParams; + reqUser: PublicUserDocument; +}) { + const { data, error } = useApiQuery>( + `/users/${reqUser.id}/imports/failed?${params.toString()}` + ); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const dataset: FailedImportDataset = data.map((e) => ({ + __related: { + user: reqUser, + }, + ...e, + })); + + return ; +} diff --git a/client/src/app/pages/dashboard/utils/ImportAnalysers.tsx b/client/src/app/pages/dashboard/utils/ImportAnalysers.tsx index 70c099d0a..0fba4c265 100644 --- a/client/src/app/pages/dashboard/utils/ImportAnalysers.tsx +++ b/client/src/app/pages/dashboard/utils/ImportAnalysers.tsx @@ -1,20 +1,18 @@ import { CreateUserMap } from "util/data"; import useSetSubheader from "components/layout/header/useSetSubheader"; -import Card from "components/layout/page/Card"; +import FailedImportsTable from "components/tables/imports/FailedImportsTable"; import ImportsTable from "components/tables/imports/ImportsTable"; import ApiError from "components/util/ApiError"; import Divider from "components/util/Divider"; +import ImportViewerOptions from "components/util/import/ImportViewerOptions"; import Loading from "components/util/Loading"; import useApiQuery from "components/util/query/useApiQuery"; -import Select from "components/util/Select"; import SelectLinkButton from "components/util/SelectLinkButton"; import { TachiConfig } from "lib/config"; import React, { useMemo, useState } from "react"; import { Route, Switch } from "react-router-dom"; import { FailedImportsReturn, ImportsReturn } from "types/api-returns"; -import { SetState } from "types/react"; import { FailedImportDataset, ImportDataset } from "types/tables"; -import FailedImportsTable from "components/tables/imports/FailedImportsTable"; export default function ImportAnalysers() { useSetSubheader(["Developer Utils", "Import Analyser"]); @@ -117,46 +115,3 @@ function ViewRecentFailedImports({ params }: { params: URLSearchParams }) { return ; } - -function ImportViewerOptions({ - userIntent, - setUserIntent, - importType, - setImportType, -}: { - userIntent: string | null; - setUserIntent: SetState; - importType: string | null; - setImportType: SetState; -}) { - return ( - <> - - -
- -
- - ); -} diff --git a/client/src/app/routes/UserRoutes.tsx b/client/src/app/routes/UserRoutes.tsx index a9f94877d..871707b11 100644 --- a/client/src/app/routes/UserRoutes.tsx +++ b/client/src/app/routes/UserRoutes.tsx @@ -29,6 +29,7 @@ import { useQuery } from "react-query"; import { Redirect, Route, Switch, useHistory, useParams } from "react-router-dom"; import { FormatGame, Game, GetGameConfig, PublicUserDocument, UserGameStats } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; +import UserImportsPage from "app/pages/dashboard/users/UserImportsPage"; import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage"; import UserPage from "../pages/dashboard/users/UserPage"; @@ -130,6 +131,11 @@ function UserProfileRoutes({ reqUser }: { reqUser: PublicUserDocument }) { + + + + + diff --git a/client/src/components/user/UserHeader.tsx b/client/src/components/user/UserHeader.tsx index 9f2af5fde..a2bf9cb5b 100644 --- a/client/src/components/user/UserHeader.tsx +++ b/client/src/components/user/UserHeader.tsx @@ -113,6 +113,11 @@ export function UserBottomNav({ Service Integrations ); + navItems.push( + + Imports + + ); // If mandates login, assume that we also use invite codes. // I'm sure we could set up an elaborate way of doing this by diff --git a/client/src/components/util/import/ImportViewerOptions.tsx b/client/src/components/util/import/ImportViewerOptions.tsx new file mode 100644 index 000000000..a163bd660 --- /dev/null +++ b/client/src/components/util/import/ImportViewerOptions.tsx @@ -0,0 +1,48 @@ +import Card from "components/layout/page/Card"; +import { TachiConfig } from "lib/config"; +import React from "react"; +import { SetState } from "types/react"; +import Select from "../Select"; + +export default function ImportViewerOptions({ + userIntent, + setUserIntent, + importType, + setImportType, +}: { + userIntent: string | null; + setUserIntent: SetState; + importType: string | null; + setImportType: SetState; +}) { + return ( + <> + + +
+ +
+ + ); +} diff --git a/server/src/server/router/api/v1/users/_userID/imports/router.ts b/server/src/server/router/api/v1/users/_userID/imports/router.ts index 88451f533..4e0c5d21e 100644 --- a/server/src/server/router/api/v1/users/_userID/imports/router.ts +++ b/server/src/server/router/api/v1/users/_userID/imports/router.ts @@ -11,75 +11,48 @@ import type { ImportTrackerDocument, ImportTypes } from "tachi-common"; const router: Router = Router({ mergeParams: true }); /** - * Return minimal information about up to 500 of this user's most recent imports. - * To control where that 500 starts from, pass the timeFinished param. + * Query this user's imports. Returns the 500 most recently-finished imports. * - * This endpoint is intended to be used by developers to triage certain bugs. - * - * @param timeFinished - Where to start counting this users 500 imports from, this - * should be a unix timestamp in milliseconds. + * @param importType - Optionally, limit the returns to only this import type. + * @param userIntent - Optionally, limit returns to only those with or without userIntent. * * @name GET /api/v1/users/:userID/imports */ -router.get("/", prValidate({ timeFinished: "*string" }), async (req, res) => { - const userID = GetTachiData(req, "requestedUser").id; +router.get( + "/", + prValidate({ + importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)), + userIntent: p.optional(p.isIn("true", "false")), + }), + async (req, res) => { + const userID = GetTachiData(req, "requestedUser").id; + const importType = req.query.importType as ImportTypes | undefined; - const query = req.query as { timeFinished: string | undefined }; + // all query input ends up as strings, so we need convert it into an optional + // boolean + const userIntent = + req.query.userIntent === undefined ? undefined : req.query.userIntent === "true"; - let timeFinished = Infinity; - - if (IsNonEmptyString(query.timeFinished)) { - timeFinished = Number(query.timeFinished); - - if (Number.isNaN(timeFinished)) { - return res.status(400).json({ - success: false, - description: `Couldn't read timeFinished as unix milliseconds.`, - }); - } - } - - const imports = await db.imports.find( - { + const query = { + userIntent, userID, - timeFinished: { $lte: timeFinished }, - }, - { - limit: 500, + importType, + }; + + DeleteUndefinedProps(query); + + const imports = await db.imports.find(query, { sort: { timeFinished: -1 }, - } - ); + limit: 500, + }); - return res.status(200).json({ - success: true, - description: `Found ${imports.length} imports.`, - body: imports, - }); -}); - -/** - * Return all of this user's imports that were made with user intent. - * - * Note that we can safely do this without rate limiting, because an import with - * userIntent implies that the user uploaded a file or something similar. Intent - * Imports are not vulnerable to being brutalised by every fervidex upload or similar. - * - * @name GET /api/v1/users/:userID/imports/with-user-intent - */ -router.get("/with-user-intent", async (req, res) => { - const userID = GetTachiData(req, "requestedUser").id; - - const importsWithIntent = await db.imports.find({ - userID, - userIntent: true, - }); - - return res.status(200).json({ - success: true, - description: `Found ${importsWithIntent.length} imports that were made with user-intent.`, - body: importsWithIntent, - }); -}); + return res.status(200).json({ + success: true, + description: `Found ${imports.length} imports.`, + body: imports, + }); + } +); /** * Return this users 500 most recent failed imports.