From e1a87723003a2606e02bbd2661166869230e4cdf Mon Sep 17 00:00:00 2001 From: Hakob3215 Date: Fri, 29 Nov 2024 20:47:58 -0800 Subject: [PATCH 1/3] Add Backend Match History Logic --- backend/models/matchModel.js | 19 +++++++++++++ backend/routes/accountRouter.js | 1 - backend/routes/matchRouter.js | 47 +++++++++++++++++++++++++++++++++ backend/server.js | 3 +++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 backend/models/matchModel.js create mode 100644 backend/routes/matchRouter.js diff --git a/backend/models/matchModel.js b/backend/models/matchModel.js new file mode 100644 index 0000000..6e106f2 --- /dev/null +++ b/backend/models/matchModel.js @@ -0,0 +1,19 @@ +const mongoose = require("mongoose"); + +// Match Schema +const matchSchema = new mongoose.Schema({ + // game type: "daily" or "room" or "single" + gameType: String, + // date when game ended + date: Date, + // player: username - score tuples + // this will only be > 1 for room games + players: [{ + username: String, + score: Number + }] + +}); + + +module.exports = mongoose.model("Match", matchSchema); \ No newline at end of file diff --git a/backend/routes/accountRouter.js b/backend/routes/accountRouter.js index 55c2807..c8dcf0d 100644 --- a/backend/routes/accountRouter.js +++ b/backend/routes/accountRouter.js @@ -9,7 +9,6 @@ const userModel = require("../models/userModel.js"); // routes to handle: // sign up // sign in -// sign out router.post("/login", (req, res) => { // check if user exists diff --git a/backend/routes/matchRouter.js b/backend/routes/matchRouter.js new file mode 100644 index 0000000..6826141 --- /dev/null +++ b/backend/routes/matchRouter.js @@ -0,0 +1,47 @@ +// handle saved games +const express = require("express"); +const router = express.Router(); + +const matchModel = require("../models/matchModel.js"); +const userModel = require("../models/userModel.js"); + +// routes: upload match, get match history + +router.post("/uploadMatch", (req, res) => { + // get match data from req + const gameType = req.body.gameType; + const date = req.body.date; + const players = req.body.players; + let match = new matchModel({ + gameType: gameType, + date: date, + players: players + }); + match.save().then(() => { + res.status(200).send("Match uploaded successfully."); + }).catch((error) => { + console.error("Error uploading match:", error); + res.status(500).json({ error: "Internal server error." }); + }); +}); + +router.get("/matchHistory", (req, res) => { + // get all matches with req userid + const userid = req.body.id; + // find username associated with userid + userModel.findById(userid).then((user) => { + matchModel.find( + {players: { $elemMatch: { username: user.username } } } + ).then((matches) => { + res.json(matches); + }).catch((error) => { + console.error("Error fetching match history:", error); + res.status(500).json({ error: "Internal server error." }); + }); + }).catch((error) => { + console.error("Error fetching user data:", error); + res.status(500).json({ error: "Internal server error." }); + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 86e0f01..5e258bc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ const cron = require('node-cron'); const spotifyRouter = require('./routes/spotifyRouter.js'); const accountRouter = require('./routes/accountRouter.js'); const gameRouter = require('./routes/gameRouter.js'); +const matchRouter = require('./routes/matchRouter.js'); const {runCronJob} = require("./utility/dailyChallengeScheduler.js"); @@ -31,6 +32,8 @@ app.use('/songModel', spotifyRouter); app.use('/account', accountRouter); // game routes app.use('/game', gameRouter); +// match routes +app.use('/match', matchRouter); server.listen(port, () => { From b39b3134316b4cee02f226179880356aef75f6b6 Mon Sep 17 00:00:00 2001 From: Hakob3215 Date: Fri, 29 Nov 2024 22:13:22 -0800 Subject: [PATCH 2/3] Implement FrontEnd for MatchHistory + Fix SignInPa Add MatchHistory page to frontend, react router, and front page. Add matchModel to db.js Fix SignInPage to not throw error when account does not exist. Fix account router to handle all signin paths with json responses. --- backend/db.js | 3 +- backend/models/matchModel.js | 2 +- backend/routes/accountRouter.js | 8 +-- backend/routes/matchRouter.js | 9 ++- backend/server.js | 3 +- frontend/src/PageRouter.js | 2 + frontend/src/pages/FrontPage.js | 6 +- frontend/src/pages/MatchHistory.js | 68 ++++++++++++++++++++++ frontend/src/pages/SignInPage.js | 4 +- frontend/src/pages/styles/MatchHistory.css | 0 10 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 frontend/src/pages/MatchHistory.js create mode 100644 frontend/src/pages/styles/MatchHistory.css diff --git a/backend/db.js b/backend/db.js index fbfe8ea..da81fc7 100644 --- a/backend/db.js +++ b/backend/db.js @@ -17,5 +17,6 @@ mongoose.connect(process.env.MONGODB_URI) const Song = require('./models/songModel.js'); const User = require('./models/userModel.js'); const DailyChallenge = require('./models/dailyChallenge.js') +const Match = require('./models/matchModel.js'); -module.exports = {User, Song, DailyChallenge}; \ No newline at end of file +module.exports = {User, Song, DailyChallenge, Match}; \ No newline at end of file diff --git a/backend/models/matchModel.js b/backend/models/matchModel.js index 6e106f2..3af6ad3 100644 --- a/backend/models/matchModel.js +++ b/backend/models/matchModel.js @@ -2,7 +2,7 @@ const mongoose = require("mongoose"); // Match Schema const matchSchema = new mongoose.Schema({ - // game type: "daily" or "room" or "single" + // game type: "Daily Challenge" or "Room" or "Single" gameType: String, // date when game ended date: Date, diff --git a/backend/routes/accountRouter.js b/backend/routes/accountRouter.js index c8dcf0d..b6b3b85 100644 --- a/backend/routes/accountRouter.js +++ b/backend/routes/accountRouter.js @@ -30,16 +30,16 @@ router.post("/login", (req, res) => { } res.status(200).json(userData); } else { // password is incorrect - res.status(201).send("Login failure"); + res.status(201).json({error: "Incorrect username, email, or password"}); } }).catch(err => { - res.status(500).send("Internal server error"); + res.status(500).json({error: "Internal server error: " + err}); }) } else { // user does not exist - res.status(201).send("Login failure"); + res.status(201).json({error: "Incorrect username, email, or password"}); } }).catch(err => { - res.status(500).send("Internal server error: " + err); + res.status(500).json({error: "Internal server error: " + err}); }); }); diff --git a/backend/routes/matchRouter.js b/backend/routes/matchRouter.js index 6826141..0daa4ca 100644 --- a/backend/routes/matchRouter.js +++ b/backend/routes/matchRouter.js @@ -25,15 +25,18 @@ router.post("/uploadMatch", (req, res) => { }); }); -router.get("/matchHistory", (req, res) => { +router.post("/matchHistory", (req, res) => { // get all matches with req userid - const userid = req.body.id; + const userid = req.body.userId; // find username associated with userid userModel.findById(userid).then((user) => { + if(!user){ + res.status(404).json({ error: "User not found." }); + } matchModel.find( {players: { $elemMatch: { username: user.username } } } ).then((matches) => { - res.json(matches); + res.status(200).json(matches); }).catch((error) => { console.error("Error fetching match history:", error); res.status(500).json({ error: "Internal server error." }); diff --git a/backend/server.js b/backend/server.js index 5e258bc..78e49da 100644 --- a/backend/server.js +++ b/backend/server.js @@ -22,6 +22,7 @@ const port = process.env.PORT || 5000; const server = http.createServer(app); const db = require('./db.js'); // connect to database +const matchModel = require('./models/matchModel.js'); // Routes // --- Spotify access token @@ -48,4 +49,4 @@ cron.schedule("0 0 * * *", async () => { }); // uncomment this and run server to generate new dailychallenge questions -runCronJob(); \ No newline at end of file +//runCronJob(); \ No newline at end of file diff --git a/frontend/src/PageRouter.js b/frontend/src/PageRouter.js index 4fb4d68..d67a7c5 100644 --- a/frontend/src/PageRouter.js +++ b/frontend/src/PageRouter.js @@ -6,6 +6,7 @@ import FrontPage from './pages/FrontPage.js'; import Game from './Game.js'; import DailyChallengePage from './pages/DailyChallenge.js'; import DailyChallengeLeaderboard from './pages/DailyChallengeLeaderboard.js'; +import MatchHistory from './pages/MatchHistory.js'; const PageRouter = () => { @@ -18,6 +19,7 @@ const PageRouter = () => { } /> } /> } /> + } /> ); diff --git a/frontend/src/pages/FrontPage.js b/frontend/src/pages/FrontPage.js index da4c1aa..dbbc12e 100644 --- a/frontend/src/pages/FrontPage.js +++ b/frontend/src/pages/FrontPage.js @@ -31,7 +31,11 @@ const FrontPage = () => { {!loggedIn ?<> Sign In Sign Up - : Play Game} + : + <> + Play Game + Match History + } ); diff --git a/frontend/src/pages/MatchHistory.js b/frontend/src/pages/MatchHistory.js new file mode 100644 index 0000000..40869d4 --- /dev/null +++ b/frontend/src/pages/MatchHistory.js @@ -0,0 +1,68 @@ +import React, {useState, useEffect} from 'react'; +import './styles/MatchHistory.css'; + +const SERVER = process.env.REACT_APP_SERVER; + +const MatchHistory = () => { + const [matches, setMatches] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const userData = localStorage.getItem("userData"); + if (!userData) { + setError("Please log in to view match history"); + setLoading(false); + return; + } + const { userId } = JSON.parse(userData); + + fetch(`${SERVER}/match/matchHistory`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId }) + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to fetch match history."); + } + response.json().then((data) => { + setMatches(data); + setLoading(false); + }); + + }).catch((err) => { + setError(err.message); + setLoading(false); + }); + }, []); + + + return(<> +

