From a6415ae694064326bb4fffc8152905a9ad28d758 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 11:37:44 -0400 Subject: [PATCH 01/77] Auth 0 setup --- auth/index.js | 3 +- package-lock.json | 94 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 + 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/auth/index.js b/auth/index.js index 07968c5..e8f00e7 100644 --- a/auth/index.js +++ b/auth/index.js @@ -88,6 +88,7 @@ router.post("/auth0", async (req, res) => { res.send({ message: "Auth0 authentication successful", + token: token, user: { id: user.id, username: user.username, @@ -169,7 +170,6 @@ router.post("/login", async (req, res) => { // Find user const user = await User.findOne({ where: { username } }); - user.checkPassword(password); if (!user) { return res.status(401).send({ error: "Invalid credentials" }); } @@ -200,6 +200,7 @@ router.post("/login", async (req, res) => { res.send({ message: "Login successful", + token: token, user: { id: user.id, username: user.username }, }); } catch (error) { diff --git a/package-lock.json b/package-lock.json index af0cf82..2dc9ed1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "license": "ISC", "dependencies": { + "@auth0/auth0-react": "^2.4.0", + "@neondatabase/serverless": "^1.0.1", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -26,6 +28,53 @@ "win-node-env": "^0.6.1" } }, + "node_modules/@auth0/auth0-react": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.4.0.tgz", + "integrity": "sha512-5bt3sO9FVupNM15IpqyYu/2OPHpLI5El7RgWLQXZOPbnCBbtl+VgdHR+H2NfhNQ4SqQtC/5uKbHWafcVcsxkiw==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ^19", + "react-dom": "^16.11.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.3.0.tgz", + "integrity": "sha512-zAW6w79UO+G1+3AxboVQIUIZy05xluSOb1ymGg2dqG0pIi0JxEtZGec05BOf2LJ9SehzW4WeCYUQsYD9BjrVpQ==", + "license": "MIT" + }, + "node_modules/@neondatabase/serverless": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.1.tgz", + "integrity": "sha512-O6yC5TT0jbw86VZVkmnzCZJB0hfxBl0JJz6f+3KHoZabjb/X08r9eFA+vuY06z1/qaovykvdkrXYq3SPUuvogA==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/@types/node": { + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -50,6 +99,17 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/pg": { + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/validator": { "version": "13.15.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", @@ -1336,6 +1396,29 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1397,6 +1480,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", diff --git a/package.json b/package.json index 7e0a0af..e339607 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "license": "ISC", "description": "", "dependencies": { + "@auth0/auth0-react": "^2.4.0", + "@neondatabase/serverless": "^1.0.1", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", From e3fad3e84bd67b770fc1ba1ddaaab69964c208f7 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 13:05:44 -0400 Subject: [PATCH 02/77] Cors update backend --- app.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 5857036..d454f87 100644 --- a/app.js +++ b/app.js @@ -16,12 +16,17 @@ const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; // body parser middleware app.use(express.json()); -app.use( - cors({ - origin: FRONTEND_URL, - credentials: true, - }) -); +const corsOptions = { + origin: [ + "http://localhost:3000", // Development + "https://capstone-2-frontend-one.vercel.app", // Production frontend + ], + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "Cookie"], +}; + +app.use(cors(corsOptions)); // cookie parser middleware app.use(cookieParser()); From c5cc9a080a88b912e4e4f7c044bbf378aa4012b5 Mon Sep 17 00:00:00 2001 From: Webbney Vallon Date: Thu, 31 Jul 2025 13:33:10 -0400 Subject: [PATCH 03/77] feat: added posts model --- .env.example | 3 --- database/db.js | 2 +- database/index.js | 2 ++ database/posts.js | 32 ++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- 5 files changed, 37 insertions(+), 6 deletions(-) delete mode 100644 .env.example create mode 100644 database/posts.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 848ad61..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -DATABASE_URL=postgres://hostname:5432/database-name -JWT_SECRET=super-secret-jwt-key -FRONTEND_URL=http://localhost:3000 diff --git a/database/db.js b/database/db.js index b251a9d..3b580f1 100644 --- a/database/db.js +++ b/database/db.js @@ -3,7 +3,7 @@ const { Sequelize } = require("sequelize"); const pg = require("pg"); // Feel free to rename the database to whatever you want! -const dbName = "capstone-1"; +const dbName = "capstone-2"; const db = new Sequelize( process.env.DATABASE_URL || `postgres://localhost:5432/${dbName}`, diff --git a/database/index.js b/database/index.js index e498df6..9e7350b 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,9 @@ const db = require("./db"); const User = require("./user"); +const Posts = require("./posts"); module.exports = { db, User, + Posts, }; diff --git a/database/posts.js b/database/posts.js new file mode 100644 index 0000000..a7d9a9b --- /dev/null +++ b/database/posts.js @@ -0,0 +1,32 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Posts = db.define("poats", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + }, + + title: { + type: DataTypes.STRING, + }, + + body: { + type: DataTypes.TEXT, + }, + + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + + status: { + type: DataTypes.STRING, + }, + + created_at: { + type: DataTypes.INTEGER, + }, +}); + +module.exports = Posts; diff --git a/package-lock.json b/package-lock.json index af0cf82..1b30289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "license": "ISC", "dependencies": { From a44842ed81ef261de9996b2745dcdb0cb394c451 Mon Sep 17 00:00:00 2001 From: Webbney Vallon Date: Thu, 31 Jul 2025 14:21:12 -0400 Subject: [PATCH 04/77] feat: added follows model --- database/follows.js | 24 ++++++++++++++++++++++++ database/index.js | 2 ++ database/posts.js | 4 ++-- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 database/follows.js diff --git a/database/follows.js b/database/follows.js new file mode 100644 index 0000000..9fa3eef --- /dev/null +++ b/database/follows.js @@ -0,0 +1,24 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Follows = db.define("follows", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + }, + + following_user_id: { + type: DataTypes.INTEGER, + }, + follows_user_id: { + type: DataTypes.INTEGER, + }, + follows_user_id: { + type: DataTypes.INTEGER, + }, + created_at: { + type: DataTypes.INTEGER, + }, +}); + +module.exports = Follows; diff --git a/database/index.js b/database/index.js index 9e7350b..f7c52f2 100644 --- a/database/index.js +++ b/database/index.js @@ -1,9 +1,11 @@ const db = require("./db"); const User = require("./user"); const Posts = require("./posts"); +const Follows = require("./follows"); module.exports = { db, User, Posts, + Follows, }; diff --git a/database/posts.js b/database/posts.js index a7d9a9b..233a9d4 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,7 +1,7 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const Posts = db.define("poats", { +const Posts = db.define("posts", { id: { type: DataTypes.INTEGER, primaryKey: true, @@ -11,7 +11,7 @@ const Posts = db.define("poats", { type: DataTypes.STRING, }, - body: { + descriptions: { type: DataTypes.TEXT, }, From adeb205601b86c3314a55f3cb4fe7000abd8ba95 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:22:50 -0400 Subject: [PATCH 05/77] will undo this --- app.js | 2 +- auth/index.js | 3 + auth/spotify.js | 195 ++++++++++++++++++++++++++++++++++++++++++++++ database/user.js | 35 ++++++++- package-lock.json | 132 +++++++++++++++++++++++++++++++ package.json | 1 + 6 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 auth/spotify.js diff --git a/app.js b/app.js index d454f87..c13a244 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,7 @@ const corsOptions = { "https://capstone-2-frontend-one.vercel.app", // Production frontend ], credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "Cookie"], }; diff --git a/auth/index.js b/auth/index.js index e8f00e7..efc5b50 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,6 +1,7 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); +const spotifyRouter = require("./spotify"); const router = express.Router(); @@ -231,4 +232,6 @@ router.get("/me", (req, res) => { }); }); +router.use("/spotify", spotifyRouter); + module.exports = { router, authenticateJWT }; diff --git a/auth/spotify.js b/auth/spotify.js new file mode 100644 index 0000000..dc76d71 --- /dev/null +++ b/auth/spotify.js @@ -0,0 +1,195 @@ +const express = require("express"); +const axios = require("axios"); +const { User } = require("../database"); + +const router = express.Router(); + +const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; + +// Import authenticateJWT middleware +const jwt = require("jsonwebtoken"); +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +// Define authenticateJWT middleware locally or import it properly +const authenticateJWT = (req, res, next) => { + const token = req.cookies.token; + + if (!token) { + return res.status(401).send({ error: "Access token required" }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).send({ error: "Invalid or expired token" }); + } + req.user = user; + next(); + }); +}; + +// Generate Spotify authorization URL +router.get("/auth-url", authenticateJWT, (req, res) => { + const scopes = [ + "user-read-private", + "user-read-email", + "user-top-read", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative" + ].join(" "); + + const params = new URLSearchParams({ + response_type: "code", + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: req.user.id.toString(), + }); + + const authUrl = `https://accounts.spotify.com/authorize?${params}`; + res.json({ authUrl }); +}); + +// Handle Spotify callback +router.post("/callback", authenticateJWT, async (req, res) => { + try { + const { code, state } = req.body; + + // Verify state matches user ID + if (state !== req.user.id.toString()) { + return res.status(400).json({ error: "Invalid state parameter" }); + } + + // Exchange code for access token + const tokenResponse = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, refresh_token, expires_in } = tokenResponse.data; + + // Get user's Spotify profile + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.data; + + // Update user with Spotify data + const user = await User.findByPk(req.user.id); + await user.update({ + spotifyId: spotifyProfile.id, + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + }); + + res.json({ + message: "Spotify connected successfully", + spotify: { + id: spotifyProfile.id, + displayName: spotifyProfile.display_name, + profileImage: spotifyProfile.images?.[0]?.url, + }, + }); + } catch (error) { + console.error("Spotify callback error:", error); + res.status(500).json({ error: "Failed to connect Spotify account" }); + } +}); + +// Get user's Spotify data +router.get("/profile", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user.spotifyId) { + return res.json({ connected: false }); + } + + // Check if token needs refresh + if (!user.isSpotifyTokenValid()) { + return res.status(401).json({ error: "Spotify token expired" }); + } + + // Get current Spotify profile + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${user.spotifyAccessToken}`, + }, + }); + + res.json({ + connected: true, + profile: profileResponse.data, + }); + } catch (error) { + console.error("Spotify profile error:", error); + res.status(500).json({ error: "Failed to get Spotify profile" }); + } +}); + +// Get user's top tracks +router.get("/top-tracks", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user.spotifyAccessToken || !user.isSpotifyTokenValid()) { + return res.status(401).json({ error: "Spotify not connected or token expired" }); + } + + const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { + headers: { + Authorization: `Bearer ${user.spotifyAccessToken}`, + }, + params: { + limit: 20, + time_range: "medium_term", + }, + }); + + res.json(response.data); + } catch (error) { + console.error("Spotify top tracks error:", error); + res.status(500).json({ error: "Failed to get top tracks" }); + } +}); + +// Disconnect Spotify +router.delete("/disconnect", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + await user.update({ + spotifyId: null, + spotifyAccessToken: null, + spotifyRefreshToken: null, + spotifyTokenExpiresAt: null, + spotifyDisplayName: null, + spotifyProfileImage: null, + }); + + res.json({ message: "Spotify disconnected successfully" }); + } catch (error) { + console.error("Spotify disconnect error:", error); + res.status(500).json({ error: "Failed to disconnect Spotify" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/database/user.js b/database/user.js index 755c757..59b83a7 100644 --- a/database/user.js +++ b/database/user.js @@ -28,6 +28,32 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, }, + // Add Spotify fields + spotifyId: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + spotifyAccessToken: { + type: DataTypes.TEXT, + allowNull: true, + }, + spotifyRefreshToken: { + type: DataTypes.TEXT, + allowNull: true, + }, + spotifyTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true, + }, + spotifyDisplayName: { + type: DataTypes.STRING, + allowNull: true, + }, + spotifyProfileImage: { + type: DataTypes.STRING, + allowNull: true, + }, }); // Instance method to check password @@ -43,4 +69,11 @@ User.hashPassword = function (password) { return bcrypt.hashSync(password, 10); }; -module.exports = User; +// Method to check if Spotify token is valid +User.prototype.isSpotifyTokenValid = function () { + return this.spotifyAccessToken && + this.spotifyTokenExpiresAt && + new Date() < this.spotifyTokenExpiresAt; +}; + +module.exports = User; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2dc9ed1..d6c6754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@auth0/auth0-react": "^2.4.0", "@neondatabase/serverless": "^1.0.1", + "axios": "^1.11.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -143,6 +144,23 @@ "node": ">= 8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -308,6 +326,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -403,6 +433,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -498,6 +537,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -585,6 +639,63 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -711,6 +822,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1350,6 +1476,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index e339607..ebb9f40 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@auth0/auth0-react": "^2.4.0", "@neondatabase/serverless": "^1.0.1", + "axios": "^1.11.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", From a0c247df2dd93fef668c3913f97232b2d66e71e1 Mon Sep 17 00:00:00 2001 From: Webbney Vallon Date: Thu, 31 Jul 2025 14:34:02 -0400 Subject: [PATCH 06/77] refactor: fixed typo --- database/posts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/posts.js b/database/posts.js index a7d9a9b..14116e9 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,7 +1,7 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const Posts = db.define("poats", { +const Posts = db.define("posts", { id: { type: DataTypes.INTEGER, primaryKey: true, From a4202ddf7978163a908e5d0fe5686853b42314a0 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:45:04 -0400 Subject: [PATCH 07/77] . --- auth/index.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/auth/index.js b/auth/index.js index efc5b50..62973c6 100644 --- a/auth/index.js +++ b/auth/index.js @@ -24,6 +24,17 @@ const authenticateJWT = (req, res, next) => { }); }; +router.get("/migrate", async (req, res) => { + try { + const { User } = require("../database"); + await User.sync({ alter: true }); // This will add missing columns + res.json({ message: "Database synced successfully" }); + } catch (error) { + console.error("Migration error:", error); + res.status(500).json({ error: error.message }); + } +}); + // Auth0 authentication route router.post("/auth0", async (req, res) => { try { From 7d53b9a98c97f5d0faf47620a82d6d75ee76ea79 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:51:43 -0400 Subject: [PATCH 08/77] g --- auth/index.js | 3 ++- vercel.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/auth/index.js b/auth/index.js index 62973c6..d5e4a8a 100644 --- a/auth/index.js +++ b/auth/index.js @@ -2,6 +2,7 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); const spotifyRouter = require("./spotify"); +const app = require("../app"); const router = express.Router(); @@ -245,4 +246,4 @@ router.get("/me", (req, res) => { router.use("/spotify", spotifyRouter); -module.exports = { router, authenticateJWT }; +module.exports = { router, authenticateJWT, app }; diff --git a/vercel.json b/vercel.json index 1b0a308..82898b1 100644 --- a/vercel.json +++ b/vercel.json @@ -2,14 +2,14 @@ "version": 2, "builds": [ { - "src": "app.js", + "src": "api/index.js", "use": "@vercel/node" } ], "routes": [ { "src": "/(.*)", - "dest": "app.js" + "dest": "api/index.js" } ] -} +} \ No newline at end of file From a9c449f433e9973dc7e773a7a59d25bdeb51d87a Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:54:14 -0400 Subject: [PATCH 09/77] g --- api/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/index.js b/api/index.js index f08162e..3c0bb61 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,8 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); +const app = require ("../app"); router.use("/test-db", testDbRouter); -module.exports = router; +module.exports = {router, app}; From f6311f0c93fc1f27ad8d7ca4320112197021944a Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 14:55:00 -0400 Subject: [PATCH 10/77] g --- api/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/index.js b/api/index.js index 3c0bb61..4433252 100644 --- a/api/index.js +++ b/api/index.js @@ -5,4 +5,4 @@ const app = require ("../app"); router.use("/test-db", testDbRouter); -module.exports = {router, app}; +module.exports = app; From 50045095ffbc6fa620fd4c3e8aa785fdf461f444 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 31 Jul 2025 15:04:40 -0400 Subject: [PATCH 11/77] undid --- api/index.js | 3 +-- auth/index.js | 3 +-- vercel.json | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/api/index.js b/api/index.js index 4433252..f08162e 100644 --- a/api/index.js +++ b/api/index.js @@ -1,8 +1,7 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); -const app = require ("../app"); router.use("/test-db", testDbRouter); -module.exports = app; +module.exports = router; diff --git a/auth/index.js b/auth/index.js index d5e4a8a..62973c6 100644 --- a/auth/index.js +++ b/auth/index.js @@ -2,7 +2,6 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); const spotifyRouter = require("./spotify"); -const app = require("../app"); const router = express.Router(); @@ -246,4 +245,4 @@ router.get("/me", (req, res) => { router.use("/spotify", spotifyRouter); -module.exports = { router, authenticateJWT, app }; +module.exports = { router, authenticateJWT }; diff --git a/vercel.json b/vercel.json index 82898b1..1b0a308 100644 --- a/vercel.json +++ b/vercel.json @@ -2,14 +2,14 @@ "version": 2, "builds": [ { - "src": "api/index.js", + "src": "app.js", "use": "@vercel/node" } ], "routes": [ { "src": "/(.*)", - "dest": "api/index.js" + "dest": "app.js" } ] -} \ No newline at end of file +} From b6bff0075535ba0b317837bde346df71bfa336d9 Mon Sep 17 00:00:00 2001 From: Micksterious <134881306+Micksterious@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:08:57 -0400 Subject: [PATCH 12/77] Delete database/posts.js this file is already on main --- database/posts.js | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 database/posts.js diff --git a/database/posts.js b/database/posts.js deleted file mode 100644 index 233a9d4..0000000 --- a/database/posts.js +++ /dev/null @@ -1,32 +0,0 @@ -const { DataTypes } = require("sequelize"); -const db = require("./db"); - -const Posts = db.define("posts", { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - }, - - title: { - type: DataTypes.STRING, - }, - - descriptions: { - type: DataTypes.TEXT, - }, - - user_id: { - type: DataTypes.INTEGER, - allowNull: false, - }, - - status: { - type: DataTypes.STRING, - }, - - created_at: { - type: DataTypes.INTEGER, - }, -}); - -module.exports = Posts; From f52422f3cbe8c8fd5fd14cc8afef0b4d834384e5 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 1 Aug 2025 11:23:18 -0400 Subject: [PATCH 13/77] Restored posts.js to resolve merge conflict --- database/posts.js | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 database/posts.js diff --git a/database/posts.js b/database/posts.js new file mode 100644 index 0000000..25945be --- /dev/null +++ b/database/posts.js @@ -0,0 +1,60 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); +const bcrypt = require("bcrypt"); + +const Post = db.define("post", { + title: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + len: [3, 20], + }, + }, + email: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + validate: { + isEmail: true, + }, + }, + auth0Id: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + passwordHash: { + type: DataTypes.STRING, + allowNull: true, + }, + role: { + type: DataTypes.ENUM("User", "Admin"), + defaultValue: "User", + allowNull: false, + }, +}); + +// Instance method to check password +User.prototype.checkPassword = function (password) { + if (!this.passwordHash) { + return false; // Auth0 users don't have passwords + } + return bcrypt.compareSync(password, this.passwordHash); +}; + +// Class method to hash password +User.hashPassword = function (password) { + return bcrypt.hashSync(password, 10); +}; + +// Method to check if Spotify token is valid +User.prototype.isSpotifyTokenValid = function () { + return ( + this.spotifyAccessToken && + this.spotifyTokenExpiresAt && + new Date() < this.spotifyTokenExpiresAt + ); +}; + +module.exports = User; From 50fb32fc89f37c56c1699e04b48bed30ebcd79b3 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Fri, 1 Aug 2025 11:53:58 -0400 Subject: [PATCH 14/77] added spotify login --- app.js | 24 ++-- auth/index.js | 110 +++++++------------ auth/spotify.js | 280 ++++++++++++++++++++++++++++++++++++++++------- database/user.js | 6 +- 4 files changed, 291 insertions(+), 129 deletions(-) diff --git a/app.js b/app.js index c13a244..9ad88fe 100644 --- a/app.js +++ b/app.js @@ -2,24 +2,23 @@ require("dotenv").config(); const express = require("express"); const morgan = require("morgan"); const path = require("path"); -const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); const app = express(); const apiRouter = require("./api"); const { router: authRouter } = require("./auth"); +const spotifyRouter = require("./auth/spotify"); const { db } = require("./database"); const cors = require("cors"); const PORT = process.env.PORT || 8080; -const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; -// body parser middleware app.use(express.json()); const corsOptions = { origin: [ - "http://localhost:3000", // Development - "https://capstone-2-frontend-one.vercel.app", // Production frontend + "http://localhost:3000", + "http://127.0.0.1:3000", + "https://capstone-2-frontend-one.vercel.app", ], credentials: true, methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], @@ -27,16 +26,13 @@ const corsOptions = { }; app.use(cors(corsOptions)); - -// cookie parser middleware app.use(cookieParser()); +app.use(morgan("dev")); +app.use(express.static(path.join(__dirname, "public"))); +app.use("/api", apiRouter); +app.use("/auth", authRouter); +app.use("/auth/spotify", spotifyRouter); -app.use(morgan("dev")); // logging middleware -app.use(express.static(path.join(__dirname, "public"))); // serve static files from public folder -app.use("/api", apiRouter); // mount api router -app.use("/auth", authRouter); // mount auth router - -// error handling middleware app.use((err, req, res, next) => { console.error(err.stack); res.sendStatus(500); @@ -56,4 +52,4 @@ const runApp = async () => { runApp(); -module.exports = app; +module.exports = app; \ No newline at end of file diff --git a/auth/index.js b/auth/index.js index 62973c6..eed714c 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,15 +1,27 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); -const spotifyRouter = require("./spotify"); const router = express.Router(); - const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; -// Middleware to authenticate JWT tokens +const cookieSettings = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 24 * 60 * 60 * 1000, + path: "/", +}; + const authenticateJWT = (req, res, next) => { - const token = req.cookies.token; + let token = req.cookies.token; + + if (!token && req.headers.authorization) { + const authHeader = req.headers.authorization; + if (authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + } if (!token) { return res.status(401).send({ error: "Access token required" }); @@ -24,18 +36,6 @@ const authenticateJWT = (req, res, next) => { }); }; -router.get("/migrate", async (req, res) => { - try { - const { User } = require("../database"); - await User.sync({ alter: true }); // This will add missing columns - res.json({ message: "Database synced successfully" }); - } catch (error) { - console.error("Migration error:", error); - res.status(500).json({ error: error.message }); - } -}); - -// Auth0 authentication route router.post("/auth0", async (req, res) => { try { const { auth0Id, email, username } = req.body; @@ -44,30 +44,24 @@ router.post("/auth0", async (req, res) => { return res.status(400).send({ error: "Auth0 ID is required" }); } - // Try to find existing user by auth0Id first let user = await User.findOne({ where: { auth0Id } }); if (!user && email) { - // If no user found by auth0Id, try to find by email user = await User.findOne({ where: { email } }); - if (user) { - // Update existing user with auth0Id user.auth0Id = auth0Id; await user.save(); } } if (!user) { - // Create new user if not found const userData = { auth0Id, email: email || null, - username: username || email?.split("@")[0] || `user_${Date.now()}`, // Use email prefix as username if no username provided - passwordHash: null, // Auth0 users don't have passwords + username: username || email?.split("@")[0] || `user_${Date.now()}`, + passwordHash: null, }; - // Ensure username is unique let finalUsername = userData.username; let counter = 1; while (await User.findOne({ where: { username: finalUsername } })) { @@ -79,7 +73,6 @@ router.post("/auth0", async (req, res) => { user = await User.create(userData); } - // Generate JWT token with auth0Id included const token = jwt.sign( { id: user.id, @@ -91,12 +84,7 @@ router.post("/auth0", async (req, res) => { { expiresIn: "24h" } ); - res.cookie("token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + res.cookie("token", token, cookieSettings); res.send({ message: "Auth0 authentication successful", @@ -109,39 +97,30 @@ router.post("/auth0", async (req, res) => { }, }); } catch (error) { - console.error("Auth0 authentication error:", error); - res.sendStatus(500); + res.status(500).send({ error: "Internal server error" }); } }); -// Signup route router.post("/signup", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { - return res - .status(400) - .send({ error: "Username and password are required" }); + return res.status(400).send({ error: "Username and password are required" }); } if (password.length < 6) { - return res - .status(400) - .send({ error: "Password must be at least 6 characters long" }); + return res.status(400).send({ error: "Password must be at least 6 characters long" }); } - // Check if user already exists const existingUser = await User.findOne({ where: { username } }); if (existingUser) { return res.status(409).send({ error: "Username already exists" }); } - // Create new user const passwordHash = User.hashPassword(password); const user = await User.create({ username, passwordHash }); - // Generate JWT token const token = jwt.sign( { id: user.id, @@ -153,45 +132,35 @@ router.post("/signup", async (req, res) => { { expiresIn: "24h" } ); - res.cookie("token", token, { - httpOnly: true, - secure: true, - sameSite: "strict", - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + res.cookie("token", token, cookieSettings); res.send({ message: "User created successfully", + token: token, user: { id: user.id, username: user.username }, }); } catch (error) { - console.error("Signup error:", error); - res.sendStatus(500); + res.status(500).send({ error: "Internal server error" }); } }); -// Login route router.post("/login", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { - res.status(400).send({ error: "Username and password are required" }); - return; + return res.status(400).send({ error: "Username and password are required" }); } - // Find user const user = await User.findOne({ where: { username } }); if (!user) { return res.status(401).send({ error: "Invalid credentials" }); } - // Check password if (!user.checkPassword(password)) { return res.status(401).send({ error: "Invalid credentials" }); } - // Generate JWT token const token = jwt.sign( { id: user.id, @@ -203,12 +172,7 @@ router.post("/login", async (req, res) => { { expiresIn: "24h" } ); - res.cookie("token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "strict", - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }); + res.cookie("token", token, cookieSettings); res.send({ message: "Login successful", @@ -216,23 +180,27 @@ router.post("/login", async (req, res) => { user: { id: user.id, username: user.username }, }); } catch (error) { - console.error("Login error:", error); - res.sendStatus(500); + res.status(500).send({ error: "Internal server error" }); } }); -// Logout route router.post("/logout", (req, res) => { res.clearCookie("token"); res.send({ message: "Logout successful" }); }); -// Get current user route (protected) router.get("/me", (req, res) => { - const token = req.cookies.token; + let token = req.cookies.token; + + if (!token && req.headers.authorization) { + const authHeader = req.headers.authorization; + if (authHeader.startsWith('Bearer ')) { + token = authHeader.substring(7); + } + } if (!token) { - return res.send({}); + return res.send({ user: null }); } jwt.verify(token, JWT_SECRET, (err, user) => { @@ -243,6 +211,4 @@ router.get("/me", (req, res) => { }); }); -router.use("/spotify", spotifyRouter); - -module.exports = { router, authenticateJWT }; +module.exports = { router, authenticateJWT }; \ No newline at end of file diff --git a/auth/spotify.js b/auth/spotify.js index dc76d71..3deb366 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -1,35 +1,240 @@ const express = require("express"); const axios = require("axios"); +const jwt = require("jsonwebtoken"); const { User } = require("../database"); +const { authenticateJWT } = require("./index"); const router = express.Router(); const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; - -// Import authenticateJWT middleware -const jwt = require("jsonwebtoken"); const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; -// Define authenticateJWT middleware locally or import it properly -const authenticateJWT = (req, res, next) => { - const token = req.cookies.token; +const cookieSettings = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 24 * 60 * 60 * 1000, + path: "/", +}; + +const refreshSpotifyToken = async (user) => { + try { + const response = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: user.spotifyRefreshToken, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, expires_in, refresh_token } = response.data; + + await user.update({ + spotifyAccessToken: access_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyRefreshToken: refresh_token || user.spotifyRefreshToken, + }); - if (!token) { - return res.status(401).send({ error: "Access token required" }); + return access_token; + } catch (error) { + throw error; + } +}; + +const getValidSpotifyToken = async (user) => { + if (!user.spotifyAccessToken || !user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); } - jwt.verify(token, JWT_SECRET, (err, user) => { - if (err) { - return res.status(403).send({ error: "Invalid or expired token" }); + if (user.isSpotifyTokenValid()) { + return user.spotifyAccessToken; + } + + return await refreshSpotifyToken(user); +}; + +// Generate safe username from Spotify data +const generateUsername = async (spotifyProfile) => { + let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; + + // Clean username - remove invalid characters and limit length + baseUsername = baseUsername + .replace(/[^a-zA-Z0-9_]/g, '_') + .substring(0, 15) + .toLowerCase(); + + // Ensure minimum length + if (baseUsername.length < 3) { + baseUsername = `spotify_${spotifyProfile.id}`.substring(0, 20); + } + + // Check for uniqueness and modify if needed + let finalUsername = baseUsername; + let counter = 1; + + while (await User.findOne({ where: { username: finalUsername } })) { + finalUsername = `${baseUsername}_${counter}`; + counter++; + + // Prevent infinite loop + if (counter > 1000) { + finalUsername = `spotify_${Date.now()}`; + break; } - req.user = user; - next(); - }); + } + + return finalUsername; }; -// Generate Spotify authorization URL +router.get("/login-url", (req, res) => { + const scopes = [ + "user-read-private", + "user-read-email", + "user-top-read", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative" + ].join(" "); + + const params = new URLSearchParams({ + response_type: "code", + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: "spotify_login", + }); + + const authUrl = `https://accounts.spotify.com/authorize?${params}`; + res.json({ authUrl }); +}); + +router.post("/login", async (req, res) => { + try { + console.log("🎡 Spotify login attempt"); + const { code, state } = req.body; + + if (state !== "spotify_login") { + console.log("❌ Invalid state parameter:", state); + return res.status(400).json({ error: "Invalid state parameter" }); + } + + if (!code) { + console.log("❌ No authorization code"); + return res.status(400).json({ error: "No authorization code provided" }); + } + + console.log("🎡 Exchanging code for tokens..."); + const tokenResponse = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, refresh_token, expires_in } = tokenResponse.data; + console.log("🎡 Tokens received successfully"); + + console.log("🎡 Getting Spotify profile..."); + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.data; + console.log("🎡 Profile received for:", spotifyProfile.id); + + // Check if user already exists with this Spotify ID + let user = await User.findOne({ where: { spotifyId: spotifyProfile.id } }); + + if (!user) { + console.log("🎡 Creating new user..."); + + // Generate safe username + const username = await generateUsername(spotifyProfile); + console.log("🎡 Generated username:", username); + + // Create new user with Spotify data + user = await User.create({ + username: username, + email: spotifyProfile.email || null, // Email might be null + spotifyId: spotifyProfile.id, + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + passwordHash: null, // No password for Spotify-only users + }); + + console.log("🎡 User created with ID:", user.id); + } else { + console.log("🎡 Updating existing user..."); + // Update existing user's Spotify tokens + await user.update({ + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + }); + } + + // Create JWT token + const token = jwt.sign( + { + id: user.id, + username: user.username, + auth0Id: user.auth0Id, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.cookie("token", token, cookieSettings); + + console.log("🎡 Spotify login successful for user:", user.username); + + res.json({ + message: "Spotify login successful", + token: token, + user: { + id: user.id, + username: user.username, + email: user.email, + }, + }); + } catch (error) { + console.error("❌ Spotify login error:", error); + console.error("❌ Error details:", { + message: error.message, + response: error.response?.data, + status: error.response?.status, + stack: error.stack + }); + res.status(500).json({ error: "Failed to login with Spotify" }); + } +}); + router.get("/auth-url", authenticateJWT, (req, res) => { const scopes = [ "user-read-private", @@ -52,17 +257,18 @@ router.get("/auth-url", authenticateJWT, (req, res) => { res.json({ authUrl }); }); -// Handle Spotify callback router.post("/callback", authenticateJWT, async (req, res) => { try { const { code, state } = req.body; - // Verify state matches user ID + if (!code) { + return res.status(400).json({ error: "No authorization code received" }); + } + if (state !== req.user.id.toString()) { return res.status(400).json({ error: "Invalid state parameter" }); } - // Exchange code for access token const tokenResponse = await axios.post( "https://accounts.spotify.com/api/token", new URLSearchParams({ @@ -81,7 +287,6 @@ router.post("/callback", authenticateJWT, async (req, res) => { const { access_token, refresh_token, expires_in } = tokenResponse.data; - // Get user's Spotify profile const profileResponse = await axios.get("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${access_token}`, @@ -90,8 +295,12 @@ router.post("/callback", authenticateJWT, async (req, res) => { const spotifyProfile = profileResponse.data; - // Update user with Spotify data const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + await user.update({ spotifyId: spotifyProfile.id, spotifyAccessToken: access_token, @@ -110,12 +319,10 @@ router.post("/callback", authenticateJWT, async (req, res) => { }, }); } catch (error) { - console.error("Spotify callback error:", error); res.status(500).json({ error: "Failed to connect Spotify account" }); } }); -// Get user's Spotify data router.get("/profile", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -124,15 +331,11 @@ router.get("/profile", authenticateJWT, async (req, res) => { return res.json({ connected: false }); } - // Check if token needs refresh - if (!user.isSpotifyTokenValid()) { - return res.status(401).json({ error: "Spotify token expired" }); - } + const accessToken = await getValidSpotifyToken(user); - // Get current Spotify profile const profileResponse = await axios.get("https://api.spotify.com/v1/me", { headers: { - Authorization: `Bearer ${user.spotifyAccessToken}`, + Authorization: `Bearer ${accessToken}`, }, }); @@ -141,38 +344,40 @@ router.get("/profile", authenticateJWT, async (req, res) => { profile: profileResponse.data, }); } catch (error) { - console.error("Spotify profile error:", error); + if (error.message === "Spotify not connected") { + return res.json({ connected: false }); + } res.status(500).json({ error: "Failed to get Spotify profile" }); } }); -// Get user's top tracks router.get("/top-tracks", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - if (!user.spotifyAccessToken || !user.isSpotifyTokenValid()) { - return res.status(401).json({ error: "Spotify not connected or token expired" }); - } + const accessToken = await getValidSpotifyToken(user); + + const timeRange = req.query.time_range || "short_term"; const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { headers: { - Authorization: `Bearer ${user.spotifyAccessToken}`, + Authorization: `Bearer ${accessToken}`, }, params: { limit: 20, - time_range: "medium_term", + time_range: timeRange, }, }); res.json(response.data); } catch (error) { - console.error("Spotify top tracks error:", error); + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } res.status(500).json({ error: "Failed to get top tracks" }); } }); -// Disconnect Spotify router.delete("/disconnect", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -187,7 +392,6 @@ router.delete("/disconnect", authenticateJWT, async (req, res) => { res.json({ message: "Spotify disconnected successfully" }); } catch (error) { - console.error("Spotify disconnect error:", error); res.status(500).json({ error: "Failed to disconnect Spotify" }); } }); diff --git a/database/user.js b/database/user.js index 59b83a7..e2147c3 100644 --- a/database/user.js +++ b/database/user.js @@ -28,7 +28,6 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, }, - // Add Spotify fields spotifyId: { type: DataTypes.STRING, allowNull: true, @@ -56,20 +55,17 @@ const User = db.define("user", { }, }); -// Instance method to check password User.prototype.checkPassword = function (password) { if (!this.passwordHash) { - return false; // Auth0 users don't have passwords + return false; } return bcrypt.compareSync(password, this.passwordHash); }; -// Class method to hash password User.hashPassword = function (password) { return bcrypt.hashSync(password, 10); }; -// Method to check if Spotify token is valid User.prototype.isSpotifyTokenValid = function () { return this.spotifyAccessToken && this.spotifyTokenExpiresAt && From bb84c2d9cf0b080ae4880ed3c4feb475d3d5902b Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Fri, 1 Aug 2025 13:21:31 -0400 Subject: [PATCH 15/77] new backend --- database/follows.js | 38 ++++++++++++++++------- database/index.js | 32 +++++++++++++++++-- database/posts.js | 76 ++++++++++++++++++++++----------------------- database/user.js | 11 +++++++ 4 files changed, 105 insertions(+), 52 deletions(-) diff --git a/database/follows.js b/database/follows.js index 9fa3eef..9aaff37 100644 --- a/database/follows.js +++ b/database/follows.js @@ -5,20 +5,36 @@ const Follows = db.define("follows", { id: { type: DataTypes.INTEGER, primaryKey: true, + autoIncrement: true, }, - - following_user_id: { - type: DataTypes.INTEGER, - }, - follows_user_id: { + followerId: { type: DataTypes.INTEGER, + allowNull: false, + field: 'follower_id', // Map to snake_case database column + references: { + model: 'users', + key: 'id' + } }, - follows_user_id: { + followingId: { type: DataTypes.INTEGER, - }, - created_at: { - type: DataTypes.INTEGER, - }, + allowNull: false, + field: 'following_id', // Map to snake_case database column + references: { + model: 'users', + key: 'id' + } + } +}, { + tableName: 'follows', + underscored: true, // This ensures snake_case column names + // Ensure a user can't follow the same person twice + indexes: [ + { + unique: true, + fields: ['follower_id', 'following_id'] // Use snake_case here + } + ] }); -module.exports = Follows; +module.exports = Follows; \ No newline at end of file diff --git a/database/index.js b/database/index.js index f7c52f2..3142d8e 100644 --- a/database/index.js +++ b/database/index.js @@ -1,11 +1,37 @@ const db = require("./db"); const User = require("./user"); -const Posts = require("./posts"); +const Post = require("./posts"); const Follows = require("./follows"); +// Set up associations +User.hasMany(Post, { + foreignKey: 'user_id', // Use snake_case for foreign key + as: 'posts' +}); + +Post.belongsTo(User, { + foreignKey: 'user_id', // Use snake_case for foreign key + as: 'author' +}); + +// User following relationships +User.belongsToMany(User, { + through: Follows, + as: 'following', + foreignKey: 'follower_id', // Use snake_case + otherKey: 'following_id' // Use snake_case +}); + +User.belongsToMany(User, { + through: Follows, + as: 'followers', + foreignKey: 'following_id', // Use snake_case + otherKey: 'follower_id' // Use snake_case +}); + module.exports = { db, User, - Posts, + Post, Follows, -}; +}; \ No newline at end of file diff --git a/database/posts.js b/database/posts.js index 25945be..40fa368 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,60 +1,60 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const bcrypt = require("bcrypt"); const Post = db.define("post", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, title: { type: DataTypes.STRING, allowNull: false, - unique: true, validate: { - len: [3, 20], + len: [1, 200], }, }, - email: { + content: { + type: DataTypes.TEXT, + allowNull: true, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id', // Map to snake_case + references: { + model: 'users', + key: 'id' + } + }, + spotifyTrackId: { type: DataTypes.STRING, allowNull: true, - unique: true, - validate: { - isEmail: true, - }, + field: 'spotify_track_id' }, - auth0Id: { + spotifyTrackName: { type: DataTypes.STRING, allowNull: true, - unique: true, + field: 'spotify_track_name' }, - passwordHash: { + spotifyArtistName: { type: DataTypes.STRING, allowNull: true, + field: 'spotify_artist_name' }, - role: { - type: DataTypes.ENUM("User", "Admin"), - defaultValue: "User", - allowNull: false, + likesCount: { + type: DataTypes.INTEGER, + defaultValue: 0, + field: 'likes_count' }, -}); - -// Instance method to check password -User.prototype.checkPassword = function (password) { - if (!this.passwordHash) { - return false; // Auth0 users don't have passwords + isPublic: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: 'is_public' } - return bcrypt.compareSync(password, this.passwordHash); -}; - -// Class method to hash password -User.hashPassword = function (password) { - return bcrypt.hashSync(password, 10); -}; - -// Method to check if Spotify token is valid -User.prototype.isSpotifyTokenValid = function () { - return ( - this.spotifyAccessToken && - this.spotifyTokenExpiresAt && - new Date() < this.spotifyTokenExpiresAt - ); -}; +}, { + tableName: 'posts', + underscored: true // Ensures snake_case column names +}); -module.exports = User; +module.exports = Post; \ No newline at end of file diff --git a/database/user.js b/database/user.js index e2147c3..5107299 100644 --- a/database/user.js +++ b/database/user.js @@ -23,36 +23,47 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, unique: true, + field: 'auth0_id' }, passwordHash: { type: DataTypes.STRING, allowNull: true, + field: 'password_hash' }, spotifyId: { type: DataTypes.STRING, allowNull: true, unique: true, + field: 'spotify_id' }, spotifyAccessToken: { type: DataTypes.TEXT, allowNull: true, + field: 'spotify_access_token' }, spotifyRefreshToken: { type: DataTypes.TEXT, allowNull: true, + field: 'spotify_refresh_token' }, spotifyTokenExpiresAt: { type: DataTypes.DATE, allowNull: true, + field: 'spotify_token_expires_at' }, spotifyDisplayName: { type: DataTypes.STRING, allowNull: true, + field: 'spotify_display_name' }, spotifyProfileImage: { type: DataTypes.STRING, allowNull: true, + field: 'spotify_profile_image' }, +}, { + tableName: 'users', + underscored: true // Ensures snake_case column names }); User.prototype.checkPassword = function (password) { From 36a814ffa5205dbd81d5146d939e4ad6e834e896 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 1 Aug 2025 13:24:11 -0400 Subject: [PATCH 16/77] edited databases/posts.js, added dummy data for post to test, added routes for /GET user post and /POST for users. --- api/index.js | 2 + api/posts.js | 44 +++++++++++++++++ database/posts.js | 57 +++++---------------- database/seed.js | 123 +++++++++++++++++++++++++++++++++++++++++----- database/user.js | 62 ++++++++++++----------- package-lock.json | 20 ++++---- 6 files changed, 212 insertions(+), 96 deletions(-) create mode 100644 api/posts.js diff --git a/api/index.js b/api/index.js index f08162e..8c9caf7 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,9 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); +const postsRouter = require("./posts"); router.use("/test-db", testDbRouter); +router.use("/posts", postsRouter); module.exports = router; diff --git a/api/posts.js b/api/posts.js new file mode 100644 index 0000000..2ba731d --- /dev/null +++ b/api/posts.js @@ -0,0 +1,44 @@ +const express = require("express"); +const router = express.Router(); +const { Posts } = require("../database"); // Correct import +const { authenticateJWT } = require("../auth"); + +// Get all posts (public) +// Get all posts by the logged-in user +router.get("/", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + const posts = await Posts.findAll({ + where: { userId }, + }); + res.json(posts); + } catch (error) { + console.error("Error fetching posts:", error); + res.status(500).json({ error: "Failed to fetch posts" }); + } +}); + +// Create a new post (requires authentication) +router.post("/", authenticateJWT, async (req, res) => { + try { + const { title, description, status } = req.body; + + if (!title) { + return res.status(400).json({ error: "Title is required" }); + } + + const newPost = await Posts.create({ + title, + description, + status, + userId: req.user.id, // from token + }); + + res.status(201).json(newPost); + } catch (error) { + console.error("Error creating post:", error); + res.status(500).json({ error: "Failed to create post" }); + } +}); + +module.exports = router; diff --git a/database/posts.js b/database/posts.js index 25945be..ad03200 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,60 +1,27 @@ const { DataTypes } = require("sequelize"); -const db = require("./db"); -const bcrypt = require("bcrypt"); +const db = require("./db"); // your Sequelize instance -const Post = db.define("post", { +const Posts = db.define("posts", { title: { type: DataTypes.STRING, allowNull: false, - unique: true, validate: { - len: [3, 20], + len: [3, 100], }, }, - email: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - validate: { - isEmail: true, - }, - }, - auth0Id: { - type: DataTypes.STRING, + description: { + type: DataTypes.TEXT, allowNull: true, - unique: true, }, - passwordHash: { - type: DataTypes.STRING, - allowNull: true, + status: { + type: DataTypes.ENUM("draft", "published"), + allowNull: false, + defaultValue: "draft", }, - role: { - type: DataTypes.ENUM("User", "Admin"), - defaultValue: "User", + userId: { + type: DataTypes.INTEGER, allowNull: false, }, }); -// Instance method to check password -User.prototype.checkPassword = function (password) { - if (!this.passwordHash) { - return false; // Auth0 users don't have passwords - } - return bcrypt.compareSync(password, this.passwordHash); -}; - -// Class method to hash password -User.hashPassword = function (password) { - return bcrypt.hashSync(password, 10); -}; - -// Method to check if Spotify token is valid -User.prototype.isSpotifyTokenValid = function () { - return ( - this.spotifyAccessToken && - this.spotifyTokenExpiresAt && - new Date() < this.spotifyTokenExpiresAt - ); -}; - -module.exports = User; +module.exports = Posts; diff --git a/database/seed.js b/database/seed.js index e58b595..fac0ef7 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,30 +1,131 @@ const db = require("./db"); -const { User } = require("./index"); +const { User, Posts } = require("./index"); const seed = async () => { try { db.logging = false; - await db.sync({ force: true }); // Drop and recreate tables + await db.sync({ force: true }); const users = await User.bulkCreate([ - { username: "admin", passwordHash: User.hashPassword("admin123") }, - { username: "user1", passwordHash: User.hashPassword("user111") }, - { username: "user2", passwordHash: User.hashPassword("user222") }, + { id: 1, username: "admin", passwordHash: User.hashPassword("admin123") }, + { id: 2, username: "user1", passwordHash: User.hashPassword("user111") }, + { id: 3, username: "user2", passwordHash: User.hashPassword("user222") }, + { id: 4, username: "user3", passwordHash: User.hashPassword("user333") }, + { id: 5, username: "user4", passwordHash: User.hashPassword("user444") }, ]); console.log(`πŸ‘€ Created ${users.length} users`); - // Create more seed data here once you've created your models - // Seed files are a great way to test your database schema! + const posts = await Posts.bulkCreate([ + // Posts for admin + { + title: "Admin Post 1", + description: "Admin is testing things.", + status: "published", + userId: 1, + }, + { + title: "Admin Post 2", + description: "Another one by admin.", + status: "draft", + userId: 1, + }, + { + title: "Admin Post 3", + description: "Admin's third post.", + status: "published", + userId: 1, + }, + + // Posts for user1 + { + title: "User1's First", + description: "Hello world!", + status: "published", + userId: 2, + }, + { + title: "User1's Second", + description: "Testing stuff.", + status: "draft", + userId: 2, + }, + { + title: "User1's Third", + description: "Still learning!", + status: "published", + userId: 2, + }, + + // Posts for user2 + { + title: "Post by User2", + description: "Yup, it's working.", + status: "draft", + userId: 3, + }, + { + title: "Second by User2", + description: "Seeding is cool.", + status: "published", + userId: 3, + }, + { + title: "User2 Final", + description: "Final test.", + status: "draft", + userId: 3, + }, + + // Posts for user3 + { + title: "u3-1", + description: "user3 post one", + status: "published", + userId: 4, + }, + { + title: "u3-2", + description: "user3 post two", + status: "draft", + userId: 4, + }, + { + title: "u3-3", + description: "user3 post three", + status: "published", + userId: 4, + }, + + // Posts for user4 + { + title: "user4 test 1", + description: "just a test", + status: "draft", + userId: 5, + }, + { + title: "user4 test 2", + description: "testing more", + status: "published", + userId: 5, + }, + { + title: "user4 test 3", + description: "this is it", + status: "draft", + userId: 5, + }, + ]); + + console.log(`πŸ“ Created ${posts.length} posts`); console.log("🌱 Seeded the database"); } catch (error) { console.error("Error seeding database:", error); - if (error.message.includes("does not exist")) { - console.log("\nπŸ€”πŸ€”πŸ€” Have you created your database??? πŸ€”πŸ€”πŸ€”"); - } + } finally { + db.close(); } - db.close(); }; seed(); diff --git a/database/user.js b/database/user.js index 59b83a7..7cbf12e 100644 --- a/database/user.js +++ b/database/user.js @@ -28,32 +28,32 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, }, - // Add Spotify fields - spotifyId: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - }, - spotifyAccessToken: { - type: DataTypes.TEXT, - allowNull: true, - }, - spotifyRefreshToken: { - type: DataTypes.TEXT, - allowNull: true, - }, - spotifyTokenExpiresAt: { - type: DataTypes.DATE, - allowNull: true, - }, - spotifyDisplayName: { - type: DataTypes.STRING, - allowNull: true, - }, - spotifyProfileImage: { - type: DataTypes.STRING, - allowNull: true, - }, + // // Add Spotify fields + // spotifyId: { + // type: DataTypes.STRING, + // allowNull: true, + // unique: true, + // }, + // spotifyAccessToken: { + // type: DataTypes.TEXT, + // allowNull: true, + // }, + // spotifyRefreshToken: { + // type: DataTypes.TEXT, + // allowNull: true, + // }, + // spotifyTokenExpiresAt: { + // type: DataTypes.DATE, + // allowNull: true, + // }, + // spotifyDisplayName: { + // type: DataTypes.STRING, + // allowNull: true, + // }, + // spotifyProfileImage: { + // type: DataTypes.STRING, + // allowNull: true, + // }, }); // Instance method to check password @@ -71,9 +71,11 @@ User.hashPassword = function (password) { // Method to check if Spotify token is valid User.prototype.isSpotifyTokenValid = function () { - return this.spotifyAccessToken && - this.spotifyTokenExpiresAt && - new Date() < this.spotifyTokenExpiresAt; + return ( + this.spotifyAccessToken && + this.spotifyTokenExpiresAt && + new Date() < this.spotifyTokenExpiresAt + ); }; -module.exports = User; \ No newline at end of file +module.exports = User; diff --git a/package-lock.json b/package-lock.json index d6c6754..386986a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -234,9 +234,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1137,16 +1137,16 @@ } }, "node_modules/morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", - "on-headers": "~1.0.2" + "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" @@ -1287,9 +1287,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" From 2c165df93665ada7cb99a10f076a91a6d091ef96 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Fri, 1 Aug 2025 14:26:16 -0400 Subject: [PATCH 17/77] WOrk? --- auth/spotify.js | 285 +++++++++++++++++++++++++---------------------- database/user.js | 19 +++- 2 files changed, 168 insertions(+), 136 deletions(-) diff --git a/auth/spotify.js b/auth/spotify.js index 3deb366..dc6ce75 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -8,7 +8,7 @@ const router = express.Router(); const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; -const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3000"; const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; const cookieSettings = { @@ -36,22 +36,22 @@ const refreshSpotifyToken = async (user) => { } ); - const { access_token, expires_in, refresh_token } = response.data; - + const { access_token, expires_in } = response.data; + await user.update({ spotifyAccessToken: access_token, spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyRefreshToken: refresh_token || user.spotifyRefreshToken, }); return access_token; } catch (error) { - throw error; + console.error("Failed to refresh Spotify token:", error.message); + throw new Error("Token refresh failed"); } }; const getValidSpotifyToken = async (user) => { - if (!user.spotifyAccessToken || !user.spotifyRefreshToken) { + if (!user.spotifyAccessToken) { throw new Error("Spotify not connected"); } @@ -59,35 +59,40 @@ const getValidSpotifyToken = async (user) => { return user.spotifyAccessToken; } + if (!user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); + } + return await refreshSpotifyToken(user); }; -// Generate safe username from Spotify data const generateUsername = async (spotifyProfile) => { let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; - // Clean username - remove invalid characters and limit length baseUsername = baseUsername .replace(/[^a-zA-Z0-9_]/g, '_') .substring(0, 15) .toLowerCase(); - // Ensure minimum length if (baseUsername.length < 3) { - baseUsername = `spotify_${spotifyProfile.id}`.substring(0, 20); + baseUsername = `spotify_${spotifyProfile.id.substring(0, 10)}`; + } + + if (baseUsername.length > 20) { + baseUsername = baseUsername.substring(0, 20); } - // Check for uniqueness and modify if needed let finalUsername = baseUsername; let counter = 1; while (await User.findOne({ where: { username: finalUsername } })) { - finalUsername = `${baseUsername}_${counter}`; + const suffix = `_${counter}`; + const maxLength = 20 - suffix.length; + finalUsername = baseUsername.substring(0, maxLength) + suffix; counter++; - // Prevent infinite loop if (counter > 1000) { - finalUsername = `spotify_${Date.now()}`; + finalUsername = `spotify_${Date.now()}`.substring(0, 20); break; } } @@ -95,44 +100,81 @@ const generateUsername = async (spotifyProfile) => { return finalUsername; }; +// Login URL endpoint router.get("/login-url", (req, res) => { - const scopes = [ - "user-read-private", - "user-read-email", - "user-top-read", - "user-read-recently-played", - "playlist-read-private", - "playlist-read-collaborative" - ].join(" "); - - const params = new URLSearchParams({ - response_type: "code", - client_id: SPOTIFY_CLIENT_ID, - scope: scopes, - redirect_uri: `${FRONTEND_URL}/callback/spotify`, - state: "spotify_login", - }); - - const authUrl = `https://accounts.spotify.com/authorize?${params}`; - res.json({ authUrl }); + try { + const scopes = [ + 'user-read-private', + 'user-read-email', + 'user-top-read', + 'user-read-recently-played', + 'playlist-read-private', + 'playlist-read-collaborative' + ].join(' '); + + const authUrl = 'https://accounts.spotify.com/authorize?' + + new URLSearchParams({ + response_type: 'code', + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: 'spotify_login', + show_dialog: true + }); + + res.json({ authUrl }); + } catch (error) { + console.error("Error generating Spotify login URL:", error.message); + res.status(500).json({ error: "Failed to generate Spotify login URL" }); + } }); +// Auth URL for connected users +router.get("/auth-url", authenticateJWT, (req, res) => { + try { + const scopes = [ + 'user-read-private', + 'user-read-email', + 'user-top-read', + 'user-read-recently-played', + 'playlist-read-private', + 'playlist-read-collaborative' + ].join(' '); + + const authUrl = 'https://accounts.spotify.com/authorize?' + + new URLSearchParams({ + response_type: 'code', + client_id: SPOTIFY_CLIENT_ID, + scope: scopes, + redirect_uri: `${FRONTEND_URL}/callback/spotify`, + state: req.user.id.toString(), + show_dialog: true + }); + + res.json({ authUrl }); + } catch (error) { + console.error("Error generating Spotify auth URL:", error.message); + res.status(500).json({ error: "Failed to generate auth URL" }); + } +}); + +// Spotify login endpoint router.post("/login", async (req, res) => { try { - console.log("🎡 Spotify login attempt"); const { code, state } = req.body; if (state !== "spotify_login") { - console.log("❌ Invalid state parameter:", state); return res.status(400).json({ error: "Invalid state parameter" }); } if (!code) { - console.log("❌ No authorization code"); return res.status(400).json({ error: "No authorization code provided" }); } - console.log("🎡 Exchanging code for tokens..."); + if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) { + return res.status(500).json({ error: "Server configuration error" }); + } + const tokenResponse = await axios.post( "https://accounts.spotify.com/api/token", new URLSearchParams({ @@ -150,9 +192,7 @@ router.post("/login", async (req, res) => { ); const { access_token, refresh_token, expires_in } = tokenResponse.data; - console.log("🎡 Tokens received successfully"); - console.log("🎡 Getting Spotify profile..."); const profileResponse = await axios.get("https://api.spotify.com/v1/me", { headers: { Authorization: `Bearer ${access_token}`, @@ -160,45 +200,58 @@ router.post("/login", async (req, res) => { }); const spotifyProfile = profileResponse.data; - console.log("🎡 Profile received for:", spotifyProfile.id); - // Check if user already exists with this Spotify ID let user = await User.findOne({ where: { spotifyId: spotifyProfile.id } }); if (!user) { - console.log("🎡 Creating new user..."); - - // Generate safe username - const username = await generateUsername(spotifyProfile); - console.log("🎡 Generated username:", username); - - // Create new user with Spotify data - user = await User.create({ - username: username, - email: spotifyProfile.email || null, // Email might be null - spotifyId: spotifyProfile.id, - spotifyAccessToken: access_token, - spotifyRefreshToken: refresh_token, - spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyDisplayName: spotifyProfile.display_name, - spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, - passwordHash: null, // No password for Spotify-only users - }); - - console.log("🎡 User created with ID:", user.id); + try { + const username = await generateUsername(spotifyProfile); + + if (username.length < 3 || username.length > 20) { + throw new Error(`Invalid username length: ${username.length}`); + } + + const userData = { + username: username, + email: spotifyProfile.email || null, + spotifyId: spotifyProfile.id, + spotifyAccessToken: access_token, + spotifyRefreshToken: refresh_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + spotifyDisplayName: spotifyProfile.display_name || null, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + passwordHash: null, + }; + + user = await User.create(userData); + } catch (validationError) { + if (validationError.name === 'SequelizeValidationError') { + const errorMessages = validationError.errors.map(err => err.message); + return res.status(400).json({ + error: "User validation failed", + details: errorMessages.join(', ') + }); + } + + if (validationError.name === 'SequelizeUniqueConstraintError') { + return res.status(400).json({ + error: "User already exists with this data", + details: validationError.message + }); + } + + throw validationError; + } } else { - console.log("🎡 Updating existing user..."); - // Update existing user's Spotify tokens await user.update({ spotifyAccessToken: access_token, spotifyRefreshToken: refresh_token, spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyDisplayName: spotifyProfile.display_name, - spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, + spotifyDisplayName: spotifyProfile.display_name || user.spotifyDisplayName, + spotifyProfileImage: spotifyProfile.images?.[0]?.url || user.spotifyProfileImage, }); } - // Create JWT token const token = jwt.sign( { id: user.id, @@ -211,8 +264,6 @@ router.post("/login", async (req, res) => { ); res.cookie("token", token, cookieSettings); - - console.log("🎡 Spotify login successful for user:", user.username); res.json({ message: "Spotify login successful", @@ -224,49 +275,34 @@ router.post("/login", async (req, res) => { }, }); } catch (error) { - console.error("❌ Spotify login error:", error); - console.error("❌ Error details:", { - message: error.message, - response: error.response?.data, - status: error.response?.status, - stack: error.stack + console.error("Spotify login error:", error.message); + + if (error.response?.status === 400) { + return res.status(400).json({ + error: "Invalid authorization code or expired", + details: error.response?.data + }); + } + + res.status(500).json({ + error: "Failed to login with Spotify", + details: process.env.NODE_ENV === 'development' ? error.message : undefined }); - res.status(500).json({ error: "Failed to login with Spotify" }); } }); -router.get("/auth-url", authenticateJWT, (req, res) => { - const scopes = [ - "user-read-private", - "user-read-email", - "user-top-read", - "user-read-recently-played", - "playlist-read-private", - "playlist-read-collaborative" - ].join(" "); - - const params = new URLSearchParams({ - response_type: "code", - client_id: SPOTIFY_CLIENT_ID, - scope: scopes, - redirect_uri: `${FRONTEND_URL}/callback/spotify`, - state: req.user.id.toString(), - }); - - const authUrl = `https://accounts.spotify.com/authorize?${params}`; - res.json({ authUrl }); -}); - +// Spotify callback for connected users router.post("/callback", authenticateJWT, async (req, res) => { try { const { code, state } = req.body; + const userId = parseInt(state); - if (!code) { - return res.status(400).json({ error: "No authorization code received" }); + if (userId !== req.user.id) { + return res.status(400).json({ error: "Invalid state parameter" }); } - if (state !== req.user.id.toString()) { - return res.status(400).json({ error: "Invalid state parameter" }); + if (!code) { + return res.status(400).json({ error: "No authorization code provided" }); } const tokenResponse = await axios.post( @@ -296,11 +332,6 @@ router.post("/callback", authenticateJWT, async (req, res) => { const spotifyProfile = profileResponse.data; const user = await User.findByPk(req.user.id); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - await user.update({ spotifyId: spotifyProfile.id, spotifyAccessToken: access_token, @@ -310,53 +341,41 @@ router.post("/callback", authenticateJWT, async (req, res) => { spotifyProfileImage: spotifyProfile.images?.[0]?.url || null, }); - res.json({ - message: "Spotify connected successfully", - spotify: { - id: spotifyProfile.id, - displayName: spotifyProfile.display_name, - profileImage: spotifyProfile.images?.[0]?.url, - }, - }); + res.json({ message: "Spotify connected successfully" }); } catch (error) { - res.status(500).json({ error: "Failed to connect Spotify account" }); + console.error("Spotify callback error:", error.message); + res.status(500).json({ error: "Failed to connect Spotify" }); } }); +// Get Spotify profile router.get("/profile", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - + if (!user.spotifyId) { return res.json({ connected: false }); } - const accessToken = await getValidSpotifyToken(user); - - const profileResponse = await axios.get("https://api.spotify.com/v1/me", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - res.json({ connected: true, - profile: profileResponse.data, + profile: { + id: user.spotifyId, + display_name: user.spotifyDisplayName, + images: user.spotifyProfileImage ? [{ url: user.spotifyProfileImage }] : [], + }, }); } catch (error) { - if (error.message === "Spotify not connected") { - return res.json({ connected: false }); - } - res.status(500).json({ error: "Failed to get Spotify profile" }); + console.error("Error getting Spotify profile:", error.message); + res.status(500).json({ error: "Failed to get profile" }); } }); +// Get top tracks router.get("/top-tracks", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); - const timeRange = req.query.time_range || "short_term"; const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { @@ -374,10 +393,12 @@ router.get("/top-tracks", authenticateJWT, async (req, res) => { if (error.message === "Spotify not connected") { return res.status(401).json({ error: "Spotify not connected" }); } + console.error("Error getting top tracks:", error.message); res.status(500).json({ error: "Failed to get top tracks" }); } }); +// Disconnect Spotify router.delete("/disconnect", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -389,9 +410,9 @@ router.delete("/disconnect", authenticateJWT, async (req, res) => { spotifyDisplayName: null, spotifyProfileImage: null, }); - res.json({ message: "Spotify disconnected successfully" }); } catch (error) { + console.error("Error disconnecting Spotify:", error.message); res.status(500).json({ error: "Failed to disconnect Spotify" }); } }); diff --git a/database/user.js b/database/user.js index 5107299..34e9c17 100644 --- a/database/user.js +++ b/database/user.js @@ -9,14 +9,17 @@ const User = db.define("user", { unique: true, validate: { len: [3, 20], + notEmpty: true, }, }, email: { type: DataTypes.STRING, - allowNull: true, + allowNull: true, // Allow null for Spotify-only users unique: true, validate: { - isEmail: true, + isEmail: { + msg: "Must be a valid email address" + }, }, }, auth0Id: { @@ -27,7 +30,7 @@ const User = db.define("user", { }, passwordHash: { type: DataTypes.STRING, - allowNull: true, + allowNull: true, // Allow null for Spotify-only users field: 'password_hash' }, spotifyId: { @@ -63,7 +66,15 @@ const User = db.define("user", { }, }, { tableName: 'users', - underscored: true // Ensures snake_case column names + underscored: true, + validate: { + // Custom validator: user must have either password or spotify connection + mustHaveAuthMethod() { + if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { + throw new Error('User must have at least one authentication method'); + } + } + } }); User.prototype.checkPassword = function (password) { From 4d7061225f235d15404669cd2517bc95c550c9b8 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Mon, 4 Aug 2025 13:35:26 -0400 Subject: [PATCH 18/77] Routes for user data --- auth/spotify.js | 132 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/auth/spotify.js b/auth/spotify.js index dc6ce75..9f70c64 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -376,7 +376,7 @@ router.get("/top-tracks", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); const accessToken = await getValidSpotifyToken(user); - const timeRange = req.query.time_range || "short_term"; + const timeRange = req.query.time_range || "long_term"; const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { headers: { @@ -398,6 +398,136 @@ router.get("/top-tracks", authenticateJWT, async (req, res) => { } }); +//route for top artist +router.get("/top-artists", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + + const timeRanges = ["short_term", "medium_term", "long_term"]; + let bestResult = null; + + for (const timeRange of timeRanges) { + try { + const response = await axios.get("https://api.spotify.com/v1/me/top/artists", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 20, + time_range: timeRange, + }, + }); + + if (response.data.items.length > 0) { + return res.json({ + ...response.data, + time_range_used: timeRange + }); + } + + if (!bestResult) { + bestResult = { + ...response.data, + time_range_used: timeRange + }; + } + } catch (error) { + console.error(`Error getting top artists for ${timeRange}:`, error.message); + } + } + + res.json(bestResult || { + items: [], + total: 0, + message: "No listening history found for artists." + }); + + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting top artists:", error.message); + res.status(500).json({ error: "Failed to get top artists" }); + } +}); + +// Get user's playlists +router.get("/playlists", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + + const response = await axios.get("https://api.spotify.com/v1/me/playlists", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 50, // Get up to 50 playlists + offset: 0, + }, + }); + + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting playlists:", error.message); + res.status(500).json({ error: "Failed to get playlists" }); + } +}); + +// Get route individual playlist +router.get("/playlists/:id", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + const playlistId = req.params.id; + + const response = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting playlist details:", error.message); + res.status(500).json({ error: "Failed to get playlist details" }); + } +}); + +// Get route for playlist tracks +router.get("/playlists/:id/tracks", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + const playlistId = req.params.id; + + const response = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 100, // Get up to 100 tracks + offset: 0, + }, + }); + + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting playlist tracks:", error.message); + res.status(500).json({ error: "Failed to get playlist tracks" }); + } +}); + // Disconnect Spotify router.delete("/disconnect", authenticateJWT, async (req, res) => { try { From 5bd356a46432efe2578479f67feb52353bd5c8f6 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Mon, 4 Aug 2025 14:33:19 -0400 Subject: [PATCH 19/77] added routes for /GET feed and myPost --- api/posts.js | 23 +++++++- database/index.js | 30 +++++------ database/posts.js | 131 ++++++++++++++++++++++++---------------------- 3 files changed, 103 insertions(+), 81 deletions(-) diff --git a/api/posts.js b/api/posts.js index 2ba731d..aca5e29 100644 --- a/api/posts.js +++ b/api/posts.js @@ -1,11 +1,30 @@ const express = require("express"); const router = express.Router(); -const { Posts } = require("../database"); // Correct import +const { Posts, User } = require("../database"); // Correct import const { authenticateJWT } = require("../auth"); // Get all posts (public) +router.get("/", async (req, res) => { + try { + const posts = await Posts.findAll({ + where: { isPublic: true }, + include: [ + { + model: User, + as: "author", + attributes: ["username", "id"], + }, + ], + }); + res.json(posts); + } catch (error) { + console.error("Error fetching posts:", error); + res.status(500).json({ error: "Failed to fetch posts" }); + } +}); + // Get all posts by the logged-in user -router.get("/", authenticateJWT, async (req, res) => { +router.get("/mine", authenticateJWT, async (req, res) => { try { const userId = req.user.id; const posts = await Posts.findAll({ diff --git a/database/index.js b/database/index.js index 3142d8e..65b9685 100644 --- a/database/index.js +++ b/database/index.js @@ -1,37 +1,37 @@ const db = require("./db"); const User = require("./user"); -const Post = require("./posts"); +const Posts = require("./posts"); const Follows = require("./follows"); // Set up associations -User.hasMany(Post, { - foreignKey: 'user_id', // Use snake_case for foreign key - as: 'posts' +User.hasMany(Posts, { + foreignKey: "user_id", // Use snake_case for foreign key + as: "posts", }); -Post.belongsTo(User, { - foreignKey: 'user_id', // Use snake_case for foreign key - as: 'author' +Posts.belongsTo(User, { + foreignKey: "user_id", // Use snake_case for foreign key + as: "author", }); // User following relationships User.belongsToMany(User, { through: Follows, - as: 'following', - foreignKey: 'follower_id', // Use snake_case - otherKey: 'following_id' // Use snake_case + as: "following", + foreignKey: "follower_id", // Use snake_case + otherKey: "following_id", // Use snake_case }); User.belongsToMany(User, { through: Follows, - as: 'followers', - foreignKey: 'following_id', // Use snake_case - otherKey: 'follower_id' // Use snake_case + as: "followers", + foreignKey: "following_id", // Use snake_case + otherKey: "follower_id", // Use snake_case }); module.exports = { db, User, - Post, + Posts, Follows, -}; \ No newline at end of file +}; diff --git a/database/posts.js b/database/posts.js index 88ef70c..2d4644f 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,70 +1,73 @@ const { DataTypes } = require("sequelize"); -const db = require("./db"); // your Sequelize instance +const db = require("./db"); -const Posts = db.define("posts", { - title: { - type: DataTypes.STRING, - allowNull: false, - validate: { - len: [3, 100], +const Posts = db.define( + "post", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + validate: { + len: [1, 200], + }, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + content: { + type: DataTypes.TEXT, + allowNull: true, + }, + status: { + type: DataTypes.ENUM("draft", "published"), + allowNull: false, + defaultValue: "draft", + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: "user_id", + references: { + model: "users", + key: "id", + }, + }, + spotifyTrackId: { + type: DataTypes.STRING, + allowNull: true, + field: "spotify_track_id", + }, + spotifyTrackName: { + type: DataTypes.STRING, + allowNull: true, + field: "spotify_track_name", + }, + spotifyArtistName: { + type: DataTypes.STRING, + allowNull: true, + field: "spotify_artist_name", + }, + likesCount: { + type: DataTypes.INTEGER, + defaultValue: 0, + field: "likes_count", + }, + isPublic: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: "is_public", }, }, - description: { - type: DataTypes.TEXT, - content: { - type: DataTypes.TEXT, - allowNull: true, - }, - userId: { - type: DataTypes.INTEGER, - allowNull: false, - field: 'user_id', // Map to snake_case - references: { - model: 'users', - key: 'id' - } - }, - spotifyTrackId: { - type: DataTypes.STRING, - allowNull: true, - }, - status: { - type: DataTypes.ENUM("draft", "published"), - allowNull: false, - defaultValue: "draft", - field: 'spotify_track_id' - }, - spotifyTrackName: { - type: DataTypes.STRING, - allowNull: true, - field: 'spotify_track_name' - }, - userId: { - type: DataTypes.INTEGER, - allowNull: false, - }, -}); - -module.exports = Posts; - - spotifyArtistName: { - type: DataTypes.STRING, - allowNull: true, - field: 'spotify_artist_name' - }, - likesCount: { - type: DataTypes.INTEGER, - defaultValue: 0, - field: 'likes_count' - }, - isPublic: { - type: DataTypes.BOOLEAN, - defaultValue: true, - field: 'is_public' + { + tableName: "posts", + underscored: true, } -}, { - tableName: 'posts', - underscored: true // Ensures snake_case column names -}); +); -module.exports = Post; \ No newline at end of file +module.exports = Posts; From 8fd2079d912ef7c60d340f48dd7d7602a39c75ef Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Tue, 5 Aug 2025 03:18:08 -0400 Subject: [PATCH 20/77] Added models AvatarURL and removed 'if published !== deleted TEMP --- api/posts.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ database/user.js | 6 +++++ 2 files changed, 75 insertions(+) diff --git a/api/posts.js b/api/posts.js index aca5e29..817a525 100644 --- a/api/posts.js +++ b/api/posts.js @@ -37,6 +37,31 @@ router.get("/mine", authenticateJWT, async (req, res) => { } }); +//get a single post by ID +router.get("/:id", async (req, res) => { + try { + const postId = req.params.id; + const post = await Posts.findByPk(postId, { + include: [ + { + model: User, + as: "author", + attributes: ["username", "id"], + }, + ], + }); + + if (!post) { + return res.status(404).json({ error: "Post not found" }); + } + + res.json(post); + } catch (error) { + console.error("Error fetching post:", error); + res.status(500).json({ error: "Failed to fetch post" }); + } +}); + // Create a new post (requires authentication) router.post("/", authenticateJWT, async (req, res) => { try { @@ -60,4 +85,48 @@ router.post("/", authenticateJWT, async (req, res) => { } }); +//Edit a post if post not published +router.patch("/:id", authenticateJWT, async (req, res) => { + try { + const postId = req.params.id; + const { title, description, content, status } = req.body; + + const post = await Posts.findByPk(postId); + + if (!post) { + return res.status(404).json({ error: "Post not found" }); + } + + post.title = title || post.title; + post.description = description || post.description; + post.content = content || post.content; + post.status = status || post.status; + + await post.save(); + + res.json(post); + } catch (error) { + console.error("Error updating post:", error); + res.status(500).json({ error: "Failed to update post" }); + } +}); + +//Delete a post +router.delete("/:id", authenticateJWT, async (req, res) => { + try { + const postId = req.params.id; + const post = await Posts.findByPk(postId); + + if (!post) { + return res.status(404).json({ error: "Post not found" }); + } + + await post.destroy(); + res.status(204).send(); + } catch (error) { + console.error("Error deleting post:", error); + res.status(500).json({ error: "Failed to delete post" }); + } +}); + module.exports = router; diff --git a/database/user.js b/database/user.js index 7abca86..416bdb8 100644 --- a/database/user.js +++ b/database/user.js @@ -35,6 +35,12 @@ const User = db.define( allowNull: true, // Allow null for Spotify-only users field: "password_hash", }, + avatarURL: { + type: DataTypes.STRING, + defaultValue: "https://static.thenounproject.com/png/5100711-200.png", + allowNull: false, + required: false, + }, spotifyId: { type: DataTypes.STRING, allowNull: true, From bda1eff0a1774dba820c61bfaf4d33c5e0b4d472 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Tue, 5 Aug 2025 12:01:52 -0400 Subject: [PATCH 21/77] Syntax --- api/index.js | 6 +- api/search.js | 184 ++++++++++++++++++++++++++++++++++++++++++++++ database/index.js | 8 +- database/posts.js | 4 +- 4 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 api/search.js diff --git a/api/index.js b/api/index.js index 8c9caf7..1a194f6 100644 --- a/api/index.js +++ b/api/index.js @@ -1,9 +1,9 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); -const postsRouter = require("./posts"); +const searchRouter = require("./search"); router.use("/test-db", testDbRouter); -router.use("/posts", postsRouter); +router.use("/search-songs", searchRouter); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/api/search.js b/api/search.js new file mode 100644 index 0000000..50fe8de --- /dev/null +++ b/api/search.js @@ -0,0 +1,184 @@ +const express = require("express"); +const axios = require("axios"); + +const router = express.Router(); + +const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; +const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; + +let spotifyAccessToken = null; +let tokenExpiresAt = null; + +const getSpotifyClientToken = async () => { + try { + if (spotifyAccessToken && tokenExpiresAt && new Date() < tokenExpiresAt) { + return spotifyAccessToken; + } + + // Get new token + const response = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "client_credentials", + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, expires_in } = response.data; + spotifyAccessToken = access_token; + tokenExpiresAt = new Date(Date.now() + (expires_in - 60) * 1000); + + return spotifyAccessToken; + } catch (error) { + console.error("Error getting Spotify client token:", error.message); + throw new Error("Failed to get Spotify access token"); + } +}; + +// Helper function to safely format tracks +const formatTracks = (tracks) => { + if (!tracks || !tracks.items) return []; + + return tracks.items + .filter(track => track && track.id && track.name) + .map(track => ({ + id: track.id, + name: track.name, + type: "track", + author: track.artists && track.artists.length > 0 + ? track.artists.map(artist => artist?.name || "Unknown Artist").join(", ") + : "Unknown Artist", + image: track.album?.images?.[0]?.url || null, + spotify_url: track.external_urls?.spotify || null, + preview_url: track.preview_url || null, + duration_ms: track.duration_ms || 0, + })); +}; + +const formatArtists = (artists) => { + if (!artists || !artists.items) return []; + + return artists.items + .filter(artist => artist && artist.id && artist.name) + .map(artist => ({ + id: artist.id, + name: artist.name, + type: "artist", + author: null, + image: artist.images?.[0]?.url || null, + spotify_url: artist.external_urls?.spotify || null, + followers: artist.followers?.total || 0, + genres: artist.genres || [], + })); +}; + +const formatPlaylists = (playlists) => { + if (!playlists || !playlists.items) return []; + + return playlists.items + .filter(playlist => playlist && playlist.id && playlist.name) + .map(playlist => ({ + id: playlist.id, + name: playlist.name, + type: "playlist", + author: playlist.owner?.display_name || "Unknown", + image: playlist.images?.[0]?.url || null, + spotify_url: playlist.external_urls?.spotify || null, + description: playlist.description || "", + track_count: playlist.tracks?.total || 0, + })); +}; + +// Search endpoint +router.get("/", async (req, res) => { + try { + const { q } = req.query; + + if (!q || q.trim().length === 0) { + return res.status(400).json({ error: "Search query 'q' is required" }); + } + + if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) { + return res.status(500).json({ error: "Spotify credentials not configured" }); + } + + // Get access token + const accessToken = await getSpotifyClientToken(); + + // Search Spotify API + const searchResponse = await axios.get("https://api.spotify.com/v1/search", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + q: q.trim(), + type: "track,artist,playlist", + limit: 5, + market: "US", + }, + }); + + const searchData = searchResponse.data || {}; + + const formattedTracks = formatTracks(searchData.tracks); + const formattedArtists = formatArtists(searchData.artists); + const formattedPlaylists = formatPlaylists(searchData.playlists); + + const allResults = [ + ...formattedTracks, + ...formattedArtists, + ...formattedPlaylists + ]; + + res.json({ + query: q, + total_results: allResults.length, + results: allResults, + breakdown: { + tracks: formattedTracks.length, + artists: formattedArtists.length, + playlists: formattedPlaylists.length, + } + }); + + } catch (error) { + console.error("Search error:", error); + + if (error.response) { + console.error("Spotify API error:", error.response.status, error.response.data); + } + + if (error.response?.status === 400) { + return res.status(400).json({ + error: "Invalid search query", + details: error.response?.data + }); + } + + if (error.response?.status === 401) { + return res.status(401).json({ + error: "Spotify authentication failed", + details: "Invalid or expired client credentials" + }); + } + + if (error.response?.status === 429) { + return res.status(429).json({ + error: "Rate limit exceeded. Please try again later." + }); + } + + res.status(500).json({ + error: "Search failed", + details: process.env.NODE_ENV === 'development' ? error.message : "Internal server error" + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/database/index.js b/database/index.js index 3142d8e..f6c3a92 100644 --- a/database/index.js +++ b/database/index.js @@ -1,15 +1,15 @@ const db = require("./db"); const User = require("./user"); -const Post = require("./posts"); +const Posts = require("./posts"); const Follows = require("./follows"); // Set up associations -User.hasMany(Post, { +User.hasMany(Posts, { foreignKey: 'user_id', // Use snake_case for foreign key as: 'posts' }); -Post.belongsTo(User, { +Posts.belongsTo(User, { foreignKey: 'user_id', // Use snake_case for foreign key as: 'author' }); @@ -32,6 +32,6 @@ User.belongsToMany(User, { module.exports = { db, User, - Post, + Posts, Follows, }; \ No newline at end of file diff --git a/database/posts.js b/database/posts.js index 193f0f6..32ba58b 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,7 +1,7 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const Post = db.define("posts", { +const Posts = db.define("posts", { title: { type: DataTypes.STRING, allowNull: false, @@ -61,5 +61,5 @@ const Post = db.define("posts", { underscored: true, }); -module.exports = Post; +module.exports = Posts; From 8e769976c49575534963d5250ed526ab84b5421e Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Tue, 5 Aug 2025 15:46:46 -0400 Subject: [PATCH 22/77] fix: updated Post model to accept type + id for Spotify item --- database/posts.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/database/posts.js b/database/posts.js index 32ba58b..8594c30 100644 --- a/database/posts.js +++ b/database/posts.js @@ -31,20 +31,16 @@ const Posts = db.define("posts", { key: "id", }, }, - spotifyTrackId: { + spotifyId: { type: DataTypes.STRING, allowNull: true, - field: "spotify_track_id", + field: "spotify_id", }, - spotifyTrackName: { - type: DataTypes.STRING, - allowNull: true, - field: "spotify_track_name", - }, - spotifyArtistName: { - type: DataTypes.STRING, + + spotifyType: { + type: DataTypes.ENUM("track", "album", "playlist","artist"), allowNull: true, - field: "spotify_artist_name", + field: "spotify_type", }, likesCount: { type: DataTypes.INTEGER, From 2a8c627b9f0601b4ca6d0cbd07630a24ccac7634 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Tue, 5 Aug 2025 16:21:57 -0400 Subject: [PATCH 23/77] feat: add a post/save draft endpoint --- api/posts.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/api/posts.js b/api/posts.js index 2ba731d..dc38d90 100644 --- a/api/posts.js +++ b/api/posts.js @@ -41,4 +41,32 @@ router.post("/", authenticateJWT, async (req, res) => { } }); +//POST api/posts/draft --- Create a draft endpoint (first time when user click on save as draft) +router.post("/draft", authenticateJWT, async (req, res) => { + try { + const { title, description, id, type } = req.body; + + if (!title || title.trim() === '') { + return res.status(400).json({ error: "Title is required" }); //tittle is required even for drafts + } + + const postData = { + title: title.trim(), + description: description || null, + status: 'draft', + userId: req.user.id, + }; + // handle single Spotify item if user included (optional) + if (id && type) { + postData.spotifyId = id; + postData.spotifyType = type; + } + const newDraft = await Posts.create(postData); + res.status(201).json(newDraft); + } catch (error) { + console.error("Error creating draft:", error); + res.status(500).json({ error: "Failed to create draft" }); + } +}); + module.exports = router; From 14bb9fd61f16a10f060d7991938fd8dd1f326e5f Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Wed, 6 Aug 2025 00:04:10 -0400 Subject: [PATCH 24/77] feat: Patch routes --- api/posts.js | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/api/posts.js b/api/posts.js index dc38d90..a6d6387 100644 --- a/api/posts.js +++ b/api/posts.js @@ -44,10 +44,10 @@ router.post("/", authenticateJWT, async (req, res) => { //POST api/posts/draft --- Create a draft endpoint (first time when user click on save as draft) router.post("/draft", authenticateJWT, async (req, res) => { try { - const { title, description, id, type } = req.body; + const { title, description, spotifyId, spotifyType } = req.body; if (!title || title.trim() === '') { - return res.status(400).json({ error: "Title is required" }); //tittle is required even for drafts + return res.status(400).json({ error: "Title is required" }); //title is required even for drafts } const postData = { @@ -57,9 +57,9 @@ router.post("/draft", authenticateJWT, async (req, res) => { userId: req.user.id, }; // handle single Spotify item if user included (optional) - if (id && type) { - postData.spotifyId = id; - postData.spotifyType = type; + if (spotifyId && spotifyType) { + postData.spotifyId = spotifyId; + postData.spotifyType = spotifyType; } const newDraft = await Posts.create(postData); res.status(201).json(newDraft); @@ -69,4 +69,39 @@ router.post("/draft", authenticateJWT, async (req, res) => { } }); +// PATCH api/posts/draft/:id ------ Update a draft endpoint +router.patch("/draft/:id", authenticateJWT, async (req, res) => { + try { + const { title, description, spotifyId, spotifyType } = req.body; + + if (title !== undefined && !title.trim()) { + return res.status(400).json({ error: "Title cannot be empty" }); + } + + const updateData = {}; + if (title !== undefined) updateData.title = title.trim(); + if (description !== undefined) updateData.description = description || null; + if (spotifyId !== undefined) updateData.spotifyId = spotifyId || null; + if (spotifyType !== undefined) updateData.spotifyType = spotifyType || null; + + const [updatedRowsCount] = await Posts.update(updateData, { + where: { + id: parseInt(req.params.id), + userId: req.user.id, + status: 'draft' + } + }); + + if (updatedRowsCount === 0) { + return res.status(404).json({ error: "Draft post not found or unauthorized" }); + } + + res.json({ message: "Draft updated successfully" }); + + } catch (error) { + console.error("Error updating draft:", error); + res.status(500).json({ error: "Failed to update draft" }); + } +}); + module.exports = router; From aecf402ffbafef5784baf30595ca562a2dcee946 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Wed, 6 Aug 2025 00:06:26 -0400 Subject: [PATCH 25/77] fix: mounted route --- api/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/index.js b/api/index.js index 1a194f6..48f2e20 100644 --- a/api/index.js +++ b/api/index.js @@ -2,8 +2,10 @@ const express = require("express"); const router = express.Router(); const testDbRouter = require("./test-db"); const searchRouter = require("./search"); +const postsRouter = require("./posts"); router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); +router.use("/posts", postsRouter); module.exports = router; \ No newline at end of file From 8fce0d5cebb4d16c310f8783e4a2862ce772395f Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Wed, 6 Aug 2025 10:38:26 -0400 Subject: [PATCH 26/77] added search component and fixed minidrawer responsiveness --- api/search.js | 55 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/api/search.js b/api/search.js index 50fe8de..65f53d7 100644 --- a/api/search.js +++ b/api/search.js @@ -46,7 +46,7 @@ const formatTracks = (tracks) => { if (!tracks || !tracks.items) return []; return tracks.items - .filter(track => track && track.id && track.name) + .filter(track => track && track.id && track.name) .map(track => ({ id: track.id, name: track.name, @@ -58,9 +58,11 @@ const formatTracks = (tracks) => { spotify_url: track.external_urls?.spotify || null, preview_url: track.preview_url || null, duration_ms: track.duration_ms || 0, + album: track.album?.name || "Unknown Album", })); }; +// Helper function to safely format artists const formatArtists = (artists) => { if (!artists || !artists.items) return []; @@ -70,7 +72,7 @@ const formatArtists = (artists) => { id: artist.id, name: artist.name, type: "artist", - author: null, + author: null, image: artist.images?.[0]?.url || null, spotify_url: artist.external_urls?.spotify || null, followers: artist.followers?.total || 0, @@ -78,11 +80,33 @@ const formatArtists = (artists) => { })); }; +// Helper function to safely format albums +const formatAlbums = (albums) => { + if (!albums || !albums.items) return []; + + return albums.items + .filter(album => album && album.id && album.name) + .map(album => ({ + id: album.id, + name: album.name, + type: "album", + author: album.artists && album.artists.length > 0 + ? album.artists.map(artist => artist?.name || "Unknown Artist").join(", ") + : "Unknown Artist", + image: album.images?.[0]?.url || null, + spotify_url: album.external_urls?.spotify || null, + release_date: album.release_date || null, + total_tracks: album.total_tracks || 0, + album_type: album.album_type || "album", + })); +}; + +// Helper function to safely format playlists const formatPlaylists = (playlists) => { if (!playlists || !playlists.items) return []; return playlists.items - .filter(playlist => playlist && playlist.id && playlist.name) + .filter(playlist => playlist && playlist.id && playlist.name) .map(playlist => ({ id: playlist.id, name: playlist.name, @@ -111,38 +135,37 @@ router.get("/", async (req, res) => { // Get access token const accessToken = await getSpotifyClientToken(); - // Search Spotify API + // Search Spotify API - now including albums const searchResponse = await axios.get("https://api.spotify.com/v1/search", { headers: { Authorization: `Bearer ${accessToken}`, }, params: { q: q.trim(), - type: "track,artist,playlist", - limit: 5, - market: "US", + type: "track,artist,album,playlist", // Added album + limit: 10, // Increased limit for better results + market: "US", }, }); const searchData = searchResponse.data || {}; + // Format each type with null checks const formattedTracks = formatTracks(searchData.tracks); const formattedArtists = formatArtists(searchData.artists); + const formattedAlbums = formatAlbums(searchData.albums); const formattedPlaylists = formatPlaylists(searchData.playlists); - const allResults = [ - ...formattedTracks, - ...formattedArtists, - ...formattedPlaylists - ]; - res.json({ query: q, - total_results: allResults.length, - results: allResults, - breakdown: { + tracks: formattedTracks, + artists: formattedArtists, + albums: formattedAlbums, + playlists: formattedPlaylists, + total_results: { tracks: formattedTracks.length, artists: formattedArtists.length, + albums: formattedAlbums.length, playlists: formattedPlaylists.length, } }); From e5a14ec3e0d6d413a7e4a5c950724a80e52aa5f1 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 Aug 2025 10:46:32 -0400 Subject: [PATCH 27/77] Update search.js --- api/search.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/api/search.js b/api/search.js index 65f53d7..d9a41ab 100644 --- a/api/search.js +++ b/api/search.js @@ -15,7 +15,6 @@ const getSpotifyClientToken = async () => { return spotifyAccessToken; } - // Get new token const response = await axios.post( "https://accounts.spotify.com/api/token", new URLSearchParams({ @@ -41,7 +40,6 @@ const getSpotifyClientToken = async () => { } }; -// Helper function to safely format tracks const formatTracks = (tracks) => { if (!tracks || !tracks.items) return []; @@ -62,7 +60,6 @@ const formatTracks = (tracks) => { })); }; -// Helper function to safely format artists const formatArtists = (artists) => { if (!artists || !artists.items) return []; @@ -80,7 +77,6 @@ const formatArtists = (artists) => { })); }; -// Helper function to safely format albums const formatAlbums = (albums) => { if (!albums || !albums.items) return []; @@ -101,7 +97,6 @@ const formatAlbums = (albums) => { })); }; -// Helper function to safely format playlists const formatPlaylists = (playlists) => { if (!playlists || !playlists.items) return []; @@ -119,7 +114,6 @@ const formatPlaylists = (playlists) => { })); }; -// Search endpoint router.get("/", async (req, res) => { try { const { q } = req.query; @@ -132,25 +126,22 @@ router.get("/", async (req, res) => { return res.status(500).json({ error: "Spotify credentials not configured" }); } - // Get access token const accessToken = await getSpotifyClientToken(); - // Search Spotify API - now including albums const searchResponse = await axios.get("https://api.spotify.com/v1/search", { headers: { Authorization: `Bearer ${accessToken}`, }, params: { q: q.trim(), - type: "track,artist,album,playlist", // Added album - limit: 10, // Increased limit for better results + type: "track,artist,album,playlist", + limit: 10, market: "US", }, }); const searchData = searchResponse.data || {}; - // Format each type with null checks const formattedTracks = formatTracks(searchData.tracks); const formattedArtists = formatArtists(searchData.artists); const formattedAlbums = formatAlbums(searchData.albums); @@ -204,4 +195,4 @@ router.get("/", async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; From 47f89bee3bf9294fcb4104d97f6c5f61ccee50f6 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Wed, 6 Aug 2025 13:44:21 -0400 Subject: [PATCH 28/77] bug fixes --- api/posts.js | 8 +++++-- database/index.js | 4 ++-- database/seed.js | 4 ++-- database/user.js | 59 ++++------------------------------------------- 4 files changed, 15 insertions(+), 60 deletions(-) diff --git a/api/posts.js b/api/posts.js index 151b15c..a31b556 100644 --- a/api/posts.js +++ b/api/posts.js @@ -1,12 +1,12 @@ const express = require("express"); const router = express.Router(); -const { Posts, User } = require("../database"); // Correct import +const { Post, User } = require("../database"); // Correct import const { authenticateJWT } = require("../auth"); // Get all posts (public) router.get("/", async (req, res) => { try { - const posts = await Posts.findAll({ + const posts = await Post.findAll({ where: { isPublic: true }, include: [ { @@ -23,6 +23,10 @@ router.get("/", async (req, res) => { } }); +router.get("/test", (req, res) => { + res.send("posts test route OK"); +}); + // Get all posts by the logged-in user router.get("/mine", authenticateJWT, async (req, res) => { try { diff --git a/database/index.js b/database/index.js index a26cd6b..a721af2 100644 --- a/database/index.js +++ b/database/index.js @@ -1,6 +1,6 @@ const db = require("./db"); const User = require("./user"); -const Posts = require("./posts"); +const Post = require("./posts"); const Follows = require("./follows"); // Set up associations @@ -32,6 +32,6 @@ User.belongsToMany(User, { module.exports = { db, User, - Posts, + Post, Follows, }; diff --git a/database/seed.js b/database/seed.js index fac0ef7..e393bc7 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,5 +1,5 @@ const db = require("./db"); -const { User, Posts } = require("./index"); +const { User, Post } = require("./index"); const seed = async () => { try { @@ -16,7 +16,7 @@ const seed = async () => { console.log(`πŸ‘€ Created ${users.length} users`); - const posts = await Posts.bulkCreate([ + const posts = await Post.bulkCreate([ // Posts for admin { title: "Admin Post 1", diff --git a/database/user.js b/database/user.js index 5989431..a757582 100644 --- a/database/user.js +++ b/database/user.js @@ -16,7 +16,7 @@ const User = db.define( }, email: { type: DataTypes.STRING, - allowNull: true, // Allow null for Spotify-only users + allowNull: true, unique: true, validate: { isEmail: { @@ -32,14 +32,13 @@ const User = db.define( }, passwordHash: { type: DataTypes.STRING, - allowNull: true, // Allow null for Spotify-only users + allowNull: true, field: "password_hash", }, avatarURL: { type: DataTypes.STRING, defaultValue: "https://static.thenounproject.com/png/5100711-200.png", allowNull: false, - required: false, }, spotifyId: { type: DataTypes.STRING, @@ -77,65 +76,17 @@ const User = db.define( tableName: "users", underscored: true, validate: { - // Custom validator: user must have either password or spotify connection mustHaveAuthMethod() { if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { throw new Error("User must have at least one authentication method"); } }, }, - }, - auth0Id: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - field: 'auth0_id' - }, - passwordHash: { - type: DataTypes.STRING, - allowNull: true, - field: 'password_hash' - }, - spotifyId: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - field: 'spotify_id' - }, - spotifyAccessToken: { - type: DataTypes.TEXT, - allowNull: true, - field: 'spotify_access_token' - }, - spotifyRefreshToken: { - type: DataTypes.TEXT, - allowNull: true, - field: 'spotify_refresh_token' - }, - spotifyTokenExpiresAt: { - type: DataTypes.DATE, - allowNull: true, - field: 'spotify_token_expires_at' - }, - spotifyDisplayName: { - type: DataTypes.STRING, - allowNull: true, - field: 'spotify_display_name' - }, - spotifyProfileImage: { - type: DataTypes.STRING, - allowNull: true, - field: 'spotify_profile_image' - }, - { - tableName: 'users', - underscored: true // Ensures snake_case column names -}); + } +); User.prototype.checkPassword = function (password) { - if (!this.passwordHash) { - return false; - } + if (!this.passwordHash) return false; return bcrypt.compareSync(password, this.passwordHash); }; From a21edf179579ba5a4f817e896e6f80199cab6926 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Wed, 6 Aug 2025 14:15:39 -0400 Subject: [PATCH 29/77] fixed bug issues --- api/index.js | 2 +- api/posts.js | 2 +- database/posts.js | 30 +++++------------------------- 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/api/index.js b/api/index.js index 48f2e20..a091c42 100644 --- a/api/index.js +++ b/api/index.js @@ -8,4 +8,4 @@ router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/api/posts.js b/api/posts.js index a31b556..9859ae8 100644 --- a/api/posts.js +++ b/api/posts.js @@ -31,7 +31,7 @@ router.get("/test", (req, res) => { router.get("/mine", authenticateJWT, async (req, res) => { try { const userId = req.user.id; - const posts = await Posts.findAll({ + const posts = await Post.findAll({ where: { userId }, }); res.json(posts); diff --git a/database/posts.js b/database/posts.js index 189a4e1..85aa902 100644 --- a/database/posts.js +++ b/database/posts.js @@ -59,30 +59,10 @@ const Post = db.define( field: "is_public", }, }, - spotifyId: { - type: DataTypes.STRING, - allowNull: true, - field: "spotify_id", - }, - - spotifyType: { - type: DataTypes.ENUM("track", "album", "playlist","artist"), - allowNull: true, - field: "spotify_type", - }, - likesCount: { - type: DataTypes.INTEGER, - defaultValue: 0, - field: "likes_count", - }, - isPublic: { - type: DataTypes.BOOLEAN, - defaultValue: true, - field: "is_public", - }, -}, { - tableName: "posts", - underscored: true, -}); + { + tableName: "posts", + underscored: true, + } +); module.exports = Post; From be261836f3460b581f55beb7987b331935971b12 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Wed, 6 Aug 2025 16:43:11 -0400 Subject: [PATCH 30/77] routes to get post by status either draft or published --- api/posts.js | 42 ++++++++++++++++++++++++++++++++++++------ database/posts.js | 16 ++++++---------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/api/posts.js b/api/posts.js index 9859ae8..d0491a7 100644 --- a/api/posts.js +++ b/api/posts.js @@ -26,7 +26,37 @@ router.get("/", async (req, res) => { router.get("/test", (req, res) => { res.send("posts test route OK"); }); +// Get post if status === draft +router.get("/drafts", authenticateJWT, async (req, res) => { + try { + const drafts = await Post.findAll({ + where: { + userId: req.user.id, + status: "draft", + }, + }); + res.json(drafts); + } catch (err) { + console.error("Error fetching draft posts:", err); + res.status(500).json({ error: "Failed to fetch drafts" }); + } +}); +// Get post if status === published +router.get("/published", authenticateJWT, async (req, res) => { + try { + const published = await Post.findAll({ + where: { + userId: req.user.id, + status: "published", + }, + }); + res.json(published); + } catch (err) { + console.error("Error fetching published posts:", err); + res.status(500).json({ error: "Failed to fetch published posts" }); + } +}); // Get all posts by the logged-in user router.get("/mine", authenticateJWT, async (req, res) => { try { @@ -45,7 +75,7 @@ router.get("/mine", authenticateJWT, async (req, res) => { router.get("/:id", async (req, res) => { try { const postId = req.params.id; - const post = await Posts.findByPk(postId, { + const post = await Post.findByPk(postId, { include: [ { model: User, @@ -75,7 +105,7 @@ router.post("/", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Title is required" }); } - const newPost = await Posts.create({ + const newPost = await Post.create({ title, description, status, @@ -109,7 +139,7 @@ router.post("/draft", authenticateJWT, async (req, res) => { postData.spotifyId = spotifyId; postData.spotifyType = spotifyType; } - const newDraft = await Posts.create(postData); + const newDraft = await Post.create(postData); res.status(201).json(newDraft); } catch (error) { console.error("Error creating draft:", error); @@ -132,7 +162,7 @@ router.patch("/draft/:id", authenticateJWT, async (req, res) => { if (spotifyId !== undefined) updateData.spotifyId = spotifyId || null; if (spotifyType !== undefined) updateData.spotifyType = spotifyType || null; - const [updatedRowsCount] = await Posts.update(updateData, { + const [updatedRowsCount] = await Post.update(updateData, { where: { id: parseInt(req.params.id), userId: req.user.id, @@ -159,7 +189,7 @@ router.patch("/:id", authenticateJWT, async (req, res) => { const postId = req.params.id; const { title, description, content, status } = req.body; - const post = await Posts.findByPk(postId); + const post = await Post.findByPk(postId); if (!post) { return res.status(404).json({ error: "Post not found" }); @@ -183,7 +213,7 @@ router.patch("/:id", authenticateJWT, async (req, res) => { router.delete("/:id", authenticateJWT, async (req, res) => { try { const postId = req.params.id; - const post = await Posts.findByPk(postId); + const post = await Post.findByPk(postId); if (!post) { return res.status(404).json({ error: "Post not found" }); diff --git a/database/posts.js b/database/posts.js index 85aa902..6df3e9d 100644 --- a/database/posts.js +++ b/database/posts.js @@ -33,20 +33,16 @@ const Post = db.define( key: "id", }, }, - spotifyTrackId: { + spotifyId: { type: DataTypes.STRING, allowNull: true, - field: "spotify_track_id", + field: "spotify_id", }, - spotifyTrackName: { - type: DataTypes.STRING, - allowNull: true, - field: "spotify_track_name", - }, - spotifyArtistName: { - type: DataTypes.STRING, + + spotifyType: { + type: DataTypes.ENUM("track", "album", "playlist", "artist"), allowNull: true, - field: "spotify_artist_name", + field: "spotify_type", }, likesCount: { type: DataTypes.INTEGER, From eeb16c24bcdf4079a180ea17775740a96ee5e975 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 7 Aug 2025 02:59:53 -0400 Subject: [PATCH 31/77] added spotify fields to post model and edited seed to include spotify fields --- database/posts.js | 7 ++ database/seed.js | 191 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 151 insertions(+), 47 deletions(-) diff --git a/database/posts.js b/database/posts.js index 6df3e9d..fa2bdc4 100644 --- a/database/posts.js +++ b/database/posts.js @@ -44,6 +44,13 @@ const Post = db.define( allowNull: true, field: "spotify_type", }, + + spotifyEmbedUrl: { + type: DataTypes.STRING, + allowNull: true, + field: "spotify_embed_url", + }, + likesCount: { type: DataTypes.INTEGER, defaultValue: 0, diff --git a/database/seed.js b/database/seed.js index e393bc7..1778f8e 100644 --- a/database/seed.js +++ b/database/seed.js @@ -17,104 +17,201 @@ const seed = async () => { console.log(`πŸ‘€ Created ${users.length} users`); const posts = await Post.bulkCreate([ - // Posts for admin + // User 1 { - title: "Admin Post 1", + title: "Admin Vibes", description: "Admin is testing things.", + status: "draft", + userId: 1, + spotifyId: "37i9dQZF1DXcBWIGoYBM5M", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + }, + { + title: "Lo-Fi Work Flow", + description: "Perfect background music.", status: "published", userId: 1, + spotifyId: "37i9dQZF1DXdPec7aLTmlC", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", }, { - title: "Admin Post 2", - description: "Another one by admin.", + title: "Rock & Roll!", + description: "Classic rock hits I love.", status: "draft", userId: 1, + spotifyId: "37i9dQZF1DWXRqgorJj26U", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DWXRqgorJj26U", + }, + { + title: "Motivation Boost", + description: "Hype music to crush the day.", + status: "published", + userId: 1, + spotifyId: "7qiZfU4dY1lWllzX7mPBI3", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/7qiZfU4dY1lWllzX7mPBI3", }, { - title: "Admin Post 3", - description: "Admin's third post.", + title: "Calm Mornings", + description: "Wake up gently.", status: "published", userId: 1, + spotifyId: "3KkXRkHbMCARz0aVfEt68P", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/3KkXRkHbMCARz0aVfEt68P", }, - // Posts for user1 + // User 2 { - title: "User1's First", - description: "Hello world!", + title: "Late Night Drive", + description: "Vibes for cruising.", status: "published", userId: 2, + spotifyId: "6habFhsOp2NvshLv26DqMb", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/6habFhsOp2NvshLv26DqMb", }, { - title: "User1's Second", - description: "Testing stuff.", - status: "draft", + title: "Hip-Hop Energy", + description: "Stay pumped.", + status: "published", userId: 2, + spotifyId: "37i9dQZF1DX0XUsuxWHRQd", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX0XUsuxWHRQd", }, { - title: "User1's Third", - description: "Still learning!", + title: "The Weekend Wave", + description: "Weekend vibes incoming.", status: "published", userId: 2, + spotifyId: "3U4isOIWM3VvDubwSI3y7a", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/3U4isOIWM3VvDubwSI3y7a", + }, + { + title: "Pop Culture Hits", + description: "All the trending pop songs.", + status: "published", + userId: 2, + spotifyId: "37i9dQZF1DXcF6B6QPhFDv", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcF6B6QPhFDv", + }, + { + title: "Mellow Mood", + description: "For rainy evenings.", + status: "published", + userId: 2, + spotifyId: "2Fxmhks0bxGSBdJ92vM42m", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/2Fxmhks0bxGSBdJ92vM42m", }, - // Posts for user2 + // User 3 { - title: "Post by User2", - description: "Yup, it's working.", - status: "draft", + title: "Throwback Thursday", + description: "Hits from the 2000s.", + status: "published", userId: 3, + spotifyId: "37i9dQZF1DWYmmr74INQlb", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DWYmmr74INQlb", }, { - title: "Second by User2", - description: "Seeding is cool.", + title: "Study Mode On", + description: "Helps me focus.", status: "published", userId: 3, + spotifyId: "37i9dQZF1DX8Uebhn9wzrS", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX8Uebhn9wzrS", }, { - title: "User2 Final", - description: "Final test.", - status: "draft", + title: "Classic Chill", + description: "Old school but gold.", + status: "published", userId: 3, + spotifyId: "6QgjcU0zLnzq5OrUoSZ3OK", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/6QgjcU0zLnzq5OrUoSZ3OK", }, - - // Posts for user3 { - title: "u3-1", - description: "user3 post one", + title: "Top Gaming Tracks", + description: "Perfect for grinding ranked.", status: "published", - userId: 4, + userId: 3, + spotifyId: "37i9dQZF1DX2sUQwD7tbmL", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX2sUQwD7tbmL", }, { - title: "u3-2", - description: "user3 post two", - status: "draft", - userId: 4, + title: "Energy Boost", + description: "Turn it up!", + status: "published", + userId: 3, + spotifyId: "4uLU6hMCjMI75M1A2tKUQC", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC", }, + // Posts for user 4 { - title: "u3-3", - description: "user3 post three", + title: "Late Night Chill", + description: "This playlist puts me to sleep (in a good way).", status: "published", userId: 4, + spotifyId: "37i9dQZF1DWVzZlRWgqAGH", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DWVzZlRWgqAGH", }, - - // Posts for user4 { - title: "user4 test 1", - description: "just a test", - status: "draft", - userId: 5, + title: "Favorite Banger", + description: "Crank it loud!", + status: "published", + userId: 4, + spotifyId: "0eGsygTp906u18L0Oimnem", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/0eGsygTp906u18L0Oimnem", }, { - title: "user4 test 2", - description: "testing more", + title: "Feel Good Mix", + description: "This always boosts my mood.", status: "published", - userId: 5, + userId: 4, + spotifyId: "37i9dQZF1DXdPec7aLTmlC", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", }, { - title: "user4 test 3", - description: "this is it", - status: "draft", - userId: 5, + title: "Bop Playlist πŸ’Ώ", + description: "My current favorite finds. Trust me.", + status: "published", + userId: 4, + spotifyId: "37i9dQZF1DXcBWIGoYBM5M", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", }, ]); From 5c1bfebd4c3fd63a34882fae3cdf7294f6c62069 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 7 Aug 2025 03:34:29 -0400 Subject: [PATCH 32/77] fixed patch route to check if status is published to prevent editting --- api/posts.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/posts.js b/api/posts.js index d0491a7..a53c0f1 100644 --- a/api/posts.js +++ b/api/posts.js @@ -195,6 +195,12 @@ router.patch("/:id", authenticateJWT, async (req, res) => { return res.status(404).json({ error: "Post not found" }); } + if (post.status === "published") { + return res + .status(403) + .json({ error: "Cannot edit already published post" }); + } + post.title = title || post.title; post.description = description || post.description; post.content = content || post.content; From adf1ed3e16feb5e4f405c2d865867844272e0ead Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 7 Aug 2025 11:29:29 -0400 Subject: [PATCH 33/77] added attributes so when its passed to postcard it shows username --- api/posts.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/posts.js b/api/posts.js index a53c0f1..b6901ea 100644 --- a/api/posts.js +++ b/api/posts.js @@ -62,7 +62,14 @@ router.get("/mine", authenticateJWT, async (req, res) => { try { const userId = req.user.id; const posts = await Post.findAll({ - where: { userId }, + where: { isPublic: true }, + include: [ + { + model: User, + as: "author", + attributes: ["username", "id"], + }, + ], }); res.json(posts); } catch (error) { From 45724eeb4355223ab1dfc94215c9b02497a2a506 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 7 Aug 2025 11:37:19 -0400 Subject: [PATCH 34/77] merge profile page backend --- api/index.js | 4 +- api/posts.js | 271 ++++++++++++++++++++++++++------- api/profile.js | 346 +++++++++++++++++++++++++++++++++++++++++++ app.js | 2 + database/follows.js | 22 +-- database/index.js | 37 +++-- database/seed.js | 354 +++++++++++++++++++------------------------- database/user.js | 182 +++++++++++++---------- 8 files changed, 858 insertions(+), 360 deletions(-) create mode 100644 api/profile.js diff --git a/api/index.js b/api/index.js index a091c42..281cb11 100644 --- a/api/index.js +++ b/api/index.js @@ -3,9 +3,11 @@ const router = express.Router(); const testDbRouter = require("./test-db"); const searchRouter = require("./search"); const postsRouter = require("./posts"); +const profileRouter = require("./profile"); router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); +router.use("/profile", profileRouter); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/api/posts.js b/api/posts.js index a53c0f1..4805c2d 100644 --- a/api/posts.js +++ b/api/posts.js @@ -1,20 +1,45 @@ const express = require("express"); const router = express.Router(); -const { Post, User } = require("../database"); // Correct import +const { Posts, User } = require("../database"); const { authenticateJWT } = require("../auth"); +// Get all published posts (public feed) +router.get("/feed", async (req, res) => { + try { + const posts = await Posts.findAll({ + where: { + status: 'published', + isPublic: true + }, + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']], + limit: 20 + }); + + res.json(posts); + } catch (error) { + console.error("Error fetching feed posts:", error); + res.status(500).json({ error: "Failed to fetch feed posts" }); + } +}); + // Get all posts (public) router.get("/", async (req, res) => { try { - const posts = await Post.findAll({ + const posts = await Posts.findAll({ where: { isPublic: true }, include: [ { model: User, as: "author", - attributes: ["username", "id"], + attributes: ["username", "id", "spotifyDisplayName", "profileImage", "spotifyProfileImage", "avatarURL"], }, ], + order: [['createdAt', 'DESC']] }); res.json(posts); } catch (error) { @@ -26,43 +51,41 @@ router.get("/", async (req, res) => { router.get("/test", (req, res) => { res.send("posts test route OK"); }); -// Get post if status === draft + +// Get posts if status === draft router.get("/drafts", authenticateJWT, async (req, res) => { try { - const drafts = await Post.findAll({ + const drafts = await Posts.findAll({ where: { userId: req.user.id, status: "draft", }, + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']] }); res.json(drafts); - } catch (err) { - console.error("Error fetching draft posts:", err); + } catch (error) { + console.error("Error fetching drafts:", error); res.status(500).json({ error: "Failed to fetch drafts" }); } }); -// Get post if status === published -router.get("/published", authenticateJWT, async (req, res) => { - try { - const published = await Post.findAll({ - where: { - userId: req.user.id, - status: "published", - }, - }); - res.json(published); - } catch (err) { - console.error("Error fetching published posts:", err); - res.status(500).json({ error: "Failed to fetch published posts" }); - } -}); -// Get all posts by the logged-in user +// Get posts by the logged-in user router.get("/mine", authenticateJWT, async (req, res) => { try { const userId = req.user.id; - const posts = await Post.findAll({ + const posts = await Posts.findAll({ where: { userId }, + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']] }); res.json(posts); } catch (error) { @@ -71,16 +94,38 @@ router.get("/mine", authenticateJWT, async (req, res) => { } }); +// Get posts if status === published +router.get("/published", authenticateJWT, async (req, res) => { + try { + const publishedPosts = await Posts.findAll({ + where: { + userId: req.user.id, + status: "published", + }, + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']] + }); + res.json(publishedPosts); + } catch (error) { + console.error("Error fetching published posts:", error); + res.status(500).json({ error: "Failed to fetch published posts" }); + } +}); + //get a single post by ID router.get("/:id", async (req, res) => { try { const postId = req.params.id; - const post = await Post.findByPk(postId, { + const post = await Posts.findByPk(postId, { include: [ { model: User, as: "author", - attributes: ["username", "id"], + attributes: ["username", "id", "spotifyDisplayName", "profileImage", "spotifyProfileImage", "avatarURL"], }, ], }); @@ -99,48 +144,93 @@ router.get("/:id", async (req, res) => { // Create a new post (requires authentication) router.post("/", authenticateJWT, async (req, res) => { try { - const { title, description, status } = req.body; + const { title, description, status, spotifyId, spotifyType, isPublic = true } = req.body; - if (!title) { + if (!title || title.trim() === '') { return res.status(400).json({ error: "Title is required" }); } - const newPost = await Post.create({ - title, - description, - status, - userId: req.user.id, // from token + if (!description || description.trim() === '') { + return res.status(400).json({ error: "Description is required" }); + } + + const postData = { + title: title.trim(), + description: description.trim(), + status: status || 'published', + userId: req.user.id, + isPublic: isPublic + }; + + // Handle Spotify embed data + if (spotifyId && spotifyType) { + const validTypes = ['track', 'album', 'playlist', 'artist']; + if (!validTypes.includes(spotifyType)) { + return res.status(400).json({ error: "Invalid Spotify type" }); + } + + postData.spotifyId = spotifyId; + postData.spotifyType = spotifyType; + } + + const newPost = await Posts.create(postData); + + // Fetch the created post with author info + const postWithAuthor = await Posts.findByPk(newPost.id, { + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }] }); - res.status(201).json(newPost); + res.status(201).json(postWithAuthor); } catch (error) { console.error("Error creating post:", error); res.status(500).json({ error: "Failed to create post" }); } }); -//POST api/posts/draft --- Create a draft endpoint (first time when user click on save as draft) +// Create a draft post router.post("/draft", authenticateJWT, async (req, res) => { try { - const { title, description, spotifyId, spotifyType } = req.body; + const { title, description, spotifyId, spotifyType, isPublic = true } = req.body; - if (!title || title.trim() === "") { - return res.status(400).json({ error: "Title is required" }); //title is required even for drafts + if (!title || title.trim() === '') { + return res.status(400).json({ error: "Title is required" }); } const postData = { title: title.trim(), - description: description || null, - status: "draft", + description: description || '', + status: 'draft', userId: req.user.id, + isPublic: isPublic }; - // handle single Spotify item if user included (optional) + + // Handle Spotify embed data if (spotifyId && spotifyType) { + const validTypes = ['track', 'album', 'playlist', 'artist']; + if (!validTypes.includes(spotifyType)) { + return res.status(400).json({ error: "Invalid Spotify type" }); + } + postData.spotifyId = spotifyId; postData.spotifyType = spotifyType; } - const newDraft = await Post.create(postData); - res.status(201).json(newDraft); + + const newDraft = await Posts.create(postData); + + // Fetch the created draft with author info + const draftWithAuthor = await Posts.findByPk(newDraft.id, { + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }] + }); + + res.status(201).json(draftWithAuthor); } catch (error) { console.error("Error creating draft:", error); res.status(500).json({ error: "Failed to create draft" }); @@ -150,7 +240,7 @@ router.post("/draft", authenticateJWT, async (req, res) => { // PATCH api/posts/draft/:id ------ Update a draft endpoint router.patch("/draft/:id", authenticateJWT, async (req, res) => { try { - const { title, description, spotifyId, spotifyType } = req.body; + const { title, description, spotifyId, spotifyType, isPublic } = req.body; if (title !== undefined && !title.trim()) { return res.status(400).json({ error: "Title cannot be empty" }); @@ -158,11 +248,17 @@ router.patch("/draft/:id", authenticateJWT, async (req, res) => { const updateData = {}; if (title !== undefined) updateData.title = title.trim(); - if (description !== undefined) updateData.description = description || null; + if (description !== undefined) updateData.description = description || ''; if (spotifyId !== undefined) updateData.spotifyId = spotifyId || null; - if (spotifyType !== undefined) updateData.spotifyType = spotifyType || null; + if (spotifyType !== undefined) { + if (spotifyType && !['track', 'album', 'playlist', 'artist'].includes(spotifyType)) { + return res.status(400).json({ error: "Invalid Spotify type" }); + } + updateData.spotifyType = spotifyType || null; + } + if (isPublic !== undefined) updateData.isPublic = isPublic; - const [updatedRowsCount] = await Post.update(updateData, { + const [updatedRowsCount] = await Posts.update(updateData, { where: { id: parseInt(req.params.id), userId: req.user.id, @@ -171,25 +267,86 @@ router.patch("/draft/:id", authenticateJWT, async (req, res) => { }); if (updatedRowsCount === 0) { - return res - .status(404) - .json({ error: "Draft post not found or unauthorized" }); + return res.status(404).json({ error: "Draft post not found or unauthorized" }); } - res.json({ message: "Draft updated successfully" }); + // Fetch updated post with author info + const updatedPost = await Posts.findByPk(req.params.id, { + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }] + }); + + res.json(updatedPost); } catch (error) { console.error("Error updating draft:", error); res.status(500).json({ error: "Failed to update draft" }); } }); +// Publish a draft post +router.patch("/:id/publish", authenticateJWT, async (req, res) => { + try { + const [updatedRowsCount] = await Posts.update( + { status: 'published' }, + { + where: { + id: parseInt(req.params.id), + userId: req.user.id, + status: 'draft' + } + } + ); + + if (updatedRowsCount === 0) { + return res.status(404).json({ error: "Draft post not found or unauthorized" }); + } + + // Fetch updated post with author info + const publishedPost = await Posts.findByPk(req.params.id, { + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }] + }); + + res.json(publishedPost); + } catch (error) { + console.error("Error publishing post:", error); + res.status(500).json({ error: "Failed to publish post" }); + } +}); + +// Like/Unlike a post +router.post("/:id/like", authenticateJWT, async (req, res) => { + try { + const post = await Posts.findByPk(req.params.id); + + if (!post) { + return res.status(404).json({ error: "Post not found" }); + } + + // Simple like increment (you can make this more sophisticated later) + await post.increment('likesCount'); + await post.reload(); + + res.json({ message: "Post liked", likesCount: post.likesCount }); + } catch (error) { + console.error("Error liking post:", error); + res.status(500).json({ error: "Failed to like post" }); + } +}); + //Edit a post if post not published router.patch("/:id", authenticateJWT, async (req, res) => { try { const postId = req.params.id; const { title, description, content, status } = req.body; - const post = await Post.findByPk(postId); + const post = await Posts.findByPk(postId); if (!post) { return res.status(404).json({ error: "Post not found" }); @@ -219,18 +376,22 @@ router.patch("/:id", authenticateJWT, async (req, res) => { router.delete("/:id", authenticateJWT, async (req, res) => { try { const postId = req.params.id; - const post = await Post.findByPk(postId); + const post = await Posts.findByPk(postId); if (!post) { return res.status(404).json({ error: "Post not found" }); } + if (post.userId !== req.user.id) { + return res.status(403).json({ error: "Unauthorized to delete this post" }); + } + await post.destroy(); - res.status(204).send(); + res.json({ message: "Post deleted successfully" }); } catch (error) { console.error("Error deleting post:", error); res.status(500).json({ error: "Failed to delete post" }); } }); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/api/profile.js b/api/profile.js new file mode 100644 index 0000000..67c9b97 --- /dev/null +++ b/api/profile.js @@ -0,0 +1,346 @@ +const express = require("express"); +const router = express.Router(); +const { User, Posts, Follows } = require("../database"); +const { authenticateJWT } = require("../auth"); + +// Get current user's profile +router.get("/me", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id, { + attributes: [ + 'id', 'username', 'email', 'firstName', 'lastName', 'bio', + 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'createdAt' + ] + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + // Get post count + const postCount = await Posts.count({ + where: { userId: user.id, status: 'published' } + }); + + // Get followers count + const followersCount = await Follows.count({ + where: { followingId: user.id } + }); + + // Get following count + const followingCount = await Follows.count({ + where: { followerId: user.id } + }); + + res.json({ + ...user.toJSON(), + stats: { + posts: postCount, + followers: followersCount, + following: followingCount + } + }); + } catch (error) { + console.error("Error fetching profile:", error); + res.status(500).json({ error: "Failed to fetch profile" }); + } +}); + +// Get current user's posts +router.get("/me/posts", authenticateJWT, async (req, res) => { + try { + const posts = await Posts.findAll({ + where: { + userId: req.user.id, + status: 'published' + }, + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']], + limit: 20 + }); + + res.json(posts); + } catch (error) { + console.error("Error fetching user posts:", error); + res.status(500).json({ error: "Failed to fetch posts" }); + } +}); + +// Update current user's profile +router.patch("/me", authenticateJWT, async (req, res) => { + try { + const { firstName, lastName, bio, profileImage } = req.body; + + // Validate data + const updateData = {}; + + if (firstName !== undefined) { + if (firstName && firstName.length > 50) { + return res.status(400).json({ error: "First name must be 50 characters or less" }); + } + updateData.firstName = firstName || null; + } + + if (lastName !== undefined) { + if (lastName && lastName.length > 50) { + return res.status(400).json({ error: "Last name must be 50 characters or less" }); + } + updateData.lastName = lastName || null; + } + + if (bio !== undefined) { + if (bio && bio.length > 500) { + return res.status(400).json({ error: "Bio must be 500 characters or less" }); + } + updateData.bio = bio || null; + } + + if (profileImage !== undefined) { + // Basic URL validation + if (profileImage && !profileImage.match(/^https?:\/\/.+/)) { + return res.status(400).json({ error: "Profile image must be a valid URL" }); + } + updateData.profileImage = profileImage || null; + } + + const [updatedRowsCount] = await User.update(updateData, { + where: { id: req.user.id } + }); + + if (updatedRowsCount === 0) { + return res.status(404).json({ error: "User not found" }); + } + + // Fetch updated user + const updatedUser = await User.findByPk(req.user.id, { + attributes: [ + 'id', 'username', 'email', 'firstName', 'lastName', 'bio', + 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'createdAt' + ] + }); + + res.json(updatedUser); + } catch (error) { + console.error("Error updating profile:", error); + + if (error.name === 'SequelizeValidationError') { + const errorMessages = error.errors.map(err => err.message); + return res.status(400).json({ + error: "Validation failed", + details: errorMessages.join(', ') + }); + } + + res.status(500).json({ error: "Failed to update profile" }); + } +}); + +// Get public profile by username +router.get("/:username", async (req, res) => { + try { + const user = await User.findOne({ + where: { username: req.params.username }, + attributes: [ + 'id', 'username', 'firstName', 'lastName', 'bio', + 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'createdAt' + ] + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + // Get post count + const postCount = await Posts.count({ + where: { userId: user.id, status: 'published', isPublic: true } + }); + + // Get followers count + const followersCount = await Follows.count({ + where: { followingId: user.id } + }); + + // Get following count + const followingCount = await Follows.count({ + where: { followerId: user.id } + }); + + res.json({ + ...user.toJSON(), + stats: { + posts: postCount, + followers: followersCount, + following: followingCount + } + }); + } catch (error) { + console.error("Error fetching public profile:", error); + res.status(500).json({ error: "Failed to fetch profile" }); + } +}); + +// Get public user's posts +router.get("/:username/posts", async (req, res) => { + try { + const user = await User.findOne({ + where: { username: req.params.username } + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const posts = await Posts.findAll({ + where: { + userId: user.id, + status: 'published', + isPublic: true + }, + include: [{ + model: User, + as: 'author', + attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']], + limit: 20 + }); + + res.json(posts); + } catch (error) { + console.error("Error fetching user posts:", error); + res.status(500).json({ error: "Failed to fetch posts" }); + } +}); + +// Follow/unfollow user +router.post("/:username/follow", authenticateJWT, async (req, res) => { + try { + const userToFollow = await User.findOne({ + where: { username: req.params.username } + }); + + if (!userToFollow) { + return res.status(404).json({ error: "User not found" }); + } + + if (userToFollow.id === req.user.id) { + return res.status(400).json({ error: "Cannot follow yourself" }); + } + + // Check if already following + const existingFollow = await Follows.findOne({ + where: { + followerId: req.user.id, + followingId: userToFollow.id + } + }); + + if (existingFollow) { + // Unfollow + await existingFollow.destroy(); + res.json({ message: "Unfollowed successfully", following: false }); + } else { + // Follow + await Follows.create({ + followerId: req.user.id, + followingId: userToFollow.id + }); + res.json({ message: "Followed successfully", following: true }); + } + } catch (error) { + console.error("Error following/unfollowing user:", error); + res.status(500).json({ error: "Failed to follow/unfollow user" }); + } +}); + +// Check if current user is following another user +router.get("/:username/following-status", authenticateJWT, async (req, res) => { + try { + const userToCheck = await User.findOne({ + where: { username: req.params.username } + }); + + if (!userToCheck) { + return res.status(404).json({ error: "User not found" }); + } + + const isFollowing = await Follows.findOne({ + where: { + followerId: req.user.id, + followingId: userToCheck.id + } + }); + + res.json({ following: !!isFollowing }); + } catch (error) { + console.error("Error checking following status:", error); + res.status(500).json({ error: "Failed to check following status" }); + } +}); + +// Get user's followers +router.get("/:username/followers", async (req, res) => { + try { + const user = await User.findOne({ + where: { username: req.params.username } + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const followers = await Follows.findAll({ + where: { followingId: user.id }, + include: [{ + model: User, + as: 'follower', + attributes: ['id', 'username', 'firstName', 'lastName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']], + limit: 50 + }); + + res.json(followers.map(follow => follow.follower)); + } catch (error) { + console.error("Error fetching followers:", error); + res.status(500).json({ error: "Failed to fetch followers" }); + } +}); + +// Get user's following +router.get("/:username/following", async (req, res) => { + try { + const user = await User.findOne({ + where: { username: req.params.username } + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const following = await Follows.findAll({ + where: { followerId: user.id }, + include: [{ + model: User, + as: 'following', + attributes: ['id', 'username', 'firstName', 'lastName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] + }], + order: [['createdAt', 'DESC']], + limit: 50 + }); + + res.json(following.map(follow => follow.following)); + } catch (error) { + console.error("Error fetching following:", error); + res.status(500).json({ error: "Failed to fetch following" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/app.js b/app.js index 9ad88fe..131c163 100644 --- a/app.js +++ b/app.js @@ -42,6 +42,8 @@ const runApp = async () => { try { await db.sync(); console.log("βœ… Connected to the database"); + await db.sync({ alter: true }); + console.log("βœ… Database synced successfully"); app.listen(PORT, () => { console.log(`πŸš€ Server is running on port ${PORT}`); }); diff --git a/database/follows.js b/database/follows.js index 9aaff37..565d5a2 100644 --- a/database/follows.js +++ b/database/follows.js @@ -2,15 +2,10 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); const Follows = db.define("follows", { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, followerId: { type: DataTypes.INTEGER, allowNull: false, - field: 'follower_id', // Map to snake_case database column + field: 'follower_id', references: { model: 'users', key: 'id' @@ -19,20 +14,25 @@ const Follows = db.define("follows", { followingId: { type: DataTypes.INTEGER, allowNull: false, - field: 'following_id', // Map to snake_case database column + field: 'following_id', references: { - model: 'users', + model: 'users', key: 'id' } } }, { tableName: 'follows', - underscored: true, // This ensures snake_case column names - // Ensure a user can't follow the same person twice + underscored: true, indexes: [ { unique: true, - fields: ['follower_id', 'following_id'] // Use snake_case here + fields: ['follower_id', 'following_id'] + }, + { + fields: ['follower_id'] + }, + { + fields: ['following_id'] } ] }); diff --git a/database/index.js b/database/index.js index a721af2..6bb51ba 100644 --- a/database/index.js +++ b/database/index.js @@ -1,37 +1,48 @@ const db = require("./db"); const User = require("./user"); -const Post = require("./posts"); +const Posts = require("./posts"); const Follows = require("./follows"); -// Set up associations -User.hasMany(Post, { - foreignKey: "user_id", // Use snake_case for foreign key +// Define Post-User associations +User.hasMany(Posts, { + foreignKey: "user_id", as: "posts", }); -Post.belongsTo(User, { - foreignKey: "user_id", // Use snake_case for foreign key +Posts.belongsTo(User, { + foreignKey: "user_id", as: "author", }); -// User following relationships +// Define User following relationships User.belongsToMany(User, { through: Follows, as: "following", - foreignKey: "follower_id", // Use snake_case - otherKey: "following_id", // Use snake_case + foreignKey: "follower_id", + otherKey: "following_id", }); User.belongsToMany(User, { through: Follows, as: "followers", - foreignKey: "following_id", // Use snake_case - otherKey: "follower_id", // Use snake_case + foreignKey: "following_id", + otherKey: "follower_id", +}); + +// Direct associations for easier querying +Follows.belongsTo(User, { + foreignKey: 'follower_id', + as: 'follower' +}); + +Follows.belongsTo(User, { + foreignKey: 'following_id', + as: 'following' }); module.exports = { db, User, - Post, + Posts, Follows, -}; +}; \ No newline at end of file diff --git a/database/seed.js b/database/seed.js index 1778f8e..c5bfc6b 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,228 +1,178 @@ -const db = require("./db"); -const { User, Post } = require("./index"); +const { db, User, Posts, Follows } = require("./index"); -const seed = async () => { +const seedDatabase = async () => { try { - db.logging = false; - await db.sync({ force: true }); + // Sync database + console.log("πŸ”„ Syncing database..."); + await db.sync({ force: false, alter: true }); + + console.log("🌱 Starting database seed..."); - const users = await User.bulkCreate([ - { id: 1, username: "admin", passwordHash: User.hashPassword("admin123") }, - { id: 2, username: "user1", passwordHash: User.hashPassword("user111") }, - { id: 3, username: "user2", passwordHash: User.hashPassword("user222") }, - { id: 4, username: "user3", passwordHash: User.hashPassword("user333") }, - { id: 5, username: "user4", passwordHash: User.hashPassword("user444") }, - ]); + // Clear existing data in correct order (to handle foreign key constraints) + await Posts.destroy({ where: {} }); + await Follows.destroy({ where: {} }); + await User.destroy({ where: {} }); - console.log(`πŸ‘€ Created ${users.length} users`); - - const posts = await Post.bulkCreate([ - // User 1 - { - title: "Admin Vibes", - description: "Admin is testing things.", - status: "draft", - userId: 1, - spotifyId: "37i9dQZF1DXcBWIGoYBM5M", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", - }, - { - title: "Lo-Fi Work Flow", - description: "Perfect background music.", - status: "published", - userId: 1, - spotifyId: "37i9dQZF1DXdPec7aLTmlC", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", - }, - { - title: "Rock & Roll!", - description: "Classic rock hits I love.", - status: "draft", - userId: 1, - spotifyId: "37i9dQZF1DWXRqgorJj26U", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DWXRqgorJj26U", - }, - { - title: "Motivation Boost", - description: "Hype music to crush the day.", - status: "published", - userId: 1, - spotifyId: "7qiZfU4dY1lWllzX7mPBI3", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/7qiZfU4dY1lWllzX7mPBI3", - }, + // Create test users + const users = await User.bulkCreate([ { - title: "Calm Mornings", - description: "Wake up gently.", - status: "published", - userId: 1, - spotifyId: "3KkXRkHbMCARz0aVfEt68P", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/3KkXRkHbMCARz0aVfEt68P", - }, + username: "musiclover92", + email: "sarah@example.com", + passwordHash: User.hashPassword("password123"), + firstName: "Sarah", + lastName: "Johnson", + bio: "Love discovering new music and sharing my favorites! Always on the hunt for new artists and hidden gems.", + spotifyDisplayName: "Sarah Johnson", + profileImage: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face", + avatarURL: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face" + }, + { + username: "vinylcollector", + email: "mike@example.com", + passwordHash: User.hashPassword("password123"), + firstName: "Mike", + lastName: "Chen", + bio: "Vinyl enthusiast and classic rock aficionado. Always hunting for rare records and sharing the stories behind the music.", + spotifyDisplayName: "Mike Chen", + profileImage: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face", + avatarURL: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face" + }, + { + username: "jazzcat", + email: "alex@example.com", + passwordHash: User.hashPassword("password123"), + firstName: "Alex", + lastName: "Rivera", + bio: "Jazz musician and music producer. Sharing smooth vibes and hidden gems from the world of jazz and beyond.", + spotifyDisplayName: "Alex Rivera", + profileImage: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", + avatarURL: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face" + }, + { + username: "indierocks", + email: "taylor@example.com", + passwordHash: User.hashPassword("password123"), + firstName: "Taylor", + lastName: "Swift", + bio: "Indie rock enthusiast and concert photographer. Living for live music experiences and discovering the next big thing.", + spotifyDisplayName: "Taylor Swift", + profileImage: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face", + avatarURL: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face" + } + ], { + returning: true + }); - // User 2 - { - title: "Late Night Drive", - description: "Vibes for cruising.", - status: "published", - userId: 2, - spotifyId: "6habFhsOp2NvshLv26DqMb", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/6habFhsOp2NvshLv26DqMb", - }, - { - title: "Hip-Hop Energy", - description: "Stay pumped.", - status: "published", - userId: 2, - spotifyId: "37i9dQZF1DX0XUsuxWHRQd", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DX0XUsuxWHRQd", - }, - { - title: "The Weekend Wave", - description: "Weekend vibes incoming.", - status: "published", - userId: 2, - spotifyId: "3U4isOIWM3VvDubwSI3y7a", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/3U4isOIWM3VvDubwSI3y7a", - }, - { - title: "Pop Culture Hits", - description: "All the trending pop songs.", - status: "published", - userId: 2, - spotifyId: "37i9dQZF1DXcF6B6QPhFDv", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DXcF6B6QPhFDv", - }, - { - title: "Mellow Mood", - description: "For rainy evenings.", - status: "published", - userId: 2, - spotifyId: "2Fxmhks0bxGSBdJ92vM42m", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/2Fxmhks0bxGSBdJ92vM42m", - }, + console.log(`βœ… Created ${users.length} users`); - // User 3 - { - title: "Throwback Thursday", - description: "Hits from the 2000s.", - status: "published", - userId: 3, - spotifyId: "37i9dQZF1DWYmmr74INQlb", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DWYmmr74INQlb", - }, - { - title: "Study Mode On", - description: "Helps me focus.", - status: "published", - userId: 3, - spotifyId: "37i9dQZF1DX8Uebhn9wzrS", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DX8Uebhn9wzrS", - }, - { - title: "Classic Chill", - description: "Old school but gold.", - status: "published", - userId: 3, - spotifyId: "6QgjcU0zLnzq5OrUoSZ3OK", + // Create test posts + const posts = await Posts.bulkCreate([ + { + title: "Just discovered this amazing track!", + description: "This song has been on repeat all day. The production quality is insane and the vocals are so smooth. Definitely adding this to my workout playlist. What do you think about this artist?", + userId: users[0].id, + status: 'published', + isPublic: true, + spotifyId: "4iV5W9uYEdYUVa79Axb7Rh", spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/6QgjcU0zLnzq5OrUoSZ3OK", + likesCount: 42 }, { - title: "Top Gaming Tracks", - description: "Perfect for grinding ranked.", - status: "published", - userId: 3, - spotifyId: "37i9dQZF1DX2sUQwD7tbmL", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DX2sUQwD7tbmL", + title: "Throwback vibes with this classic album", + description: "Sometimes you just need to go back to the classics. This album shaped my entire music taste growing up. Every single track is a masterpiece. Perfect for those late night study sessions or just chilling with friends.", + userId: users[1].id, + status: 'published', + isPublic: true, + spotifyId: "1DFixLWuPkv3KT3TnV35m3", + spotifyType: "album", + likesCount: 127 }, { - title: "Energy Boost", - description: "Turn it up!", - status: "published", - userId: 3, - spotifyId: "4uLU6hMCjMI75M1A2tKUQC", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC", - }, - // Posts for user 4 - { - title: "Late Night Chill", - description: "This playlist puts me to sleep (in a good way).", - status: "published", - userId: 4, - spotifyId: "37i9dQZF1DWVzZlRWgqAGH", + title: "Smooth jazz for your Sunday morning", + description: "Found this incredible jazz playlist that's perfect for lazy Sunday mornings. The saxophone solos are absolutely divine. Great for coffee and contemplation.", + userId: users[2].id, + status: 'published', + isPublic: true, + spotifyId: "37i9dQZF1DX0SM0LYsmbMT", spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DWVzZlRWgqAGH", + likesCount: 89 }, { - title: "Favorite Banger", - description: "Crank it loud!", - status: "published", - userId: 4, - spotifyId: "0eGsygTp906u18L0Oimnem", - spotifyType: "track", - spotifyEmbedUrl: - "https://open.spotify.com/embed/track/0eGsygTp906u18L0Oimnem", + title: "This indie band is going places", + description: "Stumbled upon this artist at a local venue last night and I'm blown away. Their sound is so unique and fresh. They're definitely going to be huge soon. Check them out before they blow up!", + userId: users[3].id, + status: 'published', + isPublic: true, + spotifyId: "3TVXtAsR1Inumwj472S9r4", + spotifyType: "artist", + likesCount: 156 }, { - title: "Feel Good Mix", - description: "This always boosts my mood.", - status: "published", - userId: 4, - spotifyId: "37i9dQZF1DXdPec7aLTmlC", + title: "Perfect study playlist", + description: "Curated the perfect instrumental playlist for deep focus sessions. No lyrics to distract, just pure musical flow to keep you in the zone.", + userId: users[0].id, + status: 'published', + isPublic: true, + spotifyId: "37i9dQZF1DWWQRwui0ExPn", spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", - }, - { - title: "Bop Playlist πŸ’Ώ", - description: "My current favorite finds. Trust me.", - status: "published", - userId: 4, - spotifyId: "37i9dQZF1DXcBWIGoYBM5M", - spotifyType: "playlist", - spotifyEmbedUrl: - "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", - }, + likesCount: 73 + }, + { + title: "Draft post for later", + description: "Working on this post about my favorite underground hip-hop artists...", + userId: users[1].id, + status: 'draft', + isPublic: false, + likesCount: 0 + } + ], { + returning: true + }); + + console.log(`βœ… Created ${posts.length} posts`); + + // Create some follow relationships + const follows = await Follows.bulkCreate([ + { followerId: users[0].id, followingId: users[1].id }, + { followerId: users[0].id, followingId: users[2].id }, + { followerId: users[1].id, followingId: users[0].id }, + { followerId: users[1].id, followingId: users[3].id }, + { followerId: users[2].id, followingId: users[0].id }, + { followerId: users[2].id, followingId: users[3].id }, + { followerId: users[3].id, followingId: users[1].id }, + { followerId: users[3].id, followingId: users[2].id } ]); - console.log(`πŸ“ Created ${posts.length} posts`); + console.log(`βœ… Created ${follows.length} follow relationships`); + + console.log("πŸŽ‰ Database seeded successfully!"); + console.log("\nπŸ“Š Seed Summary:"); + console.log(` πŸ‘₯ Users: ${users.length}`); + console.log(` πŸ“ Posts: ${posts.filter(p => p.status === 'published').length} published, ${posts.filter(p => p.status === 'draft').length} drafts`); + console.log(` πŸ”— Follows: ${follows.length}`); + + console.log("\nπŸ” Test Login Credentials:"); + users.forEach(user => { + console.log(` Username: ${user.username} | Password: password123`); + }); - console.log("🌱 Seeded the database"); } catch (error) { - console.error("Error seeding database:", error); + console.error("❌ Error seeding database:", error); + if (error.name === 'SequelizeValidationError') { + error.errors.forEach(err => { + console.error(` - ${err.path}: ${err.message}`); + }); + } + if (error.name === 'SequelizeUniqueConstraintError') { + console.error(` - Unique constraint error: ${error.message}`); + } + if (error.name === 'SequelizeForeignKeyConstraintError') { + console.error(` - Foreign key constraint error: ${error.message}`); + } } finally { - db.close(); + await db.close(); + process.exit(0); } }; -seed(); +seedDatabase(); \ No newline at end of file diff --git a/database/user.js b/database/user.js index a757582..4ee5ab4 100644 --- a/database/user.js +++ b/database/user.js @@ -2,91 +2,117 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); const bcrypt = require("bcrypt"); -const User = db.define( - "user", - { - username: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - validate: { - len: [3, 20], - notEmpty: true, - }, - }, - email: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - validate: { - isEmail: { - msg: "Must be a valid email address", - }, - }, - }, - auth0Id: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - field: "auth0_id", - }, - passwordHash: { - type: DataTypes.STRING, - allowNull: true, - field: "password_hash", - }, - avatarURL: { - type: DataTypes.STRING, - defaultValue: "https://static.thenounproject.com/png/5100711-200.png", - allowNull: false, - }, - spotifyId: { - type: DataTypes.STRING, - allowNull: true, - unique: true, - field: "spotify_id", - }, - spotifyAccessToken: { - type: DataTypes.TEXT, - allowNull: true, - field: "spotify_access_token", - }, - spotifyRefreshToken: { - type: DataTypes.TEXT, - allowNull: true, - field: "spotify_refresh_token", - }, - spotifyTokenExpiresAt: { - type: DataTypes.DATE, - allowNull: true, - field: "spotify_token_expires_at", - }, - spotifyDisplayName: { - type: DataTypes.STRING, - allowNull: true, - field: "spotify_display_name", - }, - spotifyProfileImage: { - type: DataTypes.STRING, - allowNull: true, - field: "spotify_profile_image", +const User = db.define("user", { + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + len: [3, 20], + notEmpty: true, }, }, - { - tableName: "users", - underscored: true, + email: { + type: DataTypes.STRING, + allowNull: true, + unique: true, validate: { - mustHaveAuthMethod() { - if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { - throw new Error("User must have at least one authentication method"); - } + isEmail: { + msg: "Must be a valid email address" }, }, + }, + firstName: { + type: DataTypes.STRING, + allowNull: true, + field: 'first_name', + validate: { + len: [0, 50] + } + }, + lastName: { + type: DataTypes.STRING, + allowNull: true, + field: 'last_name', + validate: { + len: [0, 50] + } + }, + bio: { + type: DataTypes.TEXT, + allowNull: true, + validate: { + len: [0, 500] + } + }, + profileImage: { + type: DataTypes.STRING, + allowNull: true, + field: 'profile_image' + }, + auth0Id: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + field: 'auth0_id' + }, + passwordHash: { + type: DataTypes.STRING, + allowNull: true, + field: 'password_hash' + }, + avatarURL: { + type: DataTypes.STRING, + defaultValue: "https://static.thenounproject.com/png/5100711-200.png", + allowNull: false, + }, + spotifyId: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + field: 'spotify_id' + }, + spotifyAccessToken: { + type: DataTypes.TEXT, + allowNull: true, + field: 'spotify_access_token' + }, + spotifyRefreshToken: { + type: DataTypes.TEXT, + allowNull: true, + field: 'spotify_refresh_token' + }, + spotifyTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'spotify_token_expires_at' + }, + spotifyDisplayName: { + type: DataTypes.STRING, + allowNull: true, + field: 'spotify_display_name' + }, + spotifyProfileImage: { + type: DataTypes.STRING, + allowNull: true, + field: 'spotify_profile_image' + }, +}, { + tableName: 'users', + underscored: true, + validate: { + mustHaveAuthMethod() { + if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { + throw new Error('User must have at least one authentication method'); + } + } } -); +}); User.prototype.checkPassword = function (password) { - if (!this.passwordHash) return false; + if (!this.passwordHash) { + return false; + } return bcrypt.compareSync(password, this.passwordHash); }; @@ -102,4 +128,4 @@ User.prototype.isSpotifyTokenValid = function () { ); }; -module.exports = User; +module.exports = User; \ No newline at end of file From ecd7c90fa222e5e62a01027bd53d4c4ccb9ce409 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 7 Aug 2025 13:22:04 -0400 Subject: [PATCH 35/77] refactor --- auth/index.js | 4 + auth/spotify.js | 19 +-- database/db.js | 3 +- database/index.js | 3 - database/seed.js | 360 +++++++++++++++++++++++++++++++++------------- 5 files changed, 275 insertions(+), 114 deletions(-) diff --git a/auth/index.js b/auth/index.js index eed714c..f781be0 100644 --- a/auth/index.js +++ b/auth/index.js @@ -36,6 +36,7 @@ const authenticateJWT = (req, res, next) => { }); }; +//auth0 account creation route router.post("/auth0", async (req, res) => { try { const { auth0Id, email, username } = req.body; @@ -101,6 +102,7 @@ router.post("/auth0", async (req, res) => { } }); +//post route to create an account router.post("/signup", async (req, res) => { try { const { username, password } = req.body; @@ -144,6 +146,7 @@ router.post("/signup", async (req, res) => { } }); +//post route to login router.post("/login", async (req, res) => { try { const { username, password } = req.body; @@ -189,6 +192,7 @@ router.post("/logout", (req, res) => { res.send({ message: "Logout successful" }); }); +//get logged in user router.get("/me", (req, res) => { let token = req.cookies.token; diff --git a/auth/spotify.js b/auth/spotify.js index 9f70c64..ab92fca 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -19,6 +19,7 @@ const cookieSettings = { path: "/", }; +//this function makes a call to get a spotify token const refreshSpotifyToken = async (user) => { try { const response = await axios.post( @@ -50,6 +51,7 @@ const refreshSpotifyToken = async (user) => { } }; +//this function uses refresh token to get a new token if the existing one expires const getValidSpotifyToken = async (user) => { if (!user.spotifyAccessToken) { throw new Error("Spotify not connected"); @@ -66,6 +68,7 @@ const getValidSpotifyToken = async (user) => { return await refreshSpotifyToken(user); }; +//this creates the username for a user based on the username provided by spotify const generateUsername = async (spotifyProfile) => { let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; @@ -100,7 +103,7 @@ const generateUsername = async (spotifyProfile) => { return finalUsername; }; -// Login URL endpoint +// uses client id to get login url for spotify router.get("/login-url", (req, res) => { try { const scopes = [ @@ -129,7 +132,7 @@ router.get("/login-url", (req, res) => { } }); -// Auth URL for connected users +// Get route for the user who is logged in router.get("/auth-url", authenticateJWT, (req, res) => { try { const scopes = [ @@ -158,7 +161,7 @@ router.get("/auth-url", authenticateJWT, (req, res) => { } }); -// Spotify login endpoint +// Post route for logging in with spotify router.post("/login", async (req, res) => { try { const { code, state } = req.body; @@ -291,7 +294,7 @@ router.post("/login", async (req, res) => { } }); -// Spotify callback for connected users +// Callback url post route for spotify router.post("/callback", authenticateJWT, async (req, res) => { try { const { code, state } = req.body; @@ -348,7 +351,7 @@ router.post("/callback", authenticateJWT, async (req, res) => { } }); -// Get Spotify profile +// Get Spotify for logged in user router.get("/profile", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -371,7 +374,7 @@ router.get("/profile", authenticateJWT, async (req, res) => { } }); -// Get top tracks +// Get top tracks for logged in user router.get("/top-tracks", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -513,7 +516,7 @@ router.get("/playlists/:id/tracks", authenticateJWT, async (req, res) => { Authorization: `Bearer ${accessToken}`, }, params: { - limit: 100, // Get up to 100 tracks + limit: 100, offset: 0, }, }); @@ -528,7 +531,7 @@ router.get("/playlists/:id/tracks", authenticateJWT, async (req, res) => { } }); -// Disconnect Spotify +// Disconnect Spotify/ does not work at the moment router.delete("/disconnect", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); diff --git a/database/db.js b/database/db.js index 3b580f1..8043e74 100644 --- a/database/db.js +++ b/database/db.js @@ -2,13 +2,12 @@ require("dotenv").config(); const { Sequelize } = require("sequelize"); const pg = require("pg"); -// Feel free to rename the database to whatever you want! const dbName = "capstone-2"; const db = new Sequelize( process.env.DATABASE_URL || `postgres://localhost:5432/${dbName}`, { - logging: false, // comment this line to enable SQL logging + logging: false, } ); diff --git a/database/index.js b/database/index.js index 6bb51ba..7e0c63a 100644 --- a/database/index.js +++ b/database/index.js @@ -3,7 +3,6 @@ const User = require("./user"); const Posts = require("./posts"); const Follows = require("./follows"); -// Define Post-User associations User.hasMany(Posts, { foreignKey: "user_id", as: "posts", @@ -14,7 +13,6 @@ Posts.belongsTo(User, { as: "author", }); -// Define User following relationships User.belongsToMany(User, { through: Follows, as: "following", @@ -29,7 +27,6 @@ User.belongsToMany(User, { otherKey: "follower_id", }); -// Direct associations for easier querying Follows.belongsTo(User, { foreignKey: 'follower_id', as: 'follower' diff --git a/database/seed.js b/database/seed.js index c5bfc6b..75e5d03 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,149 +1,305 @@ const { db, User, Posts, Follows } = require("./index"); -const seedDatabase = async () => { +const seed = async () => { try { - // Sync database + db.logging = false; console.log("πŸ”„ Syncing database..."); - await db.sync({ force: false, alter: true }); + await db.sync({ force: true }); console.log("🌱 Starting database seed..."); - // Clear existing data in correct order (to handle foreign key constraints) - await Posts.destroy({ where: {} }); - await Follows.destroy({ where: {} }); - await User.destroy({ where: {} }); - - // Create test users const users = await User.bulkCreate([ - { - username: "musiclover92", - email: "sarah@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Sarah", + { + id: 1, + username: "admin", + passwordHash: User.hashPassword("admin123"), + email: "admin@example.com", + firstName: "Admin", + lastName: "User", + bio: "System administrator and music curator. Testing all the features!", + avatarURL: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face" + }, + { + id: 2, + username: "user1", + passwordHash: User.hashPassword("user111"), + email: "user1@example.com", + firstName: "Alex", lastName: "Johnson", - bio: "Love discovering new music and sharing my favorites! Always on the hunt for new artists and hidden gems.", - spotifyDisplayName: "Sarah Johnson", - profileImage: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face" + bio: "Hip-hop enthusiast and night owl. Always looking for the next great beat.", + avatarURL: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face" }, - { - username: "vinylcollector", - email: "mike@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Mike", + { + id: 3, + username: "user2", + passwordHash: User.hashPassword("user222"), + email: "user2@example.com", + firstName: "Sarah", lastName: "Chen", - bio: "Vinyl enthusiast and classic rock aficionado. Always hunting for rare records and sharing the stories behind the music.", - spotifyDisplayName: "Mike Chen", - profileImage: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face" + bio: "Throwback music lover and study playlist curator. 2000s hits are my specialty!", + avatarURL: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face" }, - { - username: "jazzcat", - email: "alex@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Alex", - lastName: "Rivera", - bio: "Jazz musician and music producer. Sharing smooth vibes and hidden gems from the world of jazz and beyond.", - spotifyDisplayName: "Alex Rivera", - profileImage: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face" + { + id: 4, + username: "user3", + passwordHash: User.hashPassword("user333"), + email: "user3@example.com", + firstName: "Mike", + lastName: "Rodriguez", + bio: "Gaming music expert and energy boost specialist. Let's get hyped!", + avatarURL: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face" }, - { - username: "indierocks", - email: "taylor@example.com", - passwordHash: User.hashPassword("password123"), + { + id: 5, + username: "user4", + passwordHash: User.hashPassword("user444"), + email: "user4@example.com", firstName: "Taylor", - lastName: "Swift", - bio: "Indie rock enthusiast and concert photographer. Living for live music experiences and discovering the next big thing.", - spotifyDisplayName: "Taylor Swift", - profileImage: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face" - } + lastName: "Kim", + bio: "Chill vibes and feel-good music curator. Here to boost your mood with great tunes.", + avatarURL: "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150&h=150&fit=crop&crop=face" + }, ], { returning: true }); - console.log(`βœ… Created ${users.length} users`); + console.log(`πŸ‘€ Created ${users.length} users`); - // Create test posts const posts = await Posts.bulkCreate([ { - title: "Just discovered this amazing track!", - description: "This song has been on repeat all day. The production quality is insane and the vocals are so smooth. Definitely adding this to my workout playlist. What do you think about this artist?", - userId: users[0].id, - status: 'published', + title: "Admin Vibes", + description: "Admin is testing things. This is a draft post to test the draft functionality.", + status: "draft", + userId: 1, + spotifyId: "37i9dQZF1DXcBWIGoYBM5M", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + isPublic: false, + likesCount: 0 + }, + { + title: "Lo-Fi Work Flow", + description: "Perfect background music for coding sessions. These beats keep me focused without being distracting. Perfect for those long development marathons!", + status: "published", + userId: 1, + spotifyId: "37i9dQZF1DXdPec7aLTmlC", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", isPublic: true, - spotifyId: "4iV5W9uYEdYUVa79Axb7Rh", + likesCount: 23 + }, + { + title: "Rock & Roll!", + description: "Classic rock hits I love. Still working on this playlist, adding more gems as I find them.", + status: "draft", + userId: 1, + spotifyId: "37i9dQZF1DWXRqgorJj26U", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DWXRqgorJj26U", + isPublic: false, + likesCount: 0 + }, + { + title: "Motivation Boost", + description: "Hype music to crush the day. When you need that extra push to get things done, this track delivers!", + status: "published", + userId: 1, + spotifyId: "7qiZfU4dY1lWllzX7mPBI3", spotifyType: "track", - likesCount: 42 + spotifyEmbedUrl: "https://open.spotify.com/embed/track/7qiZfU4dY1lWllzX7mPBI3", + isPublic: true, + likesCount: 45 }, { - title: "Throwback vibes with this classic album", - description: "Sometimes you just need to go back to the classics. This album shaped my entire music taste growing up. Every single track is a masterpiece. Perfect for those late night study sessions or just chilling with friends.", - userId: users[1].id, - status: 'published', + title: "Calm Mornings", + description: "Wake up gently with these soothing sounds. Perfect for those slow weekend mornings with coffee.", + status: "published", + userId: 1, + spotifyId: "3KkXRkHbMCARz0aVfEt68P", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/3KkXRkHbMCARz0aVfEt68P", isPublic: true, - spotifyId: "1DFixLWuPkv3KT3TnV35m3", - spotifyType: "album", - likesCount: 127 + likesCount: 18 }, + { - title: "Smooth jazz for your Sunday morning", - description: "Found this incredible jazz playlist that's perfect for lazy Sunday mornings. The saxophone solos are absolutely divine. Great for coffee and contemplation.", - userId: users[2].id, - status: 'published', + title: "Late Night Drive", + description: "Vibes for cruising through the city when the streets are empty. Nothing beats these smooth beats at 2 AM.", + status: "published", + userId: 2, + spotifyId: "6habFhsOp2NvshLv26DqMb", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/6habFhsOp2NvshLv26DqMb", isPublic: true, - spotifyId: "37i9dQZF1DX0SM0LYsmbMT", + likesCount: 67 + }, + { + title: "Hip-Hop Energy", + description: "Stay pumped with these fresh beats. Current favorites that keep me moving throughout the day.", + status: "published", + userId: 2, + spotifyId: "37i9dQZF1DX0XUsuxWHRQd", spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DX0XUsuxWHRQd", + isPublic: true, likesCount: 89 }, { - title: "This indie band is going places", - description: "Stumbled upon this artist at a local venue last night and I'm blown away. Their sound is so unique and fresh. They're definitely going to be huge soon. Check them out before they blow up!", - userId: users[3].id, - status: 'published', + title: "The Weekend Wave", + description: "Weekend vibes incoming! This track just hits different when Friday night rolls around.", + status: "published", + userId: 2, + spotifyId: "3U4isOIWM3VvDubwSI3y7a", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/3U4isOIWM3VvDubwSI3y7a", isPublic: true, - spotifyId: "3TVXtAsR1Inumwj472S9r4", - spotifyType: "artist", - likesCount: 156 + likesCount: 34 + }, + { + title: "Pop Culture Hits", + description: "All the trending pop songs that everyone's talking about. Stay current with these bangers!", + status: "published", + userId: 2, + spotifyId: "37i9dQZF1DXcF6B6QPhFDv", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXcF6B6QPhFDv", + isPublic: true, + likesCount: 112 + }, + { + title: "Mellow Mood", + description: "For rainy evenings when you need something soft and contemplative. Perfect with a cup of tea.", + status: "published", + userId: 2, + spotifyId: "2Fxmhks0bxGSBdJ92vM42m", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/2Fxmhks0bxGSBdJ92vM42m", + isPublic: true, + likesCount: 28 }, + { - title: "Perfect study playlist", - description: "Curated the perfect instrumental playlist for deep focus sessions. No lyrics to distract, just pure musical flow to keep you in the zone.", - userId: users[0].id, - status: 'published', + title: "Throwback Thursday", + description: "Hits from the 2000s that still slap today! Nothing beats the nostalgia of these classics.", + status: "published", + userId: 3, + spotifyId: "37i9dQZF1DWYmmr74INQlb", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DWYmmr74INQlb", isPublic: true, - spotifyId: "37i9dQZF1DWWQRwui0ExPn", + likesCount: 156 + }, + { + title: "Study Mode On", + description: "Helps me focus during those long study sessions. Instrumental vibes that don't distract from the work.", + status: "published", + userId: 3, + spotifyId: "37i9dQZF1DX8Uebhn9wzrS", spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DX8Uebhn9wzrS", + isPublic: true, likesCount: 73 }, { - title: "Draft post for later", - description: "Working on this post about my favorite underground hip-hop artists...", - userId: users[1].id, - status: 'draft', - isPublic: false, - likesCount: 0 - } + title: "Classic Chill", + description: "Old school but gold. Sometimes you need to appreciate the timeless tracks that started it all.", + status: "published", + userId: 3, + spotifyId: "6QgjcU0zLnzq5OrUoSZ3OK", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/6QgjcU0zLnzq5OrUoSZ3OK", + isPublic: true, + likesCount: 41 + }, + { + title: "Top Gaming Tracks", + description: "Perfect for grinding ranked matches. These beats keep the energy high during those intense gaming sessions.", + status: "published", + userId: 3, + spotifyId: "37i9dQZF1DX2sUQwD7tbmL", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DX2sUQwD7tbmL", + isPublic: true, + likesCount: 91 + }, + { + title: "Energy Boost", + description: "Turn it up! When you need that extra motivation to power through anything life throws at you.", + status: "published", + userId: 3, + spotifyId: "4uLU6hMCjMI75M1A2tKUQC", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC", + isPublic: true, + likesCount: 64 + }, + + { + title: "Late Night Chill", + description: "This playlist puts me to sleep (in a good way). Perfect for winding down after a long day.", + status: "published", + userId: 4, + spotifyId: "37i9dQZF1DWVzZlRWgqAGH", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DWVzZlRWgqAGH", + isPublic: true, + likesCount: 37 + }, + { + title: "Favorite Banger", + description: "Crank it loud! This track never fails to get me hyped up and ready for anything.", + status: "published", + userId: 4, + spotifyId: "0eGsygTp906u18L0Oimnem", + spotifyType: "track", + spotifyEmbedUrl: "https://open.spotify.com/embed/track/0eGsygTp906u18L0Oimnem", + isPublic: true, + likesCount: 82 + }, + { + title: "Feel Good Mix", + description: "This always boosts my mood no matter what kind of day I'm having. Guaranteed smile maker!", + status: "published", + userId: 4, + spotifyId: "37i9dQZF1DXdPec7aLTmlC", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", + isPublic: true, + likesCount: 105 + }, + { + title: "Bop Playlist πŸ’Ώ", + description: "My current favorite finds. Trust me, these tracks are absolute fire and you need them in your life!", + status: "published", + userId: 4, + spotifyId: "37i9dQZF1DXcBWIGoYBM5M", + spotifyType: "playlist", + spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + isPublic: true, + likesCount: 127 + }, ], { returning: true }); - console.log(`βœ… Created ${posts.length} posts`); + console.log(`πŸ“ Created ${posts.length} posts`); - // Create some follow relationships const follows = await Follows.bulkCreate([ - { followerId: users[0].id, followingId: users[1].id }, - { followerId: users[0].id, followingId: users[2].id }, - { followerId: users[1].id, followingId: users[0].id }, - { followerId: users[1].id, followingId: users[3].id }, - { followerId: users[2].id, followingId: users[0].id }, - { followerId: users[2].id, followingId: users[3].id }, - { followerId: users[3].id, followingId: users[1].id }, - { followerId: users[3].id, followingId: users[2].id } + { followerId: 1, followingId: 2 }, + { followerId: 1, followingId: 3 }, + { followerId: 2, followingId: 1 }, + { followerId: 2, followingId: 4 }, + { followerId: 3, followingId: 1 }, + { followerId: 3, followingId: 2 }, + { followerId: 3, followingId: 5 }, + { followerId: 4, followingId: 2 }, + { followerId: 4, followingId: 3 }, + { followerId: 5, followingId: 1 }, + { followerId: 5, followingId: 3 }, + { followerId: 5, followingId: 4 }, ]); - console.log(`βœ… Created ${follows.length} follow relationships`); + console.log(`πŸ”— Created ${follows.length} follow relationships`); console.log("πŸŽ‰ Database seeded successfully!"); console.log("\nπŸ“Š Seed Summary:"); @@ -152,9 +308,11 @@ const seedDatabase = async () => { console.log(` πŸ”— Follows: ${follows.length}`); console.log("\nπŸ” Test Login Credentials:"); - users.forEach(user => { - console.log(` Username: ${user.username} | Password: password123`); - }); + console.log(" Username: admin | Password: admin123"); + console.log(" Username: user1 | Password: user111"); + console.log(" Username: user2 | Password: user222"); + console.log(" Username: user3 | Password: user333"); + console.log(" Username: user4 | Password: user444"); } catch (error) { console.error("❌ Error seeding database:", error); @@ -175,4 +333,4 @@ const seedDatabase = async () => { } }; -seedDatabase(); \ No newline at end of file +seed(); \ No newline at end of file From 9eab84825c2ec545fbb8a213aac109cc510db5fa Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 7 Aug 2025 13:40:35 -0400 Subject: [PATCH 36/77] Deleted seed.js --- database/seed.js | 178 ----------------------------------------------- 1 file changed, 178 deletions(-) delete mode 100644 database/seed.js diff --git a/database/seed.js b/database/seed.js deleted file mode 100644 index c5bfc6b..0000000 --- a/database/seed.js +++ /dev/null @@ -1,178 +0,0 @@ -const { db, User, Posts, Follows } = require("./index"); - -const seedDatabase = async () => { - try { - // Sync database - console.log("πŸ”„ Syncing database..."); - await db.sync({ force: false, alter: true }); - - console.log("🌱 Starting database seed..."); - - // Clear existing data in correct order (to handle foreign key constraints) - await Posts.destroy({ where: {} }); - await Follows.destroy({ where: {} }); - await User.destroy({ where: {} }); - - // Create test users - const users = await User.bulkCreate([ - { - username: "musiclover92", - email: "sarah@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Sarah", - lastName: "Johnson", - bio: "Love discovering new music and sharing my favorites! Always on the hunt for new artists and hidden gems.", - spotifyDisplayName: "Sarah Johnson", - profileImage: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face" - }, - { - username: "vinylcollector", - email: "mike@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Mike", - lastName: "Chen", - bio: "Vinyl enthusiast and classic rock aficionado. Always hunting for rare records and sharing the stories behind the music.", - spotifyDisplayName: "Mike Chen", - profileImage: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face" - }, - { - username: "jazzcat", - email: "alex@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Alex", - lastName: "Rivera", - bio: "Jazz musician and music producer. Sharing smooth vibes and hidden gems from the world of jazz and beyond.", - spotifyDisplayName: "Alex Rivera", - profileImage: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face" - }, - { - username: "indierocks", - email: "taylor@example.com", - passwordHash: User.hashPassword("password123"), - firstName: "Taylor", - lastName: "Swift", - bio: "Indie rock enthusiast and concert photographer. Living for live music experiences and discovering the next big thing.", - spotifyDisplayName: "Taylor Swift", - profileImage: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face", - avatarURL: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face" - } - ], { - returning: true - }); - - console.log(`βœ… Created ${users.length} users`); - - // Create test posts - const posts = await Posts.bulkCreate([ - { - title: "Just discovered this amazing track!", - description: "This song has been on repeat all day. The production quality is insane and the vocals are so smooth. Definitely adding this to my workout playlist. What do you think about this artist?", - userId: users[0].id, - status: 'published', - isPublic: true, - spotifyId: "4iV5W9uYEdYUVa79Axb7Rh", - spotifyType: "track", - likesCount: 42 - }, - { - title: "Throwback vibes with this classic album", - description: "Sometimes you just need to go back to the classics. This album shaped my entire music taste growing up. Every single track is a masterpiece. Perfect for those late night study sessions or just chilling with friends.", - userId: users[1].id, - status: 'published', - isPublic: true, - spotifyId: "1DFixLWuPkv3KT3TnV35m3", - spotifyType: "album", - likesCount: 127 - }, - { - title: "Smooth jazz for your Sunday morning", - description: "Found this incredible jazz playlist that's perfect for lazy Sunday mornings. The saxophone solos are absolutely divine. Great for coffee and contemplation.", - userId: users[2].id, - status: 'published', - isPublic: true, - spotifyId: "37i9dQZF1DX0SM0LYsmbMT", - spotifyType: "playlist", - likesCount: 89 - }, - { - title: "This indie band is going places", - description: "Stumbled upon this artist at a local venue last night and I'm blown away. Their sound is so unique and fresh. They're definitely going to be huge soon. Check them out before they blow up!", - userId: users[3].id, - status: 'published', - isPublic: true, - spotifyId: "3TVXtAsR1Inumwj472S9r4", - spotifyType: "artist", - likesCount: 156 - }, - { - title: "Perfect study playlist", - description: "Curated the perfect instrumental playlist for deep focus sessions. No lyrics to distract, just pure musical flow to keep you in the zone.", - userId: users[0].id, - status: 'published', - isPublic: true, - spotifyId: "37i9dQZF1DWWQRwui0ExPn", - spotifyType: "playlist", - likesCount: 73 - }, - { - title: "Draft post for later", - description: "Working on this post about my favorite underground hip-hop artists...", - userId: users[1].id, - status: 'draft', - isPublic: false, - likesCount: 0 - } - ], { - returning: true - }); - - console.log(`βœ… Created ${posts.length} posts`); - - // Create some follow relationships - const follows = await Follows.bulkCreate([ - { followerId: users[0].id, followingId: users[1].id }, - { followerId: users[0].id, followingId: users[2].id }, - { followerId: users[1].id, followingId: users[0].id }, - { followerId: users[1].id, followingId: users[3].id }, - { followerId: users[2].id, followingId: users[0].id }, - { followerId: users[2].id, followingId: users[3].id }, - { followerId: users[3].id, followingId: users[1].id }, - { followerId: users[3].id, followingId: users[2].id } - ]); - - console.log(`βœ… Created ${follows.length} follow relationships`); - - console.log("πŸŽ‰ Database seeded successfully!"); - console.log("\nπŸ“Š Seed Summary:"); - console.log(` πŸ‘₯ Users: ${users.length}`); - console.log(` πŸ“ Posts: ${posts.filter(p => p.status === 'published').length} published, ${posts.filter(p => p.status === 'draft').length} drafts`); - console.log(` πŸ”— Follows: ${follows.length}`); - - console.log("\nπŸ” Test Login Credentials:"); - users.forEach(user => { - console.log(` Username: ${user.username} | Password: password123`); - }); - - } catch (error) { - console.error("❌ Error seeding database:", error); - if (error.name === 'SequelizeValidationError') { - error.errors.forEach(err => { - console.error(` - ${err.path}: ${err.message}`); - }); - } - if (error.name === 'SequelizeUniqueConstraintError') { - console.error(` - Unique constraint error: ${error.message}`); - } - if (error.name === 'SequelizeForeignKeyConstraintError') { - console.error(` - Foreign key constraint error: ${error.message}`); - } - } finally { - await db.close(); - process.exit(0); - } -}; - -seedDatabase(); \ No newline at end of file From 7f7ff8b47a8dd657b4ece799c78857ec79080d76 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 7 Aug 2025 13:41:14 -0400 Subject: [PATCH 37/77] f --- database/follows.js | 21 ++++----- database/seed.js | 112 ++++++++++++-------------------------------- 2 files changed, 41 insertions(+), 92 deletions(-) diff --git a/database/follows.js b/database/follows.js index 565d5a2..60d3343 100644 --- a/database/follows.js +++ b/database/follows.js @@ -2,10 +2,15 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); const Follows = db.define("follows", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, followerId: { type: DataTypes.INTEGER, allowNull: false, - field: 'follower_id', + field: 'follower_id', references: { model: 'users', key: 'id' @@ -14,25 +19,19 @@ const Follows = db.define("follows", { followingId: { type: DataTypes.INTEGER, allowNull: false, - field: 'following_id', + field: 'following_id', references: { - model: 'users', + model: 'users', key: 'id' } } }, { tableName: 'follows', - underscored: true, + underscored: true, indexes: [ { unique: true, - fields: ['follower_id', 'following_id'] - }, - { - fields: ['follower_id'] - }, - { - fields: ['following_id'] + fields: ['follower_id', 'following_id'] // Use snake_case here } ] }); diff --git a/database/seed.js b/database/seed.js index 75e5d03..0ff560a 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,16 +1,12 @@ -const { db, User, Posts, Follows } = require("./index"); +const db = require("./db"); +const { User, Posts } = require("./index"); const seed = async () => { try { - db.logging = false; - console.log("πŸ”„ Syncing database..."); - await db.sync({ force: true }); + await db.sync({ force: true }); - console.log("🌱 Starting database seed..."); - const users = await User.bulkCreate([ { - id: 1, username: "admin", passwordHash: User.hashPassword("admin123"), email: "admin@example.com", @@ -20,7 +16,6 @@ const seed = async () => { avatarURL: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face" }, { - id: 2, username: "user1", passwordHash: User.hashPassword("user111"), email: "user1@example.com", @@ -30,7 +25,6 @@ const seed = async () => { avatarURL: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face" }, { - id: 3, username: "user2", passwordHash: User.hashPassword("user222"), email: "user2@example.com", @@ -40,7 +34,6 @@ const seed = async () => { avatarURL: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face" }, { - id: 4, username: "user3", passwordHash: User.hashPassword("user333"), email: "user3@example.com", @@ -50,7 +43,6 @@ const seed = async () => { avatarURL: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face" }, { - id: 5, username: "user4", passwordHash: User.hashPassword("user444"), email: "user4@example.com", @@ -59,16 +51,15 @@ const seed = async () => { bio: "Chill vibes and feel-good music curator. Here to boost your mood with great tunes.", avatarURL: "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150&h=150&fit=crop&crop=face" }, - ], { - returning: true - }); + ]); console.log(`πŸ‘€ Created ${users.length} users`); const posts = await Posts.bulkCreate([ + // User 1 { title: "Admin Vibes", - description: "Admin is testing things. This is a draft post to test the draft functionality.", + description: "Admin is testing things.", status: "draft", userId: 1, spotifyId: "37i9dQZF1DXcBWIGoYBM5M", @@ -79,7 +70,7 @@ const seed = async () => { }, { title: "Lo-Fi Work Flow", - description: "Perfect background music for coding sessions. These beats keep me focused without being distracting. Perfect for those long development marathons!", + description: "Perfect background music.", status: "published", userId: 1, spotifyId: "37i9dQZF1DXdPec7aLTmlC", @@ -90,7 +81,7 @@ const seed = async () => { }, { title: "Rock & Roll!", - description: "Classic rock hits I love. Still working on this playlist, adding more gems as I find them.", + description: "Classic rock hits I love.", status: "draft", userId: 1, spotifyId: "37i9dQZF1DWXRqgorJj26U", @@ -101,7 +92,7 @@ const seed = async () => { }, { title: "Motivation Boost", - description: "Hype music to crush the day. When you need that extra push to get things done, this track delivers!", + description: "Hype music to crush the day.", status: "published", userId: 1, spotifyId: "7qiZfU4dY1lWllzX7mPBI3", @@ -112,7 +103,7 @@ const seed = async () => { }, { title: "Calm Mornings", - description: "Wake up gently with these soothing sounds. Perfect for those slow weekend mornings with coffee.", + description: "Wake up gently.", status: "published", userId: 1, spotifyId: "3KkXRkHbMCARz0aVfEt68P", @@ -122,9 +113,10 @@ const seed = async () => { likesCount: 18 }, + // User 2 { title: "Late Night Drive", - description: "Vibes for cruising through the city when the streets are empty. Nothing beats these smooth beats at 2 AM.", + description: "Vibes for cruising.", status: "published", userId: 2, spotifyId: "6habFhsOp2NvshLv26DqMb", @@ -135,7 +127,7 @@ const seed = async () => { }, { title: "Hip-Hop Energy", - description: "Stay pumped with these fresh beats. Current favorites that keep me moving throughout the day.", + description: "Stay pumped.", status: "published", userId: 2, spotifyId: "37i9dQZF1DX0XUsuxWHRQd", @@ -146,7 +138,7 @@ const seed = async () => { }, { title: "The Weekend Wave", - description: "Weekend vibes incoming! This track just hits different when Friday night rolls around.", + description: "Weekend vibes incoming.", status: "published", userId: 2, spotifyId: "3U4isOIWM3VvDubwSI3y7a", @@ -157,7 +149,7 @@ const seed = async () => { }, { title: "Pop Culture Hits", - description: "All the trending pop songs that everyone's talking about. Stay current with these bangers!", + description: "All the trending pop songs.", status: "published", userId: 2, spotifyId: "37i9dQZF1DXcF6B6QPhFDv", @@ -168,7 +160,7 @@ const seed = async () => { }, { title: "Mellow Mood", - description: "For rainy evenings when you need something soft and contemplative. Perfect with a cup of tea.", + description: "For rainy evenings.", status: "published", userId: 2, spotifyId: "2Fxmhks0bxGSBdJ92vM42m", @@ -178,9 +170,10 @@ const seed = async () => { likesCount: 28 }, + // User 3 { title: "Throwback Thursday", - description: "Hits from the 2000s that still slap today! Nothing beats the nostalgia of these classics.", + description: "Hits from the 2000s.", status: "published", userId: 3, spotifyId: "37i9dQZF1DWYmmr74INQlb", @@ -191,7 +184,7 @@ const seed = async () => { }, { title: "Study Mode On", - description: "Helps me focus during those long study sessions. Instrumental vibes that don't distract from the work.", + description: "Helps me focus.", status: "published", userId: 3, spotifyId: "37i9dQZF1DX8Uebhn9wzrS", @@ -202,7 +195,7 @@ const seed = async () => { }, { title: "Classic Chill", - description: "Old school but gold. Sometimes you need to appreciate the timeless tracks that started it all.", + description: "Old school but gold.", status: "published", userId: 3, spotifyId: "6QgjcU0zLnzq5OrUoSZ3OK", @@ -213,7 +206,7 @@ const seed = async () => { }, { title: "Top Gaming Tracks", - description: "Perfect for grinding ranked matches. These beats keep the energy high during those intense gaming sessions.", + description: "Perfect for grinding ranked.", status: "published", userId: 3, spotifyId: "37i9dQZF1DX2sUQwD7tbmL", @@ -224,7 +217,7 @@ const seed = async () => { }, { title: "Energy Boost", - description: "Turn it up! When you need that extra motivation to power through anything life throws at you.", + description: "Turn it up!", status: "published", userId: 3, spotifyId: "4uLU6hMCjMI75M1A2tKUQC", @@ -234,9 +227,10 @@ const seed = async () => { likesCount: 64 }, + // User 4 { title: "Late Night Chill", - description: "This playlist puts me to sleep (in a good way). Perfect for winding down after a long day.", + description: "This playlist puts me to sleep (in a good way).", status: "published", userId: 4, spotifyId: "37i9dQZF1DWVzZlRWgqAGH", @@ -247,7 +241,7 @@ const seed = async () => { }, { title: "Favorite Banger", - description: "Crank it loud! This track never fails to get me hyped up and ready for anything.", + description: "Crank it loud!", status: "published", userId: 4, spotifyId: "0eGsygTp906u18L0Oimnem", @@ -258,7 +252,7 @@ const seed = async () => { }, { title: "Feel Good Mix", - description: "This always boosts my mood no matter what kind of day I'm having. Guaranteed smile maker!", + description: "This always boosts my mood.", status: "published", userId: 4, spotifyId: "37i9dQZF1DXdPec7aLTmlC", @@ -269,7 +263,7 @@ const seed = async () => { }, { title: "Bop Playlist πŸ’Ώ", - description: "My current favorite finds. Trust me, these tracks are absolute fire and you need them in your life!", + description: "My current favorite finds. Trust me.", status: "published", userId: 4, spotifyId: "37i9dQZF1DXcBWIGoYBM5M", @@ -278,58 +272,14 @@ const seed = async () => { isPublic: true, likesCount: 127 }, - ], { - returning: true - }); - - console.log(`πŸ“ Created ${posts.length} posts`); - - const follows = await Follows.bulkCreate([ - { followerId: 1, followingId: 2 }, - { followerId: 1, followingId: 3 }, - { followerId: 2, followingId: 1 }, - { followerId: 2, followingId: 4 }, - { followerId: 3, followingId: 1 }, - { followerId: 3, followingId: 2 }, - { followerId: 3, followingId: 5 }, - { followerId: 4, followingId: 2 }, - { followerId: 4, followingId: 3 }, - { followerId: 5, followingId: 1 }, - { followerId: 5, followingId: 3 }, - { followerId: 5, followingId: 4 }, ]); - console.log(`πŸ”— Created ${follows.length} follow relationships`); - - console.log("πŸŽ‰ Database seeded successfully!"); - console.log("\nπŸ“Š Seed Summary:"); - console.log(` πŸ‘₯ Users: ${users.length}`); - console.log(` πŸ“ Posts: ${posts.filter(p => p.status === 'published').length} published, ${posts.filter(p => p.status === 'draft').length} drafts`); - console.log(` πŸ”— Follows: ${follows.length}`); - - console.log("\nπŸ” Test Login Credentials:"); - console.log(" Username: admin | Password: admin123"); - console.log(" Username: user1 | Password: user111"); - console.log(" Username: user2 | Password: user222"); - console.log(" Username: user3 | Password: user333"); - console.log(" Username: user4 | Password: user444"); - + console.log(`πŸ“ Created ${posts.length} posts`); + console.log("🌱 Seeded the database"); } catch (error) { - console.error("❌ Error seeding database:", error); - if (error.name === 'SequelizeValidationError') { - error.errors.forEach(err => { - console.error(` - ${err.path}: ${err.message}`); - }); - } - if (error.name === 'SequelizeUniqueConstraintError') { - console.error(` - Unique constraint error: ${error.message}`); - } - if (error.name === 'SequelizeForeignKeyConstraintError') { - console.error(` - Foreign key constraint error: ${error.message}`); - } + console.error("Error seeding database:", error); } finally { - await db.close(); - process.exit(0); + db.close(); } }; From e82cdf68322d0f6f11c3719e74db64b9957f9e0c Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 7 Aug 2025 18:39:15 -0400 Subject: [PATCH 38/77] Create post --- api/posts.js | 8 +++++--- database/posts.js | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/api/posts.js b/api/posts.js index 4805c2d..be578d0 100644 --- a/api/posts.js +++ b/api/posts.js @@ -13,7 +13,7 @@ router.get("/feed", async (req, res) => { }, include: [{ model: User, - as: 'author', + as: 'author', attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] }], order: [['createdAt', 'DESC']], @@ -144,7 +144,7 @@ router.get("/:id", async (req, res) => { // Create a new post (requires authentication) router.post("/", authenticateJWT, async (req, res) => { try { - const { title, description, status, spotifyId, spotifyType, isPublic = true } = req.body; + const { title, description, status, spotifyId, spotifyType, spotifyEmbedUrl, isPublic = true } = req.body; if (!title || title.trim() === '') { return res.status(400).json({ error: "Title is required" }); @@ -171,6 +171,7 @@ router.post("/", authenticateJWT, async (req, res) => { postData.spotifyId = spotifyId; postData.spotifyType = spotifyType; + postData.spotifyEmbedUrl = spotifyEmbedUrl; } const newPost = await Posts.create(postData); @@ -194,7 +195,7 @@ router.post("/", authenticateJWT, async (req, res) => { // Create a draft post router.post("/draft", authenticateJWT, async (req, res) => { try { - const { title, description, spotifyId, spotifyType, isPublic = true } = req.body; + const { title, description, spotifyId, spotifyType, spotifyEmbedUrl, isPublic = true } = req.body; if (!title || title.trim() === '') { return res.status(400).json({ error: "Title is required" }); @@ -217,6 +218,7 @@ router.post("/draft", authenticateJWT, async (req, res) => { postData.spotifyId = spotifyId; postData.spotifyType = spotifyType; + postData.spotifyEmbedUrl = spotifyEmbedUrl; } const newDraft = await Posts.create(postData); diff --git a/database/posts.js b/database/posts.js index fa2bdc4..cdc6bf3 100644 --- a/database/posts.js +++ b/database/posts.js @@ -1,7 +1,7 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); -const Post = db.define( +const Posts = db.define( "posts", { title: { @@ -68,4 +68,4 @@ const Post = db.define( } ); -module.exports = Post; +module.exports = Posts; From 075333d6760f2e08694b8b1d2cb60f68903d6a01 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Fri, 8 Aug 2025 02:02:16 -0400 Subject: [PATCH 39/77] feat: add theme attribute to models/add new endpoints (GET/PUT /api/profile/me/theme, GET /api/profile/themes --- api/profile.js | 125 +++++++++++++++++++++++++++++++++++++++++++++-- database/user.js | 10 ++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/api/profile.js b/api/profile.js index 67c9b97..7b3c300 100644 --- a/api/profile.js +++ b/api/profile.js @@ -10,7 +10,7 @@ router.get("/me", authenticateJWT, async (req, res) => { attributes: [ 'id', 'username', 'email', 'firstName', 'lastName', 'bio', 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'createdAt' + 'avatarURL', 'profileTheme', 'createdAt' ] }); @@ -74,7 +74,7 @@ router.get("/me/posts", authenticateJWT, async (req, res) => { // Update current user's profile router.patch("/me", authenticateJWT, async (req, res) => { try { - const { firstName, lastName, bio, profileImage } = req.body; + const { firstName, lastName, bio, profileImage, profileTheme } = req.body; // Validate data const updateData = {}; @@ -108,8 +108,22 @@ router.patch("/me", authenticateJWT, async (req, res) => { updateData.profileImage = profileImage || null; } + if (profileTheme !== undefined) { + const validThemes = [ + 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', + 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', + 'shadow', 'crimson', 'neon', 'void', 'electric' + ]; + + if (profileTheme && !validThemes.includes(profileTheme)) { + return res.status(400).json({ error: "Invalid theme selection" }); + } + updateData.profileTheme = profileTheme || 'default'; + } + const [updatedRowsCount] = await User.update(updateData, { - where: { id: req.user.id } + where: { id: req.user.id }, + validate: false }); if (updatedRowsCount === 0) { @@ -121,7 +135,7 @@ router.patch("/me", authenticateJWT, async (req, res) => { attributes: [ 'id', 'username', 'email', 'firstName', 'lastName', 'bio', 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'createdAt' + 'avatarURL', 'profileTheme', 'createdAt' ] }); @@ -141,6 +155,107 @@ router.patch("/me", authenticateJWT, async (req, res) => { } }); +// Get current user's theme +router.get("/me/theme", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id, { + attributes: ['profileTheme'] + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ + theme: user.profileTheme || 'default', + message: "Theme retrieved successfully" + }); + } catch (error) { + console.error("Error fetching user theme:", error); + res.status(500).json({ error: "Failed to fetch theme" }); + } +}); + +// Update current user's theme +router.put("/me/theme", authenticateJWT, async (req, res) => { + try { + const { theme } = req.body; + + if (!theme) { + return res.status(400).json({ error: "Theme is required" }); + } + + const validThemes = [ + 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', + 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', + 'shadow', 'crimson', 'neon', 'void', 'electric' + ]; + + if (!validThemes.includes(theme)) { + return res.status(400).json({ + error: "Invalid theme selection", + validThemes: validThemes + }); + } + + const [updatedRowsCount] = await User.update( + { profileTheme: theme }, + { + where: { id: req.user.id }, + validate: false // Skip model validation since we're only updating theme + } + ); + + if (updatedRowsCount === 0) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ + theme: theme, + message: "Theme updated successfully", + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error("Error updating user theme:", error); + + if (error.name === 'SequelizeValidationError') { + const errorMessages = error.errors.map(err => err.message); + return res.status(400).json({ + error: "Validation failed", + details: errorMessages.join(', ') + }); + } + + res.status(500).json({ error: "Failed to update theme" }); + } +}); + +// get available themes +router.get("/themes", async (req, res) => { + try { + const themes = [ + 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', + 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', + 'shadow', 'crimson', 'neon', 'void', 'electric' + ]; + + const themeCategories = { + original: ['default', 'ocean', 'sunset', 'purple', 'forest', 'rose'], + pastel: ['sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky'], + dark: ['shadow', 'crimson', 'neon', 'void', 'electric'] + }; + + res.json({ + themes: themes, + categories: themeCategories, + total: themes.length + }); + } catch (error) { + console.error("Error fetching themes:", error); + res.status(500).json({ error: "Failed to fetch themes" }); + } +}); + // Get public profile by username router.get("/:username", async (req, res) => { try { @@ -149,7 +264,7 @@ router.get("/:username", async (req, res) => { attributes: [ 'id', 'username', 'firstName', 'lastName', 'bio', 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'createdAt' + 'avatarURL', 'profileTheme', 'createdAt' ] }); diff --git a/database/user.js b/database/user.js index 4ee5ab4..e777585 100644 --- a/database/user.js +++ b/database/user.js @@ -97,6 +97,16 @@ const User = db.define("user", { allowNull: true, field: 'spotify_profile_image' }, + profileTheme: { + type: DataTypes.ENUM( + 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', + 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', + 'shadow', 'crimson', 'neon', 'void', 'electric' + ), + allowNull: false, + defaultValue: 'default', + field: 'profile_theme' + }, }, { tableName: 'users', underscored: true, From e5c83761f3ef84130cc5de7f299efcacfc6991cc Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Fri, 8 Aug 2025 09:35:29 -0400 Subject: [PATCH 40/77] fix: removed put route --- api/profile.js | 54 -------------------------------------------------- 1 file changed, 54 deletions(-) diff --git a/api/profile.js b/api/profile.js index 7b3c300..77f0f51 100644 --- a/api/profile.js +++ b/api/profile.js @@ -176,60 +176,6 @@ router.get("/me/theme", authenticateJWT, async (req, res) => { } }); -// Update current user's theme -router.put("/me/theme", authenticateJWT, async (req, res) => { - try { - const { theme } = req.body; - - if (!theme) { - return res.status(400).json({ error: "Theme is required" }); - } - - const validThemes = [ - 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', - 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', - 'shadow', 'crimson', 'neon', 'void', 'electric' - ]; - - if (!validThemes.includes(theme)) { - return res.status(400).json({ - error: "Invalid theme selection", - validThemes: validThemes - }); - } - - const [updatedRowsCount] = await User.update( - { profileTheme: theme }, - { - where: { id: req.user.id }, - validate: false // Skip model validation since we're only updating theme - } - ); - - if (updatedRowsCount === 0) { - return res.status(404).json({ error: "User not found" }); - } - - res.json({ - theme: theme, - message: "Theme updated successfully", - timestamp: new Date().toISOString() - }); - } catch (error) { - console.error("Error updating user theme:", error); - - if (error.name === 'SequelizeValidationError') { - const errorMessages = error.errors.map(err => err.message); - return res.status(400).json({ - error: "Validation failed", - details: errorMessages.join(', ') - }); - } - - res.status(500).json({ error: "Failed to update theme" }); - } -}); - // get available themes router.get("/themes", async (req, res) => { try { From 0a425062b4e5b2af9696b3e9b6146684962bb15c Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Fri, 8 Aug 2025 10:00:28 -0400 Subject: [PATCH 41/77] updated model to accept any theme string instead of a fixed set --- api/profile.js | 11 +++-------- database/user.js | 11 +++++------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/api/profile.js b/api/profile.js index 77f0f51..261cb00 100644 --- a/api/profile.js +++ b/api/profile.js @@ -109,14 +109,9 @@ router.patch("/me", authenticateJWT, async (req, res) => { } if (profileTheme !== undefined) { - const validThemes = [ - 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', - 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', - 'shadow', 'crimson', 'neon', 'void', 'electric' - ]; - - if (profileTheme && !validThemes.includes(profileTheme)) { - return res.status(400).json({ error: "Invalid theme selection" }); + // allow any reasonable string + if (profileTheme && (typeof profileTheme !== 'string' || profileTheme.length > 50)) { + return res.status(400).json({ error: "Theme must be a string of 50 characters or less" }); } updateData.profileTheme = profileTheme || 'default'; } diff --git a/database/user.js b/database/user.js index e777585..4882ce7 100644 --- a/database/user.js +++ b/database/user.js @@ -98,14 +98,13 @@ const User = db.define("user", { field: 'spotify_profile_image' }, profileTheme: { - type: DataTypes.ENUM( - 'default', 'ocean', 'sunset', 'purple', 'forest', 'rose', - 'sakura', 'lavender', 'peach', 'mint', 'cotton', 'sky', - 'shadow', 'crimson', 'neon', 'void', 'electric' - ), + type: DataTypes.STRING, allowNull: false, defaultValue: 'default', - field: 'profile_theme' + field: 'profile_theme', + validate: { + len: [1, 50] // Allow themes up to 50 characters + } }, }, { tableName: 'users', From 7ee3f1c56fc50a8998d51574765d14f9431171df Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 8 Aug 2025 10:40:07 -0400 Subject: [PATCH 42/77] some refactored route changes --- api/posts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/posts.js b/api/posts.js index 0ff11aa..0c0f871 100644 --- a/api/posts.js +++ b/api/posts.js @@ -69,7 +69,7 @@ router.get("/test", (req, res) => { }); // Get posts if status === draft -router.get("/drafts", authenticateJWT, async (req, res) => { +router.get("/draft", authenticateJWT, async (req, res) => { try { const drafts = await Posts.findAll({ where: { @@ -100,7 +100,7 @@ router.get("/drafts", authenticateJWT, async (req, res) => { }); // Get posts by the logged-in user -router.get("/mine", authenticateJWT, async (req, res) => { +router.get("/my", authenticateJWT, async (req, res) => { try { const userId = req.user.id; const posts = await Posts.findAll({ From 39dfb237c72344216effdc21739532be4bca0274 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 8 Aug 2025 11:09:26 -0400 Subject: [PATCH 43/77] refactored routes and added GET draft/:id --- api/posts.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/api/posts.js b/api/posts.js index 6f4e635..7c63613 100644 --- a/api/posts.js +++ b/api/posts.js @@ -207,6 +207,43 @@ router.get("/:id", async (req, res) => { } }); +//Get a single draft post by ID +router.get("/draft/:id", authenticateJWT, async (req, res) => { + try { + const postId = req.params.id; + const draft = await Posts.findOne({ + where: { + id: postId, + userId: req.user.id, + status: "draft", + }, + include: [ + { + model: User, + as: "author", + attributes: [ + "username", + "id", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + }); + + if (!draft) { + return res.status(404).json({ error: "Draft not found" }); + } + + res.json(draft); + } catch (error) { + console.error("Error fetching draft:", error); + res.status(500).json({ error: "Failed to fetch draft" }); + } +}); + // Create a new post (requires authentication) router.post("/", authenticateJWT, async (req, res) => { try { From e781328a29386cb21b542441379cffcd6ad2bcdd Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Fri, 8 Aug 2025 17:10:27 -0400 Subject: [PATCH 44/77] feat: added sticker and user profile sticker table --- api/sticker.js | 0 database/index.js | 51 +++++++++++++++++++ database/sticker.js | 49 ++++++++++++++++++ database/userProfileSticker.js | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 api/sticker.js create mode 100644 database/sticker.js create mode 100644 database/userProfileSticker.js diff --git a/api/sticker.js b/api/sticker.js new file mode 100644 index 0000000..e69de29 diff --git a/database/index.js b/database/index.js index 7e0c63a..7c43394 100644 --- a/database/index.js +++ b/database/index.js @@ -2,6 +2,8 @@ const db = require("./db"); const User = require("./user"); const Posts = require("./posts"); const Follows = require("./follows"); +const Sticker = require("./sticker"); +const UserProfileSticker = require("./userProfileSticker"); User.hasMany(Posts, { foreignKey: "user_id", @@ -37,9 +39,58 @@ Follows.belongsTo(User, { as: 'following' }); +// Sticker associations +User.hasMany(Sticker, { + foreignKey: "uploader_id", + as: "uploadedStickers", +}); + +Sticker.belongsTo(User, { + foreignKey: "uploader_id", + as: "uploader", +}); + +// UserProfileSticker associations +User.hasMany(UserProfileSticker, { + foreignKey: "user_id", + as: "profileStickers", +}); + +Sticker.hasMany(UserProfileSticker, { + foreignKey: "sticker_id", + as: "usages", +}); + +UserProfileSticker.belongsTo(User, { + foreignKey: "user_id", + as: "user", +}); + +UserProfileSticker.belongsTo(Sticker, { + foreignKey: "sticker_id", + as: "sticker", +}); + +// Many-to-many relationship through UserProfileSticker +User.belongsToMany(Sticker, { + through: UserProfileSticker, + as: "stickers", + foreignKey: "user_id", + otherKey: "sticker_id", +}); + +Sticker.belongsToMany(User, { + through: UserProfileSticker, + as: "users", + foreignKey: "sticker_id", + otherKey: "user_id", +}); + module.exports = { db, User, Posts, Follows, + Sticker, + UserProfileSticker, }; \ No newline at end of file diff --git a/database/sticker.js b/database/sticker.js new file mode 100644 index 0000000..1426371 --- /dev/null +++ b/database/sticker.js @@ -0,0 +1,49 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Sticker = db.define("sticker", { + imageUrl: { + type: DataTypes.STRING, + allowNull: false, + field: 'image_url', + validate: { + notEmpty: true, + len: [1, 500] + } + }, + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: true, + len: [1, 100] + } + }, + type: { + type: DataTypes.ENUM('preset', 'custom'), + allowNull: false, + defaultValue: 'preset' + }, + uploaderId: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'uploader_id', + references: { + model: 'users', + key: 'id' + } + } +}, { + tableName: 'stickers', + underscored: true, + indexes: [ + { + fields: ['type'] + }, + { + fields: ['uploader_id'] + } + ] +}); + +module.exports = Sticker; diff --git a/database/userProfileSticker.js b/database/userProfileSticker.js new file mode 100644 index 0000000..18e5b1c --- /dev/null +++ b/database/userProfileSticker.js @@ -0,0 +1,91 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const UserProfileSticker = db.define("user_profile_sticker", { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id', + references: { + model: 'users', + key: 'id' + } + }, + stickerId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'sticker_id', + references: { + model: 'stickers', + key: 'id' + } + }, + positionX: { + type: DataTypes.FLOAT, + allowNull: false, + field: 'position_x', + defaultValue: 0, + validate: { + min: 0, + max: 100 + } + }, + positionY: { + type: DataTypes.FLOAT, + allowNull: false, + field: 'position_y', + defaultValue: 0, + validate: { + min: 0, + max: 100 // (0-100%) + } + }, + scale: { + type: DataTypes.FLOAT, + allowNull: false, + defaultValue: 1.0, + validate: { + min: 0.1, + max: 5.0 // 10%- 500% + } + }, + rotation: { + type: DataTypes.FLOAT, + allowNull: false, + defaultValue: 0, + validate: { + min: 0, + max: 360 //rotate degrees + } + }, + zIndex: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'z_index', + defaultValue: 1, + validate: { + min: 1, + max: 999 // Layering order + } + } +}, { + tableName: 'user_profile_stickers', + underscored: true, + indexes: [ + { + fields: ['user_id'] + }, + { + fields: ['sticker_id'] + }, + { + fields: ['user_id', 'sticker_id'], + unique: false // allow multiple of same sticker on profile + }, + { + fields: ['user_id', 'z_index'] + } + ] +}); + +module.exports = UserProfileSticker; From 9fbac2b8262e94cbb849e859184cf4312d16cf4a Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 8 Aug 2025 17:17:29 -0400 Subject: [PATCH 45/77] edited seed to included sample follow realtions as well worked testing routes to ensure they work before working on friends page. --- api/index.js | 2 +- api/profile.js | 244 +++++++++++++++++++++++++++----------------- database/follows.js | 58 +++++------ database/index.js | 14 +-- database/seed.js | 172 +++++++++++++++++++++---------- 5 files changed, 300 insertions(+), 190 deletions(-) diff --git a/api/index.js b/api/index.js index 281cb11..98aa88b 100644 --- a/api/index.js +++ b/api/index.js @@ -10,4 +10,4 @@ router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); router.use("/profile", profileRouter); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/api/profile.js b/api/profile.js index 67c9b97..7cb8bbb 100644 --- a/api/profile.js +++ b/api/profile.js @@ -8,10 +8,18 @@ router.get("/me", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id, { attributes: [ - 'id', 'username', 'email', 'firstName', 'lastName', 'bio', - 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'createdAt' - ] + "id", + "username", + "email", + "firstName", + "lastName", + "bio", + "profileImage", + "spotifyDisplayName", + "spotifyProfileImage", + "avatarURL", + "createdAt", + ], }); if (!user) { @@ -20,17 +28,17 @@ router.get("/me", authenticateJWT, async (req, res) => { // Get post count const postCount = await Posts.count({ - where: { userId: user.id, status: 'published' } + where: { userId: user.id, status: "published" }, }); // Get followers count const followersCount = await Follows.count({ - where: { followingId: user.id } + where: { followingId: user.id }, }); // Get following count const followingCount = await Follows.count({ - where: { followerId: user.id } + where: { followerId: user.id }, }); res.json({ @@ -38,8 +46,8 @@ router.get("/me", authenticateJWT, async (req, res) => { stats: { posts: postCount, followers: followersCount, - following: followingCount - } + following: followingCount, + }, }); } catch (error) { console.error("Error fetching profile:", error); @@ -51,17 +59,26 @@ router.get("/me", authenticateJWT, async (req, res) => { router.get("/me/posts", authenticateJWT, async (req, res) => { try { const posts = await Posts.findAll({ - where: { + where: { userId: req.user.id, - status: 'published' + status: "published", }, - include: [{ - model: User, - as: 'author', - attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] - }], - order: [['createdAt', 'DESC']], - limit: 20 + include: [ + { + model: User, + as: "author", + attributes: [ + "id", + "username", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 20, }); res.json(posts); @@ -75,41 +92,49 @@ router.get("/me/posts", authenticateJWT, async (req, res) => { router.patch("/me", authenticateJWT, async (req, res) => { try { const { firstName, lastName, bio, profileImage } = req.body; - + // Validate data const updateData = {}; - + if (firstName !== undefined) { if (firstName && firstName.length > 50) { - return res.status(400).json({ error: "First name must be 50 characters or less" }); + return res + .status(400) + .json({ error: "First name must be 50 characters or less" }); } updateData.firstName = firstName || null; } - + if (lastName !== undefined) { if (lastName && lastName.length > 50) { - return res.status(400).json({ error: "Last name must be 50 characters or less" }); + return res + .status(400) + .json({ error: "Last name must be 50 characters or less" }); } updateData.lastName = lastName || null; } - + if (bio !== undefined) { if (bio && bio.length > 500) { - return res.status(400).json({ error: "Bio must be 500 characters or less" }); + return res + .status(400) + .json({ error: "Bio must be 500 characters or less" }); } updateData.bio = bio || null; } - + if (profileImage !== undefined) { // Basic URL validation if (profileImage && !profileImage.match(/^https?:\/\/.+/)) { - return res.status(400).json({ error: "Profile image must be a valid URL" }); + return res + .status(400) + .json({ error: "Profile image must be a valid URL" }); } updateData.profileImage = profileImage || null; } const [updatedRowsCount] = await User.update(updateData, { - where: { id: req.user.id } + where: { id: req.user.id }, }); if (updatedRowsCount === 0) { @@ -119,24 +144,32 @@ router.patch("/me", authenticateJWT, async (req, res) => { // Fetch updated user const updatedUser = await User.findByPk(req.user.id, { attributes: [ - 'id', 'username', 'email', 'firstName', 'lastName', 'bio', - 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'createdAt' - ] + "id", + "username", + "email", + "firstName", + "lastName", + "bio", + "profileImage", + "spotifyDisplayName", + "spotifyProfileImage", + "avatarURL", + "createdAt", + ], }); res.json(updatedUser); } catch (error) { console.error("Error updating profile:", error); - - if (error.name === 'SequelizeValidationError') { - const errorMessages = error.errors.map(err => err.message); - return res.status(400).json({ - error: "Validation failed", - details: errorMessages.join(', ') + + if (error.name === "SequelizeValidationError") { + const errorMessages = error.errors.map((err) => err.message); + return res.status(400).json({ + error: "Validation failed", + details: errorMessages.join(", "), }); } - + res.status(500).json({ error: "Failed to update profile" }); } }); @@ -147,10 +180,17 @@ router.get("/:username", async (req, res) => { const user = await User.findOne({ where: { username: req.params.username }, attributes: [ - 'id', 'username', 'firstName', 'lastName', 'bio', - 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'createdAt' - ] + "id", + "username", + "firstName", + "lastName", + "bio", + "profileImage", + "spotifyDisplayName", + "spotifyProfileImage", + "avatarURL", + "createdAt", + ], }); if (!user) { @@ -159,17 +199,17 @@ router.get("/:username", async (req, res) => { // Get post count const postCount = await Posts.count({ - where: { userId: user.id, status: 'published', isPublic: true } + where: { userId: user.id, status: "published", isPublic: true }, }); // Get followers count const followersCount = await Follows.count({ - where: { followingId: user.id } + where: { followingId: user.id }, }); // Get following count const followingCount = await Follows.count({ - where: { followerId: user.id } + where: { followerId: user.id }, }); res.json({ @@ -177,8 +217,8 @@ router.get("/:username", async (req, res) => { stats: { posts: postCount, followers: followersCount, - following: followingCount - } + following: followingCount, + }, }); } catch (error) { console.error("Error fetching public profile:", error); @@ -190,7 +230,7 @@ router.get("/:username", async (req, res) => { router.get("/:username/posts", async (req, res) => { try { const user = await User.findOne({ - where: { username: req.params.username } + where: { username: req.params.username }, }); if (!user) { @@ -198,18 +238,27 @@ router.get("/:username/posts", async (req, res) => { } const posts = await Posts.findAll({ - where: { + where: { userId: user.id, - status: 'published', - isPublic: true + status: "published", + isPublic: true, }, - include: [{ - model: User, - as: 'author', - attributes: ['id', 'username', 'spotifyDisplayName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] - }], - order: [['createdAt', 'DESC']], - limit: 20 + include: [ + { + model: User, + as: "author", + attributes: [ + "id", + "username", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 20, }); res.json(posts); @@ -223,7 +272,7 @@ router.get("/:username/posts", async (req, res) => { router.post("/:username/follow", authenticateJWT, async (req, res) => { try { const userToFollow = await User.findOne({ - where: { username: req.params.username } + where: { username: req.params.username }, }); if (!userToFollow) { @@ -238,8 +287,8 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { const existingFollow = await Follows.findOne({ where: { followerId: req.user.id, - followingId: userToFollow.id - } + followingId: userToFollow.id, + }, }); if (existingFollow) { @@ -250,7 +299,7 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { // Follow await Follows.create({ followerId: req.user.id, - followingId: userToFollow.id + followingId: userToFollow.id, }); res.json({ message: "Followed successfully", following: true }); } @@ -264,7 +313,7 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { router.get("/:username/following-status", authenticateJWT, async (req, res) => { try { const userToCheck = await User.findOne({ - where: { username: req.params.username } + where: { username: req.params.username }, }); if (!userToCheck) { @@ -274,8 +323,8 @@ router.get("/:username/following-status", authenticateJWT, async (req, res) => { const isFollowing = await Follows.findOne({ where: { followerId: req.user.id, - followingId: userToCheck.id - } + followingId: userToCheck.id, + }, }); res.json({ following: !!isFollowing }); @@ -289,28 +338,36 @@ router.get("/:username/following-status", authenticateJWT, async (req, res) => { router.get("/:username/followers", async (req, res) => { try { const user = await User.findOne({ - where: { username: req.params.username } + where: { username: req.params.username }, }); if (!user) { - return res.status(404).json({ error: "User not found" }); + return res.status(404).json([]); } const followers = await Follows.findAll({ where: { followingId: user.id }, - include: [{ - model: User, - as: 'follower', - attributes: ['id', 'username', 'firstName', 'lastName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] - }], - order: [['createdAt', 'DESC']], - limit: 50 + include: [ + { + model: User, + as: "Follower", // βœ… explicitly use alias + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], }); - res.json(followers.map(follow => follow.follower)); + res.json(followers.map((f) => f.Follower)); } catch (error) { console.error("Error fetching followers:", error); - res.status(500).json({ error: "Failed to fetch followers" }); + res.status(500).json([]); } }); @@ -318,29 +375,30 @@ router.get("/:username/followers", async (req, res) => { router.get("/:username/following", async (req, res) => { try { const user = await User.findOne({ - where: { username: req.params.username } + where: { username: req.params.username }, }); + if (!user) return res.status(404).json({ error: "User not found" }); - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - const following = await Follows.findAll({ - where: { followerId: user.id }, - include: [{ - model: User, - as: 'following', - attributes: ['id', 'username', 'firstName', 'lastName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] - }], - order: [['createdAt', 'DESC']], - limit: 50 + const following = await user.getFollowing({ + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + joinTableAttributes: [], // hide join row + order: [["username", "ASC"]], + limit: 50, }); - res.json(following.map(follow => follow.following)); + res.json(following); } catch (error) { console.error("Error fetching following:", error); res.status(500).json({ error: "Failed to fetch following" }); } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/database/follows.js b/database/follows.js index 60d3343..d589f75 100644 --- a/database/follows.js +++ b/database/follows.js @@ -1,39 +1,31 @@ +// database/follows.js const { DataTypes } = require("sequelize"); const db = require("./db"); -const Follows = db.define("follows", { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, +const Follows = db.define( + "follows", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + followerId: { + type: DataTypes.INTEGER, + allowNull: false, + field: "follower_id", + references: { model: "users", key: "id" }, + onDelete: "CASCADE", + }, + followingId: { + type: DataTypes.INTEGER, + allowNull: false, + field: "following_id", + references: { model: "users", key: "id" }, + onDelete: "CASCADE", + }, }, - followerId: { - type: DataTypes.INTEGER, - allowNull: false, - field: 'follower_id', - references: { - model: 'users', - key: 'id' - } - }, - followingId: { - type: DataTypes.INTEGER, - allowNull: false, - field: 'following_id', - references: { - model: 'users', - key: 'id' - } + { + tableName: "follows", + underscored: true, + indexes: [{ unique: true, fields: ["follower_id", "following_id"] }], } -}, { - tableName: 'follows', - underscored: true, - indexes: [ - { - unique: true, - fields: ['follower_id', 'following_id'] // Use snake_case here - } - ] -}); +); -module.exports = Follows; \ No newline at end of file +module.exports = Follows; diff --git a/database/index.js b/database/index.js index 7e0c63a..6f1d1a9 100644 --- a/database/index.js +++ b/database/index.js @@ -15,26 +15,26 @@ Posts.belongsTo(User, { User.belongsToMany(User, { through: Follows, - as: "following", + as: "Following", foreignKey: "follower_id", otherKey: "following_id", }); User.belongsToMany(User, { through: Follows, - as: "followers", + as: "Followers", foreignKey: "following_id", otherKey: "follower_id", }); Follows.belongsTo(User, { - foreignKey: 'follower_id', - as: 'follower' + foreignKey: "follower_id", + as: "Follower", }); Follows.belongsTo(User, { - foreignKey: 'following_id', - as: 'following' + foreignKey: "following_id", + as: "Following", }); module.exports = { @@ -42,4 +42,4 @@ module.exports = { User, Posts, Follows, -}; \ No newline at end of file +}; diff --git a/database/seed.js b/database/seed.js index 0ff560a..a2e37a3 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,58 +1,99 @@ const db = require("./db"); -const { User, Posts } = require("./index"); +const { User, Posts, Follows } = require("./index"); const seed = async () => { try { await db.sync({ force: true }); - + const users = await User.bulkCreate([ - { - username: "admin", + { + username: "admin", passwordHash: User.hashPassword("admin123"), email: "admin@example.com", firstName: "Admin", lastName: "User", bio: "System administrator and music curator. Testing all the features!", - avatarURL: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face" + avatarURL: + "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face", }, - { - username: "user1", + { + username: "user1", passwordHash: User.hashPassword("user111"), email: "user1@example.com", firstName: "Alex", lastName: "Johnson", bio: "Hip-hop enthusiast and night owl. Always looking for the next great beat.", - avatarURL: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face" + avatarURL: + "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face", }, - { - username: "user2", + { + username: "user2", passwordHash: User.hashPassword("user222"), email: "user2@example.com", firstName: "Sarah", lastName: "Chen", bio: "Throwback music lover and study playlist curator. 2000s hits are my specialty!", - avatarURL: "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face" + avatarURL: + "https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face", }, - { - username: "user3", + { + username: "user3", passwordHash: User.hashPassword("user333"), email: "user3@example.com", firstName: "Mike", lastName: "Rodriguez", bio: "Gaming music expert and energy boost specialist. Let's get hyped!", - avatarURL: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face" + avatarURL: + "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face", }, - { - username: "user4", + { + username: "user4", passwordHash: User.hashPassword("user444"), email: "user4@example.com", firstName: "Taylor", lastName: "Kim", bio: "Chill vibes and feel-good music curator. Here to boost your mood with great tunes.", - avatarURL: "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150&h=150&fit=crop&crop=face" + avatarURL: + "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150&h=150&fit=crop&crop=face", }, ]); + // Make all users follow each other + const followData = [ + // Admin follows user1, user2, user3, user4 + { followerId: users[0].id, followingId: users[1].id }, + { followerId: users[0].id, followingId: users[2].id }, + { followerId: users[0].id, followingId: users[3].id }, + { followerId: users[0].id, followingId: users[4].id }, + + // user1 follows admin, user2, user3, user4 + { followerId: users[1].id, followingId: users[0].id }, + { followerId: users[1].id, followingId: users[2].id }, + { followerId: users[1].id, followingId: users[3].id }, + { followerId: users[1].id, followingId: users[4].id }, + + // user2 follows admin, user1, user3, user4 + { followerId: users[2].id, followingId: users[0].id }, + { followerId: users[2].id, followingId: users[1].id }, + { followerId: users[2].id, followingId: users[3].id }, + { followerId: users[2].id, followingId: users[4].id }, + + // user3 follows admin, user1, user2, user4 + { followerId: users[3].id, followingId: users[0].id }, + { followerId: users[3].id, followingId: users[1].id }, + { followerId: users[3].id, followingId: users[2].id }, + { followerId: users[3].id, followingId: users[4].id }, + + // user4 follows admin, user1, user2, user3 + { followerId: users[4].id, followingId: users[0].id }, + { followerId: users[4].id, followingId: users[1].id }, + { followerId: users[4].id, followingId: users[2].id }, + { followerId: users[4].id, followingId: users[3].id }, + ]; + + await Follows.bulkCreate(followData); + console.log(`βœ… Created ${followData.length} follow relationships`); + console.log(`πŸ‘€ Created ${users.length} users`); const posts = await Posts.bulkCreate([ @@ -64,9 +105,10 @@ const seed = async () => { userId: 1, spotifyId: "37i9dQZF1DXcBWIGoYBM5M", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", isPublic: false, - likesCount: 0 + likesCount: 0, }, { title: "Lo-Fi Work Flow", @@ -75,9 +117,10 @@ const seed = async () => { userId: 1, spotifyId: "37i9dQZF1DXdPec7aLTmlC", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", isPublic: true, - likesCount: 23 + likesCount: 23, }, { title: "Rock & Roll!", @@ -86,9 +129,10 @@ const seed = async () => { userId: 1, spotifyId: "37i9dQZF1DWXRqgorJj26U", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DWXRqgorJj26U", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DWXRqgorJj26U", isPublic: false, - likesCount: 0 + likesCount: 0, }, { title: "Motivation Boost", @@ -97,9 +141,10 @@ const seed = async () => { userId: 1, spotifyId: "7qiZfU4dY1lWllzX7mPBI3", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/7qiZfU4dY1lWllzX7mPBI3", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/7qiZfU4dY1lWllzX7mPBI3", isPublic: true, - likesCount: 45 + likesCount: 45, }, { title: "Calm Mornings", @@ -108,9 +153,10 @@ const seed = async () => { userId: 1, spotifyId: "3KkXRkHbMCARz0aVfEt68P", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/3KkXRkHbMCARz0aVfEt68P", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/3KkXRkHbMCARz0aVfEt68P", isPublic: true, - likesCount: 18 + likesCount: 18, }, // User 2 @@ -121,9 +167,10 @@ const seed = async () => { userId: 2, spotifyId: "6habFhsOp2NvshLv26DqMb", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/6habFhsOp2NvshLv26DqMb", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/6habFhsOp2NvshLv26DqMb", isPublic: true, - likesCount: 67 + likesCount: 67, }, { title: "Hip-Hop Energy", @@ -132,9 +179,10 @@ const seed = async () => { userId: 2, spotifyId: "37i9dQZF1DX0XUsuxWHRQd", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DX0XUsuxWHRQd", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX0XUsuxWHRQd", isPublic: true, - likesCount: 89 + likesCount: 89, }, { title: "The Weekend Wave", @@ -143,9 +191,10 @@ const seed = async () => { userId: 2, spotifyId: "3U4isOIWM3VvDubwSI3y7a", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/3U4isOIWM3VvDubwSI3y7a", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/3U4isOIWM3VvDubwSI3y7a", isPublic: true, - likesCount: 34 + likesCount: 34, }, { title: "Pop Culture Hits", @@ -154,9 +203,10 @@ const seed = async () => { userId: 2, spotifyId: "37i9dQZF1DXcF6B6QPhFDv", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXcF6B6QPhFDv", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcF6B6QPhFDv", isPublic: true, - likesCount: 112 + likesCount: 112, }, { title: "Mellow Mood", @@ -165,9 +215,10 @@ const seed = async () => { userId: 2, spotifyId: "2Fxmhks0bxGSBdJ92vM42m", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/2Fxmhks0bxGSBdJ92vM42m", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/2Fxmhks0bxGSBdJ92vM42m", isPublic: true, - likesCount: 28 + likesCount: 28, }, // User 3 @@ -178,9 +229,10 @@ const seed = async () => { userId: 3, spotifyId: "37i9dQZF1DWYmmr74INQlb", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DWYmmr74INQlb", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DWYmmr74INQlb", isPublic: true, - likesCount: 156 + likesCount: 156, }, { title: "Study Mode On", @@ -189,9 +241,10 @@ const seed = async () => { userId: 3, spotifyId: "37i9dQZF1DX8Uebhn9wzrS", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DX8Uebhn9wzrS", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX8Uebhn9wzrS", isPublic: true, - likesCount: 73 + likesCount: 73, }, { title: "Classic Chill", @@ -200,9 +253,10 @@ const seed = async () => { userId: 3, spotifyId: "6QgjcU0zLnzq5OrUoSZ3OK", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/6QgjcU0zLnzq5OrUoSZ3OK", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/6QgjcU0zLnzq5OrUoSZ3OK", isPublic: true, - likesCount: 41 + likesCount: 41, }, { title: "Top Gaming Tracks", @@ -211,9 +265,10 @@ const seed = async () => { userId: 3, spotifyId: "37i9dQZF1DX2sUQwD7tbmL", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DX2sUQwD7tbmL", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX2sUQwD7tbmL", isPublic: true, - likesCount: 91 + likesCount: 91, }, { title: "Energy Boost", @@ -222,9 +277,10 @@ const seed = async () => { userId: 3, spotifyId: "4uLU6hMCjMI75M1A2tKUQC", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC", isPublic: true, - likesCount: 64 + likesCount: 64, }, // User 4 @@ -235,9 +291,10 @@ const seed = async () => { userId: 4, spotifyId: "37i9dQZF1DWVzZlRWgqAGH", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DWVzZlRWgqAGH", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DWVzZlRWgqAGH", isPublic: true, - likesCount: 37 + likesCount: 37, }, { title: "Favorite Banger", @@ -246,9 +303,10 @@ const seed = async () => { userId: 4, spotifyId: "0eGsygTp906u18L0Oimnem", spotifyType: "track", - spotifyEmbedUrl: "https://open.spotify.com/embed/track/0eGsygTp906u18L0Oimnem", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/0eGsygTp906u18L0Oimnem", isPublic: true, - likesCount: 82 + likesCount: 82, }, { title: "Feel Good Mix", @@ -257,9 +315,10 @@ const seed = async () => { userId: 4, spotifyId: "37i9dQZF1DXdPec7aLTmlC", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXdPec7aLTmlC", isPublic: true, - likesCount: 105 + likesCount: 105, }, { title: "Bop Playlist πŸ’Ώ", @@ -268,9 +327,10 @@ const seed = async () => { userId: 4, spotifyId: "37i9dQZF1DXcBWIGoYBM5M", spotifyType: "playlist", - spotifyEmbedUrl: "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", isPublic: true, - likesCount: 127 + likesCount: 127, }, ]); @@ -283,4 +343,4 @@ const seed = async () => { } }; -seed(); \ No newline at end of file +seed(); From 82012c985ca0462fc0b2bceefc94c2b32c2af8a3 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sat, 9 Aug 2025 20:50:15 -0400 Subject: [PATCH 46/77] feat: Cloudinary condig + get preset sticker routes + update Sticker model for new changes --- api/index.js | 2 + api/sticker.js | 41 ++++++++++ config/cloudinary.js | 104 ++++++++++++++++++++++++ database/index.js | 4 +- database/sticker.js | 53 ++++++++++-- package-lock.json | 189 +++++++++++++++++++++++++++++++++++++++++++ package.json | 2 + 7 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 config/cloudinary.js diff --git a/api/index.js b/api/index.js index 281cb11..d5e6a4d 100644 --- a/api/index.js +++ b/api/index.js @@ -4,10 +4,12 @@ const testDbRouter = require("./test-db"); const searchRouter = require("./search"); const postsRouter = require("./posts"); const profileRouter = require("./profile"); +const stickerRouter = require("./sticker"); router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); router.use("/profile", profileRouter); +router.use("/stickers", stickerRouter); module.exports = router; \ No newline at end of file diff --git a/api/sticker.js b/api/sticker.js index e69de29..c6404ed 100644 --- a/api/sticker.js +++ b/api/sticker.js @@ -0,0 +1,41 @@ +const express = require('express'); +const { cloudinary } = require('../config/cloudinary'); + +const router = express.Router(); + +// GET /api/stickers/presets - get preset stickers from "TTP-Capstone 2/Preset" folder +router.get('/presets', async (req, res) => { + try { + // search "Preset" folder + const cloudinaryResult = await cloudinary.search + .expression('folder:"TTP-Capstone 2/Preset"/*') + .max_results(100) + .execute(); + + // transform the sticker object resources to more readable sticker format + const presetStickers = cloudinaryResult.resources.map(resource => { + return { + id: resource.public_id, + name: resource.display_name || resource.public_id.split('/').pop(), + url: resource.secure_url, + type: 'preset', + width: resource.width, + height: resource.height, + format: resource.format, + sizeBytes: resource.bytes, + cloudinaryPublicId: resource.public_id, + createdAt: resource.created_at + }; + }); + + res.json(presetStickers); + } catch (error) { + console.error('Error fetching preset stickers from Cloudinary:', error); + res.status(500).json({ + error: 'Failed to fetch preset stickers', + details: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/config/cloudinary.js b/config/cloudinary.js new file mode 100644 index 0000000..edcc1df --- /dev/null +++ b/config/cloudinary.js @@ -0,0 +1,104 @@ +const { v2: cloudinary } = require('cloudinary'); + +// Configuration +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME || 'di9wb90kg', + api_key: process.env.CLOUDINARY_API_KEY || '466125859777561', + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +// upload sticker to Cloudinary +const uploadSticker = async (file, options = {}) => { + try { + // Default options for sticker uploads + const uploadOptions = { + folder: 'TTP-Capstone 2/Custom', + resource_type: 'image', + allowed_formats: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], + max_file_size: 5000000, // 5MB + transformation: [ + { quality: 'auto', fetch_format: 'auto' }, + { width: 500, height: 500, crop: 'limit' } // 500x500 max + ], + ...options + }; + + const result = await cloudinary.uploader.upload(file, uploadOptions); + + return { + success: true, + url: result.secure_url, + publicId: result.public_id, + width: result.width, + height: result.height, + format: result.format, + bytes: result.bytes + }; + } catch (error) { + console.error('Cloudinary upload error:', error); + return { + success: false, + error: error.message + }; + } +}; + +// Function to delete sticker from Cloudinary +const deleteSticker = async (publicId) => { + try { + const result = await cloudinary.uploader.destroy(publicId); + return { + success: result.result === 'ok', + result: result.result + }; + } catch (error) { + console.error('Cloudinary delete error:', error); + return { + success: false, + error: error.message + }; + } +}; + +// get sticker details +const getStickerDetails = async (publicId) => { + try { + const result = await cloudinary.api.resource(publicId); + return { + success: true, + url: result.secure_url, + width: result.width, + height: result.height, + format: result.format, + bytes: result.bytes, + createdAt: result.created_at + }; + } catch (error) { + console.error('Cloudinary get details error:', error); + return { + success: false, + error: error.message + }; + } +}; + +// generate optimized URLs for stickers +const getOptimizedStickerUrl = (publicId, options = {}) => { + const defaultOptions = { + fetch_format: 'auto', + quality: 'auto', + width: 200, + height: 200, + crop: 'fit' + }; + + return cloudinary.url(publicId, { ...defaultOptions, ...options }); +}; + +module.exports = { + cloudinary, + uploadSticker, + deleteSticker, + getStickerDetails, + getOptimizedStickerUrl +}; \ No newline at end of file diff --git a/database/index.js b/database/index.js index 7c43394..73e6ba8 100644 --- a/database/index.js +++ b/database/index.js @@ -41,12 +41,12 @@ Follows.belongsTo(User, { // Sticker associations User.hasMany(Sticker, { - foreignKey: "uploader_id", + foreignKey: "uploaded_by", as: "uploadedStickers", }); Sticker.belongsTo(User, { - foreignKey: "uploader_id", + foreignKey: "uploaded_by", as: "uploader", }); diff --git a/database/sticker.js b/database/sticker.js index 1426371..09b16fc 100644 --- a/database/sticker.js +++ b/database/sticker.js @@ -2,21 +2,29 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); const Sticker = db.define("sticker", { - imageUrl: { + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: true, + len: [1, 100] + } + }, + url: { type: DataTypes.STRING, allowNull: false, - field: 'image_url', validate: { notEmpty: true, len: [1, 500] } }, - name: { + cloudinaryPublicId: { type: DataTypes.STRING, allowNull: false, + field: 'cloudinary_public_id', validate: { notEmpty: true, - len: [1, 100] + len: [1, 200] } }, type: { @@ -24,14 +32,39 @@ const Sticker = db.define("sticker", { allowNull: false, defaultValue: 'preset' }, - uploaderId: { + category: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'general', + validate: { + len: [1, 50] + } + }, + uploadedBy: { type: DataTypes.INTEGER, allowNull: true, - field: 'uploader_id', + field: 'uploaded_by', references: { model: 'users', key: 'id' } + }, + width: { + type: DataTypes.INTEGER, + allowNull: true + }, + height: { + type: DataTypes.INTEGER, + allowNull: true + }, + format: { + type: DataTypes.STRING(10), + allowNull: true + }, + sizeBytes: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'size_bytes' } }, { tableName: 'stickers', @@ -41,7 +74,13 @@ const Sticker = db.define("sticker", { fields: ['type'] }, { - fields: ['uploader_id'] + fields: ['category'] + }, + { + fields: ['uploaded_by'] + }, + { + fields: ['type', 'category'] } ] }); diff --git a/package-lock.json b/package-lock.json index 386986a..f1fff81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,14 @@ "@neondatabase/serverless": "^1.0.1", "axios": "^1.11.0", "bcrypt": "^6.0.0", + "cloudinary": "^2.7.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^2.0.2", "pg": "^8.16.2", "sequelize": "^6.37.7" }, @@ -144,6 +146,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -263,6 +271,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -326,6 +351,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/cloudinary": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz", + "integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -345,6 +383,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -1115,6 +1168,27 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -1185,6 +1259,67 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1489,6 +1624,17 @@ "dev": true, "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1551,6 +1697,20 @@ "react": "^19.1.1" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1848,6 +2008,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1913,6 +2090,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1935,6 +2118,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index ebb9f40..4a539a6 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ "@neondatabase/serverless": "^1.0.1", "axios": "^1.11.0", "bcrypt": "^6.0.0", + "cloudinary": "^2.7.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^2.0.2", "pg": "^8.16.2", "sequelize": "^6.37.7" }, From 4a605ac211b55ad7286574a7d887c6f5313c4157 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sat, 9 Aug 2025 20:58:27 -0400 Subject: [PATCH 47/77] test --- api/sticker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/sticker.js b/api/sticker.js index c6404ed..b598d70 100644 --- a/api/sticker.js +++ b/api/sticker.js @@ -3,7 +3,7 @@ const { cloudinary } = require('../config/cloudinary'); const router = express.Router(); -// GET /api/stickers/presets - get preset stickers from "TTP-Capstone 2/Preset" folder +// GET /api/stickers/presets - from "TTP-Capstone 2/Preset" folder router.get('/presets', async (req, res) => { try { // search "Preset" folder From bd9847e5858e6f72b371ae5ae4c2c95530c398a0 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sat, 9 Aug 2025 22:10:40 -0400 Subject: [PATCH 48/77] feat: added route to get user's custom stickers --- api/sticker.js | 34 ++++++++++++++++++++++++++++++++++ database/sticker.js | 2 +- database/userProfileSticker.js | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/api/sticker.js b/api/sticker.js index b598d70..01496b1 100644 --- a/api/sticker.js +++ b/api/sticker.js @@ -38,4 +38,38 @@ router.get('/presets', async (req, res) => { } }); +// GET /api/stickers/custom - gett custom stickers from "TTP-Capstone 2/Custom" folder +router.get('/custom', async (req, res) => { + try { + // CUstom folder + const cloudinaryResult = await cloudinary.search + .expression('folder:"TTP-Capstone 2/Custom"/*') + .max_results(100) + .execute(); + + const customStickers = cloudinaryResult.resources.map(resource => { + return { + id: resource.public_id, + name: resource.display_name || resource.public_id.split('/').pop(), + url: resource.secure_url, + type: 'custom', + width: resource.width, + height: resource.height, + format: resource.format, + sizeBytes: resource.bytes, + cloudinaryPublicId: resource.public_id, + createdAt: resource.created_at + }; + }); + + res.json(customStickers); + } catch (error) { + console.error('Error fetching custom stickers from Cloudinary:', error); + res.status(500).json({ + error: 'Failed to fetch custom stickers', + details: error.message + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/database/sticker.js b/database/sticker.js index 09b16fc..2c4126e 100644 --- a/database/sticker.js +++ b/database/sticker.js @@ -1,6 +1,6 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); - +// this is for stickers streaming from Cloudinary const Sticker = db.define("sticker", { name: { type: DataTypes.STRING, diff --git a/database/userProfileSticker.js b/database/userProfileSticker.js index 18e5b1c..6eee7a9 100644 --- a/database/userProfileSticker.js +++ b/database/userProfileSticker.js @@ -1,6 +1,6 @@ const { DataTypes } = require("sequelize"); const db = require("./db"); - +//this is to save the layout of the user's profile stickers const UserProfileSticker = db.define("user_profile_sticker", { userId: { type: DataTypes.INTEGER, From c7b4c4694d378e4fa6469edc58ed65b4021e299e Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sat, 9 Aug 2025 22:16:09 -0400 Subject: [PATCH 49/77] refactor: file name change --- api/index.js | 4 ++-- api/{sticker.js => stickers.js} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename api/{sticker.js => stickers.js} (100%) diff --git a/api/index.js b/api/index.js index d5e6a4d..16ee183 100644 --- a/api/index.js +++ b/api/index.js @@ -4,12 +4,12 @@ const testDbRouter = require("./test-db"); const searchRouter = require("./search"); const postsRouter = require("./posts"); const profileRouter = require("./profile"); -const stickerRouter = require("./sticker"); +const stickersRouter = require("./stickers"); router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); router.use("/profile", profileRouter); -router.use("/stickers", stickerRouter); +router.use("/stickers", stickersRouter); module.exports = router; \ No newline at end of file diff --git a/api/sticker.js b/api/stickers.js similarity index 100% rename from api/sticker.js rename to api/stickers.js From 9997061765907f63cb31265471b3aa85cea53c83 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sun, 10 Aug 2025 01:03:58 -0400 Subject: [PATCH 50/77] feat: added upload + upload with ai removal routes + delete mock route ( need more chanegs) --- api/stickers.js | 341 ++++++++++++++++++++++++++--- package-lock.json | 545 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- test-image.png | Bin 0 -> 70 bytes 4 files changed, 860 insertions(+), 29 deletions(-) create mode 100644 test-image.png diff --git a/api/stickers.js b/api/stickers.js index 01496b1..19f9da4 100644 --- a/api/stickers.js +++ b/api/stickers.js @@ -1,18 +1,51 @@ const express = require('express'); +const multer = require('multer'); const { cloudinary } = require('../config/cloudinary'); +const { authenticateJWT } = require('../auth'); +const { Sticker } = require('../database'); +const sharp = require('sharp'); const router = express.Router(); +// multer for uploads, accepts any field name +const createUploadMiddleware = () => { + return multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10mb max + }, + fileFilter: (req, file, cb) => { + console.log('File upload details:', { + originalname: file.originalname, + mimetype: file.mimetype, + fieldname: file.fieldname + }); + // allow common image types + const allowedMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/x-icon', + 'image/vnd.microsoft.icon' + ]; + if (allowedMimeTypes.includes(file.mimetype) || file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error(`File type not supported: ${file.mimetype}. please upload an image file.`), false); + } + }, + }).any(); +}; -// GET /api/stickers/presets - from "TTP-Capstone 2/Preset" folder +// get preset stickers from cloudinary preset folder router.get('/presets', async (req, res) => { try { - // search "Preset" folder const cloudinaryResult = await cloudinary.search .expression('folder:"TTP-Capstone 2/Preset"/*') .max_results(100) .execute(); - - // transform the sticker object resources to more readable sticker format const presetStickers = cloudinaryResult.resources.map(resource => { return { id: resource.public_id, @@ -27,7 +60,6 @@ router.get('/presets', async (req, res) => { createdAt: resource.created_at }; }); - res.json(presetStickers); } catch (error) { console.error('Error fetching preset stickers from Cloudinary:', error); @@ -38,33 +70,32 @@ router.get('/presets', async (req, res) => { } }); -// GET /api/stickers/custom - gett custom stickers from "TTP-Capstone 2/Custom" folder -router.get('/custom', async (req, res) => { +// get user's custom stickers from db +router.get('/custom', authenticateJWT, async (req, res) => { try { - // CUstom folder - const cloudinaryResult = await cloudinary.search - .expression('folder:"TTP-Capstone 2/Custom"/*') - .max_results(100) - .execute(); - - const customStickers = cloudinaryResult.resources.map(resource => { - return { - id: resource.public_id, - name: resource.display_name || resource.public_id.split('/').pop(), - url: resource.secure_url, + const userStickers = await Sticker.findAll({ + where: { type: 'custom', - width: resource.width, - height: resource.height, - format: resource.format, - sizeBytes: resource.bytes, - cloudinaryPublicId: resource.public_id, - createdAt: resource.created_at - }; + uploadedBy: req.user.id + }, + order: [['createdAt', 'DESC']] }); - + const customStickers = userStickers.map(sticker => ({ + id: sticker.cloudinaryPublicId, + name: sticker.name, + url: sticker.url, + type: sticker.type, + width: sticker.width, + height: sticker.height, + format: sticker.format, + sizeBytes: sticker.sizeBytes, + cloudinaryPublicId: sticker.cloudinaryPublicId, + createdAt: sticker.createdAt, + uploadedBy: sticker.uploadedBy + })); res.json(customStickers); } catch (error) { - console.error('Error fetching custom stickers from Cloudinary:', error); + console.error('Error fetching user custom stickers:', error); res.status(500).json({ error: 'Failed to fetch custom stickers', details: error.message @@ -72,4 +103,260 @@ router.get('/custom', async (req, res) => { } }); +// upload custom sticker +router.post('/upload', authenticateJWT, (req, res, next) => { + const uploadMiddleware = createUploadMiddleware(); + uploadMiddleware(req, res, (err) => { + if (err) { + console.error('Upload error:', err); + return res.status(400).json({ + error: 'File upload error', + details: err.message + }); + } + next(); + }); +}, async (req, res) => { + try { + const imageFile = req.files && req.files.length > 0 ? req.files[0] : null; + if (!imageFile) { + return res.status(400).json({ + error: 'No image file provided', + debug: { + files: req.files, + fileCount: req.files ? req.files.length : 0 + } + }); + } + if (!process.env.CLOUDINARY_API_SECRET) { + return res.status(500).json({ + error: 'Server configuration error: Cloudinary credentials not set' + }); + } + const timestamp = Date.now(); + const publicId = `custom_sticker_${req.user.id}_${timestamp}`; + const cloudinaryResult = await cloudinary.uploader.upload( + `data:${imageFile.mimetype};base64,${imageFile.buffer.toString('base64')}`, + { + folder: 'TTP-Capstone 2/Custom', + public_id: publicId, + resource_type: 'image', + format: 'png', + transformation: [ + { quality: 'auto' }, + { fetch_format: 'auto' } + ] + } + ); + const sticker = await Sticker.create({ + name: req.body.name || `Custom Sticker ${timestamp}`, + url: cloudinaryResult.secure_url, + cloudinaryPublicId: cloudinaryResult.public_id, + type: 'custom', + category: req.body.category || 'general', + uploadedBy: req.user.id, + width: cloudinaryResult.width, + height: cloudinaryResult.height, + format: cloudinaryResult.format, + sizeBytes: cloudinaryResult.bytes + }); + res.status(201).json({ + message: 'Sticker uploaded successfully', + sticker: { + id: sticker.id, + name: sticker.name, + url: sticker.url, + type: sticker.type, + width: sticker.width, + height: sticker.height, + format: sticker.format, + cloudinaryPublicId: sticker.cloudinaryPublicId, + createdAt: sticker.createdAt + } + }); + } catch (error) { + console.error('Error uploading sticker:', error); + res.status(500).json({ + error: 'Failed to upload sticker', + details: error.message + }); + } +}); + +// upload sticker with ai background removal +router.post('/upload-with-bg-removal', authenticateJWT, (req, res, next) => { + const uploadMiddleware = createUploadMiddleware(); + uploadMiddleware(req, res, (err) => { + if (err) { + console.error('Upload error (BG Removal):', err); + return res.status(400).json({ + error: 'File upload error', + details: err.message + }); + } + next(); + }); +}, async (req, res) => { + try { + const imageFile = req.files && req.files.length > 0 ? req.files[0] : null; + if (!imageFile) { + return res.status(400).json({ + error: 'No image file provided', + debug: { + files: req.files, + fileCount: req.files ? req.files.length : 0 + } + }); + } + if (!process.env.CLOUDINARY_API_SECRET) { + return res.status(500).json({ + error: 'Server configuration error: Cloudinary credentials not set' + }); + } + const timestamp = Date.now(); + const publicId = `bg_removed_sticker_${req.user.id}_${timestamp}`; + let uploadBuffer = imageFile.buffer; + let uploadMimetype = imageFile.mimetype; + // if gif, convert to png + if (imageFile.mimetype === 'image/gif') { + try { + const pngBuffer = await sharp(imageFile.buffer, { animated: false }) + .png() + .toBuffer(); + uploadBuffer = pngBuffer; + uploadMimetype = 'image/png'; + } catch (sharpError) { + console.error('Error converting GIF to PNG:', sharpError); + return res.status(500).json({ + error: 'Failed to convert GIF to PNG for background removal', + details: sharpError.message + }); + } + } + let cloudinaryResult; + try { + cloudinaryResult = await cloudinary.uploader.upload( + `data:${uploadMimetype};base64,${uploadBuffer.toString('base64')}`, + { + folder: 'TTP-Capstone 2/Custom', + public_id: publicId, + resource_type: 'image', + format: 'png', + background_removal: 'cloudinary_ai', + transformation: [ + { quality: 'auto' }, + { effect: 'outline:2' } + ] + } + ); + console.log('Cloudinary upload result:', cloudinaryResult); + } catch (cloudinaryError) { + console.error('Cloudinary upload error:', cloudinaryError); + return res.status(500).json({ + error: 'Cloudinary upload failed', + details: cloudinaryError.message || cloudinaryError + }); + } + if (!cloudinaryResult || !cloudinaryResult.secure_url) { + return res.status(500).json({ + error: 'Cloudinary did not return a valid result', + details: cloudinaryResult + }); + } + // save to db so /custom returns it + let sticker; + try { + sticker = await Sticker.create({ + name: req.body.name || `BG Removed Sticker ${timestamp}`, + url: cloudinaryResult.secure_url, + cloudinaryPublicId: cloudinaryResult.public_id, + type: 'custom', + category: req.body.category || 'background-removed', + uploadedBy: req.user.id, + width: cloudinaryResult.width, + height: cloudinaryResult.height, + format: cloudinaryResult.format, + sizeBytes: cloudinaryResult.bytes + }); + } catch (dbError) { + console.error('Error saving sticker to database:', dbError); + return res.status(500).json({ + error: 'Failed to save sticker to database', + details: dbError.message, + cloudinaryResult + }); + } + res.status(201).json({ + message: 'Sticker uploaded with background removal successfully', + sticker: { + id: sticker.id, + name: sticker.name, + url: sticker.url, + type: sticker.type, + width: sticker.width, + height: sticker.height, + format: sticker.format, + cloudinaryPublicId: sticker.cloudinaryPublicId, + createdAt: sticker.createdAt + }, + cloudinaryResult // for debug + }); + } catch (error) { + console.error('Error uploading sticker with background removal:', error); + res.status(500).json({ + error: 'Failed to upload sticker with background removal', + details: error.message + }); + } +}); + +// delete a custom sticker +router.delete('/:id', authenticateJWT, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + // decode and extract filename + const decodedId = decodeURIComponent(id); + const filenameOnly = decodedId.split('/').pop(); + // find sticker by filename only + const sticker = await Sticker.findOne({ + where: { + cloudinaryPublicId: filenameOnly, + uploadedBy: userId, + type: 'custom' + } + }); + if (!sticker) { + return res.status(404).json({ + error: 'Sticker not found', + debug: { + searchedId: id, + decodedId: decodedId, + filenameOnly: filenameOnly + } + }); + } + // delete from cloudinary + try { + await cloudinary.uploader.destroy(sticker.cloudinaryPublicId); + } catch (cloudinaryError) { + // ignore cloudinary errors + } + // delete from db + await sticker.destroy(); + res.json({ + message: 'Sticker deleted successfully', + deletedSticker: { + id: sticker.cloudinaryPublicId, + name: sticker.name + } + }); + } catch (error) { + res.status(500).json({ + error: 'Failed to delete sticker', + details: error.message + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f1fff81..110f937 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "morgan": "^1.10.0", "multer": "^2.0.2", "pg": "^8.16.2", - "sequelize": "^6.37.7" + "sequelize": "^6.37.7", + "sharp": "^0.34.3" }, "devDependencies": { "nodemon": "^3.1.10" @@ -50,6 +51,434 @@ "integrity": "sha512-zAW6w79UO+G1+3AxboVQIUIZy05xluSOb1ymGg2dqG0pIi0JxEtZGec05BOf2LJ9SehzW4WeCYUQsYD9BjrVpQ==", "license": "MIT" }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@neondatabase/serverless": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.1.tgz", @@ -364,6 +793,47 @@ "node": ">=9" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -504,6 +974,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -961,6 +1440,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1905,6 +2390,48 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -1977,6 +2504,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2076,6 +2612,13 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 4a539a6..d19be4d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "morgan": "^1.10.0", "multer": "^2.0.2", "pg": "^8.16.2", - "sequelize": "^6.37.7" + "sequelize": "^6.37.7", + "sharp": "^0.34.3" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/test-image.png b/test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..6e6ba1aaa5f790ba24b07c524eb2a250c2336727 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{D|jgU}|Bq?e+V8 Q5-7po>FVdQ&MBb@0K7pD(*OVf literal 0 HcmV?d00001 From 9fd08b27edfddcdbb313090f050d924df3fd684b Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sun, 10 Aug 2025 14:07:22 -0400 Subject: [PATCH 51/77] feat: updated delete route -> post route for the functionality, updated get custom sticker route with more syncing guard clauses --- api/stickers.js | 143 ++++++++++++++++++++++++++++++++++-------------- app.js | 2 +- test-image.png | Bin 70 -> 0 bytes 3 files changed, 104 insertions(+), 41 deletions(-) delete mode 100644 test-image.png diff --git a/api/stickers.js b/api/stickers.js index 19f9da4..86d170c 100644 --- a/api/stickers.js +++ b/api/stickers.js @@ -4,6 +4,7 @@ const { cloudinary } = require('../config/cloudinary'); const { authenticateJWT } = require('../auth'); const { Sticker } = require('../database'); const sharp = require('sharp'); +const { Op } = require('sequelize'); const router = express.Router(); // multer for uploads, accepts any field name @@ -70,9 +71,16 @@ router.get('/presets', async (req, res) => { } }); -// get user's custom stickers from db +// get user's custom stickers from db, only return those that exist in cloudinary folder listing, and also include cloudinary-only stickers router.get('/custom', authenticateJWT, async (req, res) => { try { + // fetch all custom stickers from cloudinary folder + const cloudinaryResult = await cloudinary.search + .expression('folder:"TTP-Capstone 2/Custom"') + .max_results(100) + .execute(); + const cloudinaryPublicIds = new Set(cloudinaryResult.resources.map(r => r.public_id)); + // fetch all db stickers for user const userStickers = await Sticker.findAll({ where: { type: 'custom', @@ -80,22 +88,54 @@ router.get('/custom', authenticateJWT, async (req, res) => { }, order: [['createdAt', 'DESC']] }); - const customStickers = userStickers.map(sticker => ({ - id: sticker.cloudinaryPublicId, - name: sticker.name, - url: sticker.url, - type: sticker.type, - width: sticker.width, - height: sticker.height, - format: sticker.format, - sizeBytes: sticker.sizeBytes, - cloudinaryPublicId: sticker.cloudinaryPublicId, - createdAt: sticker.createdAt, - uploadedBy: sticker.uploadedBy - })); - res.json(customStickers); + // keep only db stickers that exist in cloudinary folder + const stickersWithCloudinary = []; + const stickersToDelete = []; + for (const sticker of userStickers) { + if (cloudinaryPublicIds.has(sticker.cloudinaryPublicId)) { + stickersWithCloudinary.push({ + id: sticker.cloudinaryPublicId, + name: sticker.name, + url: sticker.url, + type: sticker.type, + width: sticker.width, + height: sticker.height, + format: sticker.format, + sizeBytes: sticker.sizeBytes, + cloudinaryPublicId: sticker.cloudinaryPublicId, + createdAt: sticker.createdAt, + uploadedBy: sticker.uploadedBy, + missingInDb: false + }); + } else { + stickersToDelete.push(sticker.id); + } + } + // delete db records for stickers missing from cloudinary + if (stickersToDelete.length > 0) { + await Sticker.destroy({ where: { id: stickersToDelete } }); + } + // add cloudinary-only stickers + const dbPublicIds = new Set(userStickers.map(s => s.cloudinaryPublicId)); + const cloudinaryOnly = cloudinaryResult.resources + .filter(resource => !dbPublicIds.has(resource.public_id)) + .map(resource => ({ + id: resource.public_id, + name: resource.display_name || resource.public_id.split('/').pop(), + url: resource.secure_url, + type: 'custom', + width: resource.width, + height: resource.height, + format: resource.format, + sizeBytes: resource.bytes, + cloudinaryPublicId: resource.public_id, + createdAt: resource.created_at, + uploadedBy: req.user.id, + missingInDb: true + })); + // combine both lists + res.json([...stickersWithCloudinary, ...cloudinaryOnly]); } catch (error) { - console.error('Error fetching user custom stickers:', error); res.status(500).json({ error: 'Failed to fetch custom stickers', details: error.message @@ -310,50 +350,73 @@ router.post('/upload-with-bg-removal', authenticateJWT, (req, res, next) => { } }); -// delete a custom sticker -router.delete('/:id', authenticateJWT, async (req, res) => { +// new: delete custom sticker via POST -- i couldnt get the DELETE method to work with cloudinary +router.post('/custom/delete', authenticateJWT, async (req, res) => { try { - const { id } = req.params; + const { id } = req.body; + if (!id) { + return res.status(400).json({ error: 'id is required in request body' }); + } const userId = req.user.id; - // decode and extract filename const decodedId = decodeURIComponent(id); const filenameOnly = decodedId.split('/').pop(); - // find sticker by filename only + const allUserStickers = await Sticker.findAll({ + where: { uploadedBy: userId, type: 'custom' }, + attributes: ['cloudinaryPublicId', 'url', 'name', 'id'] + }); + const allIds = allUserStickers.map(s => s.cloudinaryPublicId); + const allFilenames = allUserStickers.map(s => s.cloudinaryPublicId.split('/').pop()); + const allUrls = allUserStickers.map(s => s.url); + // try to match by full path, filename, or url + const { Op } = require('sequelize'); const sticker = await Sticker.findOne({ where: { - cloudinaryPublicId: filenameOnly, - uploadedBy: userId, - type: 'custom' + [Op.and]: [ + { uploadedBy: userId }, + { type: 'custom' }, + { + [Op.or]: [ + { cloudinaryPublicId: decodedId }, + { cloudinaryPublicId: filenameOnly }, + { url: decodedId }, + { url: filenameOnly } + ] + } + ] } }); if (!sticker) { return res.status(404).json({ - error: 'Sticker not found', + error: 'sticker not found', debug: { searchedId: id, - decodedId: decodedId, - filenameOnly: filenameOnly + decodedId, + filenameOnly, + allCloudinaryPublicIds: allIds, + allFilenames, + allUrls } }); } - // delete from cloudinary try { await cloudinary.uploader.destroy(sticker.cloudinaryPublicId); - } catch (cloudinaryError) { - // ignore cloudinary errors + await sticker.destroy(); + res.json({ + message: 'sticker deleted successfully', + deletedSticker: { + id: sticker.cloudinaryPublicId, + name: sticker.name + } + }); + } catch (deleteError) { + res.status(500).json({ + error: 'failed to delete sticker in cloudinary or db', + details: deleteError.message + }); } - // delete from db - await sticker.destroy(); - res.json({ - message: 'Sticker deleted successfully', - deletedSticker: { - id: sticker.cloudinaryPublicId, - name: sticker.name - } - }); } catch (error) { res.status(500).json({ - error: 'Failed to delete sticker', + error: 'failed to delete sticker', details: error.message }); } diff --git a/app.js b/app.js index 131c163..7efd70d 100644 --- a/app.js +++ b/app.js @@ -35,7 +35,7 @@ app.use("/auth/spotify", spotifyRouter); app.use((err, req, res, next) => { console.error(err.stack); - res.sendStatus(500); + res.status(500).json({ error: 'Internal server error', details: err.message }); }); const runApp = async () => { diff --git a/test-image.png b/test-image.png deleted file mode 100644 index 6e6ba1aaa5f790ba24b07c524eb2a250c2336727..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{D|jgU}|Bq?e+V8 Q5-7po>FVdQ&MBb@0K7pD(*OVf From d35544cb42880e838b1579962e3382cb12b4403a Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Sun, 10 Aug 2025 22:11:01 -0400 Subject: [PATCH 52/77] feat: added route for spotify item + update profile routes + model update + code cleanup --- api/profile.js | 124 +++++++++++++---------------------------------- database/user.js | 20 +++++--- 2 files changed, 47 insertions(+), 97 deletions(-) diff --git a/api/profile.js b/api/profile.js index 261cb00..e23eb80 100644 --- a/api/profile.js +++ b/api/profile.js @@ -10,7 +10,7 @@ router.get("/me", authenticateJWT, async (req, res) => { attributes: [ 'id', 'username', 'email', 'firstName', 'lastName', 'bio', 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'profileTheme', 'createdAt' + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems' ] }); @@ -150,6 +150,29 @@ router.patch("/me", authenticateJWT, async (req, res) => { } }); +// Update current user's Spotify items +router.patch("/spotify-items", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + const items = req.body; + if (!Array.isArray(items) || items.length > 5) { + return res.status(400).json({ error: "spotifyItems must be an array of up to 5 items." }); + } + await User.update({ spotifyItems: items }, { where: { id: userId } }); + const updatedUser = await User.findByPk(userId, { + attributes: [ + 'id', 'username', 'email', 'firstName', 'lastName', 'bio', + 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems' + ] + }); + res.json({ message: "Spotify items updated successfully", user: updatedUser }); + } catch (error) { + console.error("Error updating spotifyItems:", error); + res.status(500).json({ error: "Failed to update spotifyItems" }); + } +}); + // Get current user's theme router.get("/me/theme", authenticateJWT, async (req, res) => { try { @@ -205,7 +228,7 @@ router.get("/:username", async (req, res) => { attributes: [ 'id', 'username', 'firstName', 'lastName', 'bio', 'profileImage', 'spotifyDisplayName', 'spotifyProfileImage', - 'avatarURL', 'profileTheme', 'createdAt' + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems' ] }); @@ -287,10 +310,9 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { } if (userToFollow.id === req.user.id) { - return res.status(400).json({ error: "Cannot follow yourself" }); + return res.status(400).json({ error: "You cannot follow yourself" }); } - // Check if already following const existingFollow = await Follows.findOne({ where: { followerId: req.user.id, @@ -300,15 +322,20 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { if (existingFollow) { // Unfollow - await existingFollow.destroy(); - res.json({ message: "Unfollowed successfully", following: false }); + await Follows.destroy({ + where: { + followerId: req.user.id, + followingId: userToFollow.id + } + }); + return res.json({ message: "Unfollowed successfully" }); } else { // Follow await Follows.create({ followerId: req.user.id, followingId: userToFollow.id }); - res.json({ message: "Followed successfully", following: true }); + return res.json({ message: "Followed successfully" }); } } catch (error) { console.error("Error following/unfollowing user:", error); @@ -316,87 +343,4 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { } }); -// Check if current user is following another user -router.get("/:username/following-status", authenticateJWT, async (req, res) => { - try { - const userToCheck = await User.findOne({ - where: { username: req.params.username } - }); - - if (!userToCheck) { - return res.status(404).json({ error: "User not found" }); - } - - const isFollowing = await Follows.findOne({ - where: { - followerId: req.user.id, - followingId: userToCheck.id - } - }); - - res.json({ following: !!isFollowing }); - } catch (error) { - console.error("Error checking following status:", error); - res.status(500).json({ error: "Failed to check following status" }); - } -}); - -// Get user's followers -router.get("/:username/followers", async (req, res) => { - try { - const user = await User.findOne({ - where: { username: req.params.username } - }); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - const followers = await Follows.findAll({ - where: { followingId: user.id }, - include: [{ - model: User, - as: 'follower', - attributes: ['id', 'username', 'firstName', 'lastName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] - }], - order: [['createdAt', 'DESC']], - limit: 50 - }); - - res.json(followers.map(follow => follow.follower)); - } catch (error) { - console.error("Error fetching followers:", error); - res.status(500).json({ error: "Failed to fetch followers" }); - } -}); - -// Get user's following -router.get("/:username/following", async (req, res) => { - try { - const user = await User.findOne({ - where: { username: req.params.username } - }); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - const following = await Follows.findAll({ - where: { followerId: user.id }, - include: [{ - model: User, - as: 'following', - attributes: ['id', 'username', 'firstName', 'lastName', 'profileImage', 'spotifyProfileImage', 'avatarURL'] - }], - order: [['createdAt', 'DESC']], - limit: 50 - }); - - res.json(following.map(follow => follow.following)); - } catch (error) { - console.error("Error fetching following:", error); - res.status(500).json({ error: "Failed to fetch following" }); - } -}); - module.exports = router; \ No newline at end of file diff --git a/database/user.js b/database/user.js index 4882ce7..be9f4e4 100644 --- a/database/user.js +++ b/database/user.js @@ -106,16 +106,22 @@ const User = db.define("user", { len: [1, 50] // Allow themes up to 50 characters } }, + spotifyItems: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: [], + field: 'spotify_items' + }, }, { tableName: 'users', underscored: true, - validate: { - mustHaveAuthMethod() { - if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { - throw new Error('User must have at least one authentication method'); - } - } - } + // validate: { + // mustHaveAuthMethod() { + // if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { + // throw new Error('User must have at least one authentication method'); + // } + // } + //} }); User.prototype.checkPassword = function (password) { From 1d81ae494fafc933943b2eaf5090a41feb42f270 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Mon, 11 Aug 2025 13:34:38 -0400 Subject: [PATCH 53/77] Added created follow.js and routes for following and followers --- api/follow.js | 108 ++++++++++++++++++++++++++++++++++++++++++++++ api/index.js | 2 + database/index.js | 6 +-- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 api/follow.js diff --git a/api/follow.js b/api/follow.js new file mode 100644 index 0000000..bf72b65 --- /dev/null +++ b/api/follow.js @@ -0,0 +1,108 @@ +const express = require("express"); +const router = express.Router(); +const { User, Posts, Follows } = require("../database"); +const { authenticateJWT } = require("../auth"); + +// Check if current user is following another user +router.get("/:username/following-status", authenticateJWT, async (req, res) => { + try { + const userToCheck = await User.findOne({ + where: { username: req.params.username }, + }); + + if (!userToCheck) { + return res.status(404).json({ error: "User not found" }); + } + + const isFollowing = await Follows.findOne({ + where: { + followerId: req.user.id, + followingId: userToCheck.id, + }, + }); + + res.json({ following: !!isFollowing }); + } catch (error) { + console.error("Error checking following status:", error); + res.status(500).json({ error: "Failed to check following status" }); + } +}); + +// Get user's followers +router.get("/:username/followers", async (req, res) => { + try { + const user = await User.findOne({ + where: { username: req.params.username }, + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const followers = await Follows.findAll({ + where: { followingId: user.id }, + include: [ + { + model: User, + as: "follower", + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 50, + }); + + res.json(followers.map((follow) => follow.follower)); + } catch (error) { + console.error("Error fetching followers:", error); + res.status(500).json({ error: "Failed to fetch followers" }); + } +}); + +// Get user's following +router.get("/:username/following", async (req, res) => { + try { + const user = await User.findOne({ + where: { username: req.params.username }, + }); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const following = await Follows.findAll({ + where: { followerId: user.id }, + include: [ + { + model: User, + as: "following", + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 50, + }); + + res.json(following.map((follow) => follow.following)); + } catch (error) { + console.error("Error fetching following:", error); + res.status(500).json({ error: "Failed to fetch following" }); + } +}); +module.exports = router; diff --git a/api/index.js b/api/index.js index ce55835..c806def 100644 --- a/api/index.js +++ b/api/index.js @@ -5,11 +5,13 @@ const searchRouter = require("./search"); const postsRouter = require("./posts"); const profileRouter = require("./profile"); const stickersRouter = require("./stickers"); +const followRouter = require("./follow"); router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); router.use("/profile", profileRouter); +router.use("/follow", followRouter); router.use("/stickers", stickersRouter); module.exports = router; diff --git a/database/index.js b/database/index.js index 12c1a98..b987feb 100644 --- a/database/index.js +++ b/database/index.js @@ -17,21 +17,21 @@ Posts.belongsTo(User, { User.belongsToMany(User, { through: Follows, - as: "Following", + as: "following", foreignKey: "follower_id", otherKey: "following_id", }); User.belongsToMany(User, { through: Follows, - as: "Followers", + as: "followers", foreignKey: "following_id", otherKey: "follower_id", }); Follows.belongsTo(User, { foreignKey: "follower_id", - as: "Follower", + as: "follower", }); Follows.belongsTo(User, { From 165ae8840fdad22c8c44326c826a30a3e6883386 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Mon, 11 Aug 2025 14:27:55 -0400 Subject: [PATCH 54/77] small changes --- api/follow.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/api/follow.js b/api/follow.js index bf72b65..3fb5bb3 100644 --- a/api/follow.js +++ b/api/follow.js @@ -105,4 +105,28 @@ router.get("/:username/following", async (req, res) => { res.status(500).json({ error: "Failed to fetch following" }); } }); + +//get all users who are following one another +router.get("/:username/friends", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const followers = await user.getFollowers(); + const following = await user.getFollowing(); + + const friends = followers.filter((follower) => + following.some((followed) => followed.id === follower.id) + ); + + res.json(friends); + } catch (error) { + console.error("Error fetching mutual friends:", error); + res.status(500).json({ error: "Failed to fetch mutual friends" }); + } +}); + module.exports = router; From f5ebf379ac98eeed323a5fd1374eaa61994babef Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Mon, 11 Aug 2025 14:55:14 -0400 Subject: [PATCH 55/77] Created routes and a table for likes --- api/posts.js | 156 +++++++++++++++++++++++++++++------------- database/index.js | 12 +++- database/postLikes.js | 27 ++++++++ database/seed.js | 2 +- 4 files changed, 148 insertions(+), 49 deletions(-) create mode 100644 database/postLikes.js diff --git a/api/posts.js b/api/posts.js index 7c63613..76512f2 100644 --- a/api/posts.js +++ b/api/posts.js @@ -1,60 +1,62 @@ const express = require("express"); const router = express.Router(); -const { Posts, User } = require("../database"); +const { Posts, User, PostLike } = require("../database"); const { authenticateJWT } = require("../auth"); // Get all published posts (public feed) router.get("/feed", async (req, res) => { try { + const userId = req.user?.id; + const posts = await Posts.findAll({ - where: { + where: { status: "published", - isPublic: true, + isPublic: true }, include: [ { model: User, as: "author", attributes: [ - "id", "username", - "spotifyDisplayName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - order: [["createdAt", "DESC"]], - limit: 20, - include: [ - { - model: User, - as: "author", - attributes: [ "id", - "username", "spotifyDisplayName", "profileImage", "spotifyProfileImage", "avatarURL", ], }, + { + model: PostLike, + as: "likes", + include: [{ + model: User, + as: "user", + attributes: ["id"] + }] + } ], order: [["createdAt", "DESC"]], - limit: 20, }); - res.json(posts); + const postsWithLikeInfo = posts.map(post => { + const postData = post.toJSON(); + postData.likesCount = postData.likes ? postData.likes.length : 0; + postData.isLiked = userId ? + postData.likes?.some(like => like.user.id === userId) : false; + return postData; + }); + + res.json(postsWithLikeInfo); } catch (error) { console.error("Error fetching feed posts:", error); - res.status(500).json({ error: "Failed to fetch feed posts" }); + res.status(500).json({ error: "Failed to fetch posts" }); } }); -// Get all posts (public) router.get("/", async (req, res) => { try { + const userId = req.user?.id; const posts = await Posts.findAll({ where: { isPublic: true }, include: [ @@ -70,20 +72,34 @@ router.get("/", async (req, res) => { "avatarURL", ], }, + { + model: PostLike, + as: "likes", + include: [{ + model: User, + as: "user", + attributes: ["id"] + }] + } ], order: [["createdAt", "DESC"]], }); - res.json(posts); + + const postsWithLikeInfo = posts.map(post => { + const postData = post.toJSON(); + postData.likesCount = postData.likes ? postData.likes.length : 0; + postData.isLiked = userId ? + postData.likes?.some(like => like.user.id === userId) : false; + return postData; + }); + + res.json(postsWithLikeInfo); } catch (error) { console.error("Error fetching posts:", error); res.status(500).json({ error: "Failed to fetch posts" }); } }); -router.get("/test", (req, res) => { - res.send("posts test route OK"); -}); - // Get posts if status === draft router.get("/draft", authenticateJWT, async (req, res) => { try { @@ -119,28 +135,47 @@ router.get("/draft", authenticateJWT, async (req, res) => { router.get("/my", authenticateJWT, async (req, res) => { try { const userId = req.user.id; + const posts = await Posts.findAll({ - where: { userId }, + where: { userId: req.user.id }, include: [ { model: User, as: "author", attributes: [ - "id", "username", + "id", "spotifyDisplayName", "profileImage", "spotifyProfileImage", "avatarURL", ], }, + { + model: PostLike, + as: "likes", + include: [{ + model: User, + as: "user", + attributes: ["id"] + }] + } ], order: [["createdAt", "DESC"]], }); - res.json(posts); + + const postsWithLikeInfo = posts.map(post => { + const postData = post.toJSON(); + postData.likesCount = postData.likes ? postData.likes.length : 0; + postData.isLiked = userId ? + postData.likes?.some(like => like.user.id === userId) : false; + return postData; + }); + + res.json(postsWithLikeInfo); } catch (error) { - console.error("Error fetching posts:", error); - res.status(500).json({ error: "Failed to fetch posts" }); + console.error("Error fetching user posts:", error); + res.status(500).json({ error: "Failed to fetch user posts" }); } }); @@ -178,8 +213,9 @@ router.get("/published", authenticateJWT, async (req, res) => { //get a single post by ID router.get("/:id", async (req, res) => { try { - const postId = req.params.id; - const post = await Posts.findByPk(postId, { + const userId = req.user?.id; + + const post = await Posts.findByPk(req.params.id, { include: [ { model: User, @@ -193,14 +229,27 @@ router.get("/:id", async (req, res) => { "avatarURL", ], }, + { + model: PostLike, + as: "likes", + include: [{ + model: User, + as: "user", + attributes: ["id"] + }] + } ], }); if (!post) { return res.status(404).json({ error: "Post not found" }); } + const postData = post.toJSON(); + postData.likesCount = postData.likes ? postData.likes.length : 0; + postData.isLiked = userId ? + postData.likes?.some(like => like.user.id === userId) : false; - res.json(post); + res.json(postData); } catch (error) { console.error("Error fetching post:", error); res.status(500).json({ error: "Failed to fetch post" }); @@ -484,22 +533,37 @@ router.patch("/:id/publish", authenticateJWT, async (req, res) => { }); // Like/Unlike a post -router.post("/:id/like", authenticateJWT, async (req, res) => { +router.post('/:postId/like', authenticateJWT, async (req, res) => { try { - const post = await Posts.findByPk(req.params.id); + const { postId } = req.params; + const userId = req.user.id; - if (!post) { - return res.status(404).json({ error: "Post not found" }); + const existingLike = await PostLike.findOne({ + where: { postId, userId } + }); + + let isLiked; + if (existingLike) { + await existingLike.destroy(); + isLiked = false; + } else { + await PostLike.create({ postId, userId }); + isLiked = true; } - // Simple like increment (you can make this more sophisticated later) - await post.increment("likesCount"); - await post.reload(); + const likesCount = await PostLike.count({ + where: { postId } + }); + + res.json({ + success: true, + isLiked, + likesCount + }); - res.json({ message: "Post liked", likesCount: post.likesCount }); } catch (error) { - console.error("Error liking post:", error); - res.status(500).json({ error: "Failed to like post" }); + console.error('Error liking post:', error); + res.status(500).json({ error: 'Failed to like post' }); } }); diff --git a/database/index.js b/database/index.js index 73e6ba8..e749fc0 100644 --- a/database/index.js +++ b/database/index.js @@ -4,14 +4,15 @@ const Posts = require("./posts"); const Follows = require("./follows"); const Sticker = require("./sticker"); const UserProfileSticker = require("./userProfileSticker"); +const PostLike = require("./postLikes"); User.hasMany(Posts, { - foreignKey: "user_id", + foreignKey: "userId", as: "posts", }); Posts.belongsTo(User, { - foreignKey: "user_id", + foreignKey: "userId", as: "author", }); @@ -39,6 +40,12 @@ Follows.belongsTo(User, { as: 'following' }); +//Post Likes associations +Posts.hasMany(PostLike, { foreignKey: 'postId', as: 'likes' }); +PostLike.belongsTo(Posts, { foreignKey: 'postId', as: 'post' }); +User.hasMany(PostLike, { foreignKey: 'userId', as: 'userLikes' }); +PostLike.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + // Sticker associations User.hasMany(Sticker, { foreignKey: "uploaded_by", @@ -93,4 +100,5 @@ module.exports = { Follows, Sticker, UserProfileSticker, + PostLike, }; \ No newline at end of file diff --git a/database/postLikes.js b/database/postLikes.js new file mode 100644 index 0000000..82261c2 --- /dev/null +++ b/database/postLikes.js @@ -0,0 +1,27 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const PostLike = db.define("PostLike", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + postId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + } +}, { + indexes: [ + { + unique: true, + fields: ['postId', 'userId'] + } + ] +}); + +module.exports = PostLike; \ No newline at end of file diff --git a/database/seed.js b/database/seed.js index 0ff560a..bce7c0c 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,5 +1,5 @@ const db = require("./db"); -const { User, Posts } = require("./index"); +const { User, Posts, PostLike } = require("./index"); const seed = async () => { try { From 6dc79b0a743abedb3a274e012d822b54c791290b Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Tue, 12 Aug 2025 11:41:55 -0400 Subject: [PATCH 56/77] added /GET all profile routes --- api/profile.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/api/profile.js b/api/profile.js index 05ff89c..6d04290 100644 --- a/api/profile.js +++ b/api/profile.js @@ -431,4 +431,37 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { } }); +router.get("/", authenticateJWT, async (req, res) => { + try { + const search = req.query.search?.trim().toLowerCase(); + let where = {}; + + if (search) { + where = { + username: { [require("sequelize").Op.iLike]: `%${search}%` }, + }; + } + + const users = await User.findAll({ + where, + attributes: [ + "id", + "username", + "firstName", + "lastName", + "avatarURL", + "spotifyProfileImage", + "profileImage", + ], + limit: 50, + order: [["username", "ASC"]], + }); + + res.json(users); + } catch (error) { + console.error("Error fetching users:", error); + res.status(500).json({ error: "Failed to fetch users" }); + } +}); + module.exports = router; From ab0dc00d3825fc9437ac6707b6ee9f4321165258 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Tue, 12 Aug 2025 13:20:30 -0400 Subject: [PATCH 57/77] working get route for all --- api/profile.js | 72 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/api/profile.js b/api/profile.js index 6d04290..7227aa0 100644 --- a/api/profile.js +++ b/api/profile.js @@ -2,6 +2,7 @@ const express = require("express"); const router = express.Router(); const { User, Posts, Follows } = require("../database"); const { authenticateJWT } = require("../auth"); +const { Op } = require("sequelize"); // Get current user's profile router.get("/me", authenticateJWT, async (req, res) => { @@ -90,6 +91,36 @@ router.get("/me/posts", authenticateJWT, async (req, res) => { } }); +// GET all users beside logged in user +router.get("/all", authenticateJWT, async (req, res) => { + try { + const users = await User.findAll({ + where: { + id: { [Op.ne]: req.user.id }, + }, + attributes: [ + "id", + "username", + "email", + "firstName", + "lastName", + "bio", + "profileImage", + "spotifyDisplayName", + "spotifyProfileImage", + "avatarURL", + "profileTheme", + "createdAt", + "spotifyItems", + ], + }); + res.json(users); + } catch (error) { + console.error("Error fetching all users:", error); + res.status(500).json({ error: "Failed to fetch users" }); + } +}); + // Update current user's profile router.patch("/me", authenticateJWT, async (req, res) => { try { @@ -431,36 +462,35 @@ router.post("/:username/follow", authenticateJWT, async (req, res) => { } }); -router.get("/", authenticateJWT, async (req, res) => { +//Search all user +router.get("/search", authenticateJWT, async (req, res) => { + console.log( + "[/api/profile/search] query:", + req.query.q, + "user:", + req.user?.id + ); try { - const search = req.query.search?.trim().toLowerCase(); - let where = {}; + const q = (req.query.q || "").trim(); + const meId = req.user.id; + const { Op } = require("sequelize"); - if (search) { - where = { - username: { [require("sequelize").Op.iLike]: `%${search}%` }, - }; + const where = { + id: { [Op.ne]: meId }, + }; + if (q) { + where.username = { [Op.iLike]: `%${q}%` }; } const users = await User.findAll({ where, - attributes: [ - "id", - "username", - "firstName", - "lastName", - "avatarURL", - "spotifyProfileImage", - "profileImage", - ], - limit: 50, - order: [["username", "ASC"]], + attributes: ["id", "username", "email"], }); res.json(users); - } catch (error) { - console.error("Error fetching users:", error); - res.status(500).json({ error: "Failed to fetch users" }); + } catch (err) { + console.error("Error searching users:", err); + res.status(500).json({ error: "Failed to search users" }); } }); From d43d4bf729fa10cac050d2e65d1aaf6e78883dba Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Tue, 12 Aug 2025 13:59:57 -0400 Subject: [PATCH 58/77] minor changes --- api/profile.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/profile.js b/api/profile.js index 7227aa0..6fd9a38 100644 --- a/api/profile.js +++ b/api/profile.js @@ -94,10 +94,15 @@ router.get("/me/posts", authenticateJWT, async (req, res) => { // GET all users beside logged in user router.get("/all", authenticateJWT, async (req, res) => { try { + let where = { + id: { [Op.ne]: req.user.id }, + }; + // If user is authenticated, exclude their own user + if (req.user && req.user.id) { + where.id = { [Op.ne]: req.user.id }; + } const users = await User.findAll({ - where: { - id: { [Op.ne]: req.user.id }, - }, + where, attributes: [ "id", "username", From 171f94dd55a8aeddd4de2141a36a953f2ea61718 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Wed, 13 Aug 2025 11:28:21 -0400 Subject: [PATCH 59/77] feat: updated user model for visibility and images profile setting/ uoload route for pfp and wallpaper to Cloudinary, updated get and patch to fetch accordingly (#54) Co-authored-by: Frank --- api/profile.js | 163 ++++++++++++++++++++++++++++++++++------------- database/user.js | 30 +++++++++ 2 files changed, 149 insertions(+), 44 deletions(-) diff --git a/api/profile.js b/api/profile.js index 6fd9a38..7c712d1 100644 --- a/api/profile.js +++ b/api/profile.js @@ -3,26 +3,26 @@ const router = express.Router(); const { User, Posts, Follows } = require("../database"); const { authenticateJWT } = require("../auth"); const { Op } = require("sequelize"); +const multer = require('multer'); +const path = require('path'); +const { uploadSticker, cloudinary } = require('../config/cloudinary'); + +// Configure multer for file uploads +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max +}); // Get current user's profile router.get("/me", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id, { attributes: [ - "id", - "username", - "email", - "firstName", - "lastName", - "bio", - "profileImage", - "spotifyDisplayName", - "spotifyProfileImage", - "avatarURL", - "profileTheme", - "createdAt", - "spotifyItems", - ], + 'id', 'username', 'email', 'firstName', 'lastName', 'bio', + 'profileImage', 'wallpaperURL', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems', + 'showPosts', 'showUsername', 'showDateJoined', 'showSpotifyStatus' + ] }); if (!user) { @@ -129,8 +129,8 @@ router.get("/all", authenticateJWT, async (req, res) => { // Update current user's profile router.patch("/me", authenticateJWT, async (req, res) => { try { - const { firstName, lastName, bio, profileImage, profileTheme } = req.body; - + const { firstName, lastName, bio, profileImage, wallpaperURL, profileTheme, showPosts, showUsername, showDateJoined, showSpotifyStatus } = req.body; + // Validate data const updateData = {}; @@ -171,6 +171,14 @@ router.patch("/me", authenticateJWT, async (req, res) => { updateData.profileImage = profileImage || null; } + if (wallpaperURL !== undefined) { + // Basic URL validation + if (wallpaperURL && !wallpaperURL.match(/^https?:\/\/.+/)) { + return res.status(400).json({ error: "Wallpaper URL must be a valid URL" }); + } + updateData.wallpaperURL = wallpaperURL || null; + } + if (profileTheme !== undefined) { // allow any reasonable string if ( @@ -183,6 +191,56 @@ router.patch("/me", authenticateJWT, async (req, res) => { } updateData.profileTheme = profileTheme || "default"; } +//for visibikity settings + if (showPosts !== undefined) { + if (typeof showPosts !== 'boolean') { + return res.status(400).json({ error: "showPosts must be a boolean" }); + } + updateData.showPosts = showPosts; + } + if (showUsername !== undefined) { + if (typeof showUsername !== 'boolean') { + return res.status(400).json({ error: "showUsername must be a boolean" }); + } + updateData.showUsername = showUsername; + } + if (showDateJoined !== undefined) { + if (typeof showDateJoined !== 'boolean') { + return res.status(400).json({ error: "showDateJoined must be a boolean" }); + } + updateData.showDateJoined = showDateJoined; + } + if (showSpotifyStatus !== undefined) { + if (typeof showSpotifyStatus !== 'boolean') { + return res.status(400).json({ error: "showSpotifyStatus must be a boolean" }); + } + updateData.showSpotifyStatus = showSpotifyStatus; + } +//for visibikity settings + if (showPosts !== undefined) { + if (typeof showPosts !== 'boolean') { + return res.status(400).json({ error: "showPosts must be a boolean" }); + } + updateData.showPosts = showPosts; + } + if (showUsername !== undefined) { + if (typeof showUsername !== 'boolean') { + return res.status(400).json({ error: "showUsername must be a boolean" }); + } + updateData.showUsername = showUsername; + } + if (showDateJoined !== undefined) { + if (typeof showDateJoined !== 'boolean') { + return res.status(400).json({ error: "showDateJoined must be a boolean" }); + } + updateData.showDateJoined = showDateJoined; + } + if (showSpotifyStatus !== undefined) { + if (typeof showSpotifyStatus !== 'boolean') { + return res.status(400).json({ error: "showSpotifyStatus must be a boolean" }); + } + updateData.showSpotifyStatus = showSpotifyStatus; + } const [updatedRowsCount] = await User.update(updateData, { where: { id: req.user.id }, @@ -196,22 +254,14 @@ router.patch("/me", authenticateJWT, async (req, res) => { // Fetch updated user const updatedUser = await User.findByPk(req.user.id, { attributes: [ - "id", - "username", - "email", - "firstName", - "lastName", - "bio", - "profileImage", - "spotifyDisplayName", - "spotifyProfileImage", - "avatarURL", - "profileTheme", - "createdAt", - ], + 'id', 'username', 'email', 'firstName', 'lastName', 'bio', + 'profileImage', 'wallpaperURL', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', + 'showPosts', 'showUsername', 'showDateJoined', 'showSpotifyStatus' + ] }); - res.json(updatedUser); + res.json(updatedUser.toJSON()); } catch (error) { console.error("Error updating profile:", error); @@ -332,19 +382,11 @@ router.get("/:username", async (req, res) => { const user = await User.findOne({ where: { username: req.params.username }, attributes: [ - "id", - "username", - "firstName", - "lastName", - "bio", - "profileImage", - "spotifyDisplayName", - "spotifyProfileImage", - "avatarURL", - "profileTheme", - "createdAt", - "spotifyItems", - ], + 'id', 'username', 'firstName', 'lastName', 'bio', + 'profileImage', 'wallpaperURL', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems', + 'showPosts', 'showUsername', 'showDateJoined', 'showSpotifyStatus' + ] }); if (!user) { @@ -499,4 +541,37 @@ router.get("/search", authenticateJWT, async (req, res) => { } }); -module.exports = router; +// upload wallpaper or profile picture to Cloudinary (In Use folder) +router.post('/upload', authenticateJWT, upload.single('file'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + const type = req.body.type === 'wallpaper' ? 'Wallpaper' : 'Profile Picture'; + const folder = `TTP-Capstone 2/In Use/${type}`; + const uploadResult = await new Promise((resolve, reject) => { + const stream = cloudinary.uploader.upload_stream( + { + folder, + resource_type: 'image', + allowed_formats: ['jpg', 'jpeg', 'png', 'gif', 'webp'], + transformation: [ + { quality: 'auto', fetch_format: 'auto' }, + type === 'Profile Picture' ? { width: 500, height: 500, crop: 'limit' } : { width: 1920, height: 1080, crop: 'limit' } + ] + }, + (error, result) => { + if (error) return reject(error); + resolve(result); + } + ); + stream.end(req.file.buffer); + }); + res.json({ url: uploadResult.secure_url, publicId: uploadResult.public_id }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Failed to upload image' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/database/user.js b/database/user.js index be9f4e4..7343b86 100644 --- a/database/user.js +++ b/database/user.js @@ -112,6 +112,36 @@ const User = db.define("user", { defaultValue: [], field: 'spotify_items' }, + showPosts: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + field: 'show_posts' + }, + showUsername: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + field: 'show_username' + }, + showDateJoined: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + field: 'show_date_joined' + }, + showSpotifyStatus: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + field: 'show_spotify_status' + }, + wallpaperURL: { + type: DataTypes.STRING, + allowNull: true, + field: 'wallpaper_url', + defaultValue: null + }, }, { tableName: 'users', underscored: true, From 3d44e14b9dd74a3753080e276ac7adde56d5e9c9 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 13 Aug 2025 14:39:10 -0400 Subject: [PATCH 60/77] comments are succesful (#56) --- api/comments.js | 85 +++++++++++++++++++++++++++++++++++++++ api/index.js | 3 ++ api/posts.js | 12 +++--- database/comments.js | 45 +++++++++++++++++++++ database/index.js | 13 +++++- database/seed.js | 95 +++++++++++++++++++++++++++++++++----------- 6 files changed, 223 insertions(+), 30 deletions(-) create mode 100644 api/comments.js create mode 100644 database/comments.js diff --git a/api/comments.js b/api/comments.js new file mode 100644 index 0000000..3d724f9 --- /dev/null +++ b/api/comments.js @@ -0,0 +1,85 @@ +const express = require("express"); +const router = express.Router(); +const { Comments, User } = require("../database"); +const { authenticateJWT } = require("../auth"); + +// get the comments of a specific post +router.get("/posts/:postId/comments", async (req, res) => { + try { + const { postId } = req.params; + + const comments = await Comments.findAll({ + where: { post_id: postId }, + include: [ + { + model: User, + as: "author", + attributes: ["id", "username", "spotifyDisplayName", "profileImage", "avatarURL"] + } + ], + order: [["created_at", "DESC"]] + }); + + res.json(comments); + } catch (error) { + console.error('Error fetching comments:', error); + res.status(500).json({ error: 'Failed to fetch comments' }); + } +}); + +// post comments to a specific post +router.post("/posts/:postId/comments", authenticateJWT, async (req, res) => { + try { + const { postId } = req.params; + const { content, parentId } = req.body; + const userId = req.user.id; + + const comment = await Comments.create({ + post_id: postId, + user_id: userId, + content, + parent_id: parentId || null + }); + + const createdComment = await Comments.findByPk(comment.id, { + include: [ + { + model: User, + as: "author", + attributes: ["id", "username", "spotifyDisplayName", "profileImage", "avatarURL"] + } + ] + }); + + res.json(createdComment); + } catch (error) { + console.error('Error creating comment:', error); + res.status(500).json({ error: 'Failed to create comment' }); + } +}); + +// delete comments of a specific post +router.delete("/:id", authenticateJWT, async (req, res) => { + try { + const commentId = req.params.id; + const userId = req.user.id; + + const deleted = await Comments.destroy({ + where: { + id: commentId, + user_id: userId + } + }); + + if (deleted === 0) { + return res.status(404).json({ error: "Comment not found or unauthorized" }); + } + + res.status(204).end(); + } catch (error) { + console.error('Error deleting comment:', error); + res.status(500).json({ error: 'Failed to delete comment' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/api/index.js b/api/index.js index c806def..b3215a2 100644 --- a/api/index.js +++ b/api/index.js @@ -1,11 +1,13 @@ const express = require("express"); const router = express.Router(); + const testDbRouter = require("./test-db"); const searchRouter = require("./search"); const postsRouter = require("./posts"); const profileRouter = require("./profile"); const stickersRouter = require("./stickers"); const followRouter = require("./follow"); +const commentsRouter = require('./comments'); router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); @@ -13,5 +15,6 @@ router.use("/posts", postsRouter); router.use("/profile", profileRouter); router.use("/follow", followRouter); router.use("/stickers", stickersRouter); +router.use('/', commentsRouter); module.exports = router; diff --git a/api/posts.js b/api/posts.js index 76512f2..8caa0f9 100644 --- a/api/posts.js +++ b/api/posts.js @@ -1,6 +1,6 @@ const express = require("express"); const router = express.Router(); -const { Posts, User, PostLike } = require("../database"); +const { Posts, User, PostLike, Comments } = require("../database"); const { authenticateJWT } = require("../auth"); // Get all published posts (public feed) @@ -211,11 +211,12 @@ router.get("/published", authenticateJWT, async (req, res) => { }); //get a single post by ID -router.get("/:id", async (req, res) => { +router.get('/:id', async (req, res) => { try { const userId = req.user?.id; + const postId = req.params.id; - const post = await Posts.findByPk(req.params.id, { + const post = await Posts.findByPk(postId, { include: [ { model: User, @@ -238,17 +239,18 @@ router.get("/:id", async (req, res) => { attributes: ["id"] }] } - ], + ] }); if (!post) { return res.status(404).json({ error: "Post not found" }); } + const postData = post.toJSON(); postData.likesCount = postData.likes ? postData.likes.length : 0; postData.isLiked = userId ? postData.likes?.some(like => like.user.id === userId) : false; - + res.json(postData); } catch (error) { console.error("Error fetching post:", error); diff --git a/database/comments.js b/database/comments.js new file mode 100644 index 0000000..7ad313e --- /dev/null +++ b/database/comments.js @@ -0,0 +1,45 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Comments = db.define( + "comments", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + post_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "posts", key: "id" }, + onDelete: "CASCADE", + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + onDelete: "CASCADE", + }, + parent_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { model: "comments", key: "id" }, + onDelete: "CASCADE", + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + }, + { + tableName: "comments", + underscored: true, + indexes: [ + { fields: ["post_id", "created_at"] }, + { fields: ["parent_id"] }, + ], + } +); + +module.exports = Comments; \ No newline at end of file diff --git a/database/index.js b/database/index.js index 84330a9..877367e 100644 --- a/database/index.js +++ b/database/index.js @@ -5,6 +5,7 @@ const Follows = require("./follows"); const Sticker = require("./sticker"); const UserProfileSticker = require("./userProfileSticker"); const PostLike = require("./postLikes"); +const Comments = require("./comments"); User.hasMany(Posts, { foreignKey: "userId", @@ -93,6 +94,15 @@ Sticker.belongsToMany(User, { otherKey: "user_id", }); +// Comment associations +Comments.belongsTo(User, { foreignKey: "user_id", as: "author" }); +Comments.belongsTo(Posts, { foreignKey: "post_id", as: "post" }); +Comments.belongsTo(Comments, { foreignKey: "parent_id", as: "parent" }); +Comments.hasMany(Comments, { foreignKey: "parent_id", as: "replies" }); + +User.hasMany(Comments, { foreignKey: "user_id" }); +Posts.hasMany(Comments, { foreignKey: "post_id" }); + module.exports = { db, User, @@ -101,4 +111,5 @@ module.exports = { Sticker, UserProfileSticker, PostLike, -}; + Comments, +}; \ No newline at end of file diff --git a/database/seed.js b/database/seed.js index 48fe068..a7523fb 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,5 +1,5 @@ const db = require("./db"); -const { User, Posts, Follows, PostLike } = require("./index"); +const { User, Posts, Follows, PostLike, Comments } = require("./index"); const seed = async () => { try { @@ -58,37 +58,36 @@ const seed = async () => { }, ]); - // Make all users follow each other const followData = [ // Admin follows user1, user2, user3, user4 - { followerId: users[0].id, followingId: users[1].id }, - { followerId: users[0].id, followingId: users[2].id }, - { followerId: users[0].id, followingId: users[3].id }, - { followerId: users[0].id, followingId: users[4].id }, + { follower_id: users[0].id, following_id: users[1].id }, + { follower_id: users[0].id, following_id: users[2].id }, + { follower_id: users[0].id, following_id: users[3].id }, + { follower_id: users[0].id, following_id: users[4].id }, // user1 follows admin, user2, user3, user4 - { followerId: users[1].id, followingId: users[0].id }, - { followerId: users[1].id, followingId: users[2].id }, - { followerId: users[1].id, followingId: users[3].id }, - { followerId: users[1].id, followingId: users[4].id }, + { follower_id: users[1].id, following_id: users[0].id }, + { follower_id: users[1].id, following_id: users[2].id }, + { follower_id: users[1].id, following_id: users[3].id }, + { follower_id: users[1].id, following_id: users[4].id }, // user2 follows admin, user1, user3, user4 - { followerId: users[2].id, followingId: users[0].id }, - { followerId: users[2].id, followingId: users[1].id }, - { followerId: users[2].id, followingId: users[3].id }, - { followerId: users[2].id, followingId: users[4].id }, + { follower_id: users[2].id, following_id: users[0].id }, + { follower_id: users[2].id, following_id: users[1].id }, + { follower_id: users[2].id, following_id: users[3].id }, + { follower_id: users[2].id, following_id: users[4].id }, // user3 follows admin, user1, user2, user4 - { followerId: users[3].id, followingId: users[0].id }, - { followerId: users[3].id, followingId: users[1].id }, - { followerId: users[3].id, followingId: users[2].id }, - { followerId: users[3].id, followingId: users[4].id }, + { follower_id: users[3].id, following_id: users[0].id }, + { follower_id: users[3].id, following_id: users[1].id }, + { follower_id: users[3].id, following_id: users[2].id }, + { follower_id: users[3].id, following_id: users[4].id }, // user4 follows admin, user1, user2, user3 - { followerId: users[4].id, followingId: users[0].id }, - { followerId: users[4].id, followingId: users[1].id }, - { followerId: users[4].id, followingId: users[2].id }, - { followerId: users[4].id, followingId: users[3].id }, + { follower_id: users[4].id, following_id: users[0].id }, + { follower_id: users[4].id, following_id: users[1].id }, + { follower_id: users[4].id, following_id: users[2].id }, + { follower_id: users[4].id, following_id: users[3].id }, ]; await Follows.bulkCreate(followData); @@ -335,7 +334,55 @@ const seed = async () => { ]); console.log(`πŸ“ Created ${posts.length} posts`); - console.log("🌱 Seeded the database"); + + const comments = await Comments.bulkCreate([ + { + post_id: 1, + user_id: 2, + content: "Love this admin vibe! Great selection of tracks.", + parent_id: null, + }, + { + post_id: 1, + user_id: 3, + content: "Admin always has the best music taste!", + parent_id: null, + }, + { + post_id: 2, + user_id: 4, + content: "This lo-fi playlist is perfect for work. Thanks for sharing!", + parent_id: null, + }, + { + post_id: 2, + user_id: 1, + content: "Thanks! I use this every day while coding.", + parent_id: 3, + }, + { + post_id: 6, + user_id: 1, + content: "Late night drives with this track hit different πŸš—", + parent_id: null, + }, + { + post_id: 6, + user_id: 3, + content: "Totally agree! Perfect vibe for cruising.", + parent_id: 5, + }, + { + post_id: 11, + user_id: 2, + content: "2000s hits are the best! This takes me back.", + parent_id: null, + }, + ]); + + console.log(`πŸ’¬ Created ${comments.length} comments`); + console.log("🌱 Seeded the database successfully!"); + } catch (error) { console.error("Error seeding database:", error); } finally { @@ -343,4 +390,4 @@ const seed = async () => { } }; -seed(); +seed(); \ No newline at end of file From 17cba2ece72c4e8b11f07ce6aa229096f016429c Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 13 Aug 2025 18:35:18 -0400 Subject: [PATCH 61/77] Feature fork (#57) * in progress do no tmerge * Fork feature done --- api/posts.js | 748 ++++++++++++++++++++++++++---------------------- auth/spotify.js | 247 +++++++++------- 2 files changed, 538 insertions(+), 457 deletions(-) diff --git a/api/posts.js b/api/posts.js index 8caa0f9..fcbe9b5 100644 --- a/api/posts.js +++ b/api/posts.js @@ -3,23 +3,129 @@ const router = express.Router(); const { Posts, User, PostLike, Comments } = require("../database"); const { authenticateJWT } = require("../auth"); +async function ensureSpotifyAccessToken(user) { + if (!user.spotifyAccessToken && !user.spotifyRefreshToken) { + throw new Error("Spotify not linked"); + } + + const now = Date.now(); + const expMs = user.spotifyTokenExpiresAt ? new Date(user.spotifyTokenExpiresAt).getTime() : 0; + const needsRefresh = !user.spotifyAccessToken || expMs - now < 60000; + + if (!needsRefresh) return user.spotifyAccessToken; + + const params = new URLSearchParams(); + params.append("grant_type", "refresh_token"); + params.append("refresh_token", user.spotifyRefreshToken); + + const basic = Buffer.from( + `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}` + ).toString("base64"); + + const resp = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { + Authorization: `Basic ${basic}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + + if (!resp.ok) { + const t = await resp.text(); + throw new Error(`Failed to refresh Spotify token: ${resp.status} ${t}`); + } + + const data = await resp.json(); + user.spotifyAccessToken = data.access_token; + if (data.expires_in) { + user.spotifyTokenExpiresAt = new Date(Date.now() + (data.expires_in - 60) * 1000); + } + if (data.refresh_token) { + user.spotifyRefreshToken = data.refresh_token; + } + await user.save(); + return user.spotifyAccessToken; +} + +function extractPlaylistIdFromPost(post) { + const embedUrl = post.spotifyEmbedUrl; + + if (!embedUrl || typeof embedUrl !== "string") { + return null; + } + + // Handle Spotify embed URLs + // Format: https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M?utm_source=generator + if (embedUrl.includes("open.spotify.com/embed/playlist/")) { + const match = embedUrl.match(/open\.spotify\.com\/embed\/playlist\/([a-zA-Z0-9]+)/); + if (match && match[1]) { + return match[1]; + } + } + + // Handle regular Spotify playlist URLs (just in case) + // Format: https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M + if (embedUrl.includes("open.spotify.com/playlist/")) { + const match = embedUrl.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/); + if (match && match[1]) { + return match[1]; + } + } + + // Handle Spotify URIs (just in case) + // Format: spotify:playlist:37i9dQZF1DXcBWIGoYBM5M + if (embedUrl.startsWith("spotify:playlist:")) { + const id = embedUrl.split(":")[2]; + if (id) { + return id; + } + } + + return null; +} + +async function fetchAllPlaylistTrackUris(accessToken, playlistId) { + const uris = []; + let url = `https://api.spotify.com/v1/playlists/${playlistId}/tracks?fields=items(track(uri)),next&limit=100`; + while (url) { + const r = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!r.ok) { + const t = await r.text(); + throw new Error(`Fetch tracks failed: ${r.status} ${t}`); + } + const json = await r.json(); + (json.items || []).forEach((it) => { + const uri = it?.track?.uri; + if (uri) uris.push(uri); + }); + url = json.next; + } + return uris; +} + +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + // Get all published posts (public feed) router.get("/feed", async (req, res) => { try { const userId = req.user?.id; - + const posts = await Posts.findAll({ - where: { - status: "published", - isPublic: true - }, + where: { status: "published" }, include: [ { model: User, as: "author", attributes: [ "username", - "id", + "id", "spotifyDisplayName", "profileImage", "spotifyProfileImage", @@ -39,7 +145,8 @@ router.get("/feed", async (req, res) => { order: [["createdAt", "DESC"]], }); - const postsWithLikeInfo = posts.map(post => { + // Add like information to each post + const postsWithLikes = posts.map(post => { const postData = post.toJSON(); postData.likesCount = postData.likes ? postData.likes.length : 0; postData.isLiked = userId ? @@ -47,18 +154,18 @@ router.get("/feed", async (req, res) => { return postData; }); - res.json(postsWithLikeInfo); + res.json(postsWithLikes); } catch (error) { - console.error("Error fetching feed posts:", error); - res.status(500).json({ error: "Failed to fetch posts" }); + console.error("Error fetching feed:", error); + res.status(500).json({ error: "Failed to fetch feed" }); } }); router.get("/", async (req, res) => { try { - const userId = req.user?.id; + const userId = req.user?.id; + const posts = await Posts.findAll({ - where: { isPublic: true }, include: [ { model: User, @@ -66,7 +173,7 @@ router.get("/", async (req, res) => { attributes: [ "username", "id", - "spotifyDisplayName", + "spotifyDisplayName", "profileImage", "spotifyProfileImage", "avatarURL", @@ -77,7 +184,7 @@ router.get("/", async (req, res) => { as: "likes", include: [{ model: User, - as: "user", + as: "user", attributes: ["id"] }] } @@ -85,15 +192,15 @@ router.get("/", async (req, res) => { order: [["createdAt", "DESC"]], }); - const postsWithLikeInfo = posts.map(post => { + const postsWithLikes = posts.map(post => { const postData = post.toJSON(); postData.likesCount = postData.likes ? postData.likes.length : 0; - postData.isLiked = userId ? + postData.isLiked = userId ? postData.likes?.some(like => like.user.id === userId) : false; return postData; }); - res.json(postsWithLikeInfo); + res.json(postsWithLikes); } catch (error) { console.error("Error fetching posts:", error); res.status(500).json({ error: "Failed to fetch posts" }); @@ -101,11 +208,13 @@ router.get("/", async (req, res) => { }); // Get posts if status === draft -router.get("/draft", authenticateJWT, async (req, res) => { +router.get("/drafts", authenticateJWT, async (req, res) => { try { + const userId = req.user.id; + const drafts = await Posts.findAll({ where: { - userId: req.user.id, + userId: userId, status: "draft", }, include: [ @@ -113,8 +222,8 @@ router.get("/draft", authenticateJWT, async (req, res) => { model: User, as: "author", attributes: [ - "id", "username", + "id", "spotifyDisplayName", "profileImage", "spotifyProfileImage", @@ -124,6 +233,7 @@ router.get("/draft", authenticateJWT, async (req, res) => { ], order: [["createdAt", "DESC"]], }); + res.json(drafts); } catch (error) { console.error("Error fetching drafts:", error); @@ -131,99 +241,20 @@ router.get("/draft", authenticateJWT, async (req, res) => { } }); -// Get posts by the logged-in user -router.get("/my", authenticateJWT, async (req, res) => { +// Get single post by ID +router.get("/:id", async (req, res) => { try { - const userId = req.user.id; - - const posts = await Posts.findAll({ - where: { userId: req.user.id }, - include: [ - { - model: User, - as: "author", - attributes: [ - "username", - "id", - "spotifyDisplayName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - { - model: PostLike, - as: "likes", - include: [{ - model: User, - as: "user", - attributes: ["id"] - }] - } - ], - order: [["createdAt", "DESC"]], - }); - - const postsWithLikeInfo = posts.map(post => { - const postData = post.toJSON(); - postData.likesCount = postData.likes ? postData.likes.length : 0; - postData.isLiked = userId ? - postData.likes?.some(like => like.user.id === userId) : false; - return postData; - }); - - res.json(postsWithLikeInfo); - } catch (error) { - console.error("Error fetching user posts:", error); - res.status(500).json({ error: "Failed to fetch user posts" }); - } -}); - -// Get posts if status === published -router.get("/published", authenticateJWT, async (req, res) => { - try { - const publishedPosts = await Posts.findAll({ - where: { - userId: req.user.id, - status: "published", - }, - include: [ - { - model: User, - as: "author", - attributes: [ - "id", - "username", - "spotifyDisplayName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - order: [["createdAt", "DESC"]], - }); - res.json(publishedPosts); - } catch (error) { - console.error("Error fetching published posts:", error); - res.status(500).json({ error: "Failed to fetch published posts" }); - } -}); - -//get a single post by ID -router.get('/:id', async (req, res) => { - try { - const userId = req.user?.id; const postId = req.params.id; - + const userId = req.user?.id; + const post = await Posts.findByPk(postId, { include: [ { model: User, as: "author", attributes: [ - "username", "id", + "username", "spotifyDisplayName", "profileImage", "spotifyProfileImage", @@ -239,7 +270,7 @@ router.get('/:id', async (req, res) => { attributes: ["id"] }] } - ] + ], }); if (!post) { @@ -250,7 +281,7 @@ router.get('/:id', async (req, res) => { postData.likesCount = postData.likes ? postData.likes.length : 0; postData.isLiked = userId ? postData.likes?.some(like => like.user.id === userId) : false; - + res.json(postData); } catch (error) { console.error("Error fetching post:", error); @@ -258,88 +289,51 @@ router.get('/:id', async (req, res) => { } }); -//Get a single draft post by ID -router.get("/draft/:id", authenticateJWT, async (req, res) => { - try { - const postId = req.params.id; - const draft = await Posts.findOne({ - where: { - id: postId, - userId: req.user.id, - status: "draft", - }, - include: [ - { - model: User, - as: "author", - attributes: [ - "username", - "id", - "spotifyDisplayName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - }); - - if (!draft) { - return res.status(404).json({ error: "Draft not found" }); - } - - res.json(draft); - } catch (error) { - console.error("Error fetching draft:", error); - res.status(500).json({ error: "Failed to fetch draft" }); - } -}); - -// Create a new post (requires authentication) +// Create a new post router.post("/", authenticateJWT, async (req, res) => { try { const { title, description, - status, - spotifyId, + spotifyArtistId, + spotifyTrackId, + spotifyPlaylistId, + spotifyAlbumId, + spotifyArtistName, + spotifyTrackName, + spotifyPlaylistName, + spotifyAlbumName, spotifyType, spotifyEmbedUrl, - isPublic = true, + status = "published", + originalPostId, } = req.body; - if (!title || title.trim() === "") { - return res.status(400).json({ error: "Title is required" }); - } - - if (!description || description.trim() === "") { - return res.status(400).json({ error: "Description is required" }); - } - - const postData = { - title: title.trim(), - description: description.trim(), - status: status || "published", - userId: req.user.id, - isPublic: isPublic, - }; - - // Handle Spotify embed data - if (spotifyId && spotifyType) { - const validTypes = ["track", "album", "playlist", "artist"]; - if (!validTypes.includes(spotifyType)) { - return res.status(400).json({ error: "Invalid Spotify type" }); - } + const userId = req.user.id; - postData.spotifyId = spotifyId; - postData.spotifyType = spotifyType; - postData.spotifyEmbedUrl = spotifyEmbedUrl; + if (!title || !description) { + return res.status(400).json({ error: "Title and description are required" }); } - const newPost = await Posts.create(postData); + const post = await Posts.create({ + title, + description, + userId, + spotifyArtistId, + spotifyTrackId, + spotifyPlaylistId, + spotifyAlbumId, + spotifyArtistName, + spotifyTrackName, + spotifyPlaylistName, + spotifyAlbumName, + spotifyType, + spotifyEmbedUrl, + status, + originalPostId, + }); - // Fetch the created post with author info - const postWithAuthor = await Posts.findByPk(newPost.id, { + const createdPost = await Posts.findByPk(post.id, { include: [ { model: User, @@ -356,53 +350,61 @@ router.post("/", authenticateJWT, async (req, res) => { ], }); - res.status(201).json(postWithAuthor); + res.status(201).json(createdPost); } catch (error) { console.error("Error creating post:", error); res.status(500).json({ error: "Failed to create post" }); } }); -// Create a draft post -router.post("/draft", authenticateJWT, async (req, res) => { +// Update a post +router.put("/:id", authenticateJWT, async (req, res) => { try { + const postId = req.params.id; + const userId = req.user.id; + + const post = await Posts.findByPk(postId); + if (!post) { + return res.status(404).json({ error: "Post not found" }); + } + + if (post.userId !== userId) { + return res.status(403).json({ error: "Not authorized to edit this post" }); + } + const { title, description, - spotifyId, + spotifyArtistId, + spotifyTrackId, + spotifyPlaylistId, + spotifyAlbumId, + spotifyArtistName, + spotifyTrackName, + spotifyPlaylistName, + spotifyAlbumName, spotifyType, spotifyEmbedUrl, - isPublic = true, + status, } = req.body; - if (!title || title.trim() === "") { - return res.status(400).json({ error: "Title is required" }); - } - - const postData = { - title: title.trim(), - description: description || "", - status: "draft", - userId: req.user.id, - isPublic: isPublic, - }; - - // Handle Spotify embed data - if (spotifyId && spotifyType) { - const validTypes = ["track", "album", "playlist", "artist"]; - if (!validTypes.includes(spotifyType)) { - return res.status(400).json({ error: "Invalid Spotify type" }); - } - - postData.spotifyId = spotifyId; - postData.spotifyType = spotifyType; - postData.spotifyEmbedUrl = spotifyEmbedUrl; - } - - const newDraft = await Posts.create(postData); + await post.update({ + title: title || post.title, + description: description || post.description, + spotifyArtistId: spotifyArtistId !== undefined ? spotifyArtistId : post.spotifyArtistId, + spotifyTrackId: spotifyTrackId !== undefined ? spotifyTrackId : post.spotifyTrackId, + spotifyPlaylistId: spotifyPlaylistId !== undefined ? spotifyPlaylistId : post.spotifyPlaylistId, + spotifyAlbumId: spotifyAlbumId !== undefined ? spotifyAlbumId : post.spotifyAlbumId, + spotifyArtistName: spotifyArtistName !== undefined ? spotifyArtistName : post.spotifyArtistName, + spotifyTrackName: spotifyTrackName !== undefined ? spotifyTrackName : post.spotifyTrackName, + spotifyPlaylistName: spotifyPlaylistName !== undefined ? spotifyPlaylistName : post.spotifyPlaylistName, + spotifyAlbumName: spotifyAlbumName !== undefined ? spotifyAlbumName : post.spotifyAlbumName, + spotifyType: spotifyType !== undefined ? spotifyType : post.spotifyType, + spotifyEmbedUrl: spotifyEmbedUrl !== undefined ? spotifyEmbedUrl : post.spotifyEmbedUrl, + status: status || post.status, + }); - // Fetch the created draft with author info - const draftWithAuthor = await Posts.findByPk(newDraft.id, { + const updatedPost = await Posts.findByPk(postId, { include: [ { model: User, @@ -419,129 +421,56 @@ router.post("/draft", authenticateJWT, async (req, res) => { ], }); - res.status(201).json(draftWithAuthor); + res.json(updatedPost); } catch (error) { - console.error("Error creating draft:", error); - res.status(500).json({ error: "Failed to create draft" }); + console.error("Error updating post:", error); + res.status(500).json({ error: "Failed to update post" }); } }); -// PATCH api/posts/draft/:id ------ Update a draft endpoint -router.patch("/draft/:id", authenticateJWT, async (req, res) => { +// Delete a post +router.delete("/:id", authenticateJWT, async (req, res) => { try { - const { title, description, spotifyId, spotifyType, isPublic } = req.body; - - if (title !== undefined && !title.trim()) { - return res.status(400).json({ error: "Title cannot be empty" }); - } + const postId = req.params.id; + const userId = req.user.id; - const updateData = {}; - if (title !== undefined) updateData.title = title.trim(); - if (description !== undefined) updateData.description = description || ""; - if (spotifyId !== undefined) updateData.spotifyId = spotifyId || null; - if (spotifyType !== undefined) { - if ( - spotifyType && - !["track", "album", "playlist", "artist"].includes(spotifyType) - ) { - return res.status(400).json({ error: "Invalid Spotify type" }); - } - updateData.spotifyType = spotifyType || null; + const post = await Posts.findByPk(postId); + if (!post) { + return res.status(404).json({ error: "Post not found" }); } - if (isPublic !== undefined) updateData.isPublic = isPublic; - const [updatedRowsCount] = await Posts.update(updateData, { - where: { - id: parseInt(req.params.id), - userId: req.user.id, - status: "draft", - }, - }); - - if (updatedRowsCount === 0) { - return res - .status(404) - .json({ error: "Draft post not found or unauthorized" }); + if (post.userId !== userId) { + return res.status(403).json({ error: "Not authorized to delete this post" }); } - // Fetch updated post with author info - const updatedPost = await Posts.findByPk(req.params.id, { - include: [ - { - model: User, - as: "author", - attributes: [ - "id", - "username", - "spotifyDisplayName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - }); - - res.json(updatedPost); + await post.destroy(); + res.status(204).end(); } catch (error) { - console.error("Error updating draft:", error); - res.status(500).json({ error: "Failed to update draft" }); + console.error("Error deleting post:", error); + res.status(500).json({ error: "Failed to delete post" }); } }); -// Publish a draft post -router.patch("/:id/publish", authenticateJWT, async (req, res) => { +router.post('/:id/like', authenticateJWT, async (req, res) => { try { - const [updatedRowsCount] = await Posts.update( - { status: "published" }, - { - where: { - id: parseInt(req.params.id), - userId: req.user.id, - status: "draft", - }, - } - ); + const postId = req.params.id; + const userId = req.user.id; - if (updatedRowsCount === 0) { - return res - .status(404) - .json({ error: "Draft post not found or unauthorized" }); + const post = await Posts.findByPk(postId); + if (!post) { + return res.status(404).json({ error: "Post not found" }); } - // Fetch updated post with author info - const publishedPost = await Posts.findByPk(req.params.id, { - include: [ - { - model: User, - as: "author", - attributes: [ - "id", - "username", - "spotifyDisplayName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - }); - - res.json(publishedPost); - } catch (error) { - console.error("Error publishing post:", error); - res.status(500).json({ error: "Failed to publish post" }); - } -}); - -// Like/Unlike a post -router.post('/:postId/like', authenticateJWT, async (req, res) => { - try { - const { postId } = req.params; - const userId = req.user.id; + const user = await User.findByPk(userId); + if (!user) { + return res.status(401).json({ error: 'User not found. Please log in again.' }); + } const existingLike = await PostLike.findOne({ - where: { postId, userId } + where: { + postId: postId, + userId: userId + } }); let isLiked; @@ -549,12 +478,15 @@ router.post('/:postId/like', authenticateJWT, async (req, res) => { await existingLike.destroy(); isLiked = false; } else { - await PostLike.create({ postId, userId }); + await PostLike.create({ + postId: postId, + userId: userId + }); isLiked = true; } const likesCount = await PostLike.count({ - where: { postId } + where: { postId: postId } }); res.json({ @@ -565,64 +497,180 @@ router.post('/:postId/like', authenticateJWT, async (req, res) => { } catch (error) { console.error('Error liking post:', error); + + if (error.name === 'SequelizeForeignKeyConstraintError') { + return res.status(401).json({ error: 'User not found. Please log in again.' }); + } + res.status(500).json({ error: 'Failed to like post' }); } }); -//Edit a post if post not published -router.patch("/:id", authenticateJWT, async (req, res) => { +router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { try { const postId = req.params.id; - const { title, description, content, status } = req.body; + const userId = req.user.id; + const { playlistName, isPublic, isCollaborative } = req.body; - const post = await Posts.findByPk(postId); + const [post, user] = await Promise.all([ + Posts.findByPk(postId, { + include: [{ model: User, as: "author", attributes: ["username", "spotifyDisplayName"] }], + }), + User.findByPk(userId), + ]); - if (!post) { - return res.status(404).json({ error: "Post not found" }); + if (!post) return res.status(404).json({ error: "Post not found" }); + if (!user) return res.status(401).json({ error: "Unauthorized" }); + + const type = (post.spotifyType || "").toLowerCase(); + if (type !== "playlist" && !post.spotifyEmbedUrl?.includes("/playlist/")) { + return res.status(400).json({ error: "Post is not a Spotify playlist" }); } - if (post.status === "published") { - return res - .status(403) - .json({ error: "Cannot edit already published post" }); + const sourcePlaylistId = extractPlaylistIdFromPost(post); + + if (!sourcePlaylistId) { + return res.status(400).json({ + error: "Playlist ID not found on post", + embedUrl: post.spotifyEmbedUrl + }); } - post.title = title || post.title; - post.description = description || post.description; - post.content = content || post.content; - post.status = status || post.status; + const isSpotifyCurated = sourcePlaylistId.startsWith('37i9dQ'); - await post.save(); + const accessToken = await ensureSpotifyAccessToken(user); - res.json(post); - } catch (error) { - console.error("Error updating post:", error); - res.status(500).json({ error: "Failed to update post" }); - } -}); + let testPlaylist = null; + let playlistFound = false; -//Delete a post -router.delete("/:id", authenticateJWT, async (req, res) => { - try { - const postId = req.params.id; - const post = await Posts.findByPk(postId); + let testResp = await fetch(`https://api.spotify.com/v1/playlists/${sourcePlaylistId}?fields=id,name,public,collaborative,owner`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); - if (!post) { - return res.status(404).json({ error: "Post not found" }); + if (testResp.ok) { + testPlaylist = await testResp.json(); + playlistFound = true; + } else { + const tracksResp = await fetch(`https://api.spotify.com/v1/playlists/${sourcePlaylistId}/tracks?limit=1&fields=total`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (tracksResp.ok) { + const tracksData = await tracksResp.json(); + + testPlaylist = { + id: sourcePlaylistId, + name: `Spotify Playlist ${sourcePlaylistId}`, + public: true, + owner: { id: 'spotify', display_name: 'Spotify' } + }; + playlistFound = true; + } } - if (post.userId !== req.user.id) { - return res - .status(403) - .json({ error: "Unauthorized to delete this post" }); + if (!playlistFound) { + const errorText = await testResp.text(); + + let errorData; + try { + errorData = JSON.parse(errorText); + } catch (e) { + errorData = { error: { message: errorText } }; + } + + if (isSpotifyCurated) { + return res.status(400).json({ + error: "This Spotify playlist cannot be forked due to API restrictions. Try with a user-created playlist instead.", + extractedId: sourcePlaylistId, + originalUrl: post.spotifyEmbedUrl, + isSpotifyCurated: true + }); + } else { + return res.status(400).json({ + error: "Spotify playlist not found. The playlist may be private, deleted, or the link may be broken.", + extractedId: sourcePlaylistId, + originalUrl: post.spotifyEmbedUrl, + details: errorData + }); + } } - await post.destroy(); - res.json({ message: "Post deleted successfully" }); - } catch (error) { - console.error("Error deleting post:", error); - res.status(500).json({ error: "Failed to delete post" }); + const meResp = await fetch("https://api.spotify.com/v1/me", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!meResp.ok) { + const t = await meResp.text(); + return res.status(400).json({ error: `Failed to get Spotify profile: ${t}` }); + } + const me = await meResp.json(); + + const src = testPlaylist; + const authorName = post.author?.username || post.author?.spotifyDisplayName || null; + const newDesc = `Forked from ${src.name}${authorName ? ` by ${authorName}` : ""} via ${ + process.env.APP_NAME || "CapStone" + }`; + + const createResp = await fetch(`https://api.spotify.com/v1/users/${me.id}/playlists`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: playlistName || `Fork β€’ ${src.name}`, + description: newDesc, + public: isPublic !== undefined ? isPublic : true, + collaborative: isCollaborative || false + }), + }); + if (!createResp.ok) { + const t = await createResp.text(); + return res.status(400).json({ error: `Failed to create playlist: ${t}` }); + } + const created = await createResp.json(); + + try { + const uris = await fetchAllPlaylistTrackUris(accessToken, sourcePlaylistId); + + if (uris.length === 0) { + return res.json({ + success: true, + message: "Playlist forked successfully, but no tracks were found to copy.", + playlistId: created.id, + playlistUrl: created.external_urls?.spotify || null, + trackCount: 0, + }); + } + + for (const group of chunk(uris, 100)) { + const addResp = await fetch(`https://api.spotify.com/v1/playlists/${created.id}/tracks`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ uris: group }), + }); + if (!addResp.ok) { + const t = await addResp.text(); + return res.status(400).json({ error: `Failed adding tracks: ${t}` }); + } + } + + return res.json({ + success: true, + message: "Playlist successfully forked and added to your Spotify library.", + playlistId: created.id, + playlistUrl: created.external_urls?.spotify || null, + trackCount: uris.length, + }); + } catch (trackError) { + return res.status(400).json({ error: "Could not access playlist tracks. The playlist may be private or restricted." }); + } + } catch (err) { + console.error("Fork playlist error:", err); + res.status(500).json({ error: "Failed to fork playlist" }); } }); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/auth/spotify.js b/auth/spotify.js index ab92fca..d58585c 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -38,7 +38,7 @@ const refreshSpotifyToken = async (user) => { ); const { access_token, expires_in } = response.data; - + await user.update({ spotifyAccessToken: access_token, spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), @@ -70,10 +70,11 @@ const getValidSpotifyToken = async (user) => { //this creates the username for a user based on the username provided by spotify const generateUsername = async (spotifyProfile) => { - let baseUsername = spotifyProfile.display_name || spotifyProfile.id || 'spotify_user'; - + let baseUsername = + spotifyProfile.display_name || spotifyProfile.id || "spotify_user"; + baseUsername = baseUsername - .replace(/[^a-zA-Z0-9_]/g, '_') + .replace(/[^a-zA-Z0-9_]/g, "_") .substring(0, 15) .toLowerCase(); @@ -87,13 +88,13 @@ const generateUsername = async (spotifyProfile) => { let finalUsername = baseUsername; let counter = 1; - + while (await User.findOne({ where: { username: finalUsername } })) { const suffix = `_${counter}`; const maxLength = 20 - suffix.length; finalUsername = baseUsername.substring(0, maxLength) + suffix; counter++; - + if (counter > 1000) { finalUsername = `spotify_${Date.now()}`.substring(0, 20); break; @@ -107,22 +108,25 @@ const generateUsername = async (spotifyProfile) => { router.get("/login-url", (req, res) => { try { const scopes = [ - 'user-read-private', - 'user-read-email', - 'user-top-read', - 'user-read-recently-played', - 'playlist-read-private', - 'playlist-read-collaborative' - ].join(' '); - - const authUrl = 'https://accounts.spotify.com/authorize?' + + "user-read-private", + "user-read-email", + "user-top-read", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + ].join(" "); + + const authUrl = + "https://accounts.spotify.com/authorize?" + new URLSearchParams({ - response_type: 'code', + response_type: "code", client_id: SPOTIFY_CLIENT_ID, scope: scopes, redirect_uri: `${FRONTEND_URL}/callback/spotify`, - state: 'spotify_login', - show_dialog: true + state: "spotify_login", + show_dialog: true, }); res.json({ authUrl }); @@ -136,22 +140,25 @@ router.get("/login-url", (req, res) => { router.get("/auth-url", authenticateJWT, (req, res) => { try { const scopes = [ - 'user-read-private', - 'user-read-email', - 'user-top-read', - 'user-read-recently-played', - 'playlist-read-private', - 'playlist-read-collaborative' - ].join(' '); - - const authUrl = 'https://accounts.spotify.com/authorize?' + + "user-read-private", + "user-read-email", + "user-top-read", + "user-read-recently-played", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + ].join(" "); + + const authUrl = + "https://accounts.spotify.com/authorize?" + new URLSearchParams({ - response_type: 'code', + response_type: "code", client_id: SPOTIFY_CLIENT_ID, scope: scopes, redirect_uri: `${FRONTEND_URL}/callback/spotify`, state: req.user.id.toString(), - show_dialog: true + show_dialog: true, }); res.json({ authUrl }); @@ -228,21 +235,23 @@ router.post("/login", async (req, res) => { user = await User.create(userData); } catch (validationError) { - if (validationError.name === 'SequelizeValidationError') { - const errorMessages = validationError.errors.map(err => err.message); - return res.status(400).json({ - error: "User validation failed", - details: errorMessages.join(', ') + if (validationError.name === "SequelizeValidationError") { + const errorMessages = validationError.errors.map( + (err) => err.message + ); + return res.status(400).json({ + error: "User validation failed", + details: errorMessages.join(", "), }); } - - if (validationError.name === 'SequelizeUniqueConstraintError') { - return res.status(400).json({ - error: "User already exists with this data", - details: validationError.message + + if (validationError.name === "SequelizeUniqueConstraintError") { + return res.status(400).json({ + error: "User already exists with this data", + details: validationError.message, }); } - + throw validationError; } } else { @@ -250,8 +259,10 @@ router.post("/login", async (req, res) => { spotifyAccessToken: access_token, spotifyRefreshToken: refresh_token, spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), - spotifyDisplayName: spotifyProfile.display_name || user.spotifyDisplayName, - spotifyProfileImage: spotifyProfile.images?.[0]?.url || user.spotifyProfileImage, + spotifyDisplayName: + spotifyProfile.display_name || user.spotifyDisplayName, + spotifyProfileImage: + spotifyProfile.images?.[0]?.url || user.spotifyProfileImage, }); } @@ -267,7 +278,7 @@ router.post("/login", async (req, res) => { ); res.cookie("token", token, cookieSettings); - + res.json({ message: "Spotify login successful", token: token, @@ -279,17 +290,18 @@ router.post("/login", async (req, res) => { }); } catch (error) { console.error("Spotify login error:", error.message); - + if (error.response?.status === 400) { - return res.status(400).json({ + return res.status(400).json({ error: "Invalid authorization code or expired", - details: error.response?.data + details: error.response?.data, }); } - - res.status(500).json({ + + res.status(500).json({ error: "Failed to login with Spotify", - details: process.env.NODE_ENV === 'development' ? error.message : undefined + details: + process.env.NODE_ENV === "development" ? error.message : undefined, }); } }); @@ -355,7 +367,7 @@ router.post("/callback", authenticateJWT, async (req, res) => { router.get("/profile", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); - + if (!user.spotifyId) { return res.json({ connected: false }); } @@ -365,7 +377,9 @@ router.get("/profile", authenticateJWT, async (req, res) => { profile: { id: user.spotifyId, display_name: user.spotifyDisplayName, - images: user.spotifyProfileImage ? [{ url: user.spotifyProfileImage }] : [], + images: user.spotifyProfileImage + ? [{ url: user.spotifyProfileImage }] + : [], }, }); } catch (error) { @@ -381,15 +395,18 @@ router.get("/top-tracks", authenticateJWT, async (req, res) => { const accessToken = await getValidSpotifyToken(user); const timeRange = req.query.time_range || "long_term"; - const response = await axios.get("https://api.spotify.com/v1/me/top/tracks", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 20, - time_range: timeRange, - }, - }); + const response = await axios.get( + "https://api.spotify.com/v1/me/top/tracks", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 20, + time_range: timeRange, + }, + } + ); res.json(response.data); } catch (error) { @@ -406,46 +423,53 @@ router.get("/top-artists", authenticateJWT, async (req, res) => { try { const user = await User.findByPk(req.user.id); const accessToken = await getValidSpotifyToken(user); - + const timeRanges = ["short_term", "medium_term", "long_term"]; let bestResult = null; - + for (const timeRange of timeRanges) { try { - const response = await axios.get("https://api.spotify.com/v1/me/top/artists", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 20, - time_range: timeRange, - }, - }); - + const response = await axios.get( + "https://api.spotify.com/v1/me/top/artists", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 20, + time_range: timeRange, + }, + } + ); + if (response.data.items.length > 0) { return res.json({ ...response.data, - time_range_used: timeRange + time_range_used: timeRange, }); } - + if (!bestResult) { bestResult = { ...response.data, - time_range_used: timeRange + time_range_used: timeRange, }; } } catch (error) { - console.error(`Error getting top artists for ${timeRange}:`, error.message); + console.error( + `Error getting top artists for ${timeRange}:`, + error.message + ); } } - - res.json(bestResult || { - items: [], - total: 0, - message: "No listening history found for artists." - }); - + + res.json( + bestResult || { + items: [], + total: 0, + message: "No listening history found for artists.", + } + ); } catch (error) { if (error.message === "Spotify not connected") { return res.status(401).json({ error: "Spotify not connected" }); @@ -461,15 +485,18 @@ router.get("/playlists", authenticateJWT, async (req, res) => { const user = await User.findByPk(req.user.id); const accessToken = await getValidSpotifyToken(user); - const response = await axios.get("https://api.spotify.com/v1/me/playlists", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 50, // Get up to 50 playlists - offset: 0, - }, - }); + const response = await axios.get( + "https://api.spotify.com/v1/me/playlists", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 50, // Get up to 50 playlists + offset: 0, + }, + } + ); res.json(response.data); } catch (error) { @@ -488,11 +515,14 @@ router.get("/playlists/:id", authenticateJWT, async (req, res) => { const accessToken = await getValidSpotifyToken(user); const playlistId = req.params.id; - const response = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const response = await axios.get( + `https://api.spotify.com/v1/playlists/${playlistId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); res.json(response.data); } catch (error) { @@ -511,15 +541,18 @@ router.get("/playlists/:id/tracks", authenticateJWT, async (req, res) => { const accessToken = await getValidSpotifyToken(user); const playlistId = req.params.id; - const response = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 100, - offset: 0, - }, - }); + const response = await axios.get( + `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 100, + offset: 0, + }, + } + ); res.json(response.data); } catch (error) { @@ -550,4 +583,4 @@ router.delete("/disconnect", authenticateJWT, async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; From 58145588900e29064e4f7efe4f0298ecaadeb240 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Wed, 13 Aug 2025 18:46:41 -0400 Subject: [PATCH 62/77] cors --- app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.js b/app.js index 7efd70d..208c7de 100644 --- a/app.js +++ b/app.js @@ -18,7 +18,7 @@ const corsOptions = { origin: [ "http://localhost:3000", "http://127.0.0.1:3000", - "https://capstone-2-frontend-one.vercel.app", + "https://capstone-2-frontend-tan.vercel.app", ], credentials: true, methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], From 0deef288d0d83d76f36f8006897eaefb226057fc Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 14 Aug 2025 11:28:10 -0400 Subject: [PATCH 63/77] added route for following-status --- api/follow.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/api/follow.js b/api/follow.js index 3fb5bb3..9dd36c9 100644 --- a/api/follow.js +++ b/api/follow.js @@ -2,6 +2,7 @@ const express = require("express"); const router = express.Router(); const { User, Posts, Follows } = require("../database"); const { authenticateJWT } = require("../auth"); +const { Op } = require("sequelize"); // Check if current user is following another user router.get("/:username/following-status", authenticateJWT, async (req, res) => { @@ -129,4 +130,43 @@ router.get("/:username/friends", authenticateJWT, async (req, res) => { } }); +// +router.get("/all-with-follow-status", authenticateJWT, async (req, res) => { + try { + const meId = req.user.id; + + // Get all users except me + const users = await User.findAll({ + where: { id: { [Op.ne]: meId } }, + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }); + + // Get IDs of users I’m following + const following = await Follows.findAll({ + where: { followerId: meId }, + attributes: ["followingId"], + }); + const followingIds = new Set(following.map((f) => f.followingId)); + + // Attach isFollowing flag to each user + const result = users.map((u) => ({ + ...u.toJSON(), + isFollowing: followingIds.has(u.id), + })); + + res.json(result); + } catch (error) { + console.error("Error fetching users with follow status:", error); + res.status(500).json({ error: "Failed to fetch follow data" }); + } +}); + module.exports = router; From 19ab096426b5bae1870a9cd15c2d193e8f258544 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 14 Aug 2025 13:52:32 -0400 Subject: [PATCH 64/77] Revert "added route for following-status" (#59) --- api/follow.js | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/api/follow.js b/api/follow.js index 9dd36c9..3fb5bb3 100644 --- a/api/follow.js +++ b/api/follow.js @@ -2,7 +2,6 @@ const express = require("express"); const router = express.Router(); const { User, Posts, Follows } = require("../database"); const { authenticateJWT } = require("../auth"); -const { Op } = require("sequelize"); // Check if current user is following another user router.get("/:username/following-status", authenticateJWT, async (req, res) => { @@ -130,43 +129,4 @@ router.get("/:username/friends", authenticateJWT, async (req, res) => { } }); -// -router.get("/all-with-follow-status", authenticateJWT, async (req, res) => { - try { - const meId = req.user.id; - - // Get all users except me - const users = await User.findAll({ - where: { id: { [Op.ne]: meId } }, - attributes: [ - "id", - "username", - "firstName", - "lastName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }); - - // Get IDs of users I’m following - const following = await Follows.findAll({ - where: { followerId: meId }, - attributes: ["followingId"], - }); - const followingIds = new Set(following.map((f) => f.followingId)); - - // Attach isFollowing flag to each user - const result = users.map((u) => ({ - ...u.toJSON(), - isFollowing: followingIds.has(u.id), - })); - - res.json(result); - } catch (error) { - console.error("Error fetching users with follow status:", error); - res.status(500).json({ error: "Failed to fetch follow data" }); - } -}); - module.exports = router; From b47bad158734d27536dfe088e0a472674f1fa51f Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 14 Aug 2025 14:00:13 -0400 Subject: [PATCH 65/77] temp --- api/follow.js | 2 +- api/posts.js | 310 +++++++++++++++++++++++++++++++------------------- 2 files changed, 195 insertions(+), 117 deletions(-) diff --git a/api/follow.js b/api/follow.js index 9dd36c9..6ad1da1 100644 --- a/api/follow.js +++ b/api/follow.js @@ -130,7 +130,7 @@ router.get("/:username/friends", authenticateJWT, async (req, res) => { } }); -// +//get all users with follow status router.get("/all-with-follow-status", authenticateJWT, async (req, res) => { try { const meId = req.user.id; diff --git a/api/posts.js b/api/posts.js index fcbe9b5..b9ffe44 100644 --- a/api/posts.js +++ b/api/posts.js @@ -9,7 +9,9 @@ async function ensureSpotifyAccessToken(user) { } const now = Date.now(); - const expMs = user.spotifyTokenExpiresAt ? new Date(user.spotifyTokenExpiresAt).getTime() : 0; + const expMs = user.spotifyTokenExpiresAt + ? new Date(user.spotifyTokenExpiresAt).getTime() + : 0; const needsRefresh = !user.spotifyAccessToken || expMs - now < 60000; if (!needsRefresh) return user.spotifyAccessToken; @@ -39,7 +41,9 @@ async function ensureSpotifyAccessToken(user) { const data = await resp.json(); user.spotifyAccessToken = data.access_token; if (data.expires_in) { - user.spotifyTokenExpiresAt = new Date(Date.now() + (data.expires_in - 60) * 1000); + user.spotifyTokenExpiresAt = new Date( + Date.now() + (data.expires_in - 60) * 1000 + ); } if (data.refresh_token) { user.spotifyRefreshToken = data.refresh_token; @@ -50,7 +54,7 @@ async function ensureSpotifyAccessToken(user) { function extractPlaylistIdFromPost(post) { const embedUrl = post.spotifyEmbedUrl; - + if (!embedUrl || typeof embedUrl !== "string") { return null; } @@ -58,7 +62,9 @@ function extractPlaylistIdFromPost(post) { // Handle Spotify embed URLs // Format: https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M?utm_source=generator if (embedUrl.includes("open.spotify.com/embed/playlist/")) { - const match = embedUrl.match(/open\.spotify\.com\/embed\/playlist\/([a-zA-Z0-9]+)/); + const match = embedUrl.match( + /open\.spotify\.com\/embed\/playlist\/([a-zA-Z0-9]+)/ + ); if (match && match[1]) { return match[1]; } @@ -67,7 +73,9 @@ function extractPlaylistIdFromPost(post) { // Handle regular Spotify playlist URLs (just in case) // Format: https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M if (embedUrl.includes("open.spotify.com/playlist/")) { - const match = embedUrl.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/); + const match = embedUrl.match( + /open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/ + ); if (match && match[1]) { return match[1]; } @@ -125,7 +133,7 @@ router.get("/feed", async (req, res) => { as: "author", attributes: [ "username", - "id", + "id", "spotifyDisplayName", "profileImage", "spotifyProfileImage", @@ -135,22 +143,25 @@ router.get("/feed", async (req, res) => { { model: PostLike, as: "likes", - include: [{ - model: User, - as: "user", - attributes: ["id"] - }] - } + include: [ + { + model: User, + as: "user", + attributes: ["id"], + }, + ], + }, ], order: [["createdAt", "DESC"]], }); // Add like information to each post - const postsWithLikes = posts.map(post => { + const postsWithLikes = posts.map((post) => { const postData = post.toJSON(); postData.likesCount = postData.likes ? postData.likes.length : 0; - postData.isLiked = userId ? - postData.likes?.some(like => like.user.id === userId) : false; + postData.isLiked = userId + ? postData.likes?.some((like) => like.user.id === userId) + : false; return postData; }); @@ -173,7 +184,7 @@ router.get("/", async (req, res) => { attributes: [ "username", "id", - "spotifyDisplayName", + "spotifyDisplayName", "profileImage", "spotifyProfileImage", "avatarURL", @@ -182,21 +193,24 @@ router.get("/", async (req, res) => { { model: PostLike, as: "likes", - include: [{ - model: User, - as: "user", - attributes: ["id"] - }] - } + include: [ + { + model: User, + as: "user", + attributes: ["id"], + }, + ], + }, ], order: [["createdAt", "DESC"]], }); - const postsWithLikes = posts.map(post => { + const postsWithLikes = posts.map((post) => { const postData = post.toJSON(); postData.likesCount = postData.likes ? postData.likes.length : 0; - postData.isLiked = userId ? - postData.likes?.some(like => like.user.id === userId) : false; + postData.isLiked = userId + ? postData.likes?.some((like) => like.user.id === userId) + : false; return postData; }); @@ -208,7 +222,7 @@ router.get("/", async (req, res) => { }); // Get posts if status === draft -router.get("/drafts", authenticateJWT, async (req, res) => { +router.get("/draft", authenticateJWT, async (req, res) => { try { const userId = req.user.id; @@ -264,12 +278,14 @@ router.get("/:id", async (req, res) => { { model: PostLike, as: "likes", - include: [{ - model: User, - as: "user", - attributes: ["id"] - }] - } + include: [ + { + model: User, + as: "user", + attributes: ["id"], + }, + ], + }, ], }); @@ -279,8 +295,9 @@ router.get("/:id", async (req, res) => { const postData = post.toJSON(); postData.likesCount = postData.likes ? postData.likes.length : 0; - postData.isLiked = userId ? - postData.likes?.some(like => like.user.id === userId) : false; + postData.isLiked = userId + ? postData.likes?.some((like) => like.user.id === userId) + : false; res.json(postData); } catch (error) { @@ -312,7 +329,9 @@ router.post("/", authenticateJWT, async (req, res) => { const userId = req.user.id; if (!title || !description) { - return res.status(400).json({ error: "Title and description are required" }); + return res + .status(400) + .json({ error: "Title and description are required" }); } const post = await Posts.create({ @@ -369,7 +388,9 @@ router.put("/:id", authenticateJWT, async (req, res) => { } if (post.userId !== userId) { - return res.status(403).json({ error: "Not authorized to edit this post" }); + return res + .status(403) + .json({ error: "Not authorized to edit this post" }); } const { @@ -391,16 +412,35 @@ router.put("/:id", authenticateJWT, async (req, res) => { await post.update({ title: title || post.title, description: description || post.description, - spotifyArtistId: spotifyArtistId !== undefined ? spotifyArtistId : post.spotifyArtistId, - spotifyTrackId: spotifyTrackId !== undefined ? spotifyTrackId : post.spotifyTrackId, - spotifyPlaylistId: spotifyPlaylistId !== undefined ? spotifyPlaylistId : post.spotifyPlaylistId, - spotifyAlbumId: spotifyAlbumId !== undefined ? spotifyAlbumId : post.spotifyAlbumId, - spotifyArtistName: spotifyArtistName !== undefined ? spotifyArtistName : post.spotifyArtistName, - spotifyTrackName: spotifyTrackName !== undefined ? spotifyTrackName : post.spotifyTrackName, - spotifyPlaylistName: spotifyPlaylistName !== undefined ? spotifyPlaylistName : post.spotifyPlaylistName, - spotifyAlbumName: spotifyAlbumName !== undefined ? spotifyAlbumName : post.spotifyAlbumName, + spotifyArtistId: + spotifyArtistId !== undefined ? spotifyArtistId : post.spotifyArtistId, + spotifyTrackId: + spotifyTrackId !== undefined ? spotifyTrackId : post.spotifyTrackId, + spotifyPlaylistId: + spotifyPlaylistId !== undefined + ? spotifyPlaylistId + : post.spotifyPlaylistId, + spotifyAlbumId: + spotifyAlbumId !== undefined ? spotifyAlbumId : post.spotifyAlbumId, + spotifyArtistName: + spotifyArtistName !== undefined + ? spotifyArtistName + : post.spotifyArtistName, + spotifyTrackName: + spotifyTrackName !== undefined + ? spotifyTrackName + : post.spotifyTrackName, + spotifyPlaylistName: + spotifyPlaylistName !== undefined + ? spotifyPlaylistName + : post.spotifyPlaylistName, + spotifyAlbumName: + spotifyAlbumName !== undefined + ? spotifyAlbumName + : post.spotifyAlbumName, spotifyType: spotifyType !== undefined ? spotifyType : post.spotifyType, - spotifyEmbedUrl: spotifyEmbedUrl !== undefined ? spotifyEmbedUrl : post.spotifyEmbedUrl, + spotifyEmbedUrl: + spotifyEmbedUrl !== undefined ? spotifyEmbedUrl : post.spotifyEmbedUrl, status: status || post.status, }); @@ -440,7 +480,9 @@ router.delete("/:id", authenticateJWT, async (req, res) => { } if (post.userId !== userId) { - return res.status(403).json({ error: "Not authorized to delete this post" }); + return res + .status(403) + .json({ error: "Not authorized to delete this post" }); } await post.destroy(); @@ -451,7 +493,7 @@ router.delete("/:id", authenticateJWT, async (req, res) => { } }); -router.post('/:id/like', authenticateJWT, async (req, res) => { +router.post("/:id/like", authenticateJWT, async (req, res) => { try { const postId = req.params.id; const userId = req.user.id; @@ -463,14 +505,16 @@ router.post('/:id/like', authenticateJWT, async (req, res) => { const user = await User.findByPk(userId); if (!user) { - return res.status(401).json({ error: 'User not found. Please log in again.' }); + return res + .status(401) + .json({ error: "User not found. Please log in again." }); } const existingLike = await PostLike.findOne({ - where: { + where: { postId: postId, - userId: userId - } + userId: userId, + }, }); let isLiked; @@ -478,31 +522,32 @@ router.post('/:id/like', authenticateJWT, async (req, res) => { await existingLike.destroy(); isLiked = false; } else { - await PostLike.create({ + await PostLike.create({ postId: postId, - userId: userId + userId: userId, }); isLiked = true; } const likesCount = await PostLike.count({ - where: { postId: postId } + where: { postId: postId }, }); - + res.json({ success: true, isLiked, - likesCount + likesCount, }); - } catch (error) { - console.error('Error liking post:', error); - - if (error.name === 'SequelizeForeignKeyConstraintError') { - return res.status(401).json({ error: 'User not found. Please log in again.' }); + console.error("Error liking post:", error); + + if (error.name === "SequelizeForeignKeyConstraintError") { + return res + .status(401) + .json({ error: "User not found. Please log in again." }); } - - res.status(500).json({ error: 'Failed to like post' }); + + res.status(500).json({ error: "Failed to like post" }); } }); @@ -514,7 +559,13 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { const [post, user] = await Promise.all([ Posts.findByPk(postId, { - include: [{ model: User, as: "author", attributes: ["username", "spotifyDisplayName"] }], + include: [ + { + model: User, + as: "author", + attributes: ["username", "spotifyDisplayName"], + }, + ], }), User.findByPk(userId), ]); @@ -528,41 +579,47 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { } const sourcePlaylistId = extractPlaylistIdFromPost(post); - + if (!sourcePlaylistId) { - return res.status(400).json({ + return res.status(400).json({ error: "Playlist ID not found on post", - embedUrl: post.spotifyEmbedUrl + embedUrl: post.spotifyEmbedUrl, }); } - const isSpotifyCurated = sourcePlaylistId.startsWith('37i9dQ'); + const isSpotifyCurated = sourcePlaylistId.startsWith("37i9dQ"); const accessToken = await ensureSpotifyAccessToken(user); let testPlaylist = null; let playlistFound = false; - let testResp = await fetch(`https://api.spotify.com/v1/playlists/${sourcePlaylistId}?fields=id,name,public,collaborative,owner`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); + let testResp = await fetch( + `https://api.spotify.com/v1/playlists/${sourcePlaylistId}?fields=id,name,public,collaborative,owner`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ); if (testResp.ok) { testPlaylist = await testResp.json(); playlistFound = true; } else { - const tracksResp = await fetch(`https://api.spotify.com/v1/playlists/${sourcePlaylistId}/tracks?limit=1&fields=total`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); + const tracksResp = await fetch( + `https://api.spotify.com/v1/playlists/${sourcePlaylistId}/tracks?limit=1&fields=total`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ); if (tracksResp.ok) { const tracksData = await tracksResp.json(); - + testPlaylist = { id: sourcePlaylistId, name: `Spotify Playlist ${sourcePlaylistId}`, public: true, - owner: { id: 'spotify', display_name: 'Spotify' } + owner: { id: "spotify", display_name: "Spotify" }, }; playlistFound = true; } @@ -570,27 +627,29 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { if (!playlistFound) { const errorText = await testResp.text(); - + let errorData; try { errorData = JSON.parse(errorText); } catch (e) { errorData = { error: { message: errorText } }; } - + if (isSpotifyCurated) { - return res.status(400).json({ - error: "This Spotify playlist cannot be forked due to API restrictions. Try with a user-created playlist instead.", + return res.status(400).json({ + error: + "This Spotify playlist cannot be forked due to API restrictions. Try with a user-created playlist instead.", extractedId: sourcePlaylistId, originalUrl: post.spotifyEmbedUrl, - isSpotifyCurated: true + isSpotifyCurated: true, }); } else { - return res.status(400).json({ - error: "Spotify playlist not found. The playlist may be private, deleted, or the link may be broken.", + return res.status(400).json({ + error: + "Spotify playlist not found. The playlist may be private, deleted, or the link may be broken.", extractedId: sourcePlaylistId, originalUrl: post.spotifyEmbedUrl, - details: errorData + details: errorData, }); } } @@ -600,29 +659,35 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { }); if (!meResp.ok) { const t = await meResp.text(); - return res.status(400).json({ error: `Failed to get Spotify profile: ${t}` }); + return res + .status(400) + .json({ error: `Failed to get Spotify profile: ${t}` }); } const me = await meResp.json(); const src = testPlaylist; - const authorName = post.author?.username || post.author?.spotifyDisplayName || null; - const newDesc = `Forked from ${src.name}${authorName ? ` by ${authorName}` : ""} via ${ - process.env.APP_NAME || "CapStone" - }`; - - const createResp = await fetch(`https://api.spotify.com/v1/users/${me.id}/playlists`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: playlistName || `Fork β€’ ${src.name}`, - description: newDesc, - public: isPublic !== undefined ? isPublic : true, - collaborative: isCollaborative || false - }), - }); + const authorName = + post.author?.username || post.author?.spotifyDisplayName || null; + const newDesc = `Forked from ${src.name}${ + authorName ? ` by ${authorName}` : "" + } via ${process.env.APP_NAME || "CapStone"}`; + + const createResp = await fetch( + `https://api.spotify.com/v1/users/${me.id}/playlists`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: playlistName || `Fork β€’ ${src.name}`, + description: newDesc, + public: isPublic !== undefined ? isPublic : true, + collaborative: isCollaborative || false, + }), + } + ); if (!createResp.ok) { const t = await createResp.text(); return res.status(400).json({ error: `Failed to create playlist: ${t}` }); @@ -630,12 +695,16 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { const created = await createResp.json(); try { - const uris = await fetchAllPlaylistTrackUris(accessToken, sourcePlaylistId); + const uris = await fetchAllPlaylistTrackUris( + accessToken, + sourcePlaylistId + ); if (uris.length === 0) { return res.json({ success: true, - message: "Playlist forked successfully, but no tracks were found to copy.", + message: + "Playlist forked successfully, but no tracks were found to copy.", playlistId: created.id, playlistUrl: created.external_urls?.spotify || null, trackCount: 0, @@ -643,14 +712,17 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { } for (const group of chunk(uris, 100)) { - const addResp = await fetch(`https://api.spotify.com/v1/playlists/${created.id}/tracks`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ uris: group }), - }); + const addResp = await fetch( + `https://api.spotify.com/v1/playlists/${created.id}/tracks`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ uris: group }), + } + ); if (!addResp.ok) { const t = await addResp.text(); return res.status(400).json({ error: `Failed adding tracks: ${t}` }); @@ -659,13 +731,19 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { return res.json({ success: true, - message: "Playlist successfully forked and added to your Spotify library.", + message: + "Playlist successfully forked and added to your Spotify library.", playlistId: created.id, playlistUrl: created.external_urls?.spotify || null, trackCount: uris.length, }); } catch (trackError) { - return res.status(400).json({ error: "Could not access playlist tracks. The playlist may be private or restricted." }); + return res + .status(400) + .json({ + error: + "Could not access playlist tracks. The playlist may be private or restricted.", + }); } } catch (err) { console.error("Fork playlist error:", err); @@ -673,4 +751,4 @@ router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; From 115212413525c9091f37b71c9e58590e852de844 Mon Sep 17 00:00:00 2001 From: Franccesco Petta Date: Thu, 14 Aug 2025 14:19:29 -0400 Subject: [PATCH 66/77] fixed my post filtering, drafting and publishing --- api/posts.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/api/posts.js b/api/posts.js index fcbe9b5..709783f 100644 --- a/api/posts.js +++ b/api/posts.js @@ -207,6 +207,77 @@ router.get("/", async (req, res) => { } }); +// my post route +router.get("/my", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + const posts = await Posts.findAll({ + where: { userId }, + include: [ + { + model: User, + as: "author", + attributes: [ + "id", "username", "spotifyDisplayName", "profileImage", + "spotifyProfileImage", "avatarURL" + ], + }, + { + model: PostLike, + as: "likes", + include: [{ + model: User, + as: "user", + attributes: ["id"] + }] + }, + { + model: Comments, + as: "comments", + include: [{ + model: User, + as: "author", + attributes: ["id", "username", "profileImage"] + }] + } + ], + order: [["createdAt", "DESC"]], + }); + + const postsWithLikes = posts.map(post => { + const postData = post.toJSON(); + postData.likesCount = postData.likes ? postData.likes.length : 0; + postData.isLiked = postData.likes?.some(like => like.user.id === userId) || false; + postData.commentsCount = postData.comments ? postData.comments.length : 0; + return postData; + }); + + res.json(postsWithLikes); + } catch (error) { + console.error("Error fetching user's posts:", error); + res.status(500).json({ error: "Failed to fetch your posts" }); + } +}); + +router.patch("/:id", authenticateJWT, async (req, res) => { + try { + const postId = req.params.id; + const userId = req.user.id; + const updateData = req.body; + + const post = await Posts.findOne({ where: { id: postId, userId } }); + if (!post) { + return res.status(404).json({ error: "Post not found or not authorized" }); + } + + await post.update(updateData); + res.json({ success: true, post }); + } catch (error) { + console.error("Error updating post:", error); + res.status(500).json({ error: "Failed to update post" }); + } +}); + // Get posts if status === draft router.get("/drafts", authenticateJWT, async (req, res) => { try { From e6bac3136313f00c823e85f41c3b09c94499186e Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Thu, 14 Aug 2025 15:30:26 -0400 Subject: [PATCH 67/77] added route for following --- api/follow.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/follow.js b/api/follow.js index 4d26c63..6ad1da1 100644 --- a/api/follow.js +++ b/api/follow.js @@ -2,6 +2,7 @@ const express = require("express"); const router = express.Router(); const { User, Posts, Follows } = require("../database"); const { authenticateJWT } = require("../auth"); +const { Op } = require("sequelize"); // Check if current user is following another user router.get("/:username/following-status", authenticateJWT, async (req, res) => { From 8d0fe6e7787e205bf0576790d12237441e73e308 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 14 Aug 2025 18:49:53 -0400 Subject: [PATCH 68/77] Fixed search componenet, now returns user saved playlist instead of random playlist (#61) --- api/search.js | 181 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 131 insertions(+), 50 deletions(-) diff --git a/api/search.js b/api/search.js index d9a41ab..af06b2b 100644 --- a/api/search.js +++ b/api/search.js @@ -1,5 +1,7 @@ const express = require("express"); const axios = require("axios"); +const { authenticateJWT } = require("../auth"); +const { User } = require("../database"); const router = express.Router(); @@ -9,6 +11,53 @@ const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; let spotifyAccessToken = null; let tokenExpiresAt = null; +const refreshSpotifyToken = async (user) => { + try { + const response = await axios.post( + "https://accounts.spotify.com/api/token", + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: user.spotifyRefreshToken, + client_id: SPOTIFY_CLIENT_ID, + client_secret: SPOTIFY_CLIENT_SECRET, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + const { access_token, expires_in } = response.data; + + await user.update({ + spotifyAccessToken: access_token, + spotifyTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + }); + + return access_token; + } catch (error) { + console.error("Failed to refresh Spotify token:", error.message); + throw new Error("Token refresh failed"); + } +}; + +const getValidSpotifyToken = async (user) => { + if (!user.spotifyAccessToken) { + throw new Error("Spotify not connected"); + } + + if (user.isSpotifyTokenValid()) { + return user.spotifyAccessToken; + } + + if (!user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); + } + + return await refreshSpotifyToken(user); +}; + const getSpotifyClientToken = async () => { try { if (spotifyAccessToken && tokenExpiresAt && new Date() < tokenExpiresAt) { @@ -42,16 +91,18 @@ const getSpotifyClientToken = async () => { const formatTracks = (tracks) => { if (!tracks || !tracks.items) return []; - return tracks.items - .filter(track => track && track.id && track.name) - .map(track => ({ + .filter((track) => track && track.id && track.name) + .map((track) => ({ id: track.id, name: track.name, type: "track", - author: track.artists && track.artists.length > 0 - ? track.artists.map(artist => artist?.name || "Unknown Artist").join(", ") - : "Unknown Artist", + author: + track.artists && track.artists.length > 0 + ? track.artists + .map((artist) => artist?.name || "Unknown Artist") + .join(", ") + : "Unknown Artist", image: track.album?.images?.[0]?.url || null, spotify_url: track.external_urls?.spotify || null, preview_url: track.preview_url || null, @@ -62,10 +113,9 @@ const formatTracks = (tracks) => { const formatArtists = (artists) => { if (!artists || !artists.items) return []; - return artists.items - .filter(artist => artist && artist.id && artist.name) - .map(artist => ({ + .filter((artist) => artist && artist.id && artist.name) + .map((artist) => ({ id: artist.id, name: artist.name, type: "artist", @@ -79,16 +129,18 @@ const formatArtists = (artists) => { const formatAlbums = (albums) => { if (!albums || !albums.items) return []; - return albums.items - .filter(album => album && album.id && album.name) - .map(album => ({ + .filter((album) => album && album.id && album.name) + .map((album) => ({ id: album.id, name: album.name, type: "album", - author: album.artists && album.artists.length > 0 - ? album.artists.map(artist => artist?.name || "Unknown Artist").join(", ") - : "Unknown Artist", + author: + album.artists && album.artists.length > 0 + ? album.artists + .map((artist) => artist?.name || "Unknown Artist") + .join(", ") + : "Unknown Artist", image: album.images?.[0]?.url || null, spotify_url: album.external_urls?.spotify || null, release_date: album.release_date || null, @@ -99,14 +151,13 @@ const formatAlbums = (albums) => { const formatPlaylists = (playlists) => { if (!playlists || !playlists.items) return []; - return playlists.items - .filter(playlist => playlist && playlist.id && playlist.name) - .map(playlist => ({ + .filter((playlist) => playlist && playlist.id && playlist.name) + .map((playlist) => ({ id: playlist.id, name: playlist.name, type: "playlist", - author: playlist.owner?.display_name || "Unknown", + author: playlist.owner?.display_name || "You", image: playlist.images?.[0]?.url || null, spotify_url: playlist.external_urls?.spotify || null, description: playlist.description || "", @@ -114,7 +165,7 @@ const formatPlaylists = (playlists) => { })); }; -router.get("/", async (req, res) => { +router.get("/", authenticateJWT, async (req, res) => { try { const { q } = req.query; @@ -123,29 +174,53 @@ router.get("/", async (req, res) => { } if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) { - return res.status(500).json({ error: "Spotify credentials not configured" }); + return res + .status(500) + .json({ error: "Spotify credentials not configured" }); } const accessToken = await getSpotifyClientToken(); - - const searchResponse = await axios.get("https://api.spotify.com/v1/search", { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - q: q.trim(), - type: "track,artist,album,playlist", - limit: 10, - market: "US", - }, - }); + const searchResponse = await axios.get( + "https://api.spotify.com/v1/search", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + q: q.trim(), + type: "track,artist,album", + limit: 10, + market: "US", + }, + } + ); const searchData = searchResponse.data || {}; - const formattedTracks = formatTracks(searchData.tracks); const formattedArtists = formatArtists(searchData.artists); const formattedAlbums = formatAlbums(searchData.albums); - const formattedPlaylists = formatPlaylists(searchData.playlists); + + let formattedPlaylists = []; + try { + if (req.user && req.user.id) { + const user = await User.findByPk(req.user.id); + const userAccessToken = await getValidSpotifyToken(user); + const playlistsResponse = await axios.get( + "https://api.spotify.com/v1/me/playlists", + { + headers: { Authorization: `Bearer ${userAccessToken}` }, + params: { limit: 50 }, + } + ); + const allPlaylists = playlistsResponse.data.items || []; + const filteredPlaylists = allPlaylists + .filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())) + .slice(0, 10); + formattedPlaylists = formatPlaylists({ items: filteredPlaylists }); + } + } catch (err) { + formattedPlaylists = []; + } res.json({ query: q, @@ -158,39 +233,45 @@ router.get("/", async (req, res) => { artists: formattedArtists.length, albums: formattedAlbums.length, playlists: formattedPlaylists.length, - } + }, }); - } catch (error) { console.error("Search error:", error); - + if (error.response) { - console.error("Spotify API error:", error.response.status, error.response.data); + console.error( + "Spotify API error:", + error.response.status, + error.response.data + ); } - + if (error.response?.status === 400) { - return res.status(400).json({ + return res.status(400).json({ error: "Invalid search query", - details: error.response?.data + details: error.response?.data, }); } - + if (error.response?.status === 401) { - return res.status(401).json({ + return res.status(401).json({ error: "Spotify authentication failed", - details: "Invalid or expired client credentials" + details: "Invalid or expired client credentials", }); } - + if (error.response?.status === 429) { - return res.status(429).json({ - error: "Rate limit exceeded. Please try again later." + return res.status(429).json({ + error: "Rate limit exceeded. Please try again later.", }); } - res.status(500).json({ + res.status(500).json({ error: "Search failed", - details: process.env.NODE_ENV === 'development' ? error.message : "Internal server error" + details: + process.env.NODE_ENV === "development" + ? error.message + : "Internal server error", }); } }); From 5dba4f54159b8d3435ac2131fe5db1dd0d01df42 Mon Sep 17 00:00:00 2001 From: Tran Vo Date: Thu, 14 Aug 2025 21:38:45 -0400 Subject: [PATCH 69/77] feat added endoint to fetch user listening history and top genres + artist affilated --- api/index.js | 1 + auth/spotify.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/api/index.js b/api/index.js index b3215a2..efc6012 100644 --- a/api/index.js +++ b/api/index.js @@ -9,6 +9,7 @@ const stickersRouter = require("./stickers"); const followRouter = require("./follow"); const commentsRouter = require('./comments'); + router.use("/test-db", testDbRouter); router.use("/search-songs", searchRouter); router.use("/posts", postsRouter); diff --git a/auth/spotify.js b/auth/spotify.js index d58585c..d4d083c 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -583,4 +583,95 @@ router.delete("/disconnect", authenticateJWT, async (req, res) => { } }); +// Endpoint to get user's Spotify listening history, top genres, and top artists by genre +router.get("/history", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user?.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + let accessToken; + try { + accessToken = await getValidSpotifyToken(user); + } catch (tokenError) { + return res.status(401).json({ error: "Spotify token error", details: tokenError.message }); + } + + let recentTracks = []; + try { + const recentTracksRes = await axios.get( + "https://api.spotify.com/v1/me/player/recently-played", + { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { limit: 50 }, + } + ); + recentTracks = recentTracksRes.data.items || []; + } catch (recentError) { + return res.status(500).json({ error: "Failed to fetch listening history", details: recentError.response?.data || recentError.message }); + } + + let topArtists = []; + try { + const topArtistsRes = await axios.get( + "https://api.spotify.com/v1/me/top/artists", + { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { limit: 20, time_range: "medium_term" }, + } + ); + topArtists = topArtistsRes.data.items || []; + } catch (topError) { + return res.status(500).json({ error: "Failed to fetch top genres and artists", details: topError.response?.data || topError.message }); + } + + // Aggregate genres from top artists + const genreCount = {}; + topArtists.forEach((artist) => { + artist.genres.forEach((genre) => { + genreCount[genre] = (genreCount[genre] || 0) + 1; + }); + }); + // Sort genres by count + const topGenres = Object.entries(genreCount) + .sort((a, b) => b[1] - a[1]) + .map(([genre, count]) => ({ genre, count })); + + // Rank top artists by genre + const artistsByGenre = {}; + topArtists.forEach((artist) => { + artist.genres.forEach((genre) => { + if (!artistsByGenre[genre]) artistsByGenre[genre] = []; + artistsByGenre[genre].push({ + id: artist.id, + name: artist.name, + popularity: artist.popularity, + images: artist.images, + }); + }); + }); + // Sort artists in each genre by popularity + Object.keys(artistsByGenre).forEach((genre) => { + artistsByGenre[genre].sort((a, b) => b.popularity - a.popularity); + }); + + res.json({ + recentTracks: recentTracks.map((item) => ({ + track: { + id: item.track.id, + name: item.track.name, + artists: item.track.artists.map((a) => a.name), + album: item.track.album.name, + played_at: item.track.played_at, + }, + })), + topGenres, + artistsByGenre, + }); + } catch (error) { + res.status(500).json({ error: "Unexpected error in Spotify history endpoint", details: error.message }); + } +}); + module.exports = router; From a559d0b9a9dd9f8168740b44a9fec156b511c831 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 15 Aug 2025 11:32:14 -0400 Subject: [PATCH 70/77] expansion of following system (#63) --- api/follow.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/api/follow.js b/api/follow.js index 6ad1da1..02e990e 100644 --- a/api/follow.js +++ b/api/follow.js @@ -169,4 +169,77 @@ router.get("/all-with-follow-status", authenticateJWT, async (req, res) => { } }); +router.get("/", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const followers = await Follows.findAll({ + where: { followingId: user.id }, + include: [ + { + model: User, + as: "follower", + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 50, + }); + + res.json(followers.map((follow) => follow.follower)); + } catch (error) { + console.error("Error fetching my followers:", error); + res.status(500).json({ error: "Failed to fetch followers" }); + } +}); + +// Get *my* following +router.get("/", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const following = await Follows.findAll({ + where: { followerId: user.id }, + include: [ + { + model: User, + as: "following", + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 50, + }); + + res.json(following.map((follow) => follow.following)); + } catch (error) { + console.error("Error fetching my following:", error); + res.status(500).json({ error: "Failed to fetch following" }); + } +}); + module.exports = router; From 7147e92baeba0f74153df4c45c8d09218e090d51 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 15 Aug 2025 12:02:30 -0400 Subject: [PATCH 71/77] mabye sockets --- app.js | 112 ++++++++++++- package-lock.json | 343 +++++++++++++++++++++++++++++++++++++++- package.json | 4 +- public/socket-server.js | 0 vercel.json | 1 + 5 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 public/socket-server.js diff --git a/app.js b/app.js index 208c7de..80ec92e 100644 --- a/app.js +++ b/app.js @@ -3,21 +3,28 @@ const express = require("express"); const morgan = require("morgan"); const path = require("path"); const cookieParser = require("cookie-parser"); +const cors = require("cors"); +const http = require("http"); +const jwt = require("jsonwebtoken"); +const { Server } = require("socket.io"); + const app = express(); const apiRouter = require("./api"); const { router: authRouter } = require("./auth"); const spotifyRouter = require("./auth/spotify"); const { db } = require("./database"); -const cors = require("cors"); const PORT = process.env.PORT || 8080; +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; +/* ----------------------------- Express setup ----------------------------- */ app.use(express.json()); const corsOptions = { origin: [ "http://localhost:3000", "http://127.0.0.1:3000", + "http://127.0.0.1:15500", "https://capstone-2-frontend-tan.vercel.app", ], credentials: true, @@ -29,23 +36,118 @@ app.use(cors(corsOptions)); app.use(cookieParser()); app.use(morgan("dev")); app.use(express.static(path.join(__dirname, "public"))); + app.use("/api", apiRouter); app.use("/auth", authRouter); app.use("/auth/spotify", spotifyRouter); +/* ------------------------- Create HTTP + Socket.IO ------------------------ */ +const server = http.createServer(app); + +const io = new Server(server, { + cors: { + origin: corsOptions.origin, + credentials: true, + }, + // transports: ["websocket"], // optional, can force WS only +}); + +// Make io reachable in any Express route: req.app.get('io') +app.set("io", io); + +/* --------------------------- Socket auth helper --------------------------- */ +function getCookie(name, cookieHeader = "") { + const parts = cookieHeader.split(";").map((s) => s.trim()); + for (const p of parts) { + if (p.startsWith(name + "=")) + return decodeURIComponent(p.slice(name.length + 1)); + } + return null; +} + +io.use((socket, next) => { + try { + // If you pass a token from the client: io(API_URL, { auth:{token} }) + const tokenFromAuth = socket.handshake.auth?.token; + // Or rely on httpOnly cookie sent automatically withCredentials: true + const tokenFromCookie = getCookie( + "token", + socket.request.headers.cookie || "" + ); + const token = tokenFromAuth || tokenFromCookie; + if (!token) return next(new Error("No token")); + + const payload = jwt.verify(token, JWT_SECRET); + socket.userId = String(payload.id); + socket.username = payload.username; + next(); + } catch (err) { + next(new Error("Bad token")); + } +}); + +/* ----------------------------- Presence store ----------------------------- */ +// userId -> Set +const socketsByUser = new Map(); + +io.on("connection", (socket) => { + const userId = socket.userId; + + // Track this connection + if (!socketsByUser.has(userId)) socketsByUser.set(userId, new Set()); + const set = socketsByUser.get(userId); + const firstConnection = set.size === 0; + set.add(socket.id); + + // Personal room for future targeted notifications + socket.join(`user:${userId}`); + + // Send snapshot of who is online to the newly connected socket + const onlineIds = [...socketsByUser.entries()] + .filter(([, s]) => s.size > 0) + .map(([id]) => id); + socket.emit("presence:snapshot", onlineIds); + + // Broadcast "came online" if this was the first tab for the user + if (firstConnection) { + io.emit("presence:update", { userId, online: true }); + } + + socket.on("disconnect", () => { + const s = socketsByUser.get(userId); + if (!s) return; + s.delete(socket.id); + if (s.size === 0) { + io.emit("presence:update", { userId, online: false }); + } + }); +}); + +// Optional: quick REST snapshot for debugging +app.get("/api/presence/online", (req, res) => { + const online = [...socketsByUser.entries()] + .filter(([, s]) => s.size > 0) + .map(([id]) => id); + res.json(online); +}); + +/* --------------------------------- Errors -------------------------------- */ app.use((err, req, res, next) => { console.error(err.stack); - res.status(500).json({ error: 'Internal server error', details: err.message }); + res + .status(500) + .json({ error: "Internal server error", details: err.message }); }); +/* --------------------------------- Boot ---------------------------------- */ const runApp = async () => { try { await db.sync(); console.log("βœ… Connected to the database"); await db.sync({ alter: true }); console.log("βœ… Database synced successfully"); - app.listen(PORT, () => { - console.log(`πŸš€ Server is running on port ${PORT}`); + server.listen(PORT, () => { + console.log(`πŸš€ API + WebSockets running on port ${PORT}`); }); } catch (err) { console.error("❌ Unable to connect to the database:", err); @@ -54,4 +156,4 @@ const runApp = async () => { runApp(); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/package-lock.json b/package-lock.json index 110f937..5e10542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "multer": "^2.0.2", "pg": "^8.16.2", "sequelize": "^6.37.7", - "sharp": "^0.34.3" + "sharp": "^0.34.3", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" }, "devDependencies": { "nodemon": "^3.1.10" @@ -507,6 +509,21 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -605,6 +622,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -1039,6 +1065,125 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2526,6 +2671,173 @@ "node": ">=10" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2727,6 +3039,35 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index d19be4d..379885c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "multer": "^2.0.2", "pg": "^8.16.2", "sequelize": "^6.37.7", - "sharp": "^0.34.3" + "sharp": "^0.34.3", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/public/socket-server.js b/public/socket-server.js new file mode 100644 index 0000000..e69de29 diff --git a/vercel.json b/vercel.json index 1b0a308..9a6f1a9 100644 --- a/vercel.json +++ b/vercel.json @@ -10,6 +10,7 @@ { "src": "/(.*)", "dest": "app.js" + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] } ] } From 1b2d350657b5effd6a78f7d7a2d7335d257da53d Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 15 Aug 2025 13:07:12 -0400 Subject: [PATCH 72/77] updated --- app.js | 16 +++++++++------- public/socket-server.js | 35 +++++++++++++++++++++++++++++++++++ vercel.json | 4 ++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index 80ec92e..4822d35 100644 --- a/app.js +++ b/app.js @@ -7,17 +7,14 @@ const cors = require("cors"); const http = require("http"); const jwt = require("jsonwebtoken"); const { Server } = require("socket.io"); - const app = express(); const apiRouter = require("./api"); const { router: authRouter } = require("./auth"); const spotifyRouter = require("./auth/spotify"); const { db } = require("./database"); - const PORT = process.env.PORT || 8080; const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; -/* ----------------------------- Express setup ----------------------------- */ app.use(express.json()); const corsOptions = { @@ -36,7 +33,6 @@ app.use(cors(corsOptions)); app.use(cookieParser()); app.use(morgan("dev")); app.use(express.static(path.join(__dirname, "public"))); - app.use("/api", apiRouter); app.use("/auth", authRouter); app.use("/auth/spotify", spotifyRouter); @@ -123,7 +119,15 @@ io.on("connection", (socket) => { }); }); -// Optional: quick REST snapshot for debugging +// Every 10s, broadcast a full presence snapshot to all sockets +const SNAPSHOT_MS = 10_000; +setInterval(() => { + const onlineIds = [...socketsByUser.entries()] + .filter(([, s]) => s.size > 0) + .map(([id]) => String(id)); // normalize + io.emit("presence:snapshot", onlineIds); +}, SNAPSHOT_MS); + app.get("/api/presence/online", (req, res) => { const online = [...socketsByUser.entries()] .filter(([, s]) => s.size > 0) @@ -131,7 +135,6 @@ app.get("/api/presence/online", (req, res) => { res.json(online); }); -/* --------------------------------- Errors -------------------------------- */ app.use((err, req, res, next) => { console.error(err.stack); res @@ -139,7 +142,6 @@ app.use((err, req, res, next) => { .json({ error: "Internal server error", details: err.message }); }); -/* --------------------------------- Boot ---------------------------------- */ const runApp = async () => { try { await db.sync(); diff --git a/public/socket-server.js b/public/socket-server.js index e69de29..d887933 100644 --- a/public/socket-server.js +++ b/public/socket-server.js @@ -0,0 +1,35 @@ +const { Server } = require("socket.io"); + +let io; + +const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; +const corsOptions = + process.env.NODE_ENV === "production" + ? { + origin: FRONTEND_URL, + credentials: true, + } + : { + cors: "*", + }; + +const initSocketServer = (server) => { + try { + io = new Server(server, corsOptions); + + io.on("connection", (socket) => { + console.log(`πŸ”— User ${socket.id} connected to sockets`); + + socket.on("disconnect", () => { + console.log(`πŸ”— User ${socket.id} disconnected from sockets`); + }); + + // Define event handlers here... + }); + } catch (error) { + console.error("❌ Error initializing socket server:"); + console.error(error); + } +}; + +module.exports = initSocketServer; diff --git a/vercel.json b/vercel.json index 9a6f1a9..8be8eb5 100644 --- a/vercel.json +++ b/vercel.json @@ -9,8 +9,8 @@ "routes": [ { "src": "/(.*)", - "dest": "app.js" - "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + "dest": "app.js", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] } ] } From b05d1648cb24f54a3f2fad16b5798222464551a8 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 15 Aug 2025 13:22:55 -0400 Subject: [PATCH 73/77] updated --- public/socket-server.js => socket-server.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename public/socket-server.js => socket-server.js (100%) diff --git a/public/socket-server.js b/socket-server.js similarity index 100% rename from public/socket-server.js rename to socket-server.js From 9e3775a7e663be840df972d537f1841b2d240590 Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 15 Aug 2025 14:24:16 -0400 Subject: [PATCH 74/77] refactored file structure --- app.js | 107 +++--------------------------------------------- ws/realtime.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 101 deletions(-) create mode 100644 ws/realtime.js diff --git a/app.js b/app.js index 4822d35..0266d7e 100644 --- a/app.js +++ b/app.js @@ -3,17 +3,15 @@ const express = require("express"); const morgan = require("morgan"); const path = require("path"); const cookieParser = require("cookie-parser"); -const cors = require("cors"); -const http = require("http"); -const jwt = require("jsonwebtoken"); -const { Server } = require("socket.io"); const app = express(); const apiRouter = require("./api"); const { router: authRouter } = require("./auth"); const spotifyRouter = require("./auth/spotify"); const { db } = require("./database"); +const cors = require("cors"); +const http = require("http"); + const PORT = process.env.PORT || 8080; -const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; app.use(express.json()); @@ -37,103 +35,10 @@ app.use("/api", apiRouter); app.use("/auth", authRouter); app.use("/auth/spotify", spotifyRouter); -/* ------------------------- Create HTTP + Socket.IO ------------------------ */ +/* --------------------- http server + socket attach ------------------ */ const server = http.createServer(app); - -const io = new Server(server, { - cors: { - origin: corsOptions.origin, - credentials: true, - }, - // transports: ["websocket"], // optional, can force WS only -}); - -// Make io reachable in any Express route: req.app.get('io') -app.set("io", io); - -/* --------------------------- Socket auth helper --------------------------- */ -function getCookie(name, cookieHeader = "") { - const parts = cookieHeader.split(";").map((s) => s.trim()); - for (const p of parts) { - if (p.startsWith(name + "=")) - return decodeURIComponent(p.slice(name.length + 1)); - } - return null; -} - -io.use((socket, next) => { - try { - // If you pass a token from the client: io(API_URL, { auth:{token} }) - const tokenFromAuth = socket.handshake.auth?.token; - // Or rely on httpOnly cookie sent automatically withCredentials: true - const tokenFromCookie = getCookie( - "token", - socket.request.headers.cookie || "" - ); - const token = tokenFromAuth || tokenFromCookie; - if (!token) return next(new Error("No token")); - - const payload = jwt.verify(token, JWT_SECRET); - socket.userId = String(payload.id); - socket.username = payload.username; - next(); - } catch (err) { - next(new Error("Bad token")); - } -}); - -/* ----------------------------- Presence store ----------------------------- */ -// userId -> Set -const socketsByUser = new Map(); - -io.on("connection", (socket) => { - const userId = socket.userId; - - // Track this connection - if (!socketsByUser.has(userId)) socketsByUser.set(userId, new Set()); - const set = socketsByUser.get(userId); - const firstConnection = set.size === 0; - set.add(socket.id); - - // Personal room for future targeted notifications - socket.join(`user:${userId}`); - - // Send snapshot of who is online to the newly connected socket - const onlineIds = [...socketsByUser.entries()] - .filter(([, s]) => s.size > 0) - .map(([id]) => id); - socket.emit("presence:snapshot", onlineIds); - - // Broadcast "came online" if this was the first tab for the user - if (firstConnection) { - io.emit("presence:update", { userId, online: true }); - } - - socket.on("disconnect", () => { - const s = socketsByUser.get(userId); - if (!s) return; - s.delete(socket.id); - if (s.size === 0) { - io.emit("presence:update", { userId, online: false }); - } - }); -}); - -// Every 10s, broadcast a full presence snapshot to all sockets -const SNAPSHOT_MS = 10_000; -setInterval(() => { - const onlineIds = [...socketsByUser.entries()] - .filter(([, s]) => s.size > 0) - .map(([id]) => String(id)); // normalize - io.emit("presence:snapshot", onlineIds); -}, SNAPSHOT_MS); - -app.get("/api/presence/online", (req, res) => { - const online = [...socketsByUser.entries()] - .filter(([, s]) => s.size > 0) - .map(([id]) => id); - res.json(online); -}); +// This line pulls all websocket/presence code out of app.js +require("./ws/realtime")(server, app, { corsOptions }); app.use((err, req, res, next) => { console.error(err.stack); diff --git a/ws/realtime.js b/ws/realtime.js new file mode 100644 index 0000000..f4592fc --- /dev/null +++ b/ws/realtime.js @@ -0,0 +1,109 @@ +// realtime/index.js +const jwt = require("jsonwebtoken"); +const { Server } = require("socket.io"); + +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +// simple cookie parser for socket handshake +function getCookie(name, cookieHeader = "") { + const parts = (cookieHeader || "").split(";").map((s) => s.trim()); + for (const p of parts) + if (p.startsWith(name + "=")) + return decodeURIComponent(p.slice(name.length + 1)); + return null; +} + +function attachRealtime(server, app, { corsOptions }) { + const io = new Server(server, { + cors: { + origin: corsOptions.origin, + credentials: true, + allowedHeaders: ["Authorization", "Content-Type", "Cookie"], + }, + // keep both so it works behind some hosts/proxies + transports: ["websocket", "polling"], + }); + + // expose io to express routes if you ever need it + app.set("io", io); + + /* --------------------------- auth middleware --------------------------- */ + io.use((socket, next) => { + try { + const tokenFromAuth = socket.handshake.auth?.token; + const tokenFromCookie = getCookie("token", socket.request.headers.cookie); + const token = tokenFromAuth || tokenFromCookie; + if (!token) return next(new Error("No token")); + + const payload = jwt.verify(token, JWT_SECRET); + socket.userId = String(payload.id); + socket.username = payload.username; + next(); + } catch (err) { + next(new Error("Bad token")); + } + }); + + /* --------------------------- presence tracking ------------------------- */ + // userId -> Set + const socketsByUser = new Map(); + + const broadcastSnapshot = () => { + const onlineIds = [...socketsByUser.entries()] + .filter(([, s]) => s.size > 0) + .map(([id]) => String(id)); + io.emit("presence:snapshot", onlineIds); + }; + + io.on("connection", (socket) => { + const userId = String(socket.userId); + + // track this connection + if (!socketsByUser.has(userId)) socketsByUser.set(userId, new Set()); + const set = socketsByUser.get(userId); + const firstConnection = set.size === 0; + set.add(socket.id); + + // optional personal room + socket.join(`user:${userId}`); + + // tell THIS socket who is online right now + socket.emit( + "presence:snapshot", + [...socketsByUser.entries()] + .filter(([, s]) => s.size > 0) + .map(([id]) => String(id)) + ); + + // if this is the user’s first tab/connection, announce they came online + if (firstConnection) { + io.emit("presence:update", { userId, online: true }); + // optional: refresh everyone’s list only when state changes + broadcastSnapshot(); + } + + socket.on("disconnect", () => { + const s = socketsByUser.get(userId); + if (!s) return; + s.delete(socket.id); + if (s.size === 0) { + socketsByUser.delete(userId); // cleanup + io.emit("presence:update", { userId, online: false }); + broadcastSnapshot(); + } + }); + }); + + /* --------------------------- optional REST helper ---------------------- */ + // GET /api/presence/online -> returns array of online userIds + app.get("/api/presence/online", (req, res) => { + const online = [...socketsByUser.entries()] + .filter(([, s]) => s.size > 0) + .map(([id]) => String(id)); + res.json(online); + }); + + return io; +} + +module.exports = attachRealtime; From c1df74ec331f866295fe102907048131f85f6138 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 15 Aug 2025 14:42:35 -0400 Subject: [PATCH 75/77] updates (#67) --- api/follow.js | 146 +++++++++++++++++++++++++------------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/api/follow.js b/api/follow.js index 02e990e..e292662 100644 --- a/api/follow.js +++ b/api/follow.js @@ -4,6 +4,79 @@ const { User, Posts, Follows } = require("../database"); const { authenticateJWT } = require("../auth"); const { Op } = require("sequelize"); +router.get("/me/followers", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const followers = await Follows.findAll({ + where: { followingId: user.id }, + include: [ + { + model: User, + as: "follower", + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 50, + }); + + res.json(followers.map((follow) => follow.follower)); + } catch (error) { + console.error("Error fetching my followers:", error); + res.status(500).json({ error: "Failed to fetch followers" }); + } +}); + +// Get *my* following +router.get("/me/following", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const following = await Follows.findAll({ + where: { followerId: user.id }, + include: [ + { + model: User, + as: "following", + attributes: [ + "id", + "username", + "firstName", + "lastName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + limit: 50, + }); + + res.json(following.map((follow) => follow.following)); + } catch (error) { + console.error("Error fetching my following:", error); + res.status(500).json({ error: "Failed to fetch following" }); + } +}); + // Check if current user is following another user router.get("/:username/following-status", authenticateJWT, async (req, res) => { try { @@ -169,77 +242,4 @@ router.get("/all-with-follow-status", authenticateJWT, async (req, res) => { } }); -router.get("/", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - const followers = await Follows.findAll({ - where: { followingId: user.id }, - include: [ - { - model: User, - as: "follower", - attributes: [ - "id", - "username", - "firstName", - "lastName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - order: [["createdAt", "DESC"]], - limit: 50, - }); - - res.json(followers.map((follow) => follow.follower)); - } catch (error) { - console.error("Error fetching my followers:", error); - res.status(500).json({ error: "Failed to fetch followers" }); - } -}); - -// Get *my* following -router.get("/", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - const following = await Follows.findAll({ - where: { followerId: user.id }, - include: [ - { - model: User, - as: "following", - attributes: [ - "id", - "username", - "firstName", - "lastName", - "profileImage", - "spotifyProfileImage", - "avatarURL", - ], - }, - ], - order: [["createdAt", "DESC"]], - limit: 50, - }); - - res.json(following.map((follow) => follow.following)); - } catch (error) { - console.error("Error fetching my following:", error); - res.status(500).json({ error: "Failed to fetch following" }); - } -}); - module.exports = router; From fe1fdb3dc626f56bd1602a0cf261b1eabfe5db3a Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 15 Aug 2025 15:24:09 -0400 Subject: [PATCH 76/77] Feature messages (#68) * updates * table creation and api routes --- api/messages.js | 80 ++++++++++++++++++++++++++++++++++++++++++++ app.js | 1 + database/index.js | 9 +++++ database/messages.js | 34 +++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 api/messages.js create mode 100644 database/messages.js diff --git a/api/messages.js b/api/messages.js new file mode 100644 index 0000000..b17db7e --- /dev/null +++ b/api/messages.js @@ -0,0 +1,80 @@ +const express = require("express"); +const router = express.Router(); +const { User, Follows, Message } = require("../database"); +const { authenticateJWT } = require("../auth"); + +// Get list of friends (users you can DM) +router.get("/friends", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const following = await user.getFollowing(); + const followers = await user.getFollowers(); + const friends = following.filter(f => + followers.some(fl => fl.id === f.id) + ); + res.json(friends); + } catch (err) { + res.status(500).json({ error: "Failed to fetch friends" }); + } +}); + +// Get message history between logged-in user and another user +router.get("/:userId", authenticateJWT, async (req, res) => { + try { + const otherUserId = parseInt(req.params.userId, 10); + const messages = await Message.findAll({ + where: { + [Message.sequelize.Op.or]: [ + { senderId: req.user.id, receiverId: otherUserId }, + { senderId: otherUserId, receiverId: req.user.id } + ] + }, + order: [["createdAt", "ASC"]], + }); + res.json(messages); + } catch (err) { + res.status(500).json({ error: "Failed to fetch messages" }); + } +}); + +// Send a new message to another user +router.post("/:userId", authenticateJWT, async (req, res) => { + try { + const receiverId = parseInt(req.params.userId, 10); + const { content } = req.body; + if (!content) return res.status(400).json({ error: "Message content required" }); + + const message = await Message.create({ + senderId: req.user.id, + receiverId, + content, + read: false, + }); + + res.status(201).json(message); + } catch (err) { + res.status(500).json({ error: "Failed to send message" }); + } +}); + +// Mark messages as read +router.patch("/:userId/read", authenticateJWT, async (req, res) => { + try { + const otherUserId = parseInt(req.params.userId, 10); + await Message.update( + { read: true }, + { + where: { + senderId: otherUserId, + receiverId: req.user.id, + read: false, + }, + } + ); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Failed to mark messages as read" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/app.js b/app.js index 0266d7e..a7e76d8 100644 --- a/app.js +++ b/app.js @@ -34,6 +34,7 @@ app.use(express.static(path.join(__dirname, "public"))); app.use("/api", apiRouter); app.use("/auth", authRouter); app.use("/auth/spotify", spotifyRouter); +app.use("/api/messages", require("./api/messages")); /* --------------------- http server + socket attach ------------------ */ const server = http.createServer(app); diff --git a/database/index.js b/database/index.js index 877367e..b55b045 100644 --- a/database/index.js +++ b/database/index.js @@ -6,6 +6,7 @@ const Sticker = require("./sticker"); const UserProfileSticker = require("./userProfileSticker"); const PostLike = require("./postLikes"); const Comments = require("./comments"); +const Message = require("./messages"); User.hasMany(Posts, { foreignKey: "userId", @@ -103,6 +104,13 @@ Comments.hasMany(Comments, { foreignKey: "parent_id", as: "replies" }); User.hasMany(Comments, { foreignKey: "user_id" }); Posts.hasMany(Comments, { foreignKey: "post_id" }); +//message associations +Message.belongsTo(User, { as: "sender", foreignKey: "senderId" }); +Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" }); + +User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" }); +User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" }); + module.exports = { db, User, @@ -112,4 +120,5 @@ module.exports = { UserProfileSticker, PostLike, Comments, + Message, }; \ No newline at end of file diff --git a/database/messages.js b/database/messages.js new file mode 100644 index 0000000..0429e0f --- /dev/null +++ b/database/messages.js @@ -0,0 +1,34 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("./db"); +const User = require("./user"); + +const Message = sequelize.define("Message", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + senderId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + }, + receiverId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + read: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, +}, { + timestamps: true, +}); + + +module.exports = Message; \ No newline at end of file From c2cc47a0ebc27bda23e6d47cc0145badb93c2b5e Mon Sep 17 00:00:00 2001 From: Michelangelo Date: Fri, 15 Aug 2025 15:57:46 -0400 Subject: [PATCH 77/77] added middleware for checking spotify users --- auth/spotify.js | 540 +++++++++++++++++++++++++++--------------------- 1 file changed, 303 insertions(+), 237 deletions(-) diff --git a/auth/spotify.js b/auth/spotify.js index d4d083c..ecfe314 100644 --- a/auth/spotify.js +++ b/auth/spotify.js @@ -19,6 +19,26 @@ const cookieSettings = { path: "/", }; +// middlewares/requireSpotifyAuth.js +async function requireSpotifyAuth(req, res, next) { + try { + const user = await User.findByPk(req.user.id, { + attributes: ["spotifyDisplayName", "spotifyProfileImage"], + }); + + if (!user || !user.spotifyDisplayName) { + // Redirect for non-Spotify users + return res.status(302).redirect("/connect-spotify"); + // Or: return res.status(403).json({ error: "Spotify not connected" }); + } + + next(); + } catch (err) { + console.error("Spotify auth check failed:", err); + res.status(500).json({ error: "Server error" }); + } +} + //this function makes a call to get a spotify token const refreshSpotifyToken = async (user) => { try { @@ -389,289 +409,335 @@ router.get("/profile", authenticateJWT, async (req, res) => { }); // Get top tracks for logged in user -router.get("/top-tracks", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); - const timeRange = req.query.time_range || "long_term"; +router.get( + "/top-tracks", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + const timeRange = req.query.time_range || "long_term"; - const response = await axios.get( - "https://api.spotify.com/v1/me/top/tracks", - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 20, - time_range: timeRange, - }, - } - ); + const response = await axios.get( + "https://api.spotify.com/v1/me/top/tracks", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 20, + time_range: timeRange, + }, + } + ); - res.json(response.data); - } catch (error) { - if (error.message === "Spotify not connected") { - return res.status(401).json({ error: "Spotify not connected" }); + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting top tracks:", error.message); + res.status(500).json({ error: "Failed to get top tracks" }); } - console.error("Error getting top tracks:", error.message); - res.status(500).json({ error: "Failed to get top tracks" }); } -}); +); //route for top artist -router.get("/top-artists", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); - - const timeRanges = ["short_term", "medium_term", "long_term"]; - let bestResult = null; +router.get( + "/top-artists", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + + const timeRanges = ["short_term", "medium_term", "long_term"]; + let bestResult = null; + + for (const timeRange of timeRanges) { + try { + const response = await axios.get( + "https://api.spotify.com/v1/me/top/artists", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 20, + time_range: timeRange, + }, + } + ); - for (const timeRange of timeRanges) { - try { - const response = await axios.get( - "https://api.spotify.com/v1/me/top/artists", - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 20, - time_range: timeRange, - }, + if (response.data.items.length > 0) { + return res.json({ + ...response.data, + time_range_used: timeRange, + }); } - ); - if (response.data.items.length > 0) { - return res.json({ - ...response.data, - time_range_used: timeRange, - }); - } - - if (!bestResult) { - bestResult = { - ...response.data, - time_range_used: timeRange, - }; + if (!bestResult) { + bestResult = { + ...response.data, + time_range_used: timeRange, + }; + } + } catch (error) { + console.error( + `Error getting top artists for ${timeRange}:`, + error.message + ); } - } catch (error) { - console.error( - `Error getting top artists for ${timeRange}:`, - error.message - ); } - } - res.json( - bestResult || { - items: [], - total: 0, - message: "No listening history found for artists.", + res.json( + bestResult || { + items: [], + total: 0, + message: "No listening history found for artists.", + } + ); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); } - ); - } catch (error) { - if (error.message === "Spotify not connected") { - return res.status(401).json({ error: "Spotify not connected" }); + console.error("Error getting top artists:", error.message); + res.status(500).json({ error: "Failed to get top artists" }); } - console.error("Error getting top artists:", error.message); - res.status(500).json({ error: "Failed to get top artists" }); } -}); +); // Get user's playlists -router.get("/playlists", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); +router.get( + "/playlists", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); - const response = await axios.get( - "https://api.spotify.com/v1/me/playlists", - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 50, // Get up to 50 playlists - offset: 0, - }, - } - ); + const response = await axios.get( + "https://api.spotify.com/v1/me/playlists", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 50, // Get up to 50 playlists + offset: 0, + }, + } + ); - res.json(response.data); - } catch (error) { - if (error.message === "Spotify not connected") { - return res.status(401).json({ error: "Spotify not connected" }); + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting playlists:", error.message); + res.status(500).json({ error: "Failed to get playlists" }); } - console.error("Error getting playlists:", error.message); - res.status(500).json({ error: "Failed to get playlists" }); } -}); +); // Get route individual playlist -router.get("/playlists/:id", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); - const playlistId = req.params.id; +router.get( + "/playlists/:id", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + const playlistId = req.params.id; - const response = await axios.get( - `https://api.spotify.com/v1/playlists/${playlistId}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); + const response = await axios.get( + `https://api.spotify.com/v1/playlists/${playlistId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); - res.json(response.data); - } catch (error) { - if (error.message === "Spotify not connected") { - return res.status(401).json({ error: "Spotify not connected" }); + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting playlist details:", error.message); + res.status(500).json({ error: "Failed to get playlist details" }); } - console.error("Error getting playlist details:", error.message); - res.status(500).json({ error: "Failed to get playlist details" }); } -}); +); // Get route for playlist tracks -router.get("/playlists/:id/tracks", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - const accessToken = await getValidSpotifyToken(user); - const playlistId = req.params.id; +router.get( + "/playlists/:id/tracks", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + const accessToken = await getValidSpotifyToken(user); + const playlistId = req.params.id; - const response = await axios.get( - `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - params: { - limit: 100, - offset: 0, - }, - } - ); + const response = await axios.get( + `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + limit: 100, + offset: 0, + }, + } + ); - res.json(response.data); - } catch (error) { - if (error.message === "Spotify not connected") { - return res.status(401).json({ error: "Spotify not connected" }); + res.json(response.data); + } catch (error) { + if (error.message === "Spotify not connected") { + return res.status(401).json({ error: "Spotify not connected" }); + } + console.error("Error getting playlist tracks:", error.message); + res.status(500).json({ error: "Failed to get playlist tracks" }); } - console.error("Error getting playlist tracks:", error.message); - res.status(500).json({ error: "Failed to get playlist tracks" }); } -}); +); // Disconnect Spotify/ does not work at the moment -router.delete("/disconnect", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user.id); - await user.update({ - spotifyId: null, - spotifyAccessToken: null, - spotifyRefreshToken: null, - spotifyTokenExpiresAt: null, - spotifyDisplayName: null, - spotifyProfileImage: null, - }); - res.json({ message: "Spotify disconnected successfully" }); - } catch (error) { - console.error("Error disconnecting Spotify:", error.message); - res.status(500).json({ error: "Failed to disconnect Spotify" }); +router.delete( + "/disconnect", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + await user.update({ + spotifyId: null, + spotifyAccessToken: null, + spotifyRefreshToken: null, + spotifyTokenExpiresAt: null, + spotifyDisplayName: null, + spotifyProfileImage: null, + }); + res.json({ message: "Spotify disconnected successfully" }); + } catch (error) { + console.error("Error disconnecting Spotify:", error.message); + res.status(500).json({ error: "Failed to disconnect Spotify" }); + } } -}); +); // Endpoint to get user's Spotify listening history, top genres, and top artists by genre -router.get("/history", authenticateJWT, async (req, res) => { - try { - const user = await User.findByPk(req.user?.id); - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - let accessToken; +router.get( + "/history", + authenticateJWT, + requireSpotifyAuth, + async (req, res) => { try { - accessToken = await getValidSpotifyToken(user); - } catch (tokenError) { - return res.status(401).json({ error: "Spotify token error", details: tokenError.message }); - } + const user = await User.findByPk(req.user?.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } - let recentTracks = []; - try { - const recentTracksRes = await axios.get( - "https://api.spotify.com/v1/me/player/recently-played", - { - headers: { Authorization: `Bearer ${accessToken}` }, - params: { limit: 50 }, - } - ); - recentTracks = recentTracksRes.data.items || []; - } catch (recentError) { - return res.status(500).json({ error: "Failed to fetch listening history", details: recentError.response?.data || recentError.message }); - } + let accessToken; + try { + accessToken = await getValidSpotifyToken(user); + } catch (tokenError) { + return res + .status(401) + .json({ error: "Spotify token error", details: tokenError.message }); + } - let topArtists = []; - try { - const topArtistsRes = await axios.get( - "https://api.spotify.com/v1/me/top/artists", - { - headers: { Authorization: `Bearer ${accessToken}` }, - params: { limit: 20, time_range: "medium_term" }, - } - ); - topArtists = topArtistsRes.data.items || []; - } catch (topError) { - return res.status(500).json({ error: "Failed to fetch top genres and artists", details: topError.response?.data || topError.message }); - } + let recentTracks = []; + try { + const recentTracksRes = await axios.get( + "https://api.spotify.com/v1/me/player/recently-played", + { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { limit: 50 }, + } + ); + recentTracks = recentTracksRes.data.items || []; + } catch (recentError) { + return res.status(500).json({ + error: "Failed to fetch listening history", + details: recentError.response?.data || recentError.message, + }); + } - // Aggregate genres from top artists - const genreCount = {}; - topArtists.forEach((artist) => { - artist.genres.forEach((genre) => { - genreCount[genre] = (genreCount[genre] || 0) + 1; + let topArtists = []; + try { + const topArtistsRes = await axios.get( + "https://api.spotify.com/v1/me/top/artists", + { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { limit: 20, time_range: "medium_term" }, + } + ); + topArtists = topArtistsRes.data.items || []; + } catch (topError) { + return res.status(500).json({ + error: "Failed to fetch top genres and artists", + details: topError.response?.data || topError.message, + }); + } + + // Aggregate genres from top artists + const genreCount = {}; + topArtists.forEach((artist) => { + artist.genres.forEach((genre) => { + genreCount[genre] = (genreCount[genre] || 0) + 1; + }); }); - }); - // Sort genres by count - const topGenres = Object.entries(genreCount) - .sort((a, b) => b[1] - a[1]) - .map(([genre, count]) => ({ genre, count })); - - // Rank top artists by genre - const artistsByGenre = {}; - topArtists.forEach((artist) => { - artist.genres.forEach((genre) => { - if (!artistsByGenre[genre]) artistsByGenre[genre] = []; - artistsByGenre[genre].push({ - id: artist.id, - name: artist.name, - popularity: artist.popularity, - images: artist.images, + // Sort genres by count + const topGenres = Object.entries(genreCount) + .sort((a, b) => b[1] - a[1]) + .map(([genre, count]) => ({ genre, count })); + + // Rank top artists by genre + const artistsByGenre = {}; + topArtists.forEach((artist) => { + artist.genres.forEach((genre) => { + if (!artistsByGenre[genre]) artistsByGenre[genre] = []; + artistsByGenre[genre].push({ + id: artist.id, + name: artist.name, + popularity: artist.popularity, + images: artist.images, + }); }); }); - }); - // Sort artists in each genre by popularity - Object.keys(artistsByGenre).forEach((genre) => { - artistsByGenre[genre].sort((a, b) => b.popularity - a.popularity); - }); + // Sort artists in each genre by popularity + Object.keys(artistsByGenre).forEach((genre) => { + artistsByGenre[genre].sort((a, b) => b.popularity - a.popularity); + }); - res.json({ - recentTracks: recentTracks.map((item) => ({ - track: { - id: item.track.id, - name: item.track.name, - artists: item.track.artists.map((a) => a.name), - album: item.track.album.name, - played_at: item.track.played_at, - }, - })), - topGenres, - artistsByGenre, - }); - } catch (error) { - res.status(500).json({ error: "Unexpected error in Spotify history endpoint", details: error.message }); + res.json({ + recentTracks: recentTracks.map((item) => ({ + track: { + id: item.track.id, + name: item.track.name, + artists: item.track.artists.map((a) => a.name), + album: item.track.album.name, + played_at: item.track.played_at, + }, + })), + topGenres, + artistsByGenre, + }); + } catch (error) { + res.status(500).json({ + error: "Unexpected error in Spotify history endpoint", + details: error.message, + }); + } } -}); +); module.exports = router;