diff --git a/.env.production b/.env.production index c9800a26..4bc8765d 100644 --- a/.env.production +++ b/.env.production @@ -2,4 +2,5 @@ # We have to include these at build time, so this file is used to inject them into the build process. NEXT_PUBLIC_API_URL=/api/ NEXT_PUBLIC_SLACK_CLIENT_ID=10831824934.7404945710466 -NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-1BFJYBDC76 \ No newline at end of file +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-1BFJYBDC76 +NEXT_PUBLIC_RECAPTCHA_KEY=6Le63OUqAAAAABxxDrbaU9OywDLLHqutVwbw7a9d \ No newline at end of file diff --git a/.env.test b/.env.test index 67bb80a9..3eae1f85 100644 --- a/.env.test +++ b/.env.test @@ -4,4 +4,6 @@ DEVELOPER_EMAILS=["test@gmail.com"] TOA_URL=https://example.com TOA_APP_ID=123 -TOA_KEY=456 \ No newline at end of file +TOA_KEY=456 + +DEFAULT_IMAGE=https://example.com/default.jpg \ No newline at end of file diff --git a/components/Container.tsx b/components/Container.tsx index a3ee606d..e3c29d1c 100644 --- a/components/Container.tsx +++ b/components/Container.tsx @@ -74,11 +74,6 @@ export default function Container(props: ContainerProps) { }, [eventSearch]); useEffect(() => { - if (window.location.href.includes("signin")) { - console.log("triggered"); - location.reload(); - } - const loadTeams = async () => { if (!user) { return; @@ -231,7 +226,7 @@ export default function Container(props: ContainerProps) { ) : ( @@ -268,7 +263,7 @@ export default function Container(props: ContainerProps) {

Wait a minute...

You need to sign in first!