Match History

+ {loading ?

Loading...

:
+ {Array.isArray(matches) && matches.length === 0 ?

No matches found

: + matches.map((match, index) => { + return(
+

{match.gameType}

+

{match.date}

+ {match.players + .sort((a, b) => b.score - a.score) + .map((player, index) => { + return(
+

{player.username}

+

{player.score}

+
) + })} +
) + }) + } +
} + {error ?

{error}

: null} + + ) +} + +export default MatchHistory; \ No newline at end of file diff --git a/frontend/src/pages/SignInPage.js b/frontend/src/pages/SignInPage.js index 5919ed2..d4c1145 100644 --- a/frontend/src/pages/SignInPage.js +++ b/frontend/src/pages/SignInPage.js @@ -3,7 +3,6 @@ import React from 'react'; import './styles/SignInPage.css'; const SERVER = process.env.REACT_APP_SERVER; -console.log(SERVER); // TODO: @@ -58,6 +57,9 @@ const SignInPage = () => { localStorage.setItem("userData", JSON.stringify(data)); console.log("dataaaa: ", JSON.stringify(data)); navigate("/"); + } else { + // Unsuccessful login + alert(data.error); } }) .catch((error) => { diff --git a/frontend/src/pages/styles/MatchHistory.css b/frontend/src/pages/styles/MatchHistory.css new file mode 100644 index 0000000..e69de29 From 1f1ddbb7078812067f078b8863390e14656e8102 Mon Sep 17 00:00:00 2001 From: Haotian Yi Date: Sat, 30 Nov 2024 11:33:51 -0800 Subject: [PATCH 3/3] Refactored proxy organization to better accommodate change of providers --- backend/controller/dumbProxy.js | 77 +++++++++++++ backend/controller/providerProxy.js | 165 ++++++++++++++++++++++++++++ backend/controller/spotifyProxy.js | 92 +--------------- 3 files changed, 248 insertions(+), 86 deletions(-) create mode 100644 backend/controller/dumbProxy.js create mode 100644 backend/controller/providerProxy.js diff --git a/backend/controller/dumbProxy.js b/backend/controller/dumbProxy.js new file mode 100644 index 0000000..75042fe --- /dev/null +++ b/backend/controller/dumbProxy.js @@ -0,0 +1,77 @@ +const { getAccessToken } = require("../utility/tokenManager"); +const axios = require('axios'); +const ProviderProxy = require("./providerProxy"); + +/* Singleton class to manage all spotify-related Requests */ +class DumbProxy extends ProviderProxy { + static instance = null; + + // Singleton method + static getInstance() { + // Create a singleton instance if none available + if (DumbProxy.instance == null) { + DumbProxy.instance = new DumbProxy() + } + + return DumbProxy.instance + } + + // Get track with ID + async getTrack(trackId) { + console.log(`Trying to get track with id ${trackId}`) + + if (!(trackId in this.cache)) { + // request for specific track + console.log(`Requesting track with id ${trackId} from Spotify`) + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => {console.log(error)}); + + this.addToCache(trackId, response.data) // add to cache + } catch (error) { + // TODO: error handling + throw error + } + } + + return this.cache[trackId] + } + + // Recommends tracks from genre + // TODO: for now we assume there is only one genre + async recommendTracks(genre, limit = 10) { + // Dumb proxy provides a list of randomly-generated tracks + let tracks = [] + for (let i = 0; i < limit; i++) { + let track = { + genre: genre, + artists: [ + { + "name": `Artist ${i + 1}`, + } + ], + name: `Track ${i + 1}`, + preview_url: `dumb` // empty + } + tracks.push(track) + } + return tracks + } +} + +async function test() { + let proxy = DumbProxy.getInstance() + let tracks = await proxy.recommendTracks("pop") + console.log(tracks) +} + +// test() + +module.exports = DumbProxy \ No newline at end of file diff --git a/backend/controller/providerProxy.js b/backend/controller/providerProxy.js new file mode 100644 index 0000000..e8d0fa4 --- /dev/null +++ b/backend/controller/providerProxy.js @@ -0,0 +1,165 @@ +const { getAccessToken } = require("../utility/tokenManager"); +const axios = require('axios') + +// Common filter functions +const PREVIEW_GENRE_FILTER_FAC = (genre) => (track) => track.preview_url && track.genre == genre +const PREVIEW_FILTER = (track) => track.preview_url +const ALL_FILTER = (track) => true + +/* Singleton class to manage all spotify-related Requests */ +class ProviderProxy { + static instance = null; + + constructor() { + this.cache = new Map() // dictionary - trackId: trackInfo + this.rateLimit = 0 // rate limit estimate + this.rlHandler = new CacheHandler() + } + + // Singleton method + static getInstance() { + // Create a singleton instance if none available + if (ProviderProxy.instance == null) { + ProviderProxy.instance = new ProviderProxy() + } + + return ProviderProxy.instance + } + + // Add to cache + addToCache(key, response) { + const cacheDuration = CacheHandler.cacheDuration(this.rateLimit) + + // Store response with a timeout to remove it after cacheDuration + const timeoutId = setTimeout(() => { + this.cache.delete(key) + console.log(`Cache expired for key: ${key}`) + }, cacheDuration) + + if (this.cache.has(key)) { + console.log(`Replaced track#${key}`) + } else console.log(`Added track#${key}`) + + console.log(JSON.stringify(response)) + this.cache.set(key, {response, timeoutId}) + } + + // Clear the cache + clearCache() { + // Clear all timeouts and cache entries + for (const [key, { timeoutId }] of this.cache.entries()) { + clearTimeout(timeoutId) + this.cache.delete(key) + } + } + + // Get track with ID + async getTrack(trackId) { + console.log(`Trying to get track with id ${trackId}`) + + if (!(trackId in this.cache)) { + // request for specific track + console.log(`Requesting track with id ${trackId} from Spotify`) + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => {console.log(error)}); + + this.addToCache(trackId, response.data) // add to cache + } catch (error) { + // TODO: error handling + throw error + } + } + + return this.cache[trackId] + } + + // Recommends tracks from genre + // TODO: for now we assume there is only one genre + async recommendTracks(genre, limit = 10) { + try { + const accessToken = await getAccessToken(); + const response = await axios.get( + `https://api.spotify.com/v1/recommendations?seed_genres=${genre}&limit=${limit}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ).catch((error) => console.log(error)); + + console.log("Getting responses") + response.data.tracks.forEach(track => { + track.genre = genre // append genre to the song + this.addToCache(track.id, track) + }); + + return response.data.tracks + } catch (error) { + // TODO: error handling + throw error + } + + // TODO: Form recommendation from cache + // TODO: could race condition happens here? + } + + // (base) Get a random track with filter + // returns null if no match + getRandomTrackFilter(filter) { + const filteredValues = [...this.cache.values()] + .map(entry => entry.response) + .filter((value) => filter(value)) + if (filteredValues.length == 0) return null + return filteredValues[~~(Math.random() * filteredValues.length)] + } + + // Get a random track + getRandomTrack() { + return this.getRandomTrackFilter(ALL_FILTER) + } + + // Get a random track by genre + async getRandomTrackByGenre(genre) { + console.log("Fetching new recommendations of genre", genre) + await this.recommendTracks(genre) + + console.log("Filtering results") + const filter = PREVIEW_GENRE_FILTER_FAC(genre) + const result = this.getRandomTrackFilter(filter) + + return result + } +} + +// Factory to produce different strategies handling rate limits +const LOW_RATE_CACHE = 600000 // 10 minutes +const MID_RATE_CACHE = LOW_RATE_CACHE * 2 // 20 minutes +const HIGH_RATE_CACHE = MID_RATE_CACHE * 2 // 40 minutes + +class CacheHandler { + constructor() { + + } + + static cacheDuration(rateLimit) { + // TODO: strategies + return LOW_RATE_CACHE + } +} + +async function test() { + const proxy = ProviderProxy.getInstance() + console.log(proxy.getRandomTrackByGenre("pop")) + await proxy.recommendTracks("pop", 10) + console.log(proxy.getRandomTrackByGenre("pop")) +} + +module.exports = ProviderProxy \ No newline at end of file diff --git a/backend/controller/spotifyProxy.js b/backend/controller/spotifyProxy.js index 8dff0a9..aa2afef 100644 --- a/backend/controller/spotifyProxy.js +++ b/backend/controller/spotifyProxy.js @@ -1,21 +1,11 @@ const { getAccessToken } = require("../utility/tokenManager"); -const axios = require('axios') - -// Common filter functions -const PREVIEW_GENRE_FILTER_FAC = (genre) => (track) => track.preview_url && track.genre == genre -const PREVIEW_FILTER = (track) => track.preview_url -const ALL_FILTER = (track) => true +const axios = require('axios'); +const providerProxy = require("./providerProxy"); /* Singleton class to manage all spotify-related Requests */ -class SpotifyProxy { +class SpotifyProxy extends providerProxy { static instance = null; - constructor() { - this.cache = new Map() // dictionary - trackId: trackInfo - this.rateLimit = 0 // rate limit estimate - this.rlHandler = new CacheHandler() - } - // Singleton method static getInstance() { // Create a singleton instance if none available @@ -26,33 +16,6 @@ class SpotifyProxy { return SpotifyProxy.instance } - // Add to cache - addToCache(key, response) { - const cacheDuration = CacheHandler.cacheDuration(this.rateLimit) - - // Store response with a timeout to remove it after cacheDuration - const timeoutId = setTimeout(() => { - this.cache.delete(key) - console.log(`Cache expired for key: ${key}`) - }, cacheDuration) - - if (this.cache.has(key)) { - console.log(`Replaced track#${key}`) - } else console.log(`Added track#${key}`) - - console.log(JSON.stringify(response)) - this.cache.set(key, {response, timeoutId}) - } - - // Clear the cache - clearCache() { - // Clear all timeouts and cache entries - for (const [key, { timeoutId }] of this.cache.entries()) { - clearTimeout(timeoutId) - this.cache.delete(key) - } - } - // Get track with ID async getTrack(trackId) { console.log(`Trying to get track with id ${trackId}`) @@ -100,6 +63,8 @@ class SpotifyProxy { track.genre = genre // append genre to the song this.addToCache(track.id, track) }); + + return response.data.tracks } catch (error) { // TODO: error handling throw error @@ -108,49 +73,6 @@ class SpotifyProxy { // TODO: Form recommendation from cache // TODO: could race condition happens here? } - - // (base) Get a random track with filter - // returns null if no match - getRandomTrackFilter(filter) { - const filteredValues = [...this.cache.values()] - .map(entry => entry.response) - .filter((value) => filter(value)) - if (filteredValues.length == 0) return null - return filteredValues[~~(Math.random() * filteredValues.length)] - } - - // Get a random track - getRandomTrack() { - return this.getRandomTrackFilter(ALL_FILTER) - } - - // Get a random track by genre - async getRandomTrackByGenre(genre) { - console.log("Fetching new recommendations of genre", genre) - await this.recommendTracks(genre) - - console.log("Filtering results") - const filter = PREVIEW_GENRE_FILTER_FAC(genre) - const result = this.getRandomTrackFilter(filter) - - return result - } -} - -// Factory to produce different strategies handling rate limits -const LOW_RATE_CACHE = 600000 // 10 minutes -const MID_RATE_CACHE = LOW_RATE_CACHE * 2 // 20 minutes -const HIGH_RATE_CACHE = MID_RATE_CACHE * 2 // 40 minutes - -class CacheHandler { - constructor() { - - } - - static cacheDuration(rateLimit) { - // TODO: strategies - return LOW_RATE_CACHE - } } async function test() { @@ -160,6 +82,4 @@ async function test() { console.log(proxy.getRandomTrackByGenre("pop")) } -module.exports = { - SpotifyProxy -} \ No newline at end of file +module.exports = SpotifyProxy \ No newline at end of file