From 3714f11e5e64bf600ee585d51d5f28fe7da35d10 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 10 Feb 2025 18:05:34 -0500 Subject: [PATCH 01/43] Bump unified-api-nextjs --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8eb49f4..5a8f2d4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.9" + "unified-api-nextjs": "^1.0.12" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -10660,20 +10660,20 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified-api": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.19.tgz", - "integrity": "sha512-8fQ/fnOTHtEzNTQPPQ+vlFP3jq4vje+7D8adn1yh2TETIkEvgaVvxFbfG4BKyYyp1ZRJxvLH42kYz14WlU8/6A==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.23.tgz", + "integrity": "sha512-/wjTGPe9cjApEYRIeFBLt140fCSWaeE5WKysJ31X0jYowLFucE5rrAdxkzXLYyt+zCG3YUv0MizkAkh57zwhDw==", "dependencies": { "omit-call-signature": "^1.0.16" } }, "node_modules/unified-api-nextjs": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.10.tgz", - "integrity": "sha512-EkW0g3shBuLkbCkX/I3uQHq+ug31rzsHNGEXnOZc2UHypaD5OfQjKV5irwwGtc0iucwYzkDvKKd8aX6dgXXTkQ==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.12.tgz", + "integrity": "sha512-iWUOyrFXQadfFlU/+WalXemhIiR+HOnltJP3f+llz0z1khUTB1u16LplRTml+fXIqZVoS6CGdjjafmcwfbyYVw==", "dependencies": { "next": "^15.1.2", - "unified-api": "^1.0.19" + "unified-api": "^1.0.23" } }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index e4fc769a..c2514a64 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.9" + "unified-api-nextjs": "^1.0.12" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", From 8d343e9f7029f0a6a8d9fc2b0d8e9ab18dfe637f Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 11 Feb 2025 16:57:48 -0500 Subject: [PATCH 02/43] Bump unified-api-nextjs --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a8f2d4e..61b6fd87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.12" + "unified-api-nextjs": "^1.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -10660,20 +10660,20 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified-api": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.23.tgz", - "integrity": "sha512-/wjTGPe9cjApEYRIeFBLt140fCSWaeE5WKysJ31X0jYowLFucE5rrAdxkzXLYyt+zCG3YUv0MizkAkh57zwhDw==", + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.24.tgz", + "integrity": "sha512-sRvF3Jnc1bM8cs7tqxhg8yukZ4fVV8+6Z9dUSO57hvBaYMsekoclytMJcBf04ZbqWRNBj35b0hISO0/hKJ/CPQ==", "dependencies": { "omit-call-signature": "^1.0.16" } }, "node_modules/unified-api-nextjs": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.12.tgz", - "integrity": "sha512-iWUOyrFXQadfFlU/+WalXemhIiR+HOnltJP3f+llz0z1khUTB1u16LplRTml+fXIqZVoS6CGdjjafmcwfbyYVw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.13.tgz", + "integrity": "sha512-FxTXcL8wcVCdvQhi2skC05F+GwRRXtwaDqF1EypkAk6rz4Emm65OOxVgZM8NMWNcHipxd26e4DKGz9bbrm+3nQ==", "dependencies": { "next": "^15.1.2", - "unified-api": "^1.0.23" + "unified-api": "^1.0.24" } }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index c2514a64..8089d591 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.12" + "unified-api-nextjs": "^1.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", From b5088c8ee26c66c573df0d7dda9887682f77a1fd Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Sat, 15 Feb 2025 19:19:41 -0500 Subject: [PATCH 03/43] Start fallbacks --- lib/api/ClientApi.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index bb65b3af..ba4163ff 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -47,6 +47,7 @@ import toast from "react-hot-toast"; import { RequestHelper } from "unified-api"; import { createNextRoute, NextApiTemplate } from "unified-api-nextjs"; import { Report } from "../Types"; +import LocalStorageDbInterface from "../client/dbinterfaces/LocalStorageDbInterface"; const requestHelper = new RequestHelper( process.env.NEXT_PUBLIC_API_URL ?? "", // Replace undefined when env is not present (ex: for testing builds) @@ -616,6 +617,25 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(reports); }, + afterResponse: async (res, ranFallback) => { + if (ranFallback || !res) return; + + console.log("Adding reports to local db:", res); + + const localDb = new LocalStorageDbInterface(); + await localDb.init(); + for (const report of res) { + console.log(report, new ObjectId(res[0]._id), new ObjectId(res[0]._id).toString()); + await localDb.addObject(CollectionId.Reports, report); + } + + // await Promise.all( + // res.map((report) => { + // console.log("Adding report to local db:", report); + // return localDb.addObject(CollectionId.Reports, report).then(() => console.log("Added report to local db:", report)); + // }), + // ); + }, }); allCompetitionMatches = createNextRoute< From 774ede2c08b58530eb15ab59f3004844c086423d Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 18 Feb 2025 16:17:03 -0500 Subject: [PATCH 04/43] Did some stuff, don't remember what --- lib/api/ClientApi.ts | 51 +++++++++++++++++++++++++++++++++----------- package-lock.json | 24 ++++++++++----------- package.json | 4 ++-- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index b4dd793d..77d0a2f3 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -618,24 +618,51 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(reports); }, + fallback: async ([compId, submitted, usePublicData]) => { + const localDb = new LocalStorageDbInterface(); + await localDb.init(); + + const comp = await localDb.findObjectById( + CollectionId.Competitions, + new ObjectId(compId), + ); + + if (!comp) return []; + + const usedComps = + usePublicData && comp.tbaId !== NotLinkedToTba + ? await localDb.findObjects(CollectionId.Competitions, { + publicData: true, + tbaId: comp.tbaId, + gameId: comp.gameId, + }) + : [comp]; + + if (usePublicData && !comp.publicData) usedComps.push(comp); + + const reports = ( + await localDb.findObjects(CollectionId.Reports, { + match: { $in: usedComps.flatMap((m) => m.matches) }, + submitted: submitted ? true : { $exists: true }, + }) + ) + // Filter out comments from other competitions + .map((report) => + comp.matches.includes(report.match) + ? report + : { ...report, data: { ...report.data, comments: "" } }, + ); + return reports; + }, afterResponse: async (res, ranFallback) => { if (ranFallback || !res) return; - console.log("Adding reports to local db:", res); - const localDb = new LocalStorageDbInterface(); await localDb.init(); - for (const report of res) { - console.log(report, new ObjectId(res[0]._id), new ObjectId(res[0]._id).toString()); - await localDb.addObject(CollectionId.Reports, report); - } - // await Promise.all( - // res.map((report) => { - // console.log("Adding report to local db:", report); - // return localDb.addObject(CollectionId.Reports, report).then(() => console.log("Added report to local db:", report)); - // }), - // ); + await Promise.all( + res.map((report) => localDb.addObject(CollectionId.Reports, report)), + ); }, }); diff --git a/package-lock.json b/package-lock.json index be9724b7..8a76aebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", - "mongo-anywhere": "^1.1.5", + "mongo-anywhere": "^1.1.6", "mongodb": "^5.0.0", "next": "^15.1.6", "next-auth": "^4.24.11", @@ -49,7 +49,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.13" + "unified-api-nextjs": "^1.0.14" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -7878,9 +7878,9 @@ } }, "node_modules/mongo-anywhere": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.5.tgz", - "integrity": "sha512-9qWUSv7kS2oD/MPLLV5Y4AjhTFE7VTkhdDWXsG9eeddOgmRHzRrKX5IV+C/ryDcRoJwcMMIIehnD96RsOse25g==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.6.tgz", + "integrity": "sha512-qE+vMJF7ylUH5RGXCnAfU8nRPZTR+Yk6tb71dGoFtZy+Tye8AXANq5z7fgu5OPtTbDOLlqJHME/3j7s0xItMxw==", "dependencies": { "bson": "^5.0.0", "minimongo": "^6.19.0", @@ -10680,20 +10680,20 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified-api": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.24.tgz", - "integrity": "sha512-sRvF3Jnc1bM8cs7tqxhg8yukZ4fVV8+6Z9dUSO57hvBaYMsekoclytMJcBf04ZbqWRNBj35b0hISO0/hKJ/CPQ==", + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.25.tgz", + "integrity": "sha512-HgZZECO/0o7JguK6wSvB/5tr4zrhl5Bs/QcKSA/iA4ts446/wfL5a+uDMoIbv/XV72ZFSVYdARexi61xUzpn9Q==", "dependencies": { "omit-call-signature": "^1.0.16" } }, "node_modules/unified-api-nextjs": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.13.tgz", - "integrity": "sha512-FxTXcL8wcVCdvQhi2skC05F+GwRRXtwaDqF1EypkAk6rz4Emm65OOxVgZM8NMWNcHipxd26e4DKGz9bbrm+3nQ==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.14.tgz", + "integrity": "sha512-uwBCm4e38rU9L/VFzoS5P6LrTIBaS6kOSV7tUPdjZz8AldwBLJB+pML5DcXeBxWj3GLX44FucaRVTM/BD2SCLw==", "dependencies": { "next": "^15.1.2", - "unified-api": "^1.0.24" + "unified-api": "^1.0.25" } }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 5498f8eb..aad5a20a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", - "mongo-anywhere": "^1.1.5", + "mongo-anywhere": "^1.1.6", "mongodb": "^5.0.0", "next": "^15.1.6", "next-auth": "^4.24.11", @@ -58,7 +58,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.13" + "unified-api-nextjs": "^1.0.14" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", From 5d05ac6c8bd73c1463e1b93620bf44ee594aba20 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 24 Feb 2025 16:47:21 -0500 Subject: [PATCH 05/43] Moved sw.ts --- lib/{ => client}/sw.ts | 4 ++++ next.config.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) rename lib/{ => client}/sw.ts (88%) diff --git a/lib/sw.ts b/lib/client/sw.ts similarity index 88% rename from lib/sw.ts rename to lib/client/sw.ts index feea1757..55a8b231 100644 --- a/lib/sw.ts +++ b/lib/client/sw.ts @@ -12,6 +12,10 @@ declare const self: ServiceWorkerGlobalScope; const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, + // precacheOptions: { + // cleanupOutdatedCaches: true, + // concurrency: 20, + // }, skipWaiting: true, clientsClaim: true, navigationPreload: true, diff --git a/next.config.ts b/next.config.ts index b495f998..62063e60 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,8 +2,9 @@ import packageConfig from "./package.json"; import withSerwistInit from "@serwist/next"; const withSerwist = withSerwistInit({ - swSrc: "lib/sw.ts", + swSrc: "lib/client/sw.ts", swDest: "public/sw.js", + cacheOnNavigation: true, additionalPrecacheEntries: [ { url: "/offline", revision: new Date().toDateString() }, ], From f7d734f3fdd6d1cb98401dc71526fb2f67499514 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 24 Feb 2025 17:18:08 -0500 Subject: [PATCH 06/43] Misc changes. No more URL on offline page, no precache config in sw.ts --- lib/client/sw.ts | 4 ---- pages/offline.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/client/sw.ts b/lib/client/sw.ts index 55a8b231..feea1757 100644 --- a/lib/client/sw.ts +++ b/lib/client/sw.ts @@ -12,10 +12,6 @@ declare const self: ServiceWorkerGlobalScope; const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, - // precacheOptions: { - // cleanupOutdatedCaches: true, - // concurrency: 20, - // }, skipWaiting: true, clientsClaim: true, navigationPreload: true, diff --git a/pages/offline.tsx b/pages/offline.tsx index 58292676..dcae8f6b 100644 --- a/pages/offline.tsx +++ b/pages/offline.tsx @@ -12,7 +12,7 @@ export default function Offline() { >
- The page you are trying to access ({router.pathname}) is not available + The page you are trying to access is not available offline. If this is an error, please contact the developers.
From e87219190db3c4a56eb659255e40172f436cd03e Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 25 Feb 2025 15:57:30 -0500 Subject: [PATCH 07/43] Don't remember what I did --- lib/client/sw.ts | 23 +++++++++++++++++++++-- pages/profile.tsx | 25 ++++++------------------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/lib/client/sw.ts b/lib/client/sw.ts index feea1757..80b2fab1 100644 --- a/lib/client/sw.ts +++ b/lib/client/sw.ts @@ -1,6 +1,7 @@ import { defaultCache } from "@serwist/next/worker"; import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; -import { Serwist } from "serwist"; +import { CacheFirst, Serwist } from "serwist"; +import { request } from "@/lib/TheOrangeAlliance"; declare global { interface WorkerGlobalScope extends SerwistGlobalConfig { @@ -15,7 +16,18 @@ const serwist = new Serwist({ skipWaiting: true, clientsClaim: true, navigationPreload: true, - runtimeCaching: defaultCache, + runtimeCaching: [ + { + matcher: ({ request }) => { + console.log(request); + return request.destination === "" && request.url.includes("api"); + }, + handler: new CacheFirst({ + cacheName: "api", + }), + }, + ...defaultCache, + ], fallbacks: { entries: [ { @@ -26,4 +38,11 @@ const serwist = new Serwist({ }, }); +self.addEventListener("fetch", async (event) => { + console.log(event.request); + event.respondWith( + await serwist.handleRequest({ request: event.request, event: event })!, + ); +}); + serwist.addEventListeners(); diff --git a/pages/profile.tsx b/pages/profile.tsx index d4e0f46c..98943236 100644 --- a/pages/profile.tsx +++ b/pages/profile.tsx @@ -1,6 +1,5 @@ import { useCurrentSession } from "@/lib/client/useCurrentSession"; import { useEffect, useState } from "react"; - import ClientApi from "@/lib/api/ClientApi"; import { Team } from "@/lib/Types"; import Container from "@/components/Container"; @@ -11,10 +10,6 @@ import Avatar from "@/components/Avatar"; import { IoCheckmarkCircle, IoMail } from "react-icons/io5"; import Loading from "@/components/Loading"; import { FaPlus } from "react-icons/fa"; -import { getDatabase } from "@/lib/MongoDB"; -import CollectionId from "@/lib/client/CollectionId"; -import { GetServerSideProps } from "next"; -import { serializeDatabaseObject } from "@/lib/UrlResolver"; import TeamCard from "@/components/TeamCard"; import { UpdateModal } from "@/components/UpdateModal"; import { Analytics } from "@/lib/client/Analytics"; @@ -23,10 +18,9 @@ import XpProgressBar from "@/components/XpProgressBar"; const api = new ClientApi(); -export default function Profile(props: { teamList: Team[] }) { - const { session, status } = useCurrentSession(); +export default function Profile() { + const { session } = useCurrentSession(); const user = session?.user; - const teamList = props.teamList; const owner = user?.owner ? user?.owner?.length > 0 : false; const member = user?.teams ? user.teams?.length > 0 : false; @@ -70,6 +64,9 @@ export default function Profile(props: { teamList: Team[] }) { hideMenu={false} title="Profile" > + ) : ( - teamList.map((team) => ( + teams.map((team) => (
{ @@ -236,13 +233,3 @@ export default function Profile(props: { teamList: Team[] }) { ); } - -export const getServerSideProps: GetServerSideProps = async (context) => { - const db = await getDatabase(); - const teams = await db.findObjects(CollectionId.Teams, {}); - const serializedTeams = teams.map((team) => serializeDatabaseObject(team)); - - return { - props: { teamList: serializedTeams }, - }; -}; From ece5f760fd456518dc56fb74e94bd0358206eaec Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 3 Mar 2025 16:18:47 -0500 Subject: [PATCH 08/43] Bump version in lock --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2899b545..2f005215 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sj3", - "version": "1.2.5", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sj3", - "version": "1.2.5", + "version": "1.2.6", "license": "CC BY-NC-SA 4.0", "dependencies": { "dependencies": "^0.0.1", From af599b3b24fe74f4e2e3464cb355e7696dc29c18 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 11 Mar 2025 16:52:47 -0400 Subject: [PATCH 09/43] Fix sign in --- .gitignore | 2 +- lib/Auth.ts | 10 +++------- lib/DbInterfaceAuthAdapter.ts | 3 ++- lib/api/ClientApi.ts | 4 +++- lib/client/sw.ts | 30 +++++++++--------------------- 5 files changed, 18 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 40dfa349..d088198b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ yarn-error.log* /certs/production.pem /certs/production-key.pem - +.env.*.local .env.local .env diff --git a/lib/Auth.ts b/lib/Auth.ts index 813f972d..10efc0d8 100644 --- a/lib/Auth.ts +++ b/lib/Auth.ts @@ -136,13 +136,9 @@ export const AuthenticationOptions: AuthOptions = { (typedUser as User).lastSignInDateTime?.toDateString() !== today.toDateString() ) { - db.updateObjectById( - CollectionId.Users, - new ObjectId(typedUser._id?.toString()), - { - lastSignInDateTime: today, - }, - ); + db.updateObjectById(CollectionId.Users, new ObjectId(typedUser._id), { + lastSignInDateTime: today, + }); } new ResendUtils().createContact(typedUser as User); diff --git a/lib/DbInterfaceAuthAdapter.ts b/lib/DbInterfaceAuthAdapter.ts index 1867b94b..593b0133 100644 --- a/lib/DbInterfaceAuthAdapter.ts +++ b/lib/DbInterfaceAuthAdapter.ts @@ -270,7 +270,8 @@ export default function DbInterfaceAuthAdapter( rollbar.warn("Account already exists when linking account", { account, }); - return format.from(existingAccount); + + return format.from(account); } await db.addObject(CollectionId.Accounts, account); diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 72bc66fa..a2084afa 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -668,7 +668,9 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(reports); }, - fallback: async ([compId, submitted, usePublicData]) => { + fallback: async (compId, submitted, usePublicData) => { + console.log("Running fallback for competitionReports..."); + const localDb = new LocalStorageDbInterface(); await localDb.init(); diff --git a/lib/client/sw.ts b/lib/client/sw.ts index 80b2fab1..3becedd0 100644 --- a/lib/client/sw.ts +++ b/lib/client/sw.ts @@ -1,7 +1,6 @@ -import { defaultCache } from "@serwist/next/worker"; import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; -import { CacheFirst, Serwist } from "serwist"; -import { request } from "@/lib/TheOrangeAlliance"; +import { Serwist } from "serwist"; +import { defaultCache } from "@serwist/next/worker"; declare global { interface WorkerGlobalScope extends SerwistGlobalConfig { @@ -16,18 +15,7 @@ const serwist = new Serwist({ skipWaiting: true, clientsClaim: true, navigationPreload: true, - runtimeCaching: [ - { - matcher: ({ request }) => { - console.log(request); - return request.destination === "" && request.url.includes("api"); - }, - handler: new CacheFirst({ - cacheName: "api", - }), - }, - ...defaultCache, - ], + runtimeCaching: defaultCache, fallbacks: { entries: [ { @@ -38,11 +26,11 @@ const serwist = new Serwist({ }, }); -self.addEventListener("fetch", async (event) => { - console.log(event.request); - event.respondWith( - await serwist.handleRequest({ request: event.request, event: event })!, - ); -}); +// self.addEventListener("fetch", async (event) => { +// console.log(event.request); +// event.respondWith( +// await serwist.handleRequest({ request: event.request, event: event })!, +// ); +// }); serwist.addEventListeners(); From 0223e5fc73b4ccb993a80ff43758ffccdcd25821 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 11 Mar 2025 17:52:43 -0400 Subject: [PATCH 10/43] Almost finish fallback for competitionMatches --- lib/api/ClientApi.ts | 28 +++++++++++++++++++++++++--- package-lock.json | 24 ++++++++++++------------ package.json | 4 ++-- pages/profile.tsx | 3 --- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index a2084afa..544226ce 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -679,6 +679,8 @@ export default class ClientApi extends NextApiTemplate { new ObjectId(compId), ); + console.log("comp", comp); + if (!comp) return []; const usedComps = @@ -688,9 +690,11 @@ export default class ClientApi extends NextApiTemplate { tbaId: comp.tbaId, gameId: comp.gameId, }) - : [comp]; + : []; - if (usePublicData && !comp.publicData) usedComps.push(comp); + usedComps.push(comp); + + console.log("usedComps", usedComps); const reports = ( await localDb.findObjects(CollectionId.Reports, { @@ -704,6 +708,8 @@ export default class ClientApi extends NextApiTemplate { ? report : { ...report, data: { ...report.data, comments: "" } }, ); + + console.log("reports", reports); return reports; }, afterResponse: async (res, ranFallback) => { @@ -713,7 +719,23 @@ export default class ClientApi extends NextApiTemplate { await localDb.init(); await Promise.all( - res.map((report) => localDb.addObject(CollectionId.Reports, report)), + res.map(async (report) => { + if (!report._id) return; + + if ( + await localDb.findObjectById( + CollectionId.Reports, + new ObjectId(report._id), + ) + ) { + return localDb.updateObjectById( + CollectionId.Reports, + new ObjectId(report._id), + report, + ); + } + return localDb.addObject(CollectionId.Reports, report); + }), ); }, }); diff --git a/package-lock.json b/package-lock.json index 7c0be8bc..184939ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", - "mongo-anywhere": "^1.1.11", + "mongo-anywhere": "^1.1.12", "mongodb": "^5.0.0", "next": "^15.1.6", "next-auth": "^4.24.11", @@ -51,7 +51,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.14" + "unified-api-nextjs": "^1.0.15" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -7916,9 +7916,9 @@ } }, "node_modules/mongo-anywhere": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.11.tgz", - "integrity": "sha512-wM5FMS7sj6vZAEw9XaRaWFYqNeA8slKcQxxWdhyN4a8xGBrOxN2U4wF33kuAkYTgqsdy5fxS3NSF4KkQAUq5Zg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.13.tgz", + "integrity": "sha512-E1mpEiwSFM3atUPc0pTTLHEIsvtBlfkrR+2lcBK6s0/N5VGVGPJPATBlohGuur0i4NLRt0hdFISiuSKb93U3uw==", "dependencies": { "bson": "^5.0.0", "minimongo": "^6.19.0", @@ -10791,20 +10791,20 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified-api": { - "version": "1.0.25", - "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.25.tgz", - "integrity": "sha512-HgZZECO/0o7JguK6wSvB/5tr4zrhl5Bs/QcKSA/iA4ts446/wfL5a+uDMoIbv/XV72ZFSVYdARexi61xUzpn9Q==", + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.27.tgz", + "integrity": "sha512-JiEU2HFITwV+tpJB1IMEFInXa7xYWGAeFF1GFtlFYvoyvR3WcK6Jgh0joXiqVtxeOb+kVOQcgjqQWv9TafA15w==", "dependencies": { "omit-call-signature": "^1.0.16" } }, "node_modules/unified-api-nextjs": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.14.tgz", - "integrity": "sha512-uwBCm4e38rU9L/VFzoS5P6LrTIBaS6kOSV7tUPdjZz8AldwBLJB+pML5DcXeBxWj3GLX44FucaRVTM/BD2SCLw==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.15.tgz", + "integrity": "sha512-Hn1Ss6qjUsA3x2JuCHFOmEz6E7p4kEWdSfYFhD/R0Xjwo+Ca2gNcVglNmwjDns8RNcW1jK+skiVUCFf/MsvbQQ==", "dependencies": { "next": "^15.1.2", - "unified-api": "^1.0.25" + "unified-api": "^1.0.26" } }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index d0591021..b2df6890 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", - "mongo-anywhere": "^1.1.11", + "mongo-anywhere": "^1.1.12", "mongodb": "^5.0.0", "next": "^15.1.6", "next-auth": "^4.24.11", @@ -60,7 +60,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.14" + "unified-api-nextjs": "^1.0.15" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/pages/profile.tsx b/pages/profile.tsx index 99a956d8..eee91aef 100644 --- a/pages/profile.tsx +++ b/pages/profile.tsx @@ -89,9 +89,6 @@ export default function Profile() { hideMenu={false} title="Profile" > - Date: Thu, 13 Mar 2025 18:34:49 -0400 Subject: [PATCH 11/43] Make comp index load documents via api --- lib/MongoDB.ts | 2 +- lib/api/ClientApi.ts | 45 +++- lib/client/dbinterfaces/CachedDbInterface.ts | 2 +- lib/client/dbinterfaces/DbInterface.ts | 1 - .../dbinterfaces/InMemoryDbInterface.ts | 2 +- .../dbinterfaces/LocalStorageDbInterface.ts | 2 +- lib/slugToId.ts | 17 +- lib/testutils/TestUtils.ts | 14 + .../[seasonSlug]/[competitonSlug]/index.tsx | 70 +++-- tests/lib/api/ClientApi.test.ts | 240 +++++++++++++++++- tests/lib/slugToId.test.ts | 2 +- 11 files changed, 344 insertions(+), 53 deletions(-) diff --git a/lib/MongoDB.ts b/lib/MongoDB.ts index d196dca9..ed12abdb 100644 --- a/lib/MongoDB.ts +++ b/lib/MongoDB.ts @@ -10,7 +10,7 @@ import DbInterface, { import { default as BaseMongoDbInterface } from "mongo-anywhere/MongoDbInterface"; import CachedDbInterface from "./client/dbinterfaces/CachedDbInterface"; import { cacheOptions } from "./client/dbinterfaces/CachedDbInterface"; -import { findObjectBySlugLookUp } from "./slugToId"; +import findObjectBySlugLookUp from "./slugToId"; if (!process.env.MONGODB_URI) { // Necessary to allow connections from files running outside of Next diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 544226ce..247178f8 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -25,6 +25,8 @@ import { NotLinkedToTba, removeDuplicates } from "../client/ClientUtils"; import { addXp, generatePitReports, + getSeasonFromComp, + getTeamFromComp, getTeamFromMatch, getTeamFromReport, onTeam, @@ -48,8 +50,8 @@ import { RequestHelper } from "unified-api"; import { createNextRoute, NextApiTemplate } from "unified-api-nextjs"; import { Report } from "../Types"; import Logger from "../client/Logger"; -import getRollbar, { RollbarInterface } from "../client/RollbarUtils"; import LocalStorageDbInterface from "../client/dbinterfaces/LocalStorageDbInterface"; +import findObjectBySlugLookUp, { slugToId } from "../slugToId"; const requestHelper = new RequestHelper( process.env.NEXT_PUBLIC_API_URL ?? "", // Replace undefined when env is not present (ex: for testing builds) @@ -2605,4 +2607,45 @@ export default class ClientApi extends NextApiTemplate { return res.status(200).send({ result: "success" }); }, }); + + findCompSeasonAndTeamByCompSlug = createNextRoute< + [string], + { comp: Competition; season: Season; team: Team } | undefined, + ApiDependencies, + { team: Team; season: Season; comp: Competition } + >({ + isAuthorized: async (req, res, { db, userPromise }, [compSlug]) => { + const user = await userPromise; + if (!user) return { authorized: false, authData: undefined }; + + const comp = await findObjectBySlugLookUp( + await db, + CollectionId.Competitions, + compSlug, + ); + + if (!comp) return { authorized: false, authData: undefined }; + + const team = await getTeamFromComp(await db, comp); + + if (!team || !team.users.includes(user._id!.toString())) + return { authorized: false, authData: undefined }; + + const season = await getSeasonFromComp(await db, comp); + + if (!season) return { authorized: false, authData: undefined }; + + return { + authorized: true, + authData: { + comp, + season, + team, + }, + }; + }, + handler: async (req, res, deps, authData, args) => { + return res.status(200).send(authData); + }, + }); } diff --git a/lib/client/dbinterfaces/CachedDbInterface.ts b/lib/client/dbinterfaces/CachedDbInterface.ts index 2ae214e5..53d20c24 100644 --- a/lib/client/dbinterfaces/CachedDbInterface.ts +++ b/lib/client/dbinterfaces/CachedDbInterface.ts @@ -9,7 +9,7 @@ import DbInterface, { import { default as BaseCachedDbInterface } from "mongo-anywhere/CachedDbInterface"; import NodeCache from "node-cache"; import { CacheOperation } from "mongo-anywhere/CachedDbInterface"; -import { findObjectBySlugLookUp } from "@/lib/slugToId"; +import findObjectBySlugLookUp from "@/lib/slugToId"; export const cacheOptions: NodeCache.Options = { stdTTL: 1 * 60, diff --git a/lib/client/dbinterfaces/DbInterface.ts b/lib/client/dbinterfaces/DbInterface.ts index 698a8a63..322339df 100644 --- a/lib/client/dbinterfaces/DbInterface.ts +++ b/lib/client/dbinterfaces/DbInterface.ts @@ -4,7 +4,6 @@ import CollectionId, { SluggedCollectionId, } from "../CollectionId"; import { default as BaseDbInterface } from "mongo-anywhere/DbInterface"; -import slugToId from "@/lib/slugToId"; export type WithStringOrObjectIdId = Omit & { _id?: ObjectId | string; diff --git a/lib/client/dbinterfaces/InMemoryDbInterface.ts b/lib/client/dbinterfaces/InMemoryDbInterface.ts index 779f931f..bc1ab213 100644 --- a/lib/client/dbinterfaces/InMemoryDbInterface.ts +++ b/lib/client/dbinterfaces/InMemoryDbInterface.ts @@ -7,7 +7,7 @@ import DbInterface, { WithStringOrObjectIdId, } from "@/lib/client/dbinterfaces/DbInterface"; import { default as BaseInMemoryDbInterface } from "mongo-anywhere/InMemoryDbInterface"; -import slugToId, { findObjectBySlugLookUp } from "@/lib/slugToId"; +import findObjectBySlugLookUp from "@/lib/slugToId"; export default class InMemoryDbInterface extends BaseInMemoryDbInterface< diff --git a/lib/client/dbinterfaces/LocalStorageDbInterface.ts b/lib/client/dbinterfaces/LocalStorageDbInterface.ts index b7975a2d..86dc4428 100644 --- a/lib/client/dbinterfaces/LocalStorageDbInterface.ts +++ b/lib/client/dbinterfaces/LocalStorageDbInterface.ts @@ -7,7 +7,7 @@ import DbInterface, { WithStringOrObjectIdId, } from "@/lib/client/dbinterfaces/DbInterface"; import { default as BaseLocalStorageDbInterface } from "mongo-anywhere/LocalStorageDbInterface"; -import { findObjectBySlugLookUp } from "@/lib/slugToId"; +import findObjectBySlugLookUp from "@/lib/slugToId"; export default class LocalStorageDbInterface extends BaseLocalStorageDbInterface< diff --git a/lib/slugToId.ts b/lib/slugToId.ts index d0a3c14f..ec2a3dbf 100644 --- a/lib/slugToId.ts +++ b/lib/slugToId.ts @@ -19,11 +19,18 @@ function getSlugLookup() { return global.slugLookup; } -async function slugToId( +/** + * You are probably looking for findObjectBySlugLookUp! + * Don't use this function except in this file or for tests. + */ +export async function slugToId( db: DbInterface | Promise, - collection: SluggedCollectionId, + collection: TId, slug: string, -): Promise<{ id: ObjectId | undefined; object: object | undefined }> { +): Promise<{ + id: ObjectId | undefined; + object: CollectionIdToType | undefined; +}> { if (db instanceof Promise) { db = await db; } @@ -48,7 +55,7 @@ async function slugToId( return { id: collectionSlugs.get(slug), object: undefined }; } -export async function findObjectBySlugLookUp< +export default async function findObjectBySlugLookUp< TId extends SluggedCollectionId, TObj extends CollectionIdToType, >(db: DbInterface, collection: TId, slug: string): Promise { @@ -63,5 +70,3 @@ export async function findObjectBySlugLookUp< return await db.findObjectById(collection, id); } - -export default slugToId; diff --git a/lib/testutils/TestUtils.ts b/lib/testutils/TestUtils.ts index 7514764a..98919e3c 100644 --- a/lib/testutils/TestUtils.ts +++ b/lib/testutils/TestUtils.ts @@ -101,6 +101,20 @@ export async function getTestApiParams< ]; } +export async function getTestAuthParams>( + res: TestRes, + deps: + | Partial + | Partial<{ + db: DbInterface; + user: Partial; + resend: ResendInterface; + }>, + args: TArgs, +) { + return (await getTestApiParams(res, deps, args, {})).slice(0, 4); +} + export function getTestRollbar(): RollbarInterface { return { error: jest.fn(), diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx index 91442621..af04591b 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback } from "react"; import ClientApi from "@/lib/api/ClientApi"; import { @@ -10,39 +10,27 @@ import { User, Team, Competition, - CompPicklistGroup, Season, } from "@/lib/Types"; import { useCurrentSession } from "@/lib/client/useCurrentSession"; import useInterval from "@/lib/client/useInterval"; -import { - NotLinkedToTba, - download, - getIdsInProgressFromTimestamps, - makeObjSerializeable, -} from "@/lib/client/ClientUtils"; -import { games } from "@/lib/games"; -import { defaultGameId } from "@/lib/client/GameId"; -import { GetServerSideProps } from "next"; -import UrlResolver from "@/lib/UrlResolver"; import Container from "@/components/Container"; import CompHeaderCard from "@/components/competition/CompHeaderCard"; import EditMatchModal from "@/components/competition/EditMatchModal"; import InsightsAndSettingsCard from "@/components/competition/InsightsAndSettingsCard"; import MatchScheduleCard from "@/components/competition/MatchScheduleCard"; import PitScoutingCard from "@/components/competition/PitScoutingCard"; +import { useRouter } from "next/router"; const api = new ClientApi(); -export default function CompetitionIndex({ - team, - competition: comp, - season, -}: { - team: Team | undefined; - competition: Competition | undefined; - season: Season | undefined; -}) { +export default function CompetitionIndex() { + const router = useRouter(); + + const [team, setTeam] = useState(); + const [season, setSeason] = useState(); + const [comp, setComp] = useState(); + const { session, status } = useCurrentSession(); const isManager = (session?.user?._id !== undefined && @@ -76,7 +64,6 @@ export default function CompetitionIndex({ const [loadingScoutStats, setLoadingScoutStats] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false); - const [submissionRate, setSubmissionRate] = useState(0); const [submittedReports, setSubmittedReports] = useState( undefined, ); @@ -100,6 +87,26 @@ export default function CompetitionIndex({ string | undefined >(); + // Load team, season, and competition + useEffect(() => { + const compSlug = router.query?.competitonSlug as string | undefined; + + if (!compSlug) return; + + api.findCompSeasonAndTeamByCompSlug(compSlug).then((res) => { + if (!res) { + alert("Competition not found"); + return; + } + + const { team, season, comp } = res; + + setTeam(team); + setSeason(season); + setComp(comp); + }); + }, [router.query.competitonSlug]); + const regeneratePitReports = useCallback(async () => { console.log("Regenerating pit reports..."); const { pitReports: pitReportIds } = await api.regeneratePitReports( @@ -132,6 +139,8 @@ export default function CompetitionIndex({ const loadMatches = useCallback( async (silent: boolean = false) => { + if (!comp) return; + if (!silent) setLoadingMatches(true); window.location.hash = ""; @@ -471,20 +480,3 @@ export default function CompetitionIndex({ ); } - -export const getServerSideProps: GetServerSideProps = async (context) => { - const resolved = await UrlResolver(context, 3); - if ("redirect" in resolved) { - return resolved; - } - - return { - props: { - competition: resolved.competition - ? makeObjSerializeable(resolved.competition) - : null, - team: resolved.team ? makeObjSerializeable(resolved.team) : null, - season: resolved.season ? makeObjSerializeable(resolved.season) : null, - }, - }; -}; diff --git a/tests/lib/api/ClientApi.test.ts b/tests/lib/api/ClientApi.test.ts index 24b6c6ad..fb34883a 100644 --- a/tests/lib/api/ClientApi.test.ts +++ b/tests/lib/api/ClientApi.test.ts @@ -1,7 +1,12 @@ import ClientApi from "@/lib/api/ClientApi"; import CollectionId from "@/lib/client/CollectionId"; +import DbInterface from "@/lib/client/dbinterfaces/DbInterface"; import { GameId } from "@/lib/client/GameId"; -import { getTestApiParams, getTestApiUtils } from "@/lib/testutils/TestUtils"; +import { + getTestApiParams, + getTestApiUtils, + getTestAuthParams, +} from "@/lib/testutils/TestUtils"; import { AllianceColor, Competition, @@ -1107,3 +1112,236 @@ describe(`${ClientApi.name}.${api.changeTeamNumberForReport.name}`, () => { ); }); }); + +describe(`${ClientApi.name}.${api.findCompSeasonAndTeamByCompSlug.name}`, () => { + async function createDocuments( + db: DbInterface, + user: User, + addUserToTeam: boolean, + ) { + const comp = await db.addObject( + CollectionId.Competitions, + new Competition( + "Test Competition", + "test-competition", + "test-tbaId", + 0, + 0, + [], + [], + "", + false, + ), + ); + + const season = await db.addObject( + CollectionId.Seasons, + new Season("Test Season", "test-season", 2022, GameId.IntoTheDeep, [ + comp._id!.toString(), + ]), + ); + + const team = await db.addObject( + CollectionId.Teams, + new Team( + "Test Team", + "test-team", + "tbaId", + 1234, + League.FRC, + false, + [], + addUserToTeam ? [user._id!.toString()] : [], + [], + [], + [], + [season._id!.toString()], + ), + ); + + return { comp, season, team }; + } + + test("Returns comp, team, and season", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, season, team } = await createDocuments(db, user, true); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authData } = await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authData).toEqual({ + comp, + season, + team, + }); + }); + + test("Returns undefined for authData if user is not on the team", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, season, team } = await createDocuments(db, user, false); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authData } = await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authData).toBeUndefined(); + }); + + test("Returns undefined for authData if comp not found", async () => { + const { db, res, user } = await getTestApiUtils(); + + await createDocuments(db, user, true); + + const params = await getTestApiParams(res, { db, user }, ["not-found"]); + const { authData } = await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authData).toBeUndefined(); + }); + + test("Returns undefined for authData if team not found", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, team } = await createDocuments(db, user, true); + + await db.deleteObjectById(CollectionId.Teams, team._id!); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authData } = await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authData).toBeUndefined(); + }); + + test("Returns undefined for authData if season not found", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, season } = await createDocuments(db, user, true); + + await db.deleteObjectById( + CollectionId.Seasons, + season._id! as unknown as ObjectId, + ); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authData } = await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authData).toBeUndefined(); + }); + + test("Denies access if user is not logged in", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, season, team } = await createDocuments(db, user, true); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authorized } = + await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + [""], + ); + + expect(authorized).toBe(false); + }); + + test("Denies access if user is not on team", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, season, team } = await createDocuments(db, user, false); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authorized } = + await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authorized).toBe(false); + }); + + test("Denies access if comp not found", async () => { + const { db, res, user } = await getTestApiUtils(); + + await createDocuments(db, user, true); + + const params = await getTestApiParams(res, { db, user }, ["not-found"]); + const { authorized } = + await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authorized).toBe(false); + }); + + test("Denies access if team not found", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, team } = await createDocuments(db, user, true); + + await db.deleteObjectById(CollectionId.Teams, team._id!); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authorized } = + await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authorized).toBe(false); + }); + + test("Denies access if season not found", async () => { + const { db, res, user } = await getTestApiUtils(); + + const { comp, season } = await createDocuments(db, user, true); + + await db.deleteObjectById( + CollectionId.Seasons, + season._id! as unknown as ObjectId, + ); + + const params = await getTestApiParams(res, { db, user }, [comp.slug]); + const { authorized } = + await api.findCompSeasonAndTeamByCompSlug.isAuthorized( + params[0], + params[1], + params[2], + params[4], + ); + + expect(authorized).toBe(false); + }); +}); diff --git a/tests/lib/slugToId.test.ts b/tests/lib/slugToId.test.ts index 31f7fede..d0b99cad 100644 --- a/tests/lib/slugToId.test.ts +++ b/tests/lib/slugToId.test.ts @@ -1,5 +1,5 @@ import InMemoryDbInterface from "@/lib/client/dbinterfaces/InMemoryDbInterface"; -import slugToId, { findObjectBySlugLookUp } from "@/lib/slugToId"; +import findObjectBySlugLookUp, { slugToId } from "@/lib/slugToId"; import { ObjectId } from "bson"; beforeEach(() => { From cabe60837f35d5ba17ebd2ec71e1336b88bbaeb7 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Fri, 14 Mar 2025 17:36:02 -0400 Subject: [PATCH 12/43] Fix sign in error --- lib/DbInterfaceAuthAdapter.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/DbInterfaceAuthAdapter.ts b/lib/DbInterfaceAuthAdapter.ts index 593b0133..b2ca5758 100644 --- a/lib/DbInterfaceAuthAdapter.ts +++ b/lib/DbInterfaceAuthAdapter.ts @@ -271,7 +271,16 @@ export default function DbInterfaceAuthAdapter( account, }); - return format.from(account); + let formattedAccount: AdapterAccount; + + // Sometimes gives an error about not finding toHexString. + try { + formattedAccount = format.from(existingAccount); + } catch (e) { + account.userId = new ObjectId(account.userId) as any; + formattedAccount = format.from(account); + } + return formattedAccount; } await db.addObject(CollectionId.Accounts, account); From 56b569aa1a2a085eb612bcc3a54e88401b240f2d Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Fri, 14 Mar 2025 17:40:43 -0400 Subject: [PATCH 13/43] Comp page loads --- components/competition/MatchScheduleCard.tsx | 389 ++++++++++-------- lib/api/ClientApi.ts | 51 ++- lib/api/GearboxRequestHelper.ts | 27 ++ lib/api/LocalApiDependencies.ts | 7 + package-lock.json | 16 +- package.json | 2 +- .../[seasonSlug]/[competitonSlug]/index.tsx | 2 + tests/lib/api/ClientApi.test.ts | 2 +- 8 files changed, 280 insertions(+), 216 deletions(-) create mode 100644 lib/api/GearboxRequestHelper.ts create mode 100644 lib/api/LocalApiDependencies.ts diff --git a/components/competition/MatchScheduleCard.tsx b/components/competition/MatchScheduleCard.tsx index 87837ec8..3fbab14c 100644 --- a/components/competition/MatchScheduleCard.tsx +++ b/components/competition/MatchScheduleCard.tsx @@ -7,14 +7,209 @@ import { Team, User, } from "@/lib/Types"; -import Link from "next/link"; import { BsGearFill } from "react-icons/bs"; import { FaSync, FaCheck, FaInfoCircle } from "react-icons/fa"; import { MdErrorOutline } from "react-icons/md"; import Avatar from "../Avatar"; import Loading from "../Loading"; import { AdvancedSession } from "@/lib/client/useCurrentSession"; -import { useEffect } from "react"; + +function MatchCard({ + match, + index, + isManager, + openEditMatchModal, + remindUserOnSlack, + session, + team, + seasonSlug, + compSlug, + reportsById, + usersById, +}: { + match: Match; + index: number; + isManager: boolean | undefined; + openEditMatchModal: (match: Match) => void; + remindUserOnSlack: (userId: string) => void; + session: AdvancedSession; + team: Team | undefined; + seasonSlug: string | undefined; + compSlug: string | undefined; + reportsById: { [id: string]: Report }; + usersById: { [id: string]: User }; +}) { + return ( +
+
+
+

Match {match.number}

+ {isManager && ( + + )} +
+
+
+ {match.reports.map((reportId) => { + const report = reportsById[reportId]; + + if (!report) + return ( +
+ ); + + const submitted = report.submitted; + const mine = session && report.user === session.user?._id; + const ours = report.robotNumber === team?.number; + let color = !submitted + ? report.color === AllianceColor.Red + ? "bg-red-500" + : "bg-blue-500" + : "bg-slate-500"; + color = ours + ? !report.submitted + ? "bg-purple-500" + : "bg-purple-300" + : color; + + const timeSinceCheckIn = + report.checkInTimestamp && + (new Date().getTime() - + new Date(report.checkInTimestamp as any).getTime()) / + 1000; + + return ( + +

{report.robotNumber}

+
+ ); + })} +
+ +
+ {match.reports.map((reportId) => { + const report = reportsById[reportId]; + const user = usersById[report?.user!]; + + return ( +
+ {user ? ( + remindUserOnSlack(user._id!)} + gearSize={25} + /> + ) : ( +
+ )} +
+ ); + })} +
+
+
+ {match.subjectiveScouter && usersById[match.subjectiveScouter] ? ( +
+ {match.assignedSubjectiveScouterHasSubmitted ? ( + + ) : ( + match.subjectiveReportsCheckInTimestamps && + getIdsInProgressFromTimestamps( + match.subjectiveReportsCheckInTimestamps, + ).includes(match.subjectiveScouter) && ( +
+ +
+ ) + )} + {isManager && usersById[match.subjectiveScouter ?? ""]?.slackId ? ( + + ) : ( +
+ Subjective Scouter:{" "} + {usersById[match.subjectiveScouter ?? ""].name}{" "} +
+ +
+
+ )} +
+ ) : ( +
+ No subjective scouter assigned{" "} +
+ +
+
+ )} +
+ + Add Subjective Report ( + {`${match.subjectiveReports ? match.subjectiveReports.length : 0} submitted, + ${ + match.subjectiveReportsCheckInTimestamps + ? getIdsInProgressFromTimestamps( + match.subjectiveReportsCheckInTimestamps, + ).length + : 0 + } in progress`} + ) + +
+ ); +} export default function MatchScheduleCard(props: { team: Team | undefined; @@ -127,184 +322,20 @@ export default function MatchScheduleCard(props: {
{displayedMatches.map((match, index) => ( -
-
-
-

- Match {match.number} -

- {isManager && ( - - )} -
-
-
- {match.reports.map((reportId) => { - const report = reportsById[reportId]; - - if (!report) - return ( - - ); - - const submitted = report.submitted; - const mine = - session && report.user === session.user?._id; - const ours = report.robotNumber === team?.number; - let color = !submitted - ? report.color === AllianceColor.Red - ? "bg-red-500" - : "bg-blue-500" - : "bg-slate-500"; - color = ours - ? !report.submitted - ? "bg-purple-500" - : "bg-purple-300" - : color; - - const timeSinceCheckIn = - report.checkInTimestamp && - (new Date().getTime() - - new Date( - report.checkInTimestamp as any, - ).getTime()) / - 1000; - - return ( - -

{report.robotNumber}

-
- ); - })} -
- -
- {match.reports.map((reportId) => { - const report = reportsById[reportId]; - const user = usersById[report?.user!]; - - return ( -
- {user ? ( - remindUserOnSlack(user._id!)} - gearSize={25} - /> - ) : ( -
- )} -
- ); - })} -
-
-
- {match.subjectiveScouter && - usersById[match.subjectiveScouter] ? ( -
- {match.assignedSubjectiveScouterHasSubmitted ? ( - - ) : ( - match.subjectiveReportsCheckInTimestamps && - getIdsInProgressFromTimestamps( - match.subjectiveReportsCheckInTimestamps, - ).includes(match.subjectiveScouter) && ( -
- -
- ) - )} - {isManager && - usersById[match.subjectiveScouter ?? ""] - ?.slackId ? ( - - ) : ( -
- Subjective Scouter:{" "} - {usersById[match.subjectiveScouter ?? ""].name}{" "} -
- -
-
- )} -
- ) : ( -
- No subjective scouter assigned{" "} -
- -
-
- )} -
- - Add Subjective Report ( - {`${match.subjectiveReports ? match.subjectiveReports.length : 0} submitted, - ${ - match.subjectiveReportsCheckInTimestamps - ? getIdsInProgressFromTimestamps( - match.subjectiveReportsCheckInTimestamps, - ).length - : 0 - } in progress`} - ) - -
+ match={match} + index={index} + isManager={isManager} + openEditMatchModal={openEditMatchModal} + remindUserOnSlack={remindUserOnSlack} + session={session} + team={team} + seasonSlug={seasonSlug} + compSlug={comp?.slug} + reportsById={reportsById} + usersById={usersById} + /> ))}
diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 247178f8..247f6330 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -45,21 +45,15 @@ import { Statbotics } from "../Statbotics"; import { TheBlueAlliance } from "../TheBlueAlliance"; import { SlackNotLinkedError } from "./Errors"; import { _id } from "@next-auth/mongodb-adapter"; -import toast from "react-hot-toast"; -import { RequestHelper } from "unified-api"; import { createNextRoute, NextApiTemplate } from "unified-api-nextjs"; import { Report } from "../Types"; import Logger from "../client/Logger"; import LocalStorageDbInterface from "../client/dbinterfaces/LocalStorageDbInterface"; import findObjectBySlugLookUp, { slugToId } from "../slugToId"; +import GearboxRequestHelper from "./GearboxRequestHelper"; +import LocalApiDependencies from "./LocalApiDependencies"; -const requestHelper = new RequestHelper( - process.env.NEXT_PUBLIC_API_URL ?? "", // Replace undefined when env is not present (ex: for testing builds) - (url) => - toast.error( - `Failed API request: ${url}. If this is an error, please contact the developers.`, - ), -); +const requestHelper = new GearboxRequestHelper(); const logger = new Logger(["API"]); @@ -632,7 +626,8 @@ export default class ClientApi extends NextApiTemplate { [string, boolean, boolean], Report[], ApiDependencies, - { team: Team; comp: Competition } + { team: Team; comp: Competition }, + LocalApiDependencies >({ isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), @@ -670,24 +665,19 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(reports); }, - fallback: async (compId, submitted, usePublicData) => { - console.log("Running fallback for competitionReports..."); - - const localDb = new LocalStorageDbInterface(); - await localDb.init(); + fallback: async ({ dbPromise }, [compId, submitted, usePublicData]) => { + const db = await dbPromise; - const comp = await localDb.findObjectById( + const comp = await db.findObjectById( CollectionId.Competitions, new ObjectId(compId), ); - console.log("comp", comp); - if (!comp) return []; const usedComps = usePublicData && comp.tbaId !== NotLinkedToTba - ? await localDb.findObjects(CollectionId.Competitions, { + ? await db.findObjects(CollectionId.Competitions, { publicData: true, tbaId: comp.tbaId, gameId: comp.gameId, @@ -699,7 +689,7 @@ export default class ClientApi extends NextApiTemplate { console.log("usedComps", usedComps); const reports = ( - await localDb.findObjects(CollectionId.Reports, { + await db.findObjects(CollectionId.Reports, { match: { $in: usedComps.flatMap((m) => m.matches) }, submitted: submitted ? true : { $exists: true }, }) @@ -714,29 +704,28 @@ export default class ClientApi extends NextApiTemplate { console.log("reports", reports); return reports; }, - afterResponse: async (res, ranFallback) => { + afterResponse: async ({ dbPromise }, res, ranFallback) => { if (ranFallback || !res) return; - const localDb = new LocalStorageDbInterface(); - await localDb.init(); + const db = await dbPromise; await Promise.all( res.map(async (report) => { if (!report._id) return; if ( - await localDb.findObjectById( + await db.findObjectById( CollectionId.Reports, new ObjectId(report._id), ) ) { - return localDb.updateObjectById( + return db.updateObjectById( CollectionId.Reports, new ObjectId(report._id), report, ); } - return localDb.addObject(CollectionId.Reports, report); + return db.addObject(CollectionId.Reports, report); }), ); }, @@ -2612,7 +2601,8 @@ export default class ClientApi extends NextApiTemplate { [string], { comp: Competition; season: Season; team: Team } | undefined, ApiDependencies, - { team: Team; season: Season; comp: Competition } + { team: Team; season: Season; comp: Competition }, + LocalApiDependencies >({ isAuthorized: async (req, res, { db, userPromise }, [compSlug]) => { const user = await userPromise; @@ -2647,5 +2637,12 @@ export default class ClientApi extends NextApiTemplate { handler: async (req, res, deps, authData, args) => { return res.status(200).send(authData); }, + afterResponse: async ({ dbPromise }, res, ranFallback) => { + if (ranFallback || !res) return Promise.resolve(); + + const db = await dbPromise; + + const { comp, season, team } = res; + }, }); } diff --git a/lib/api/GearboxRequestHelper.ts b/lib/api/GearboxRequestHelper.ts new file mode 100644 index 00000000..a2ac2e17 --- /dev/null +++ b/lib/api/GearboxRequestHelper.ts @@ -0,0 +1,27 @@ +import toast from "react-hot-toast"; +import { RequestHelper } from "unified-api"; +import LocalStorageDbInterface from "../client/dbinterfaces/LocalStorageDbInterface"; +import LocalApiDependencies from "./LocalApiDependencies"; +export default class GearboxRequestHelper extends RequestHelper { + constructor() { + super( + process.env.NEXT_PUBLIC_API_URL ?? "", // Replace undefined when env is not present (ex: for testing builds) + (url) => + toast.error( + `Failed API request: ${url}. If this is an error, please contact the developers.`, + ), + ); + } + + async getLocalDependencies() { + const dbPromise = (async () => { + const db = new LocalStorageDbInterface(); + await db.init(); + return db; + })(); + + return { + dbPromise, + } as LocalApiDependencies; + } +} diff --git a/lib/api/LocalApiDependencies.ts b/lib/api/LocalApiDependencies.ts new file mode 100644 index 00000000..1d368347 --- /dev/null +++ b/lib/api/LocalApiDependencies.ts @@ -0,0 +1,7 @@ +import DbInterface from "../client/dbinterfaces/DbInterface"; + +type LocalApiDependencies = { + dbPromise: Promise; +}; + +export default LocalApiDependencies; diff --git a/package-lock.json b/package-lock.json index 184939ba..4f6b38b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.15" + "unified-api-nextjs": "^1.1.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -10791,20 +10791,20 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified-api": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.27.tgz", - "integrity": "sha512-JiEU2HFITwV+tpJB1IMEFInXa7xYWGAeFF1GFtlFYvoyvR3WcK6Jgh0joXiqVtxeOb+kVOQcgjqQWv9TafA15w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.1.1.tgz", + "integrity": "sha512-dKddrWOGydTjreMWT9L0zmA/eetSARvGeo0T2LwO+6zd0vGgvvn72QRsfyaKMnkv6WORSEK9EpqOEVtSpIh1lQ==", "dependencies": { "omit-call-signature": "^1.0.16" } }, "node_modules/unified-api-nextjs": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.15.tgz", - "integrity": "sha512-Hn1Ss6qjUsA3x2JuCHFOmEz6E7p4kEWdSfYFhD/R0Xjwo+Ca2gNcVglNmwjDns8RNcW1jK+skiVUCFf/MsvbQQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.1.1.tgz", + "integrity": "sha512-zTPqpRjyzVymD7pLpRRUY8tZOf2zJqCIlqA/pJGu3kgH/oicbMq8r4loRZwyIYnHZ8M4GaigYhX2HQ9r3vsjtQ==", "dependencies": { "next": "^15.1.2", - "unified-api": "^1.0.26" + "unified-api": "^1.1.1" } }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index b2df6890..48ab9747 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.15" + "unified-api-nextjs": "^1.1.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx index af04591b..4a9b4450 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx @@ -180,6 +180,8 @@ export default function CompetitionIndex() { const loadReports = useCallback( async (silent: boolean = false) => { + if (!comp) return; + const scoutingStats = (reps: Report[]) => { if (!silent) setLoadingScoutStats(true); let submittedCount = 0; diff --git a/tests/lib/api/ClientApi.test.ts b/tests/lib/api/ClientApi.test.ts index fb34883a..bce0d03b 100644 --- a/tests/lib/api/ClientApi.test.ts +++ b/tests/lib/api/ClientApi.test.ts @@ -1344,4 +1344,4 @@ describe(`${ClientApi.name}.${api.findCompSeasonAndTeamByCompSlug.name}`, () => expect(authorized).toBe(false); }); -}); +}); \ No newline at end of file From e94098c49a918e5e0b792c8f847b2e3cad827788 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Fri, 14 Mar 2025 18:02:21 -0400 Subject: [PATCH 14/43] Update mongo-anywhere to get addOrUpdateObject --- lib/client/dbinterfaces/CachedDbInterface.ts | 7 +++++++ lib/client/dbinterfaces/DbInterface.ts | 8 ++++++++ lib/client/dbinterfaces/InMemoryDbInterface.ts | 7 +++++++ lib/client/dbinterfaces/LocalStorageDbInterface.ts | 7 +++++++ package-lock.json | 8 ++++---- package.json | 2 +- 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/client/dbinterfaces/CachedDbInterface.ts b/lib/client/dbinterfaces/CachedDbInterface.ts index 53d20c24..2d006921 100644 --- a/lib/client/dbinterfaces/CachedDbInterface.ts +++ b/lib/client/dbinterfaces/CachedDbInterface.ts @@ -74,4 +74,11 @@ export default class CachedDbInterface >(collection: TId, slug: string): Promise { return findObjectBySlugLookUp(this, collection, slug); } + + addOrUpdateObject< + TId extends CollectionId, + TObj extends CollectionIdToType, + >(collection: TId, object: TObj): Promise { + return super.addOrUpdateObject(collection, object); + } } diff --git a/lib/client/dbinterfaces/DbInterface.ts b/lib/client/dbinterfaces/DbInterface.ts index 322339df..267a2e12 100644 --- a/lib/client/dbinterfaces/DbInterface.ts +++ b/lib/client/dbinterfaces/DbInterface.ts @@ -62,4 +62,12 @@ export default interface DbInterface collection: CollectionId, query: object, ): Promise; + + addOrUpdateObject< + TId extends CollectionId, + TObj extends CollectionIdToType, + >( + collection: TId, + object: TObj, + ): Promise; } diff --git a/lib/client/dbinterfaces/InMemoryDbInterface.ts b/lib/client/dbinterfaces/InMemoryDbInterface.ts index bc1ab213..23707ac9 100644 --- a/lib/client/dbinterfaces/InMemoryDbInterface.ts +++ b/lib/client/dbinterfaces/InMemoryDbInterface.ts @@ -65,4 +65,11 @@ export default class InMemoryDbInterface >(collection: TId, slug: string): Promise { return findObjectBySlugLookUp(this, collection, slug); } + + addOrUpdateObject< + TId extends CollectionId, + TObj extends CollectionIdToType, + >(collection: TId, object: TObj): Promise { + return super.addOrUpdateObject(collection, object); + } } diff --git a/lib/client/dbinterfaces/LocalStorageDbInterface.ts b/lib/client/dbinterfaces/LocalStorageDbInterface.ts index 86dc4428..f4a536b6 100644 --- a/lib/client/dbinterfaces/LocalStorageDbInterface.ts +++ b/lib/client/dbinterfaces/LocalStorageDbInterface.ts @@ -65,4 +65,11 @@ export default class LocalStorageDbInterface >(collection: TId, slug: string): Promise { return findObjectBySlugLookUp(this, collection, slug); } + + addOrUpdateObject< + TId extends CollectionId, + TObj extends CollectionIdToType, + >(collection: TId, object: TObj): Promise { + return super.addOrUpdateObject(collection, object); + } } diff --git a/package-lock.json b/package-lock.json index 4f6b38b7..37cea494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", - "mongo-anywhere": "^1.1.12", + "mongo-anywhere": "^1.1.14", "mongodb": "^5.0.0", "next": "^15.1.6", "next-auth": "^4.24.11", @@ -7916,9 +7916,9 @@ } }, "node_modules/mongo-anywhere": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.13.tgz", - "integrity": "sha512-E1mpEiwSFM3atUPc0pTTLHEIsvtBlfkrR+2lcBK6s0/N5VGVGPJPATBlohGuur0i4NLRt0hdFISiuSKb93U3uw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.14.tgz", + "integrity": "sha512-IrG1XfYkj5r9fdS7cLGuUydv96doHcmmfyOCGaweiG6PcOZN30tEWUtbRF9fyN6Rmv3oNe7P9KVRMUMlwJGa7w==", "dependencies": { "bson": "^5.0.0", "minimongo": "^6.19.0", diff --git a/package.json b/package.json index 48ab9747..73e6ffa6 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "jose": "^5.9.6", "levenary": "^1.1.1", "minimongo": "^6.19.0", - "mongo-anywhere": "^1.1.12", + "mongo-anywhere": "^1.1.14", "mongodb": "^5.0.0", "next": "^15.1.6", "next-auth": "^4.24.11", From 30098c0549952d4486a4d09c6708a84c4d015b34 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Fri, 14 Mar 2025 18:09:38 -0400 Subject: [PATCH 15/43] Add fallback for findCompSeasonAndTeamByCompSlug --- lib/api/ClientApi.ts | 48 +++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 247f6330..694a2de6 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -686,8 +686,6 @@ export default class ClientApi extends NextApiTemplate { usedComps.push(comp); - console.log("usedComps", usedComps); - const reports = ( await db.findObjects(CollectionId.Reports, { match: { $in: usedComps.flatMap((m) => m.matches) }, @@ -701,7 +699,6 @@ export default class ClientApi extends NextApiTemplate { : { ...report, data: { ...report.data, comments: "" } }, ); - console.log("reports", reports); return reports; }, afterResponse: async ({ dbPromise }, res, ranFallback) => { @@ -713,19 +710,7 @@ export default class ClientApi extends NextApiTemplate { res.map(async (report) => { if (!report._id) return; - if ( - await db.findObjectById( - CollectionId.Reports, - new ObjectId(report._id), - ) - ) { - return db.updateObjectById( - CollectionId.Reports, - new ObjectId(report._id), - report, - ); - } - return db.addObject(CollectionId.Reports, report); + return db.addOrUpdateObject(CollectionId.Reports, report); }), ); }, @@ -2637,12 +2622,43 @@ export default class ClientApi extends NextApiTemplate { handler: async (req, res, deps, authData, args) => { return res.status(200).send(authData); }, + fallback: async ({ dbPromise }, [compSlug]) => { + const db = await dbPromise; + + const comp = await findObjectBySlugLookUp( + await db, + CollectionId.Competitions, + compSlug, + ); + + if (!comp) return undefined; + + const team = await getTeamFromComp(await db, comp); + + if (!team) return undefined; + + const season = await getSeasonFromComp(await db, comp); + + if (!season) return undefined; + + return { + comp, + season, + team, + }; + }, afterResponse: async ({ dbPromise }, res, ranFallback) => { if (ranFallback || !res) return Promise.resolve(); const db = await dbPromise; const { comp, season, team } = res; + + await Promise.all([ + db.addOrUpdateObject(CollectionId.Competitions, comp), + db.addOrUpdateObject(CollectionId.Seasons, season), + db.addOrUpdateObject(CollectionId.Teams, team), + ]); }, }); } From 7cc0939f8e6274785ef5593b9b63b329e354b472 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 17 Mar 2025 17:40:33 -0400 Subject: [PATCH 16/43] Comp page now loads offline! --- lib/api/ClientApi.ts | 158 ++++++++++++++++++++++----- lib/api/ClientApiUtils.ts | 53 +++++++++ tests/lib/api/ClientApiUtils.test.ts | 0 3 files changed, 183 insertions(+), 28 deletions(-) create mode 100644 lib/api/ClientApiUtils.ts create mode 100644 tests/lib/api/ClientApiUtils.test.ts diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 694a2de6..9555b5dc 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -52,6 +52,10 @@ import LocalStorageDbInterface from "../client/dbinterfaces/LocalStorageDbInterf import findObjectBySlugLookUp, { slugToId } from "../slugToId"; import GearboxRequestHelper from "./GearboxRequestHelper"; import LocalApiDependencies from "./LocalApiDependencies"; +import { + findObjectByIdFallback, + saveObjectAfterResponse, +} from "./ClientApiUtils"; const requestHelper = new GearboxRequestHelper(); @@ -701,26 +705,16 @@ export default class ClientApi extends NextApiTemplate { return reports; }, - afterResponse: async ({ dbPromise }, res, ranFallback) => { - if (ranFallback || !res) return; - - const db = await dbPromise; - - await Promise.all( - res.map(async (report) => { - if (!report._id) return; - - return db.addOrUpdateObject(CollectionId.Reports, report); - }), - ); - }, + afterResponse: async (deps, res, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Reports, res, ranFallback), }); allCompetitionMatches = createNextRoute< [string], Match[], ApiDependencies, - { team: Team; comp: Competition } + { team: Team; comp: Competition }, + LocalApiDependencies >({ isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), @@ -738,6 +732,24 @@ export default class ClientApi extends NextApiTemplate { }); return res.status(200).send(matches); }, + afterResponse: async (deps, res, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Matches, res, ranFallback), + fallback: async ({ dbPromise }, [compId]) => { + const db = await dbPromise; + + const comp = await db.findObjectById( + CollectionId.Competitions, + new ObjectId(compId), + ); + + if (!comp) return []; + + const matches = await db.findObjects(CollectionId.Matches, { + _id: { $in: comp.matches.map((matchId) => new ObjectId(matchId)) }, + }); + + return matches; + }, }); matchReports = createNextRoute< @@ -1206,6 +1218,9 @@ export default class ClientApi extends NextApiTemplate { max: rankings?.length, }); }, + fallback: () => { + return Promise.resolve({ place: "?", max: "?" }); + }, }); getPitReports = createNextRoute< @@ -1842,7 +1857,8 @@ export default class ClientApi extends NextApiTemplate { [string, Match[]], SubjectiveReport[], ApiDependencies, - { team: Team; comp: Competition } + { team: Team; comp: Competition }, + LocalApiDependencies >({ isAuthorized: (req, res, deps, [compId]) => AccessLevels.IfOnTeamThatOwnsComp(req, res, deps, compId), @@ -1875,6 +1891,21 @@ export default class ClientApi extends NextApiTemplate { return res.status(200).send(reports); }, + afterResponse: async (deps, reports, ranFallback) => + saveObjectAfterResponse( + deps, + CollectionId.SubjectiveReports, + reports, + ranFallback, + ), + fallback: async ({ dbPromise }, [compId, matches]) => { + const db = await dbPromise; + + const matchIds = matches.map((match) => match._id?.toString()); + return db.findObjects(CollectionId.SubjectiveReports, { + match: { $in: matchIds }, + }); + }, }); removeUserFromTeam = createNextRoute< @@ -1947,7 +1978,8 @@ export default class ClientApi extends NextApiTemplate { [string], User | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [id]) => { @@ -1958,13 +1990,16 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(user); }, + afterResponse: async (deps, user, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Users, user, ranFallback), }); findBulkUsersById = createNextRoute< [string[]], User[], ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [ids]) => { @@ -1974,13 +2009,18 @@ export default class ClientApi extends NextApiTemplate { }); return res.status(200).send(users); }, + afterResponse: async (deps, users, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Users, users, ranFallback), + fallback: async (deps, [ids]) => + findObjectByIdFallback(deps, CollectionId.Users, ids), }); findTeamById = createNextRoute< [string], Team | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [id]) => { @@ -1991,13 +2031,18 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(team); }, + afterResponse: async (deps, team, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Teams, team, ranFallback), + fallback: async (deps, [id]) => + findObjectByIdFallback(deps, CollectionId.Teams, id), }); findTeamByNumberAndLeague = createNextRoute< [number, League], Team | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async ( @@ -2021,13 +2066,31 @@ export default class ClientApi extends NextApiTemplate { return res.status(200).send(team); }, + afterResponse: async (deps, team, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Teams, team, ranFallback), + fallback: async ({ dbPromise }, [number, league]) => { + const db = await dbPromise; + + const query = + league === League.FRC + ? { + number: number, + $or: [{ league: league }, { tbaId: { $exists: true } }], + } + : { number: number, league: league }; + + const team = await db.findObject(CollectionId.Teams, query); + + return team; + }, }); findSeasonById = createNextRoute< [string], Season | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [id]) => { @@ -2038,13 +2101,18 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(season); }, + afterResponse: async (deps, season, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Seasons, season, ranFallback), + fallback: async (deps, [id]) => + findObjectByIdFallback(deps, CollectionId.Seasons, id), }); findCompetitionById = createNextRoute< [string], Competition | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [id]) => { @@ -2055,13 +2123,23 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(competition); }, + afterResponse: async (deps, competition, ranFallback) => + saveObjectAfterResponse( + deps, + CollectionId.Competitions, + competition, + ranFallback, + ), + fallback: async (deps, [id]) => + findObjectByIdFallback(deps, CollectionId.Competitions, id), }); findMatchById = createNextRoute< [string], Match | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [id]) => { @@ -2072,13 +2150,18 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(match); }, + afterResponse: async (deps, match, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Matches, match, ranFallback), + fallback: async (deps, [id]) => + findObjectByIdFallback(deps, CollectionId.Matches, id), }); findReportById = createNextRoute< [string], Report | undefined, ApiDependencies, - void + void, + LocalApiDependencies >({ isAuthorized: AccessLevels.AlwaysAuthorized, handler: async (req, res, { db: dbPromise }, authData, [id]) => { @@ -2089,26 +2172,41 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send(report); }, + afterResponse: async (deps, report, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Reports, report, ranFallback), + fallback: async (deps, [id]) => + findObjectByIdFallback(deps, CollectionId.Reports, id), }); findPitreportById = createNextRoute< [string], Pitreport | undefined, ApiDependencies, - { team: Team; comp: Competition; pitReport: Pitreport } + { team: Team; comp: Competition; pitReport: Pitreport }, + LocalApiDependencies >({ isAuthorized: (req, res, deps, [id]) => AccessLevels.IfOnTeamThatOwnsPitReport(req, res, deps, id), handler: async (req, res, deps, { pitReport }, args) => { return res.status(200).send(pitReport); }, + afterResponse: async (deps, pitReport, ranFallback) => + saveObjectAfterResponse( + deps, + CollectionId.PitReports, + pitReport, + ranFallback, + ), + fallback: async (deps, [id]) => + findObjectByIdFallback(deps, CollectionId.PitReports, id), }); findBulkPitReportsById = createNextRoute< [string[]], Pitreport[], ApiDependencies, - { team: Team; comp: Competition; pitReport: Pitreport }[] + { team: Team; comp: Competition; pitReport: Pitreport }[], + LocalApiDependencies >({ isAuthorized: async (req, res, deps, [ids]) => { const reports = await Promise.all( @@ -2125,6 +2223,10 @@ export default class ClientApi extends NextApiTemplate { handler: async (req, res, deps, authData, args) => { return res.status(200).send(authData.map((report) => report.pitReport)); }, + afterResponse: async (deps, res, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.PitReports, res, ranFallback), + fallback: async (deps, [pitReportIds]) => + findObjectByIdFallback(deps, CollectionId.PitReports, pitReportIds), }); updateUser = createNextRoute< @@ -2626,18 +2728,18 @@ export default class ClientApi extends NextApiTemplate { const db = await dbPromise; const comp = await findObjectBySlugLookUp( - await db, + db, CollectionId.Competitions, compSlug, ); if (!comp) return undefined; - const team = await getTeamFromComp(await db, comp); + const team = await getTeamFromComp(db, comp); if (!team) return undefined; - const season = await getSeasonFromComp(await db, comp); + const season = await getSeasonFromComp(db, comp); if (!season) return undefined; diff --git a/lib/api/ClientApiUtils.ts b/lib/api/ClientApiUtils.ts new file mode 100644 index 00000000..1895b39c --- /dev/null +++ b/lib/api/ClientApiUtils.ts @@ -0,0 +1,53 @@ +/** + * @tested_by tests/lib/api/ClientApiUtils.test.ts + */ +import { ObjectId } from "bson"; +import CollectionId, { CollectionIdToType } from "../client/CollectionId"; +import DbInterface from "../client/dbinterfaces/DbInterface"; + +export async function saveObjectAfterResponse< + TId extends CollectionId, + TObj extends CollectionIdToType, +>( + { dbPromise }: { dbPromise: Promise }, + collectionid: TId, + object: TObj | (TObj | undefined)[] | undefined, + ranFallback: boolean, +) { + if (ranFallback) return; + + const db = await dbPromise; + + if (Array.isArray(object)) + await Promise.all( + object + .filter((obj) => obj !== undefined) + .map((obj) => db.addOrUpdateObject(collectionid, obj!)), + ); + else if (object) await db.addOrUpdateObject(collectionid, object); +} + +type AllowUndefinedIfNotArray = TArr extends any[] + ? TEle[] + : TEle | undefined; + +export async function findObjectByIdFallback< + TCollectionId extends CollectionId, + TIdArg extends string | string[], + TObj extends AllowUndefinedIfNotArray< + TIdArg, + CollectionIdToType + >, +>( + { dbPromise }: { dbPromise: Promise }, + collectionId: TCollectionId, + id: TIdArg, +): Promise { + const db = await dbPromise; + if (Array.isArray(id)) { + return db.findObjects(collectionId, { + _id: { $in: id.map((i) => new ObjectId(i)) }, + }) as Promise; + } + return db.findObjectById(collectionId, new ObjectId(id)) as Promise; +} diff --git a/tests/lib/api/ClientApiUtils.test.ts b/tests/lib/api/ClientApiUtils.test.ts new file mode 100644 index 00000000..e69de29b From f87535bb1cc396776e5a2ce3b654b5dbe92655de Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 17 Mar 2025 17:53:55 -0400 Subject: [PATCH 17/43] Add tests for ClientApiUtils --- tests/lib/api/ClientApiUtils.test.ts | 306 +++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/tests/lib/api/ClientApiUtils.test.ts b/tests/lib/api/ClientApiUtils.test.ts index e69de29b..20ff4db3 100644 --- a/tests/lib/api/ClientApiUtils.test.ts +++ b/tests/lib/api/ClientApiUtils.test.ts @@ -0,0 +1,306 @@ +import { + findObjectByIdFallback, + saveObjectAfterResponse, +} from "@/lib/api/ClientApiUtils"; +import CollectionId from "@/lib/client/CollectionId"; +import { getTestApiUtils } from "@/lib/testutils/TestUtils"; +import { Report } from "@/lib/Types"; +import { ObjectId } from "bson"; + +describe(saveObjectAfterResponse.name, () => { + test("Adds object to the database when ranFallback is false, only a single object is passed, and the object does not already exist", async () => { + const { db } = await getTestApiUtils(); + + const obj = { + _id: new ObjectId(), + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + obj, + false, + ); + + const foundObj = await db.findObjectById( + CollectionId.Reports, + obj._id as any as ObjectId, + ); + + expect(foundObj).toEqual(obj); + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(1); + }); + + test("Updates object in the database when ranFallback is false, only a single object is passed, and the object already exists", async () => { + const { db } = await getTestApiUtils(); + + const obj = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj); + + const updatedObj = { + ...obj, + someField: "updatedValue", + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + updatedObj, + false, + ); + + const foundObj = await db.findObjectById( + CollectionId.Reports, + updatedObj._id as any as ObjectId, + ); + + expect(foundObj).toEqual(updatedObj); + }); + + test("Does not add or update object in the database when ranFallback is true", async () => { + const { db } = await getTestApiUtils(); + + const obj = { + _id: new ObjectId(), + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + obj, + true, + ); + + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(0); + }); + + test("Adds multiple objects to the database when ranFallback is false, and an array of objects is passed", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + } as any as Report; + + const obj2 = { + _id: new ObjectId(), + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + [obj1, obj2], + false, + ); + + const foundObj1 = await db.findObjectById( + CollectionId.Reports, + obj1._id as any as ObjectId, + ); + + const foundObj2 = await db.findObjectById( + CollectionId.Reports, + obj2._id as any as ObjectId, + ); + + expect(foundObj1).toEqual(obj1); + expect(foundObj2).toEqual(obj2); + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(2); + }); + + test("Does not add or update objects in the database when ranFallback is true and an array of objects is passed", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + } as any as Report; + + const obj2 = { + _id: new ObjectId(), + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + [obj1, obj2], + true, + ); + + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(0); + }); + + test("Updates multiple objects in the database when ranFallback is false, and an array of objects is passed", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + } as any as Report; + + const obj2 = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj1); + await db.addObject(CollectionId.Reports, obj2); + + const updatedObj1 = { + ...obj1, + someField: "updatedValue1", + } as any as Report; + + const updatedObj2 = { + ...obj2, + someField: "updatedValue2", + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + [updatedObj1, updatedObj2], + false, + ); + + const foundObj1 = await db.findObjectById( + CollectionId.Reports, + updatedObj1._id as any as ObjectId, + ); + + const foundObj2 = await db.findObjectById( + CollectionId.Reports, + updatedObj2._id as any as ObjectId, + ); + + expect(foundObj1).toEqual(updatedObj1); + expect(foundObj2).toEqual(updatedObj2); + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(2); + }); + + test("Updates existing objects and adds new ones when ranFallback is false, and a mix of existing and new objects is passed", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + } as any as Report; + + const obj2 = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj1); + + const updatedObj1 = { + ...obj1, + someField: "updatedValue1", + } as any as Report; + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + [updatedObj1, obj2], + false, + ); + + const foundObj1 = await db.findObjectById( + CollectionId.Reports, + updatedObj1._id as any as ObjectId, + ); + + const foundObj2 = await db.findObjectById( + CollectionId.Reports, + obj2._id as any as ObjectId, + ); + + expect(foundObj1).toEqual(updatedObj1); + expect(foundObj2).toEqual(obj2); + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(2); + }); +}); + +describe(findObjectByIdFallback.name, () => { + test("Finds a single object by ID when passed a single ID", async () => { + const { db } = await getTestApiUtils(); + + const obj = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj); + + const foundObj = await findObjectByIdFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Reports, + obj._id!.toString(), + ); + + expect(foundObj).toEqual(obj); + }); + + test("Finds multiple objects by ID when passed an array of IDs", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + } as any as Report; + + const obj2 = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj1); + await db.addObject(CollectionId.Reports, obj2); + + const foundObjs = await findObjectByIdFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Reports, + [obj1._id!.toString(), obj2._id!.toString()], + ); + + expect(foundObjs).toEqual([obj1, obj2]); + }); + + test("Returns undefined when no object is found", async () => { + const { db } = await getTestApiUtils(); + + const foundObj = await findObjectByIdFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Reports, + new ObjectId().toString(), + ); + + expect(foundObj).toBeUndefined(); + }); + + test("Returns only the existing objects when some IDs do not exist", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj1); + + const foundObjs = await findObjectByIdFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Reports, + [obj1._id!.toString(), new ObjectId().toString()], + ); + + expect(foundObjs).toEqual([obj1]); + }); +}); From f66f5423b8ee6062981132aaf37a27026a9dfc9a Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 17 Mar 2025 17:57:15 -0400 Subject: [PATCH 18/43] Add test for handling undefined object in saveObjectAfterResponse --- tests/lib/api/ClientApiUtils.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/lib/api/ClientApiUtils.test.ts b/tests/lib/api/ClientApiUtils.test.ts index 20ff4db3..5c8256ae 100644 --- a/tests/lib/api/ClientApiUtils.test.ts +++ b/tests/lib/api/ClientApiUtils.test.ts @@ -83,6 +83,33 @@ describe(saveObjectAfterResponse.name, () => { expect(await db.countObjects(CollectionId.Reports, {})).toEqual(0); }); + test("Does not add or update object when given an undefined object", async () => { + const { db } = await getTestApiUtils(); + + const obj = { + _id: new ObjectId(), + } as any as Report; + + await db.addObject(CollectionId.Reports, obj); + + await saveObjectAfterResponse( + { + dbPromise: Promise.resolve(db), + }, + CollectionId.Reports, + undefined, + false, + ); + + const foundObj = await db.findObjectById( + CollectionId.Reports, + obj._id as any as ObjectId, + ); + + expect(foundObj).toEqual(obj); + expect(await db.countObjects(CollectionId.Reports, {})).toEqual(1); + }); + test("Adds multiple objects to the database when ranFallback is false, and an array of objects is passed", async () => { const { db } = await getTestApiUtils(); From 1e98d0e667c2a6861b34766420808512f1fe238b Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Thu, 20 Mar 2025 18:54:57 -0400 Subject: [PATCH 19/43] Stuff, not sure what --- lib/api/ClientApi.ts | 20 +++++ lib/api/ClientApiUtils.ts | 27 +++++- lib/slugToId.ts | 1 - pages/[teamSlug]/index.tsx | 123 ++++++++++++++------------- tests/lib/api/ClientApiUtils.test.ts | 80 ++++++++++++++++- 5 files changed, 190 insertions(+), 61 deletions(-) diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 9555b5dc..d9f3971d 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -54,6 +54,7 @@ import GearboxRequestHelper from "./GearboxRequestHelper"; import LocalApiDependencies from "./LocalApiDependencies"; import { findObjectByIdFallback, + findObjectBySlugFallback, saveObjectAfterResponse, } from "./ClientApiUtils"; @@ -2037,6 +2038,25 @@ export default class ClientApi extends NextApiTemplate { findObjectByIdFallback(deps, CollectionId.Teams, id), }); + findTeamBySlug = createNextRoute< + [string], + Team | undefined, + ApiDependencies, + void, + LocalApiDependencies + >({ + isAuthorized: AccessLevels.AlwaysAuthorized, + handler: async (req, res, { db: dbPromise }, authData, [slug]) => { + const db = await dbPromise; + + return db.findObjectBySlug(CollectionId.Teams, slug); + }, + afterResponse: async (deps, res, ranFallback) => + saveObjectAfterResponse(deps, CollectionId.Teams, res, ranFallback), + fallback: async (deps, [slug]) => + findObjectBySlugFallback(deps, CollectionId.Teams, slug), + }); + findTeamByNumberAndLeague = createNextRoute< [number, League], Team | undefined, diff --git a/lib/api/ClientApiUtils.ts b/lib/api/ClientApiUtils.ts index 1895b39c..a728665a 100644 --- a/lib/api/ClientApiUtils.ts +++ b/lib/api/ClientApiUtils.ts @@ -2,8 +2,12 @@ * @tested_by tests/lib/api/ClientApiUtils.test.ts */ import { ObjectId } from "bson"; -import CollectionId, { CollectionIdToType } from "../client/CollectionId"; +import CollectionId, { + CollectionIdToType, + SluggedCollectionId, +} from "../client/CollectionId"; import DbInterface from "../client/dbinterfaces/DbInterface"; +import { slugToId } from "../slugToId"; export async function saveObjectAfterResponse< TId extends CollectionId, @@ -51,3 +55,24 @@ export async function findObjectByIdFallback< } return db.findObjectById(collectionId, new ObjectId(id)) as Promise; } + +export async function findObjectBySlugFallback< + TCollectionId extends SluggedCollectionId, + TSlugArg extends string | string[], + TObj extends AllowUndefinedIfNotArray< + TSlugArg, + CollectionIdToType + >, +>( + { dbPromise }: { dbPromise: Promise }, + collectionId: TCollectionId, + slug: TSlugArg, +): Promise { + const db = await dbPromise; + if (Array.isArray(slug)) { + return Promise.all( + slug.map((s) => db.findObjectBySlug(collectionId, s)), + ) as Promise; + } + return db.findObjectBySlug(collectionId, slug) as Promise; +} diff --git a/lib/slugToId.ts b/lib/slugToId.ts index ec2a3dbf..bef3fe06 100644 --- a/lib/slugToId.ts +++ b/lib/slugToId.ts @@ -21,7 +21,6 @@ function getSlugLookup() { /** * You are probably looking for findObjectBySlugLookUp! - * Don't use this function except in this file or for tests. */ export async function slugToId( db: DbInterface | Promise, diff --git a/pages/[teamSlug]/index.tsx b/pages/[teamSlug]/index.tsx index 64aa3a99..33603540 100644 --- a/pages/[teamSlug]/index.tsx +++ b/pages/[teamSlug]/index.tsx @@ -37,6 +37,7 @@ import AddToSlack from "@/components/AddToSlack"; import { Analytics } from "@/lib/client/Analytics"; import { redirect } from "next/dist/server/api-utils"; import { makeObjSerializeable } from "../../lib/client/ClientUtils"; +import { useRouter } from "next/router"; const api = new ClientApi(); @@ -455,9 +456,11 @@ function Settings(props: TeamPageProps) { ); } -export default function TeamIndex(props: TeamPageProps) { +export default function TeamIndex() { const { session, status } = useCurrentSession(); - const team = props.team; + const router = useRouter(); + + const [team, setTeam] = useState(); const isFrc = team?.tbaId || team?.league === League.FRC; @@ -465,17 +468,21 @@ export default function TeamIndex(props: TeamPageProps) { const isManager = team?.owners.includes(session?.user?._id as string); + useEffect(() => { + if (!router.query.teamSlug) return; + + api.findTeamBySlug(router.query.teamSlug as string).then((team) => { + if (!team) return; + + setTeam(team); + }); + }, [router.query]); + return ( - {props.team?.alliance ? "Alliance" : "Team"}{" "} + {team?.alliance ? "Alliance" : "Team"}{" "} {team?.number}
@@ -516,7 +523,7 @@ export default function TeamIndex(props: TeamPageProps) {
{isFrc ? "FRC" : "FTC"}
- {props.team?.alliance ? ( + {team?.alliance ? ( <> ) : ( { - const db = await getDatabase(); - const resolved = await UrlResolver(context, 1); - if ("redirect" in resolved) { - return resolved; - } - - const seasonIds = resolved.team?.seasons.map( - (seasonId) => new ObjectId(seasonId), - ); - const userIds = resolved.team?.users.map((userId) => new ObjectId(userId)); - const seasons = await db.findObjects(CollectionId.Seasons, { - _id: { $in: seasonIds }, - }); - - var users = await db.findObjects(CollectionId.Users, { - _id: { $in: userIds }, - }); - - users = users.map((user) => { - var c = structuredClone(user); - c._id = user?._id?.toString(); - c.teams = user.teams.map((id) => String(id)); - return c; - }); - - const currentSeason = seasons[seasons.length - 1]; - var comp = undefined; - if (currentSeason) { - comp = await db.findObjectById( - CollectionId.Competitions, - new ObjectId( - currentSeason.competitions[currentSeason.competitions.length - 1], - ), - ); - } - - return { - props: { - team: resolved.team, - users: makeObjSerializeable(users), - currentCompetition: serializeDatabaseObject(comp), - currentSeason: serializeDatabaseObject(currentSeason), - pastSeasons: serializeDatabaseObjects(seasons), - }, - }; -}; +// export const getServerSideProps: GetServerSideProps = async (context) => { +// const db = await getDatabase(); +// const resolved = await UrlResolver(context, 1); +// if ("redirect" in resolved) { +// return resolved; +// } + +// const seasonIds = resolved.team?.seasons.map( +// (seasonId) => new ObjectId(seasonId), +// ); +// const userIds = resolved.team?.users.map((userId) => new ObjectId(userId)); +// const seasons = await db.findObjects(CollectionId.Seasons, { +// _id: { $in: seasonIds }, +// }); + +// var users = await db.findObjects(CollectionId.Users, { +// _id: { $in: userIds }, +// }); + +// users = users.map((user) => { +// var c = structuredClone(user); +// c._id = user?._id?.toString(); +// c.teams = user.teams.map((id) => String(id)); +// return c; +// }); + +// const currentSeason = seasons[seasons.length - 1]; +// var comp = undefined; +// if (currentSeason) { +// comp = await db.findObjectById( +// CollectionId.Competitions, +// new ObjectId( +// currentSeason.competitions[currentSeason.competitions.length - 1], +// ), +// ); +// } + +// return { +// props: { +// team: resolved.team, +// users: makeObjSerializeable(users), +// currentCompetition: serializeDatabaseObject(comp), +// currentSeason: serializeDatabaseObject(currentSeason), +// pastSeasons: serializeDatabaseObjects(seasons), +// }, +// }; +// }; diff --git a/tests/lib/api/ClientApiUtils.test.ts b/tests/lib/api/ClientApiUtils.test.ts index 5c8256ae..5f640829 100644 --- a/tests/lib/api/ClientApiUtils.test.ts +++ b/tests/lib/api/ClientApiUtils.test.ts @@ -1,10 +1,11 @@ import { findObjectByIdFallback, + findObjectBySlugFallback, saveObjectAfterResponse, } from "@/lib/api/ClientApiUtils"; import CollectionId from "@/lib/client/CollectionId"; import { getTestApiUtils } from "@/lib/testutils/TestUtils"; -import { Report } from "@/lib/Types"; +import { Report, Team } from "@/lib/Types"; import { ObjectId } from "bson"; describe(saveObjectAfterResponse.name, () => { @@ -331,3 +332,80 @@ describe(findObjectByIdFallback.name, () => { expect(foundObjs).toEqual([obj1]); }); }); + +describe(findObjectBySlugFallback.name, () => { + test("Finds a single object by slug when passed a single slug", async () => { + const { db } = await getTestApiUtils(); + + const obj = { + _id: new ObjectId(), + slug: "test-slug", + } as any as Team; + + await db.addObject(CollectionId.Teams, obj); + + const foundObj = await findObjectBySlugFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Teams, + obj.slug!, + ); + + expect(foundObj).toEqual(obj); + }); + + test("Finds multiple objects by slug when passed an array of slugs", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + slug: "test-slug-1", + } as any as Team; + + const obj2 = { + _id: new ObjectId(), + slug: "test-slug-2", + } as any as Team; + + await db.addObject(CollectionId.Teams, obj1); + await db.addObject(CollectionId.Teams, obj2); + + const foundObjs = await findObjectBySlugFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Teams, + [obj1.slug!, obj2.slug!], + ); + + expect(foundObjs).toEqual([obj1, obj2]); + }); + + test("Returns undefined when no object is found for a single slug", async () => { + const { db } = await getTestApiUtils(); + + const foundObj = await findObjectBySlugFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Teams, + "non-existent-slug", + ); + + expect(foundObj).toBeUndefined(); + }); + + test("Returns only the existing objects when some slugs do not exist", async () => { + const { db } = await getTestApiUtils(); + + const obj1 = { + _id: new ObjectId(), + slug: "test-slug-1", + } as any as Team; + + await db.addObject(CollectionId.Teams, obj1); + + const foundObjs = await findObjectBySlugFallback( + { dbPromise: Promise.resolve(db) }, + CollectionId.Teams, + [obj1.slug!, "non-existent-slug"], + ); + + expect(foundObjs).toEqual([obj1]); + }); +}); From 310d1ecb01870a7bcdd13c51f774b8f2cf6ac0cf Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 24 Mar 2025 17:34:11 -0400 Subject: [PATCH 20/43] Fix build --- components/competition/MatchScheduleCard.tsx | 318 +++++++++---------- 1 file changed, 153 insertions(+), 165 deletions(-) diff --git a/components/competition/MatchScheduleCard.tsx b/components/competition/MatchScheduleCard.tsx index f3489b19..0e374d9b 100644 --- a/components/competition/MatchScheduleCard.tsx +++ b/components/competition/MatchScheduleCard.tsx @@ -43,183 +43,171 @@ function MatchCard({
-
-
-

- Match {match.number} -

- {isManager && ( - - )} -
-
-
- {match.reports.map((reportId) => { - const report = reportsById[reportId]; + > +
+
+

Match {match.number}

+ {isManager && ( + + )} +
+
+
+ {match.reports.map((reportId) => { + const report = reportsById[reportId]; - if (!report) - return ( - - ); + if (!report) + return ( + + ); - const submitted = report.submitted; - const mine = - session && report.user === session.user?._id; - const ours = report.robotNumber === team?.number; - let color = !submitted - ? report.color === AllianceColor.Red - ? "bg-red-500" - : "bg-blue-500" - : "bg-slate-500"; - color = ours - ? !report.submitted - ? "bg-purple-500" - : "bg-purple-300" - : color; + const submitted = report.submitted; + const mine = session && report.user === session.user?._id; + const ours = report.robotNumber === team?.number; + let color = !submitted + ? report.color === AllianceColor.Red + ? "bg-red-500" + : "bg-blue-500" + : "bg-slate-500"; + color = ours + ? !report.submitted + ? "bg-purple-500" + : "bg-purple-300" + : color; - const timeSinceCheckIn = - report.checkInTimestamp && - (new Date().getTime() - - new Date( - report.checkInTimestamp as any, - ).getTime()) / - 1000; + const timeSinceCheckIn = + report.checkInTimestamp && + (new Date().getTime() - + new Date(report.checkInTimestamp as any).getTime()) / + 1000; - return ( - -

{report.robotNumber}

-
- ); - })} -
+ > +

{report.robotNumber}

+ + ); + })} +
-
- {match.reports.map((reportId) => { - const report = reportsById[reportId]; - const user = usersById[report?.user!]; +
+ {match.reports.map((reportId) => { + const report = reportsById[reportId]; + const user = usersById[report?.user!]; - return ( -
- {user ? ( - remindUserOnSlack(user._id!)} - gearSize={25} - /> - ) : ( -
- )} -
- ); - })} -
-
-
- {match.subjectiveScouter && - usersById[match.subjectiveScouter] ? ( -
- {match.assignedSubjectiveScouterHasSubmitted ? ( - - ) : ( - match.subjectiveReportsCheckInTimestamps && - getIdsInProgressFromTimestamps( - match.subjectiveReportsCheckInTimestamps, - ).includes(match.subjectiveScouter) && ( -
- -
- ) - )} - {isManager && - usersById[match.subjectiveScouter ?? ""] - ?.slackId ? ( - - ) : ( -
- Subjective Scouter:{" "} - { - usersById[match.subjectiveScouter ?? ""].name - }{" "} -
- )} -
- ) : ( -
No subjective scouter assigned
- )} -
- -
-
- + {user ? ( + remindUserOnSlack(user._id!)} + gearSize={25} + /> + ) : ( +
+ )} +
+ ); + })} +
+
+
+ {match.subjectiveScouter && usersById[match.subjectiveScouter] ? ( +
+ {match.assignedSubjectiveScouterHasSubmitted ? ( + + ) : ( + match.subjectiveReportsCheckInTimestamps && + getIdsInProgressFromTimestamps( + match.subjectiveReportsCheckInTimestamps, + ).includes(match.subjectiveScouter) && ( +
+ +
+ ) + )} + {isManager && usersById[match.subjectiveScouter ?? ""]?.slackId ? ( + + ) : ( +
+ Subjective Scouter:{" "} + {usersById[match.subjectiveScouter ?? ""].name}{" "} +
+ )} +
+ ) : ( +
No subjective scouter assigned
+ )} +
+ +
+
+
- Add Subjective Report ( - {`${match.subjectiveReports ? match.subjectiveReports.length : 0} submitted, + href={`/${team?.slug}/${seasonSlug}/${compSlug}/${match._id}/subjective`} + > + Add Subjective Report ( + {`${match.subjectiveReports ? match.subjectiveReports.length : 0} submitted, ${ - match.subjectiveReportsCheckInTimestamps - ? getIdsInProgressFromTimestamps( - match.subjectiveReportsCheckInTimestamps, - ).length - : 0 - } in progress`} - ) - -
+ match.subjectiveReportsCheckInTimestamps + ? getIdsInProgressFromTimestamps( + match.subjectiveReportsCheckInTimestamps, + ).length + : 0 + } in progress`} + ) + +
); } From c5ab63ad27589bd6234768d1284998014eb49b91 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 24 Mar 2025 17:37:46 -0400 Subject: [PATCH 21/43] Fix build again --- lib/api/ClientApi.ts | 81 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 9f237b02..20a06fcd 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -712,7 +712,11 @@ export default class ClientApi extends NextApiTemplate { new ObjectId(compId), ); - if (!comp) return []; + if (!comp) + return { + quantReports: [], + pitReports: {}, + }; const usedComps = usePublicData && comp.tbaId !== NotLinkedToTba @@ -725,23 +729,66 @@ export default class ClientApi extends NextApiTemplate { usedComps.push(comp); - const reports = ( - await db.findObjects(CollectionId.Reports, { - match: { $in: usedComps.flatMap((m) => m.matches) }, - submitted: submitted ? true : { $exists: true }, - }) - ) - // Filter out comments from other competitions - .map((report) => - comp.matches.includes(report.match) - ? report - : { ...report, data: { ...report.data, comments: "" } }, - ); + const [reports, pitReports] = await Promise.all([ + ( + await db.findObjects(CollectionId.Reports, { + match: { $in: usedComps.flatMap((m) => m.matches) }, + submitted: submitted ? true : { $exists: true }, + }) + ) + // Filter out comments from other competitions + .map((report) => + comp.matches.includes(report.match) + ? report + : { ...report, data: { ...report.data, comments: "" } }, + ), + ( + await db.findObjects(CollectionId.PitReports, { + _id: { + $in: usedComps + .flatMap((m) => m.pitReports) + .map((id) => new ObjectId(id)), + }, + submitted: submitted ? true : { $exists: true }, + }) + ) + .map((pitReport) => + comp.pitReports.includes(pitReport._id!.toString()) + ? pitReport + : ({ + ...pitReport, + data: { ...pitReport.data, comments: "" }, + } as Pitreport), + ) + .reduce( + (dict, pitReport) => { + dict[pitReport.teamNumber] ??= []; + dict[pitReport.teamNumber].push(pitReport); + return dict; + }, + {} as { [team: number]: Pitreport[] }, + ), + ]); - return reports; + return { + quantReports: reports, + pitReports: pitReports, + }; + }, + afterResponse: async (deps, res, ranFallback) => { + saveObjectAfterResponse( + deps, + CollectionId.Reports, + res.quantReports, + ranFallback, + ), + saveObjectAfterResponse( + deps, + CollectionId.PitReports, + Object.values(res.pitReports).flat(), + ranFallback, + ); }, - afterResponse: async (deps, res, ranFallback) => - saveObjectAfterResponse(deps, CollectionId.Reports, res, ranFallback), }); allCompetitionMatches = createNextRoute< @@ -2790,7 +2837,7 @@ export default class ClientApi extends NextApiTemplate { }, }); - findCompSeasonAndTeamByCompSlug = createNextRoute< + findCompSeasonAndTeamByCompSlug = createNextRoute< [string], { comp: Competition; season: Season; team: Team } | undefined, ApiDependencies, From e34a4b5eddc896b3a03e0e41d6ce8bd73e536d2e Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 24 Mar 2025 17:55:28 -0400 Subject: [PATCH 22/43] Fix unit tests --- lib/api/ClientApiUtils.ts | 2 +- pages/[teamSlug]/index.tsx | 11 ++++++++--- tests/lib/api/ClientApiUtils.test.ts | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/api/ClientApiUtils.ts b/lib/api/ClientApiUtils.ts index a728665a..dfead672 100644 --- a/lib/api/ClientApiUtils.ts +++ b/lib/api/ClientApiUtils.ts @@ -72,7 +72,7 @@ export async function findObjectBySlugFallback< if (Array.isArray(slug)) { return Promise.all( slug.map((s) => db.findObjectBySlug(collectionId, s)), - ) as Promise; + ).then((objs) => objs.filter((obj) => obj != undefined)) as Promise; } return db.findObjectBySlug(collectionId, slug) as Promise; } diff --git a/pages/[teamSlug]/index.tsx b/pages/[teamSlug]/index.tsx index 33603540..64d81c5d 100644 --- a/pages/[teamSlug]/index.tsx +++ b/pages/[teamSlug]/index.tsx @@ -478,6 +478,11 @@ export default function TeamIndex() { }); }, [router.query]); + const tabProps: TeamPageProps = { + team, + currentSeason: team?.seasons[team.seasons.length - 1], + }; + return ( ) : ( <> )} - {page === 1 ? : <>} - {page === 2 ? : <>} + {page === 1 ? : <>} + {page === 2 ? : <>}
); diff --git a/tests/lib/api/ClientApiUtils.test.ts b/tests/lib/api/ClientApiUtils.test.ts index 5f640829..02e5c645 100644 --- a/tests/lib/api/ClientApiUtils.test.ts +++ b/tests/lib/api/ClientApiUtils.test.ts @@ -4,6 +4,7 @@ import { saveObjectAfterResponse, } from "@/lib/api/ClientApiUtils"; import CollectionId from "@/lib/client/CollectionId"; +import { slugToId } from "@/lib/slugToId"; import { getTestApiUtils } from "@/lib/testutils/TestUtils"; import { Report, Team } from "@/lib/Types"; import { ObjectId } from "bson"; @@ -395,7 +396,7 @@ describe(findObjectBySlugFallback.name, () => { const obj1 = { _id: new ObjectId(), - slug: "test-slug-1", + slug: "test-slug-3", // Don't overlap with other test slugs } as any as Team; await db.addObject(CollectionId.Teams, obj1); From b27b5392ed7adeb91265d70b25b88fd115b04593 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Mon, 24 Mar 2025 18:00:50 -0400 Subject: [PATCH 23/43] Team page now fetches props --- pages/[teamSlug]/index.tsx | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/pages/[teamSlug]/index.tsx b/pages/[teamSlug]/index.tsx index 64d81c5d..ae4eb461 100644 --- a/pages/[teamSlug]/index.tsx +++ b/pages/[teamSlug]/index.tsx @@ -461,26 +461,51 @@ export default function TeamIndex() { const router = useRouter(); const [team, setTeam] = useState(); + const [seasons, setSeasons] = useState(); + const [mostRecentComp, setMostRecentComp] = useState(); + const [users, setUsers] = useState(); const isFrc = team?.tbaId || team?.league === League.FRC; const [page, setPage] = useState(0); - const isManager = team?.owners.includes(session?.user?._id as string); + const isManager = + team?.owners.includes(session?.user?._id as string) ?? false; useEffect(() => { if (!router.query.teamSlug) return; - api.findTeamBySlug(router.query.teamSlug as string).then((team) => { + api.findTeamBySlug(router.query.teamSlug as string).then(async (team) => { if (!team) return; setTeam(team); + + api.findBulkUsersById(team.users).then(setUsers); + + const seasons = ( + await Promise.all( + team.seasons.map((seasonId) => api.findSeasonById(seasonId)), + ) + ).filter((season) => season != undefined) as Season[]; + + setSeasons(seasons); + + const mostRecentComp = await api.findCompetitionById( + seasons[seasons.length - 1].competitions[ + seasons[seasons.length - 1].competitions.length - 1 + ], + ); + setMostRecentComp(mostRecentComp); }); }, [router.query]); const tabProps: TeamPageProps = { team, - currentSeason: team?.seasons[team.seasons.length - 1], + currentSeason: seasons?.[seasons.length - 1], + currentCompetition: mostRecentComp, + pastSeasons: seasons, + users, + isManager, }; return ( From 079b6153af544dee5baf023c23194d2faf89f865 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 25 Mar 2025 16:30:31 -0400 Subject: [PATCH 24/43] Make season page load offline --- lib/api/AccessLevels.ts | 29 +++++++++ lib/api/ClientApi.ts | 71 +++++++++++++++++++++ pages/[teamSlug]/[seasonSlug]/index.tsx | 83 ++++++++++++++----------- tests/lib/api/AccessLevels.test.ts | 55 ++++++++++++++++ 4 files changed, 202 insertions(+), 36 deletions(-) diff --git a/lib/api/AccessLevels.ts b/lib/api/AccessLevels.ts index df6e5039..75dbd821 100644 --- a/lib/api/AccessLevels.ts +++ b/lib/api/AccessLevels.ts @@ -161,6 +161,35 @@ namespace AccessLevels { }; } + export async function IfSeasonOwnerBySlug( + req: NextApiRequest, + res: NextResponse, + { userPromise, db }: UserAndDb, + seasonSlug: string, + ) { + const user = await userPromise; + if (!user) { + return { authorized: false, authData: undefined }; + } + + const season = await ( + await db + ).findObjectBySlug(CollectionId.Seasons, seasonSlug); + if (!season) { + return { authorized: false, authData: undefined }; + } + + const team = await getTeamFromSeason(await db, season); + if (!team) { + return { authorized: false, authData: undefined }; + } + + return { + authorized: team.owners.includes(user._id?.toString()!), + authData: { team, season }, + }; + } + export async function IfMatchOwner( req: NextApiRequest, res: NextResponse, diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 20a06fcd..c99c2989 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -31,6 +31,7 @@ import { getTeamFromComp, getTeamFromMatch, getTeamFromReport, + getTeamFromSeason, onTeam, ownsTeam, } from "./ApiUtils"; @@ -2916,4 +2917,74 @@ export default class ClientApi extends NextApiTemplate { ]); }, }); + + getSeasonPageData = createNextRoute< + [string], + { team?: Team; season?: Season; comps?: Competition[] }, + ApiDependencies, + { team: Team; season: Season }, + LocalApiDependencies + >({ + isAuthorized: (req, res, deps, [seasonSlug]) => + AccessLevels.IfSeasonOwnerBySlug(req, res, deps, seasonSlug), + handler: async ( + req, + res, + { db: dbPromise }, + { team, season }, + [seasonId], + ) => { + const db = await dbPromise; + + const comps = await db.findObjects(CollectionId.Competitions, { + _id: { $in: season.competitions.map((id) => new ObjectId(id)) }, + }); + + return res.status(200).send({ team, season, comps }); + }, + afterResponse: async (deps, res, ranFallback) => { + if (!res || !ranFallback) return; + + const { team, season, comps } = res; + + saveObjectAfterResponse(deps, CollectionId.Teams, team, ranFallback); + saveObjectAfterResponse(deps, CollectionId.Seasons, season, ranFallback); + saveObjectAfterResponse( + deps, + CollectionId.Competitions, + comps, + ranFallback, + ); + }, + fallback: async ({ dbPromise }, [seasonSlug]) => { + const db = await dbPromise; + + const season = await db.findObjectBySlug( + CollectionId.Seasons, + seasonSlug, + ); + + if (!season) + return { + team: undefined, + season: undefined, + comps: undefined, + }; + + const comps = await db.findObjects(CollectionId.Competitions, { + _id: { $in: season.competitions.map((id) => new ObjectId(id)) }, + }); + + const team = await getTeamFromSeason(db, season); + + if (!team) + return { + team: undefined, + season: undefined, + comps: undefined, + }; + + return { team, season, comps }; + }, + }); } diff --git a/pages/[teamSlug]/[seasonSlug]/index.tsx b/pages/[teamSlug]/[seasonSlug]/index.tsx index 50c97d8b..0a8f8309 100644 --- a/pages/[teamSlug]/[seasonSlug]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/index.tsx @@ -7,29 +7,44 @@ import Link from "next/link"; import { useCurrentSession } from "@/lib/client/useCurrentSession"; import Flex from "@/components/Flex"; import Card from "@/components/Card"; -import { getDatabase } from "@/lib/MongoDB"; -import CollectionId from "@/lib/client/CollectionId"; import CompetitionCard from "@/components/CompetitionCard"; import Loading from "@/components/Loading"; import { FaPlus, FaTrash } from "react-icons/fa"; -import { ObjectId } from "bson"; import toast from "react-hot-toast"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; const api = new ClientApi(); -type SeasonPageProps = { - team: Team; - season: Season; - competitions: Competition[]; -}; - -export default function Home(props: SeasonPageProps) { +export default function Home() { const { session, status } = useCurrentSession(); - const team = props.team; - const season = props.season; - const comps = props.competitions; + const router = useRouter(); + + const [team, setTeam] = useState(); + const [season, setSeason] = useState(); + const [comps, setComps] = useState(); + const owner = team?.owners.includes(session?.user?._id as string); + useEffect(() => { + // Find the team, season, and competitions + + const { seasonSlug } = router.query; + if (!seasonSlug) return; + + api.getSeasonPageData(seasonSlug as string).then((data) => { + if (!data) { + toast.error("Season not found."); + return; + } + + const { team, season, comps } = data; + setTeam(team); + setSeason(season); + setComps(comps); + }); + }, [router.query.seasonSlug]); + function deleteSeason() { if (!season?._id) return; @@ -52,6 +67,25 @@ export default function Home(props: SeasonPageProps) { } else toast.error("Season not deleted."); } + if (!team || !season || !comps) { + return ( + + + + + + + + ); + } + return ( ); } - -export const getServerSideProps: GetServerSideProps = async (context) => { - const db = await getDatabase(); - const resolved = await UrlResolver(context, 2); - if ("redirect" in resolved) { - return resolved; - } - - const team = resolved.team; - const season = resolved.season; - - const comp = await db.findObjects(CollectionId.Competitions, { - _id: { $in: season?.competitions.map((id) => new ObjectId(id)) }, - }); - - return { - props: { - team: team, - season: season, - competitions: serializeDatabaseObjects(comp), - }, - }; -}; diff --git a/tests/lib/api/AccessLevels.test.ts b/tests/lib/api/AccessLevels.test.ts index 4e06ed87..1daa000a 100644 --- a/tests/lib/api/AccessLevels.test.ts +++ b/tests/lib/api/AccessLevels.test.ts @@ -320,6 +320,61 @@ describe(`AccessLevels.${AccessLevels.IfSeasonOwner.name}`, () => { returnsFalseIfDocumentDoesNotExist(AccessLevels.IfSeasonOwner)); }); +describe(`AccessLevels.${AccessLevels.IfSeasonOwnerBySlug.name}`, () => { + test(`AccessLevels.${AccessLevels.IfSeasonOwnerBySlug}: Returns false if user does not own season`, async () => { + const { res, user, db } = await getTestApiUtils(); + + const season = await db.addObject( + CollectionId.Seasons, + {} as any as Season, + ); + await db.addObject(CollectionId.Teams, { + seasons: [season._id!.toString()], + owners: [], + } as any as Team); + + expect( + ( + await AccessLevels.IfSeasonOwnerBySlug( + undefined as any, + res, + { userPromise: Promise.resolve(user), db: Promise.resolve(db) }, + season.slug!, + ) + ).authorized, + ).toBe(false); + }); + + test(`AccessLevels.${AccessLevels.IfSeasonOwnerBySlug}: Returns true if user owns season`, async () => { + const { res, user, db } = await getTestApiUtils(); + + const season = await db.addObject(CollectionId.Seasons, { + slug: "test-slug", + } as any as Season); + await db.addObject(CollectionId.Teams, { + seasons: [season._id!.toString()], + owners: [user._id!.toString()], + } as any as Team); + + expect( + ( + await AccessLevels.IfSeasonOwnerBySlug( + undefined as any, + res, + { userPromise: Promise.resolve(user), db: Promise.resolve(db) }, + season.slug!, + ) + ).authorized, + ).toBe(true); + }); + + test(`AccessLevels.${AccessLevels.IfSeasonOwnerBySlug}: Returns false if user is not signed in`, () => + returnsFalseIfUserIsNotSignedIn(AccessLevels.IfSeasonOwnerBySlug)); + + test(`AccessLevels.${AccessLevels.IfSeasonOwnerBySlug}: Returns false if season does not exist`, () => + returnsFalseIfDocumentDoesNotExist(AccessLevels.IfSeasonOwnerBySlug)); +}); + describe(`AccessLevels.${AccessLevels.IfMatchOwner.name}`, () => { test(`AccessLevels.${AccessLevels.IfMatchOwner}: Returns false if user does not own match`, async () => { const { res, user, db } = await getTestApiUtils(); From 98f12b6063ec07a88487854190f36e4755c12d62 Mon Sep 17 00:00:00 2001 From: renatodellosso Date: Tue, 25 Mar 2025 18:06:57 -0400 Subject: [PATCH 25/43] Start offline pit reports --- lib/api/ClientApi.ts | 91 ++++++++- lib/api/GearboxRequestHelper.ts | 2 + lib/api/LocalApiDependencies.ts | 2 + lib/client/LocalStorageInterface.ts | 36 ++++ .../[competitonSlug]/pit/[...pitreportId].tsx | 182 +++++++++--------- 5 files changed, 220 insertions(+), 93 deletions(-) create mode 100644 lib/client/LocalStorageInterface.ts diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index c99c2989..1a1e5a07 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -2461,7 +2461,8 @@ export default class ClientApi extends NextApiTemplate { [string, object], { result: string }, ApiDependencies, - { team: Team; comp: Competition } + { team: Team; comp: Competition }, + LocalApiDependencies >({ isAuthorized: (req, res, deps, [pitreportId]) => AccessLevels.IfOnTeamThatOwnsPitReport(req, res, deps, pitreportId), @@ -2481,6 +2482,20 @@ export default class ClientApi extends NextApiTemplate { ); return res.status(200).send({ result: "success" }); }, + beforeCall: async ({ dbPromise }, [pitreportId, newReport]) => { + if (typeof pitreportId != "string") + throw new Error("Pit report ID is not a string"); + + // Await the promise to ensure we don't leave the page before updating the report + await ( + await dbPromise + ).addOrUpdateObject(CollectionId.PitReports, { + _id: new ObjectId(pitreportId) as any as string, + ...newReport, + } as Pitreport); + + return Promise.resolve([pitreportId, newReport]); + }, }); speedTest = createNextRoute< @@ -2987,4 +3002,78 @@ export default class ClientApi extends NextApiTemplate { return { team, season, comps }; }, }); + + getPitReportPageData = createNextRoute< + [string], + { + pitReport?: Pitreport; + compName?: string; + gameId?: GameId; + teamNumber?: number; + }, + ApiDependencies, + { team: Team; comp: Competition; pitReport: Pitreport }, + LocalApiDependencies + >({ + isAuthorized: (req, res, deps, [pitReportId]) => + AccessLevels.IfOnTeamThatOwnsPitReport(req, res, deps, pitReportId), + handler: async ( + req, + res, + { db: dbPromise }, + { team, comp, pitReport }, + [pitReportId], + ) => { + const db = await dbPromise; + + const season = await getSeasonFromComp(db, comp); + console.log("Season", season); + + if (!season) return res.status(404).send({ error: "Season not found" }); + + return res.status(200).send({ + pitReport, + compName: comp.name, + gameId: season.gameId, + teamNumber: team.number, + }); + }, + afterResponse: async (deps, res, ranFallback) => { + if (!res || ranFallback) return; + + const { pitReport, compName, gameId, teamNumber } = res; + if (!pitReport) return; + + saveObjectAfterResponse( + deps, + CollectionId.PitReports, + pitReport, + ranFallback, + ); + + deps.localStorage.set(`${pitReport._id!.toString()}.compName`, compName); + deps.localStorage.set(`${pitReport._id!.toString()}.gameId`, gameId); + deps.localStorage.set( + `${pitReport._id!.toString()}.teamNumber`, + teamNumber, + ); + }, + fallback: async ({ dbPromise, localStorage }, [pitReportId]) => { + const db = await dbPromise; + + const pitReport = await db.findObjectById( + CollectionId.PitReports, + new ObjectId(pitReportId), + ); + if (!pitReport) return {}; + + const [compName, gameId, teamNumber] = await Promise.all([ + localStorage.get(`${pitReport._id!.toString()}.compName`), + localStorage.get(`${pitReport._id!.toString()}.gameId`), + localStorage.get(`${pitReport._id!.toString()}.teamNumber`), + ]); + + return { pitReport, compName, gameId, teamNumber }; + }, + }); } diff --git a/lib/api/GearboxRequestHelper.ts b/lib/api/GearboxRequestHelper.ts index a2ac2e17..8ff73b54 100644 --- a/lib/api/GearboxRequestHelper.ts +++ b/lib/api/GearboxRequestHelper.ts @@ -2,6 +2,7 @@ import toast from "react-hot-toast"; import { RequestHelper } from "unified-api"; import LocalStorageDbInterface from "../client/dbinterfaces/LocalStorageDbInterface"; import LocalApiDependencies from "./LocalApiDependencies"; +import { LocalStorage } from "../client/LocalStorageInterface"; export default class GearboxRequestHelper extends RequestHelper { constructor() { super( @@ -22,6 +23,7 @@ export default class GearboxRequestHelper extends RequestHelper { return { dbPromise, + localStorage: new LocalStorage(window.localStorage), } as LocalApiDependencies; } } diff --git a/lib/api/LocalApiDependencies.ts b/lib/api/LocalApiDependencies.ts index 1d368347..5e855621 100644 --- a/lib/api/LocalApiDependencies.ts +++ b/lib/api/LocalApiDependencies.ts @@ -1,7 +1,9 @@ import DbInterface from "../client/dbinterfaces/DbInterface"; +import LocalStorageInterface from "../client/LocalStorageInterface"; type LocalApiDependencies = { dbPromise: Promise; + localStorage: LocalStorageInterface; }; export default LocalApiDependencies; diff --git a/lib/client/LocalStorageInterface.ts b/lib/client/LocalStorageInterface.ts new file mode 100644 index 00000000..b50572fd --- /dev/null +++ b/lib/client/LocalStorageInterface.ts @@ -0,0 +1,36 @@ +export default interface LocalStorageInterface { + get: (key: string) => Promise; + set: (key: string, value: T) => Promise; +} + +export class MockLocalStorage implements LocalStorageInterface { + private storage: Record = {}; + + async get(key: string): Promise { + return this.storage[key]; + } + + async set(key: string, value: T): Promise { + this.storage[key] = value; + } +} + +export class LocalStorage implements LocalStorageInterface { + private storage: Storage; + + constructor(storage: Storage) { + this.storage = storage; + } + + async get(key: string): Promise { + const value = this.storage.getItem(key); + if (value) { + return JSON.parse(value) as T; + } + return undefined; + } + + async set(key: string, value: T): Promise { + this.storage.setItem(key, JSON.stringify(value)); + } +} diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx index cefe76a4..03ebd8aa 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/pit/[...pitreportId].tsx @@ -8,45 +8,53 @@ import { Pitreport, PitReportData, } from "@/lib/Types"; -import { useState, useCallback, Fragment } from "react"; +import { useState, useCallback, Fragment, useEffect } from "react"; import { FaRobot } from "react-icons/fa"; import { Analytics } from "@/lib/client/Analytics"; -import { - camelCaseToTitleCase, - makeObjSerializeable, -} from "@/lib/client/ClientUtils"; +import { camelCaseToTitleCase } from "@/lib/client/ClientUtils"; import { GameId } from "@/lib/client/GameId"; -import CollectionId from "@/lib/client/CollectionId"; import { games } from "@/lib/games"; -import { getDatabase } from "@/lib/MongoDB"; -import UrlResolver, { serializeDatabaseObject } from "@/lib/UrlResolver"; -import { ObjectId } from "bson"; -import { GetServerSideProps } from "next"; import Flex from "@/components/Flex"; import Checkbox from "@/components/forms/Checkboxes"; import FieldPositionSelector from "@/components/forms/FieldPositionSelector"; import ImageUpload from "@/components/forms/ImageUpload"; import Card from "@/components/Card"; import Container from "@/components/Container"; +import { useRouter } from "next/router"; +import Loading from "@/components/Loading"; const api = new ClientApi(); -export default function PitReportForm(props: { - pitReport: Pitreport; - layout: FormLayout; - compId?: string; - usersteamNumber?: number; - compName?: string; - username?: string; - game: Game; -}) { +export default function PitReportForm() { const { session } = useCurrentSession(); + const router = useRouter(); + + const [pitReport, setPitReport] = useState(); + const [compName, setCompName] = useState(); + const [teamNumber, setTeamNumber] = useState(); + const [game, setGame] = useState(); + + const username = session?.user?.name; + + useEffect(() => { + // Fetch page data + + const pitReportId = (router.query.pitreportId as string[0])?.[0]; + if (!pitReportId) return; - const [pitreport, setPitreport] = useState(props.pitReport); + api.getPitReportPageData(pitReportId as string).then((data) => { + if (!data) return; + + setPitReport(data.pitReport); + setCompName(data.compName); + setTeamNumber(data.teamNumber); + setGame(games[data.gameId as GameId]); + }); + }, [router.query.pitreportId]); const setCallback = useCallback( (key: any, value: boolean | string | number | object) => { - setPitreport((old) => { + setPitReport((old) => { let copy = structuredClone(old); //@ts-expect-error copy.data[key] = value; @@ -57,22 +65,23 @@ export default function PitReportForm(props: { ); async function submit() { + if (!pitReport) return; + // Remove _id from object - const { _id, ...report } = pitreport; + const { _id, ...report } = pitReport; - console.log("Submitting pitreport", report); api - .updatePitreport(props.pitReport?._id!, { + .updatePitreport(pitReport?._id!, { ...report, submitted: true, submitter: session?.user?._id, }) .then(() => { Analytics.pitReportSubmitted( - pitreport.teamNumber, - props.usersteamNumber ?? -1, - props.compName ?? "Unknown", - props.username ?? "Unknown", + pitReport.teamNumber, + teamNumber ?? -1, + compName ?? "Unknown", + username ?? "Unknown", ); }) .finally(() => { @@ -94,7 +103,7 @@ export default function PitReportForm(props: { return ( ); @@ -105,7 +114,7 @@ export default function PitReportForm(props: { key={index} label={element.label ?? (element.key as string)} dataKey={key} - data={pitreport} + data={pitReport!} callback={setCallback} divider={!isLastInHeader} /> @@ -123,7 +132,7 @@ export default function PitReportForm(props: { setCallback(key, e.target.value)} type="number" className="input input-bordered" @@ -136,7 +145,7 @@ export default function PitReportForm(props: { return (