- +
diff --git a/components/competition/InsightsAndSettingsCard.tsx b/components/competition/InsightsAndSettingsCard.tsx index 8cd8f25b..6d7d10a8 100644 --- a/components/competition/InsightsAndSettingsCard.tsx +++ b/components/competition/InsightsAndSettingsCard.tsx @@ -1,89 +1,136 @@ -import { NotLinkedToTba } from "@/lib/client/ClientUtils"; +import { download, NotLinkedToTba } from "@/lib/client/ClientUtils"; import { defaultGameId } from "@/lib/client/GameId"; import { Round } from "@/lib/client/StatsMath"; import { games } from "@/lib/games"; -import { Competition, Pitreport, Report, Team } from "@/lib/Types"; +import { Competition, MatchType, Pitreport, Report, Team } from "@/lib/Types"; import Link from "next/link"; -import { ChangeEvent } from "react"; +import { ChangeEvent, useState } from "react"; import { BsGearFill, BsClipboard2Check } from "react-icons/bs"; import { FaSync, FaBinoculars, FaUserCheck, FaDatabase } from "react-icons/fa"; import { FaUserGroup } from "react-icons/fa6"; +import ClientApi from "@/lib/api/ClientApi"; + +const api = new ClientApi(); export default function InsightsAndSettingsCard(props: { - showSettings: boolean; - setShowSettings: (value: boolean) => void; isManager: boolean | undefined; comp: Competition | undefined; reloadCompetition: () => void; assignScouters: () => void; - exportAsCsv: () => void; - exportPending: boolean; showSubmittedMatches: boolean; toggleShowSubmittedMatches: () => void; assigningMatches: boolean; regeneratePitReports: () => void; - newCompName: string | undefined; - setNewCompName: (value: string) => void; - newCompTbaId: string | undefined; - setNewCompTbaId: (value: string) => void; - saveCompChanges: () => void; - redAlliance: number[]; - setRedAlliance: (value: number[]) => void; - blueAlliance: number[]; - setBlueAlliance: (value: number[]) => void; - matchNumber: number | undefined; - setMatchNumber: (value: number) => void; - createMatch: () => void; - teamToAdd: number; - setTeamToAdd: (value: number) => void; - addTeam: () => void; submittedReports: number | undefined; reports: Report[]; loadingScoutStats: boolean; pitreports: Pitreport[]; submittedPitreports: number | undefined; - togglePublicData: (e: ChangeEvent) => void; seasonSlug: string | undefined; team: Team | undefined; - allianceIndices: number[]; }) { + const [showSettings, setShowSettings] = useState(false); const { - showSettings, - setShowSettings, isManager, comp, reloadCompetition, assignScouters, - exportAsCsv, - exportPending, showSubmittedMatches, toggleShowSubmittedMatches, assigningMatches, - newCompName, - setNewCompName, - newCompTbaId, - setNewCompTbaId, - saveCompChanges, - redAlliance, - setRedAlliance, - blueAlliance, - setBlueAlliance, - matchNumber, - setMatchNumber, - createMatch, - teamToAdd, - setTeamToAdd, - addTeam, submittedReports, reports, loadingScoutStats, pitreports, submittedPitreports, - togglePublicData, seasonSlug, team, - allianceIndices, } = props; + const [newCompName, setNewCompName] = useState(comp?.name); + const [newCompTbaId, setNewCompTbaId] = useState(comp?.tbaId); + const [exportPending, setExportPending] = useState(false); + const [teamToAdd, setTeamToAdd] = useState(0); + const [blueAlliance, setBlueAlliance] = useState([]); + const [redAlliance, setRedAlliance] = useState([]); + const [matchNumber, setMatchNumber] = useState(undefined); + + const exportAsCsv = async () => { + setExportPending(true); + + const res = await api.exportCompAsCsv(comp?._id!).catch((e) => { + console.error(e); + return { csv: undefined }; + }); + + if (!res) { + console.error("failed to export"); + } + + if (res.csv) { + download(`${comp?.name ?? "Competition"}.csv`, res.csv, "text/csv"); + } else { + console.error("No CSV data returned from server"); + } + + setExportPending(false); + }; + + const createMatch = async () => { + try { + await api.createMatch( + comp?._id!, + Number(matchNumber), + 0, + MatchType.Qualifying, + blueAlliance as number[], + redAlliance as number[], + ); + } catch (e) { + console.error(e); + } + + location.reload(); + }; + + const allianceIndices: number[] = []; + for (let i = 0; i < games[comp?.gameId ?? defaultGameId].allianceSize; i++) { + allianceIndices.push(i); + } + + async function saveCompChanges() { + // Check if tbaId is valid + if (!comp?.tbaId || !comp?.name || !comp?._id) return; + + let tbaId = newCompTbaId; + const autoFillData = await api.competitionAutofill(tbaId ?? ""); + if ( + !autoFillData?.name && + !confirm(`Invalid TBA ID: ${tbaId}. Save changes anyway?`) + ) + return; + + await api.updateCompNameAndTbaId( + comp?._id, + newCompName ?? "Unnamed", + tbaId ?? NotLinkedToTba, + ); + location.reload(); + } + + function togglePublicData(e: ChangeEvent) { + if (!comp?._id) return; + api.setCompPublicData(comp?._id, e.target.checked); + } + + function addTeam() { + console.log("Adding pit report for team", teamToAdd); + if (!teamToAdd || teamToAdd < 1 || !comp?._id) return; + + api + .createPitReportForTeam(teamToAdd, comp?._id) + // We can't just pass location.reload, it will throw "illegal invocation." I don't know why. -Renato + .finally(() => location.reload()); + } return (
@@ -367,7 +414,7 @@ export default function InsightsAndSettingsCard(props: { ? (+( Round(submittedReports / reports.length) * 100 )).toFixed(0) - : "?"} + : "0"} %
@@ -394,7 +441,7 @@ export default function InsightsAndSettingsCard(props: { ? (+( Round(submittedReports / reports.length) * 100 )).toFixed(0) - : "?"} + : "0"} %
@@ -412,12 +459,9 @@ export default function InsightsAndSettingsCard(props: {
- {!submittedPitreports && submittedPitreports !== 0 - ? "?" - : submittedPitreports} - / + {!submittedPitreports ? "0" : submittedPitreports}/ {!pitreports || pitreports.length === 0 - ? "?" + ? "0" : pitreports.length}
diff --git a/components/competition/MatchScheduleCard.tsx b/components/competition/MatchScheduleCard.tsx index 21f12dcf..f58876fc 100644 --- a/components/competition/MatchScheduleCard.tsx +++ b/components/competition/MatchScheduleCard.tsx @@ -14,6 +14,7 @@ import { MdErrorOutline } from "react-icons/md"; import Avatar from "../Avatar"; import Loading from "../Loading"; import { AdvancedSession } from "@/lib/client/useCurrentSession"; +import { useEffect } from "react"; export default function MatchScheduleCard(props: { team: Team | undefined; @@ -62,11 +63,14 @@ export default function MatchScheduleCard(props: { showSubmittedMatches, } = props; - const displayedMatches = showSubmittedMatches - ? matches - : matches.filter((match) => - match.reports.some((reportId) => !reportsById[reportId]?.submitted), - ); + const unsubmittedMatches: Match[] = []; + + for (const match of matches) { + if (match.reports.some((reportId) => !reportsById[reportId]?.submitted)) + unsubmittedMatches.push(match); + } + + const displayedMatches = showSubmittedMatches ? matches : unsubmittedMatches; return (
@@ -80,38 +84,24 @@ export default function MatchScheduleCard(props: { )} {isManager && - matchesAssigned === false && - Object.keys(usersById).length >= 6 ? ( - matchesAssigned !== undefined ? ( + matchesAssigned === false && + Object.keys(usersById).length >= 6 && + (!assigningMatches ? (
-
- {!assigningMatches - ? "Matches are not assigned" - : "Assigning matches"} -
- {!assigningMatches ? ( - - ) : ( - - )} +
Matches are not assigned.
+
) : ( - ) - ) : ( - <> - )} + ))}
{loadingMatches || loadingReports || loadingUsers ? (
diff --git a/environment.d.ts b/environment.d.ts index 4b552d1e..ca36c2f7 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -49,6 +49,9 @@ declare global { DEVELOPER_EMAILS: string; + NEXT_PUBLIC_RECAPTCHA_KEY: string; + RECAPTCHA_SECRET: string; + NODE_ENV: "development" | "production"; } } diff --git a/index.ts b/index.ts index 1414cd66..1ba75545 100644 --- a/index.ts +++ b/index.ts @@ -9,12 +9,15 @@ import { request, createServer as createServerHttp, } from "http"; +import Logger from "./lib/client/Logger"; -console.log("Starting server..."); +const logger = new Logger(["STARTUP"]); + +logger.log("Starting server..."); const dev = process.env.NODE_ENV !== "production"; -console.log("Constants set"); +logger.debug("Constants set"); const useHttps = existsSync("./certs/key.pem") && existsSync("./certs/cert.pem"); @@ -27,26 +30,28 @@ const httpsOptions = useHttps : {}; const port = useHttps ? 443 : 80; -console.log(`Using port ${port}`); +logger.debug(`Using port ${port}`); const app = next({ dev, port }); const handle = app.getRequestHandler(); -console.log("App preparing..."); +logger.debug("App preparing..."); app.prepare().then(() => { - console.log("App prepared. Creating server..."); + logger.debug("App prepared. Creating server..."); + + const ioLogger = new Logger(["NETWORKIO"]); async function handleRaw( req: IncomingMessage, res: ServerResponse, ) { const start = Date.now(); - console.log(`IN: ${req.method} ${req.url}`); + ioLogger.debug(`IN: ${req.method} ${req.url}`); if (!req.url) return; const parsedUrl = parse(req.url, true); handle(req, res, parsedUrl).then(() => - console.log( + ioLogger.debug( `OUT: ${req.method} ${req.url} ${res.statusCode} in ${Date.now() - start}ms`, ), ); @@ -59,20 +64,20 @@ app.prepare().then(() => { : createServerHttp(handleRaw) ) .listen(port, () => { - console.log( + logger.info( process.env.NODE_ENV + ` Server Running At: ${useHttps ? "https" : "http"}://localhost:` + port, ); }) .on("error", (err: Error) => { - console.log(err); + logger.error(err); throw err; }); - console.log("Server created. Listening: " + server.listening); + logger.debug("Server created. Listening: " + server.listening); } catch (err) { - console.log(err); + logger.error(err); throw err; } }); diff --git a/lib/Auth.ts b/lib/Auth.ts index 4900d320..5f7fdfef 100644 --- a/lib/Auth.ts +++ b/lib/Auth.ts @@ -1,22 +1,22 @@ import NextAuth, { AuthOptions } from "next-auth"; import Google from "next-auth/providers/google"; -import GitHubProvider from "next-auth/providers/github"; import SlackProvider from "next-auth/providers/slack"; -import { MongoDBAdapter } from "@next-auth/mongodb-adapter"; -import { getDatabase, clientPromise } from "./MongoDB"; +import { getDatabase } from "./MongoDB"; import { ObjectId } from "bson"; import { User } from "./Types"; -import { GenerateSlug, repairUser } from "./Utils"; +import { GenerateSlug } from "./Utils"; import { Analytics } from "@/lib/client/Analytics"; import Email from "next-auth/providers/email"; import ResendUtils from "./ResendUtils"; import CollectionId from "./client/CollectionId"; import { AdapterUser } from "next-auth/adapters"; -import { wait } from "./client/ClientUtils"; +import DbInterfaceAuthAdapter from "./DbInterfaceAuthAdapter"; +import Logger from "./client/Logger"; -const db = getDatabase(); +const logger = new Logger(["AUTH"], true); -const adapter = MongoDBAdapter(clientPromise, { databaseName: process.env.DB }); +const cachedDb = getDatabase(); +const adapter = DbInterfaceAuthAdapter(cachedDb); export const AuthenticationOptions: AuthOptions = { secret: process.env.NEXTAUTH_SECRET, @@ -24,56 +24,64 @@ export const AuthenticationOptions: AuthOptions = { Google({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET, + allowDangerousEmailAccountLinking: true, profile: async (profile) => { + logger.debug("Google profile:", profile); + + const existingUser = await ( + await cachedDb + ).findObject(CollectionId.Users, { email: profile.email }); + if (existingUser) { + return existingUser; + } + const user = new User( profile.name, profile.email, profile.picture, false, - await GenerateSlug( - await getDatabase(), - CollectionId.Users, - profile.name, - ), + await GenerateSlug(await cachedDb, CollectionId.Users, profile.name), [], [], ); + user.id = profile.sub; + return user; }, }), - /* - GitHubProvider({ - clientId: process.env.GITHUB_ID as string, - clientSecret: process.env.GITHUB_SECRET as string, - profile: async (profile) => { - const user = new User(profile.login, profile.email, profile.avatar_url, false, await GenerateSlug(CollectionId.Users, profile.login), [], []); - user.id = profile.id; - return user; - }, - }), - */ SlackProvider({ clientId: process.env.NEXT_PUBLIC_SLACK_CLIENT_ID as string, clientSecret: process.env.SLACK_CLIENT_SECRET as string, + allowDangerousEmailAccountLinking: true, profile: async (profile) => { + logger.debug("Slack profile:", profile); + + const existing = await ( + await cachedDb + ).findObject(CollectionId.Users, { email: profile.email }); + + if (existing) { + existing.id = profile.sub; + console.log("Found existing user:", existing); + return existing; + } + const user = new User( profile.name, profile.email, profile.picture, false, - await GenerateSlug( - await getDatabase(), - CollectionId.Users, - profile.name, - ), + await GenerateSlug(await cachedDb, CollectionId.Users, profile.name), [], [], profile.sub, 10, 1, ); + user.id = profile.sub; + return user; }, }), @@ -91,9 +99,7 @@ export const AuthenticationOptions: AuthOptions = { ], callbacks: { async session({ session, user }) { - session.user = await ( - await db - ).findObjectById(CollectionId.Users, new ObjectId(user.id)); + session.user = user; return session; }, @@ -106,55 +112,47 @@ export const AuthenticationOptions: AuthOptions = { * For email sign in, runs when the "Sign In" button is clicked (before email is sent). */ async signIn({ user }) { + const startTime = Date.now(); + logger.debug(`User is signing in: ${user.name}, ${user.email}`); + Analytics.signIn(user.name ?? "Unknown User"); + const db = await getDatabase(); let typedUser = user as Partial; - if (!typedUser.slug || typedUser._id?.toString() != typedUser.id) { - const repairUserOnceItIsInDb = async () => { - console.log( - "User is incomplete, waiting for it to be in the database.", - ); - let foundUser: User | undefined = undefined; - while (!foundUser) { - foundUser = await ( - await db - ).findObject(CollectionId.Users, { email: typedUser.email }); - - if (!foundUser) await wait(50); - } - - console.log("User is incomplete, filling in missing fields."); - typedUser._id = foundUser._id; - typedUser.lastSignInDateTime = new Date(); + const existingUser = await db.findObject(CollectionId.Users, { + email: typedUser.email, + }); - typedUser = await repairUser(await db, typedUser); - - console.log("User updated:", typedUser._id?.toString()); - }; - - repairUserOnceItIsInDb(); - } + typedUser._id = existingUser?._id; const today = new Date(); if ( (typedUser as User).lastSignInDateTime?.toDateString() !== today.toDateString() ) { - // We use user.id since user._id strangely doesn't exist on user. - await getDatabase().then((db) => - db.updateObjectById( - CollectionId.Users, - new ObjectId(typedUser._id?.toString()), - { - lastSignInDateTime: today, - }, - ), + db.updateObjectById( + CollectionId.Users, + new ObjectId(typedUser._id?.toString()), + { + lastSignInDateTime: today, + }, ); } new ResendUtils().createContact(typedUser as User); + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + logger.log( + "User is signed in:", + typedUser.name, + typedUser.email, + typedUser._id?.toString(), + "Elapsed time:", + elapsedTime + "ms", + ); return true; }, }, @@ -170,7 +168,7 @@ export const AuthenticationOptions: AuthOptions = { }, }, pages: { - //signIn: "/signin", + signIn: "/signin", }, }; diff --git a/lib/DbInterfaceAuthAdapter.ts b/lib/DbInterfaceAuthAdapter.ts new file mode 100644 index 00000000..8077043c --- /dev/null +++ b/lib/DbInterfaceAuthAdapter.ts @@ -0,0 +1,373 @@ +import { format, MongoDBAdapter } from "@next-auth/mongodb-adapter"; +import { + Adapter, + AdapterAccount, + AdapterSession, + AdapterUser, + VerificationToken, +} from "next-auth/adapters"; +import DbInterface from "./client/dbinterfaces/DbInterface"; +import CollectionId from "./client/CollectionId"; +import { User, Session } from "./Types"; +import { GenerateSlug } from "./Utils"; +import { ObjectId } from "bson"; +import Logger from "./client/Logger"; + +/** + * @tested_by tests/lib/DbInterfaceAuthAdapter.test.ts + */ +export default function DbInterfaceAuthAdapter( + dbPromise: Promise, + baseLogger?: Logger, +): Adapter { + const logger = + (baseLogger && baseLogger.extend(["ADAPTER"])) ?? + new Logger(["AUTH"], false); + + const adapter: Adapter = { + createUser: async (data: Record) => { + const db = await dbPromise; + + const adapterUser = format.to(data); + + logger.debug("Creating user:", adapterUser.name); + + // Check if user already exists + const existingUser = await db.findObject(CollectionId.Users, { + email: adapterUser.email, + }); + + if (existingUser) { + // If user exists, return existing user + logger.warn("User already exists:", existingUser.name); + return format.from(existingUser); + } + + logger.debug("Creating user:", adapterUser); + + const user = new User( + adapterUser.name ?? "Unknown", + adapterUser.email, + adapterUser.image ?? process.env.DEFAULT_IMAGE, + false, + await GenerateSlug( + db, + CollectionId.Users, + adapterUser.name ?? "Unknown", + ), + [], + [], + adapterUser.id, + 0, + 1, + ); + + user._id = new ObjectId(adapterUser._id) as any; + + const dbUser = await db.addObject(CollectionId.Users, user); + logger.info("Created user:", dbUser._id?.toString()); + return format.from(dbUser); + }, + getUser: async (id: string) => { + const db = await dbPromise; + + if (id.length !== 24) return null; + + logger.debug("Getting user:", id); + + const user = await db.findObjectById( + CollectionId.Users, + new ObjectId(id), + ); + + if (!user) return null; + return format.from(user); + }, + getUserByEmail: async (email: string) => { + const db = await dbPromise; + + logger.debug("Getting user by email:", email); + + const account = await db.findObject(CollectionId.Users, { email }); + + if (!account) return null; + return format.from(account); + }, + getUserByAccount: async ( + providerAccountId: Pick, + ) => { + const db = await dbPromise; + + logger.debug( + "Getting user by account:", + providerAccountId.providerAccountId, + ); + + const account = await db.findObject(CollectionId.Accounts, { + providerAccountId: providerAccountId.providerAccountId, + }); + + if (!account) { + logger.warn("Account not found:", providerAccountId.provider); + return null; + } + + const user = await db.findObjectById( + CollectionId.Users, + account.userId as any as ObjectId, + ); + + if (!user) { + logger.warn("User not found:", account.userId); + return null; + } + return format.from(user); + }, + updateUser: async ( + data: Partial & Pick, + ) => { + const db = await dbPromise; + const { _id, ...user } = format.to(data); + + logger.debug("Updating user:", _id); + + const existing = await db.findObjectById( + CollectionId.Users, + new ObjectId(_id), + ); + + user.id = existing?._id?.toString()!; + + await db.updateObjectById( + CollectionId.Users, + new ObjectId(_id), + user as Partial, + ); + + return format.from({ ...existing, ...user, _id: _id }); + }, + deleteUser: async (id: string) => { + const db = await dbPromise; + + logger.log("Deleting user:", id); + + const user = await db.findObjectById( + CollectionId.Users, + new ObjectId(id), + ); + if (!user) { + logger.warn("User not found:", id); + return null; + } + + const account = await db.findObject(CollectionId.Accounts, { + userId: user._id, + }); + + const session = await db.findObject(CollectionId.Sessions, { + userId: user._id, + }); + + const promises = [ + db.deleteObjectById(CollectionId.Users, new ObjectId(id)), + ]; + + if (account) { + promises.push( + db.deleteObjectById(CollectionId.Accounts, new ObjectId(account._id)), + ); + } + + if (session) { + promises.push( + db.deleteObjectById(CollectionId.Sessions, new ObjectId(session._id)), + ); + } + + await Promise.all(promises); + + return format.from(user); + }, + linkAccount: async (data: Record) => { + const db = await dbPromise; + const account = format.to(data); + + logger.debug( + "Linking account:", + account.providerAccountId, + "User:", + account.userId, + ); + + const existing = await db.findObject(CollectionId.Accounts, { + providerAccountId: account.providerAccountId, + }); + + if (existing) { + logger.warn("Account already exists:", existing.providerAccountId); + return format.from(existing); + } + + await db.addObject(CollectionId.Accounts, account); + + return account; + }, + unlinkAccount: async ( + providerAccountId: Pick, + ) => { + const db = await dbPromise; + + logger.debug("Unlinking account:", providerAccountId.providerAccountId); + + const account = await db.findObject(CollectionId.Accounts, { + providerAccountId: providerAccountId.providerAccountId, + }); + + if (!account) { + logger.warn("Account not found:", providerAccountId.providerAccountId); + return null; + } + + await db.deleteObjectById( + CollectionId.Accounts, + new ObjectId(account._id), + ); + + return format.from(account); + }, + getSessionAndUser: async (sessionToken: string) => { + const db = await dbPromise; + + logger.debug("Getting session and user:", sessionToken); + + const session = await db.findObject(CollectionId.Sessions, { + sessionToken, + }); + + if (!session) { + logger.warn("Session not found:", sessionToken); + return null; + } + + const user = await db.findObjectById( + CollectionId.Users, + new ObjectId(session.userId), + ); + + if (!user) { + logger.warn("User not found:", session.userId); + return null; + } + + return { + session: format.from(session), + user: { + ...format.from(user), + _id: user._id, + }, + }; + }, + createSession: async (data: Record) => { + logger.debug("Creating session:", data); + + const db = await dbPromise; + const session = format.to(data); + + session.userId = new ObjectId(session.userId) as any; + + const dbSession = await db.addObject( + CollectionId.Sessions, + session as unknown as Session, + ); + + return format.from(dbSession); + }, + updateSession: async ( + data: Partial & Pick, + ) => { + const db = await dbPromise; + const { _id, ...session } = format.to(data); + + logger.debug("Updating session:", session.sessionToken); + + const existing = await db.findObject(CollectionId.Sessions, { + sessionToken: session.sessionToken, + }); + + if (!existing) { + logger.warn("Session not found:", session.sessionToken); + return null; + } + + if (session.userId) { + session.userId = new ObjectId(session.userId) as any; + } + + await db.updateObjectById( + CollectionId.Sessions, + new ObjectId(existing._id), + session as unknown as Partial, + ); + + return format.from({ ...existing, ...data }); + }, + deleteSession: async (sessionToken: string) => { + const db = await dbPromise; + + logger.debug("Deleting session:", sessionToken); + + const session = await db.findObject(CollectionId.Sessions, { + sessionToken, + }); + + if (!session) { + logger.warn("Session not found:", sessionToken); + return null; + } + + await db.deleteObjectById( + CollectionId.Sessions, + new ObjectId(session._id), + ); + + return format.from(session); + }, + createVerificationToken: async (token: VerificationToken) => { + const db = await dbPromise; + + logger.debug("Creating verification token:", token.identifier); + + await db.addObject( + CollectionId.VerificationTokens, + format.to(token) as VerificationToken, + ); + return token; + }, + useVerificationToken: async (token: { + identifier: string; + token: string; + }) => { + const db = await dbPromise; + + logger.info("Using verification token:", token.identifier); + + const existing = await db.findObject(CollectionId.VerificationTokens, { + token: token.token, + }); + + if (!existing) { + logger.warn("Verification token not found:", token.token); + return null; + } + + await db.deleteObjectById( + CollectionId.VerificationTokens, + new ObjectId(existing._id), + ); + + return format.from(existing); + }, + }; + + return adapter; +} diff --git a/lib/MongoDB.ts b/lib/MongoDB.ts index f7343f11..f79f6362 100644 --- a/lib/MongoDB.ts +++ b/lib/MongoDB.ts @@ -30,13 +30,17 @@ clientPromise = global.clientPromise; export { clientPromise }; -export async function getDatabase(): Promise { +export async function getDatabase( + useCache: boolean = true, +): Promise { if (!global.interface) { await clientPromise; - const dbInterface = new CachedDbInterface( - new MongoDBInterface(clientPromise), - cacheOptions, - ); + + const mongo = new MongoDBInterface(clientPromise); + + const dbInterface = useCache + ? new CachedDbInterface(mongo, cacheOptions) + : mongo; await dbInterface.init(); global.interface = dbInterface; diff --git a/lib/TheBlueAlliance.ts b/lib/TheBlueAlliance.ts index deb03646..c5c3baf9 100644 --- a/lib/TheBlueAlliance.ts +++ b/lib/TheBlueAlliance.ts @@ -266,7 +266,7 @@ export namespace TheBlueAlliance { .filter((match) => match.comp_level === CompetitionLevel.QM) .map((data, index) => { return new Match( - index + 1, + data.match_number, undefined, data.key, data.time, diff --git a/lib/Types.ts b/lib/Types.ts index 52539d0a..d006e7f0 100644 --- a/lib/Types.ts +++ b/lib/Types.ts @@ -30,6 +30,9 @@ export interface Account extends NextAuthAccount { export interface Session extends NextAuthSession { _id: string; + sessionToken: string; + userId: ObjectId; + expires: string; } export class User implements NextAuthUser { diff --git a/lib/Utils.ts b/lib/Utils.ts index af274cff..09503f38 100644 --- a/lib/Utils.ts +++ b/lib/Utils.ts @@ -92,6 +92,34 @@ export function mentionUserInSlack(user: { return user.slackId ? `<@${user.slackId}>` : (user.name ?? ""); } +export async function populateMissingUserFields( + user: Partial, + generateSlug: (name: string) => Promise, +): Promise { + const name = user.name ?? user.email?.split("@")[0] ?? "Unknown User"; + + const filled: Omit = { + id: user._id?.toString() ?? new ObjectId().toString(), + name, + image: user.image ?? "https://4026.org/user.jpg", + slug: user.slug ?? (await generateSlug(name ?? "Unknown User")), + email: user.email ?? "", + teams: user.teams ?? [], + owner: user.owner ?? [], + slackId: user.slackId ?? "", + onboardingComplete: user.onboardingComplete ?? false, + admin: user.admin ?? false, + xp: user.xp ?? 0, + level: user.level ?? 0, + resendContactId: user.resendContactId ?? undefined, + lastSignInDateTime: user.lastSignInDateTime ?? undefined, + }; + + if (user._id) (filled as User)._id = user._id as unknown as string; + + return filled as User; +} + /** * If a user is missing fields, this function will populate them with default values and update the user in the DB. * @@ -125,20 +153,9 @@ export async function repairUser( const name = user.name ?? user.email?.split("@")[0] ?? "Unknown User"; // User is incomplete, fill in the missing fields - user = { - ...user, - id: id?.toString(), - name, - image: user.image ?? "https://4026.org/user.jpg", - slug: user.slug ?? (await GenerateSlug(db, CollectionId.Users, name)), - teams: user.teams ?? [], - owner: user.owner ?? [], - slackId: user.slackId ?? "", - onboardingComplete: user.onboardingComplete ?? false, - admin: user.admin ?? false, - xp: user.xp ?? 0, - level: user.level ?? 0, - } as User; + user = await populateMissingUserFields(user, async (name) => + GenerateSlug(db, CollectionId.Users, name), + ); if (updateDocument) { await db.updateObjectById( diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index 03c2ea5e..c6f36f40 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -315,12 +315,14 @@ export default class ClientApi extends NextApiTemplate { gameId, ), ); - team!.seasons = [...team!.seasons, String(season._id)]; + + const { _id, ...updatedTeam } = team; + updatedTeam.seasons = [...team.seasons, String(season._id)]; await db.updateObjectById( CollectionId.Teams, new ObjectId(teamId), - team!, + updatedTeam!, ); return res.status(200).send(season); @@ -427,12 +429,14 @@ export default class ClientApi extends NextApiTemplate { ), ); - season.competitions = [...season.competitions, String(comp._id)]; + const { _id, ...updatedSeason } = season; + + updatedSeason.competitions = [...season.competitions, String(comp._id)]; await db.updateObjectById( CollectionId.Seasons, new ObjectId(season._id), - season, + updatedSeason, ); // Create reports diff --git a/lib/client/ClientUtils.ts b/lib/client/ClientUtils.ts index 2eee055a..e0f85d77 100644 --- a/lib/client/ClientUtils.ts +++ b/lib/client/ClientUtils.ts @@ -144,7 +144,7 @@ export function promisify( } /** - * Tested to be accurate to within 150ms + * Tested to not go more than 150ms over the specified time and not less than 2ms under the specified time * * @tested_by tests/lib/client/ClientUtils.test.ts */ diff --git a/lib/client/CollectionId.ts b/lib/client/CollectionId.ts index 5eb209a3..00c0c646 100644 --- a/lib/client/CollectionId.ts +++ b/lib/client/CollectionId.ts @@ -1,3 +1,4 @@ +import { VerificationToken } from "next-auth/adapters"; import { Season, Competition, @@ -12,6 +13,7 @@ import { CompPicklistGroup, WebhookHolder, } from "../Types"; +import { ObjectId } from "bson"; enum CollectionId { Seasons = "Seasons", @@ -22,6 +24,7 @@ enum CollectionId { Users = "users", Accounts = "accounts", Sessions = "sessions", + VerificationTokens = "verification_tokens", Forms = "Forms", PitReports = "Pitreports", Picklists = "Picklists", @@ -51,14 +54,16 @@ export type CollectionIdToType = ? Account : Id extends CollectionId.Sessions ? Session - : Id extends CollectionId.PitReports - ? Pitreport - : Id extends CollectionId.Picklists - ? CompPicklistGroup - : Id extends CollectionId.SubjectiveReports - ? SubjectiveReport - : Id extends CollectionId.Webhooks - ? WebhookHolder - : Id extends CollectionId.Misc - ? any - : any; + : Id extends CollectionId.VerificationTokens + ? VerificationToken & { _id: ObjectId } + : Id extends CollectionId.PitReports + ? Pitreport + : Id extends CollectionId.Picklists + ? CompPicklistGroup + : Id extends CollectionId.SubjectiveReports + ? SubjectiveReport + : Id extends CollectionId.Webhooks + ? WebhookHolder + : Id extends CollectionId.Misc + ? any + : any; diff --git a/lib/client/Logger.ts b/lib/client/Logger.ts new file mode 100644 index 00000000..121cf32e --- /dev/null +++ b/lib/client/Logger.ts @@ -0,0 +1,57 @@ +export enum LogLevel { + Error, + Warning, + Info, + Debug, +} + +export default class Logger { + constructor( + private tags: string[], + private enabled: boolean = true, + ) {} + + private prefix(level: LogLevel) { + return `[${this.tags.join(", ")}] [${LogLevel[level]}]`; + } + + public extend(tags: string[]) { + return new Logger([...this.tags, ...tags], this.enabled); + } + + public print(level: LogLevel, ...args: unknown[]) { + if (!this.enabled) return; + + const prefix = this.prefix(level); + + if (level === LogLevel.Error) { + console.error(prefix, ...args); + } else if (level === LogLevel.Warning) { + console.warn(prefix, ...args); + } else if (level === LogLevel.Info) { + console.info(prefix, ...args); + } else if (level === LogLevel.Debug) { + console.debug(prefix, ...args); + } + } + + public error(...args: unknown[]) { + this.print(LogLevel.Error, ...args); + } + + public warn(...args: unknown[]) { + this.print(LogLevel.Warning, ...args); + } + + public info(...args: unknown[]) { + this.print(LogLevel.Info, ...args); + } + + public debug(...args: unknown[]) { + this.print(LogLevel.Debug, ...args); + } + + public log(...args: unknown[]) { + this.info(...args); + } +} diff --git a/package-lock.json b/package-lock.json index 1eeff82a..5daa24a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sj3", - "version": "1.2.2", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sj3", - "version": "1.2.2", + "version": "1.2.5", "license": "CC BY-NC-SA 4.0", "dependencies": { "dependencies": "^0.0.1", @@ -40,6 +40,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.3.1", "react-ga4": "^2.1.0", + "react-google-recaptcha-v3": "^1.10.1", "react-hot-toast": "^2.5.1", "react-icons": "^5.4.0", "react-p5": "^1.4.1", @@ -9115,6 +9116,18 @@ "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-2.1.0.tgz", "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" }, + "node_modules/react-google-recaptcha-v3": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.10.1.tgz", + "integrity": "sha512-K3AYzSE0SasTn+XvV2tq+6YaxM+zQypk9rbCgG4OVUt7Rh4ze9basIKefoBz9sC0CNslJj9N1uwTTgRMJQbQJQ==", + "dependencies": { + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "react": "^16.3 || ^17.0 || ^18.0", + "react-dom": "^17.0 || ^18.0" + } + }, "node_modules/react-hot-toast": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.1.tgz", diff --git a/package.json b/package.json index cb47b22d..93bc53bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sj3", - "version": "1.2.2", + "version": "1.2.6", "private": true, "repository": "https://github.com/Decatur-Robotics/Gearbox", "license": "CC BY-NC-SA 4.0", @@ -49,6 +49,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.3.1", "react-ga4": "^2.1.0", + "react-google-recaptcha-v3": "^1.10.1", "react-hot-toast": "^2.5.1", "react-icons": "^5.4.0", "react-p5": "^1.4.1", diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx index e75417c2..9a40bc17 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx @@ -49,11 +49,6 @@ export default function CompetitionIndex({ team?.owners.includes(session?.user?._id)) ?? false; - const [showSettings, setShowSettings] = useState(false); - const [matchNumber, setMatchNumber] = useState(undefined); - const [blueAlliance, setBlueAlliance] = useState([]); - const [redAlliance, setRedAlliance] = useState([]); - const [matches, setMatches] = useState([]); const [showSubmittedMatches, setShowSubmittedMatches] = useState(false); @@ -105,11 +100,6 @@ export default function CompetitionIndex({ string | undefined >(); - const [teamToAdd, setTeamToAdd] = useState(0); - - const [newCompName, setNewCompName] = useState(comp?.name); - const [newCompTbaId, setNewCompTbaId] = useState(comp?.tbaId); - const regeneratePitReports = useCallback(async () => { console.log("Regenerating pit reports..."); const { pitReports: pitReportIds } = await api.regeneratePitReports( @@ -348,23 +338,6 @@ export default function CompetitionIndex({ } }; - const createMatch = async () => { - try { - await api.createMatch( - comp?._id!, - Number(matchNumber), - 0, - MatchType.Qualifying, - blueAlliance as number[], - redAlliance as number[], - ); - } catch (e) { - console.error(e); - } - - location.reload(); - }; - // useEffect(() => { // if ( // qualificationMatches.length > 0 && @@ -395,29 +368,6 @@ export default function CompetitionIndex({ loadMatches(matches !== undefined); } - const [exportPending, setExportPending] = useState(false); - - const exportAsCsv = async () => { - setExportPending(true); - - const res = await api.exportCompAsCsv(comp?._id!).catch((e) => { - console.error(e); - return { csv: undefined }; - }); - - if (!res) { - console.error("failed to export"); - } - - if (res.csv) { - download(`${comp?.name ?? "Competition"}.csv`, res.csv, "text/csv"); - } else { - console.error("No CSV data returned from server"); - } - - setExportPending(false); - }; - useEffect(() => { if (ranking || !comp?.tbaId || !team?.number) return; @@ -442,51 +392,11 @@ export default function CompetitionIndex({ ); useInterval(loadMatchesInterval, 5000); - function togglePublicData(e: ChangeEvent) { - if (!comp?._id) return; - api.setCompPublicData(comp?._id, e.target.checked); - } - function remindUserOnSlack(userId: string) { if (userId && team?._id && isManager && confirm("Remind scouter on Slack?")) api.remindSlack(team._id.toString(), userId); } - function addTeam() { - console.log("Adding pit report for team", teamToAdd); - if (!teamToAdd || teamToAdd < 1 || !comp?._id) return; - - api - .createPitReportForTeam(teamToAdd, comp?._id) - // We can't just pass location.reload, it will throw "illegal invocation." I don't know why. -Renato - .finally(() => location.reload()); - } - - async function saveCompChanges() { - // Check if tbaId is valid - if (!comp?.tbaId || !comp?.name || !comp?._id) return; - - let tbaId = newCompTbaId; - const autoFillData = await api.competitionAutofill(tbaId ?? ""); - if ( - !autoFillData?.name && - !confirm(`Invalid TBA ID: ${tbaId}. Save changes anyway?`) - ) - return; - - await api.updateCompNameAndTbaId( - comp?._id, - newCompName ?? "Unnamed", - tbaId ?? NotLinkedToTba, - ); - location.reload(); - } - - const allianceIndices: number[] = []; - for (let i = 0; i < games[comp?.gameId ?? defaultGameId].allianceSize; i++) { - allianceIndices.push(i); - } - return ( ) { +async function getAuth(req: NextApiRequest, res: NextApiResponse) { + const path = [ + "", + ...(Array.isArray(req.query.nextauth) + ? req.query.nextauth + : [req.query.nextauth]), + ].join("/"); + + if (path === "/signin/email" && process.env.RECAPTCHA_SECRET) { + const { email, captchaToken } = req.body; + const isHuman = await fetch( + `https://www.google.com/recaptcha/api/siteverify`, + { + method: "post", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + }, + body: `secret=${process.env.RECAPTCHA_SECRET}&response=${captchaToken}`, + }, + ) + .then((res) => res.json()) + .then((json) => json.success) + .catch((err) => { + throw new Error(`Error in Google Siteverify API. ${err.message}`); + }); + + if (!isHuman) { + res.status(400).end(); + + console.log("User is not human:", email); + return; + } + } return Auth(req, res); } diff --git a/pages/event/[...eventName].tsx b/pages/event/[...eventName].tsx index 0d79ef45..eab648a1 100644 --- a/pages/event/[...eventName].tsx +++ b/pages/event/[...eventName].tsx @@ -28,19 +28,11 @@ export default function PublicEvent() { useEffect(() => { const eventName = window.location.pathname.split("/event/")[1]; - if (eventData === null) { - api.initialEventData(eventName).then((data) => { - setEventData(data); - }); - setTimeout(() => { - console.log("Event not found"); - if (stateRef.current === null) { - console.log("Event is null"); - setEventData(undefined); - } - }, 10000); - } else if (teamEvents === null) { - const firstRanking = eventData?.firstRanking; + api.initialEventData(eventName).then((data) => { + console.log(data); + setEventData(data); + + const firstRanking = data?.firstRanking; firstRanking?.map(({ team_key }) => api @@ -55,8 +47,8 @@ export default function PublicEvent() { }), ), ); - } - }); + }); + }, []); // We must always have the same number of React hooks, so we generate refs even if we aren't using them yet const countdownRefs = { @@ -82,7 +74,7 @@ export default function PublicEvent() { ); } - if (eventData === undefined || eventData.firstRanking === undefined) { + if (eventData === undefined) { return ( -
- Error:{" "} - {eventData?.comp.tbaId === NotLinkedToTba - ? "Comp Not Linked to TBA" - : "Event not found"} -
+
Error: Event not found
); } - const oprs = eventData!.oprRanking.oprs; - //@ts-ignore - const first = eventData!.firstRanking; + const oprs = eventData.oprRanking?.oprs; + const firstRanking = eventData.firstRanking; const statbotics = teamEvents ?? []; const findStatboticsStats = (key: string) => { @@ -350,15 +336,15 @@ export default function PublicEvent() {
)} - {first.length > 0 ? ( + {firstRanking?.length > 0 ? (

Ranking

- {statbotics.length < first.length && ( + {statbotics.length < firstRanking.length && ( )}
@@ -383,7 +369,7 @@ export default function PublicEvent() { - {first.map((ranking: any, index: number) => ( + {firstRanking.map((ranking: any, index: number) => ( {findStatboticsStats(ranking.team_key)?.record.qual diff --git a/pages/signin.tsx b/pages/signin.tsx index ac60a004..7c618200 100644 --- a/pages/signin.tsx +++ b/pages/signin.tsx @@ -1,25 +1,107 @@ +import Container from "@/components/Container"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + GoogleReCaptchaProvider, + useGoogleReCaptcha, +} from "react-google-recaptcha-v3"; import { FaGoogle, FaSlack } from "react-icons/fa"; -export default function SignIn() { +function SignInCard() { + const router = useRouter(); + const emailRef = useRef(null); + const { executeRecaptcha } = useGoogleReCaptcha(); + + const [error, setError] = useState(router.query.error as string); + + useEffect(() => { + if (router.query.error) { + setError(router.query.error as string); + } + }, [router.query.error]); + + function signInWithCallbackUrl(provider: string, options?: object) { + const callbackUrl = router.query.callbackUrl as string; + + signIn(provider, { callbackUrl, ...options }); + } + async function logInWithEmail() { + const email = emailRef.current?.value; + + if (!email) { + setError("Email is required"); + return; + } + + if (!executeRecaptcha) { + setError("Recaptcha not available"); + return; + } + + const captchaToken = await executeRecaptcha(); + + signInWithCallbackUrl("email", { email, captchaToken }); + } + return ( -
-
-
-

Sign In

-

Choose a login provider

-
-
- - - -

For Team 4026 Only:

- + + + +
+
+

Email Sign In

+ +
); } + +export default function SignIn() { + return ( + + +
+ +
+
+
+ ); +} diff --git a/scripts/fixTeamMembership.ts b/scripts/fixTeamMembership.ts new file mode 100644 index 00000000..9ab2fc8c --- /dev/null +++ b/scripts/fixTeamMembership.ts @@ -0,0 +1,57 @@ +import CollectionId from "@/lib/client/CollectionId"; +import { getDatabase } from "@/lib/MongoDB"; +import { ObjectId } from "bson"; + +async function fixTeamMembership() { + console.log("Fixing team membership and ownership..."); + + console.log("Getting database..."); + const db = await getDatabase(); + + console.log("Finding teams..."); + const teams = await db.findObjects(CollectionId.Teams, {}); + + console.log(`Found ${teams.length} teams.`); + + const users: { [id: string]: { teams: string[]; owner: string[] } } = {}; + + for (const team of teams) { + console.log( + `Processing team ${team._id}... Users: ${team.users.length}, Owners: ${team.owners.length}`, + ); + + for (const user of team.users) { + if (!users[user]) { + users[user] = { teams: [], owner: [] }; + } + + users[user].teams.push(team._id.toString()); + } + + for (const user of team.owners) { + if (!users[user]) { + users[user] = { teams: [], owner: [] }; + } + + users[user].owner.push(team._id.toString()); + } + } + + console.log(`Found ${Object.keys(users).length} users who are on teams.`); + + for (const userId in users) { + const user = users[userId]; + + console.log( + `Updating user ${userId}... Teams: ${user.teams.length}, Owners: ${user.owner.length}`, + ); + await db.updateObjectById(CollectionId.Users, new ObjectId(userId), { + teams: user.teams, + owner: user.owner, + }); + } + + process.exit(0); +} + +fixTeamMembership(); diff --git a/scripts/loadUsersIntoResend.ts b/scripts/loadUsersIntoResend.ts index 3743745b..09352172 100644 --- a/scripts/loadUsersIntoResend.ts +++ b/scripts/loadUsersIntoResend.ts @@ -1,7 +1,6 @@ import { getDatabase } from "@/lib/MongoDB"; import CollectionId from "@/lib/client/CollectionId"; import ResendUtils from "@/lib/ResendUtils"; -import { User } from "@/lib/Types"; async function loadUsersIntoResend() { console.log("Loading users into Resend..."); diff --git a/tests/lib/DbInterfaceAuthAdapter.test.ts b/tests/lib/DbInterfaceAuthAdapter.test.ts new file mode 100644 index 00000000..df7b3ce9 --- /dev/null +++ b/tests/lib/DbInterfaceAuthAdapter.test.ts @@ -0,0 +1,171 @@ +import CollectionId from "@/lib/client/CollectionId"; +import InMemoryDbInterface from "@/lib/client/dbinterfaces/InMemoryDbInterface"; +import DbInterfaceAuthAdapter from "@/lib/DbInterfaceAuthAdapter"; +import { _id } from "@next-auth/mongodb-adapter"; +import { ObjectId } from "bson"; +import { get } from "http"; + +const prototype = DbInterfaceAuthAdapter(undefined as any); + +async function getAdapterAndDb() { + const db = new InMemoryDbInterface(); + await db.init(); + + return { + adapter: DbInterfaceAuthAdapter(Promise.resolve(db)), + db, + }; +} + +describe(prototype.createUser.name, () => { + test("Adds a user to the database", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + name: "Test User", + email: "test@gmail.com", + image: "test.png", + }; + + await adapter.createUser(user); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser).toMatchObject(user); + }); + + test("Populates fields with default values", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + name: "Test User", + email: "test@gmail.com", + image: "test.png", + }; + + await adapter.createUser(user); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser?.name).toBeDefined(); + expect(foundUser?.email).toBeDefined(); + expect(foundUser?.image).toBeDefined; + expect(foundUser?.admin).toBeDefined(); + expect(foundUser?.slug).toBeDefined(); + expect(foundUser?.teams).toBeDefined(); + expect(foundUser?.owner).toBeDefined(); + expect(foundUser?.level).toBeDefined(); + expect(foundUser?.xp).toBeDefined(); + }); + + test("Populates missing fields with defaults", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + email: "test@gmail.com", + }; + + await adapter.createUser(user); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser?.name).toBeDefined(); + expect(foundUser?.image).toBeDefined(); + }); + + test("Does not create a new user if one already exists", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + email: "test@gmail.com", + }; + + await adapter.createUser(user); + await adapter.createUser(user); + + expect( + await db.countObjects(CollectionId.Users, { email: user.email }), + ).toBe(1); + }); +}); + +describe(prototype.getUser!.name, () => { + test("Returns a user from the database without their _id", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + image: "test.png", + }; + + await db.addObject(CollectionId.Users, user as any); + + const foundUser = await adapter.getUser!(user._id.toString()); + + const { _id, ...userWithoutId } = user; + + expect(foundUser).toMatchObject(userWithoutId); + }); + + test("Returns null if given an id of the wrong length", async () => { + const { adapter } = await getAdapterAndDb(); + + const foundUser = await adapter.getUser!("1234567890123456789012345"); + + expect(foundUser).toBeNull(); + }); + + test("Returns null if the user doesn't exist", async () => { + const { adapter } = await getAdapterAndDb(); + + const foundUser = await adapter.getUser!(new ObjectId().toString()); + + expect(foundUser).toBeNull(); + }); +}); + +describe(prototype.getUserByEmail!.name, () => { + test("Returns a user from the database", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + const { _id, ...addedUser } = await db.addObject( + CollectionId.Users, + user as any, + ); + + const foundUser = await adapter.getUserByEmail!(user.email); + + expect(foundUser).toMatchObject(addedUser); + }); + + test("Returns user without their _id", async () => { + const { db, adapter } = await getAdapterAndDb(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + await db.addObject(CollectionId.Users, user as any); + + const foundUser = await adapter.getUserByEmail!(user.email); + + const { _id, ...userWithoutId } = user; + + expect(foundUser).toMatchObject(userWithoutId); + }); +}); diff --git a/tests/lib/client/ClientUtils.test.ts b/tests/lib/client/ClientUtils.test.ts index e78704db..e91fdeef 100644 --- a/tests/lib/client/ClientUtils.test.ts +++ b/tests/lib/client/ClientUtils.test.ts @@ -84,7 +84,7 @@ describe(wait.name, () => { const start = Date.now(); await wait(duration); const end = Date.now(); - expect(end - start).toBeGreaterThanOrEqual(duration); + expect(end - start).toBeGreaterThanOrEqual(duration - 2); } });