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/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/follow.js b/api/follow.js new file mode 100644 index 0000000..e292662 --- /dev/null +++ b/api/follow.js @@ -0,0 +1,245 @@ +const express = require("express"); +const router = express.Router(); +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 { + 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" }); + } +}); + +//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" }); + } +}); + +//get all users with follow status +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; diff --git a/api/index.js b/api/index.js index f08162e..efc6012 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,21 @@ 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); +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/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/api/posts.js b/api/posts.js new file mode 100644 index 0000000..ab413fb --- /dev/null +++ b/api/posts.js @@ -0,0 +1,825 @@ +const express = require("express"); +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" }, + 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"]], + }); + + // 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 + ? postData.likes?.some((like) => like.user.id === userId) + : false; + return postData; + }); + + res.json(postsWithLikes); + } catch (error) { + 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 posts = await Posts.findAll({ + 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 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; + return postData; + }); + + res.json(postsWithLikes); + } catch (error) { + console.error("Error fetching posts:", error); + res.status(500).json({ error: "Failed to fetch posts" }); + } +}); + +// 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("/draft", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + + const drafts = await Posts.findAll({ + where: { + userId: userId, + status: "draft", + }, + include: [ + { + model: User, + as: "author", + attributes: [ + "username", + "id", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + order: [["createdAt", "DESC"]], + }); + + res.json(drafts); + } catch (error) { + console.error("Error fetching drafts:", error); + res.status(500).json({ error: "Failed to fetch drafts" }); + } +}); + +// Get single post by ID +router.get("/:id", async (req, res) => { + try { + const postId = req.params.id; + const userId = req.user?.id; + + const post = await Posts.findByPk(postId, { + include: [ + { + model: User, + as: "author", + attributes: [ + "id", + "username", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "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(postData); + } catch (error) { + console.error("Error fetching post:", error); + res.status(500).json({ error: "Failed to fetch post" }); + } +}); + +// Create a new post +router.post("/", authenticateJWT, async (req, res) => { + try { + const { + title, + description, + spotifyArtistId, + spotifyTrackId, + spotifyPlaylistId, + spotifyAlbumId, + spotifyArtistName, + spotifyTrackName, + spotifyPlaylistName, + spotifyAlbumName, + spotifyType, + spotifyEmbedUrl, + status = "published", + originalPostId, + } = req.body; + + const userId = req.user.id; + + if (!title || !description) { + return res + .status(400) + .json({ error: "Title and description are required" }); + } + + const post = await Posts.create({ + title, + description, + userId, + spotifyArtistId, + spotifyTrackId, + spotifyPlaylistId, + spotifyAlbumId, + spotifyArtistName, + spotifyTrackName, + spotifyPlaylistName, + spotifyAlbumName, + spotifyType, + spotifyEmbedUrl, + status, + originalPostId, + }); + + const createdPost = await Posts.findByPk(post.id, { + include: [ + { + model: User, + as: "author", + attributes: [ + "id", + "username", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + }); + + res.status(201).json(createdPost); + } catch (error) { + console.error("Error creating post:", error); + res.status(500).json({ error: "Failed to create post" }); + } +}); + +// 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, + spotifyArtistId, + spotifyTrackId, + spotifyPlaylistId, + spotifyAlbumId, + spotifyArtistName, + spotifyTrackName, + spotifyPlaylistName, + spotifyAlbumName, + spotifyType, + spotifyEmbedUrl, + status, + } = req.body; + + 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, + }); + + const updatedPost = await Posts.findByPk(postId, { + include: [ + { + model: User, + as: "author", + attributes: [ + "id", + "username", + "spotifyDisplayName", + "profileImage", + "spotifyProfileImage", + "avatarURL", + ], + }, + ], + }); + + res.json(updatedPost); + } 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 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 delete this post" }); + } + + await post.destroy(); + res.status(204).end(); + } catch (error) { + console.error("Error deleting post:", error); + res.status(500).json({ error: "Failed to delete post" }); + } +}); + +router.post("/:id/like", 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" }); + } + + 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: postId, + userId: userId, + }, + }); + + let isLiked; + if (existingLike) { + await existingLike.destroy(); + isLiked = false; + } else { + await PostLike.create({ + postId: postId, + userId: userId, + }); + isLiked = true; + } + + const likesCount = await PostLike.count({ + where: { postId: postId }, + }); + + res.json({ + success: true, + isLiked, + 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." }); + } + + res.status(500).json({ error: "Failed to like post" }); + } +}); + +router.post("/:id/fork-playlist", authenticateJWT, async (req, res) => { + try { + const postId = req.params.id; + const userId = req.user.id; + const { playlistName, isPublic, isCollaborative } = req.body; + + 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 (!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" }); + } + + const sourcePlaylistId = extractPlaylistIdFromPost(post); + + if (!sourcePlaylistId) { + return res.status(400).json({ + error: "Playlist ID not found on post", + embedUrl: post.spotifyEmbedUrl, + }); + } + + 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}` }, + } + ); + + 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 (!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, + }); + } + } + + 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; diff --git a/api/profile.js b/api/profile.js new file mode 100644 index 0000000..7c712d1 --- /dev/null +++ b/api/profile.js @@ -0,0 +1,577 @@ +const express = require("express"); +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', 'wallpaperURL', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems', + 'showPosts', 'showUsername', 'showDateJoined', 'showSpotifyStatus' + ] + }); + + 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" }); + } +}); + +// 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, + 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 { + const { firstName, lastName, bio, profileImage, wallpaperURL, profileTheme, showPosts, showUsername, showDateJoined, showSpotifyStatus } = 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; + } + + 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 ( + 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"; + } +//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 }, + validate: false, + }); + + 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', 'wallpaperURL', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', + 'showPosts', 'showUsername', 'showDateJoined', 'showSpotifyStatus' + ] + }); + + res.json(updatedUser.toJSON()); + } 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" }); + } +}); + +// 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 { + 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" }); + } +}); + +// 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 { + const user = await User.findOne({ + where: { username: req.params.username }, + attributes: [ + 'id', 'username', 'firstName', 'lastName', 'bio', + 'profileImage', 'wallpaperURL', 'spotifyDisplayName', 'spotifyProfileImage', + 'avatarURL', 'profileTheme', 'createdAt', 'spotifyItems', + 'showPosts', 'showUsername', 'showDateJoined', 'showSpotifyStatus' + ] + }); + + 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: "You cannot follow yourself" }); + } + + const existingFollow = await Follows.findOne({ + where: { + followerId: req.user.id, + followingId: userToFollow.id, + }, + }); + + if (existingFollow) { + // Unfollow + 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, + }); + return res.json({ message: "Followed successfully" }); + } + } catch (error) { + console.error("Error following/unfollowing user:", error); + res.status(500).json({ error: "Failed to follow/unfollow user" }); + } +}); + +//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 q = (req.query.q || "").trim(); + const meId = req.user.id; + const { Op } = require("sequelize"); + + const where = { + id: { [Op.ne]: meId }, + }; + if (q) { + where.username = { [Op.iLike]: `%${q}%` }; + } + + const users = await User.findAll({ + where, + attributes: ["id", "username", "email"], + }); + + res.json(users); + } catch (err) { + console.error("Error searching users:", err); + res.status(500).json({ error: "Failed to search users" }); + } +}); + +// 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/api/search.js b/api/search.js new file mode 100644 index 0000000..af06b2b --- /dev/null +++ b/api/search.js @@ -0,0 +1,279 @@ +const express = require("express"); +const axios = require("axios"); +const { authenticateJWT } = require("../auth"); +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; + +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) { + return spotifyAccessToken; + } + + 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"); + } +}; + +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, + album: track.album?.name || "Unknown Album", + })); +}; + +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 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", + })); +}; + +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 || "You", + image: playlist.images?.[0]?.url || null, + spotify_url: playlist.external_urls?.spotify || null, + description: playlist.description || "", + track_count: playlist.tracks?.total || 0, + })); +}; + +router.get("/", authenticateJWT, 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" }); + } + + 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", + limit: 10, + market: "US", + }, + } + ); + + const searchData = searchResponse.data || {}; + const formattedTracks = formatTracks(searchData.tracks); + const formattedArtists = formatArtists(searchData.artists); + const formattedAlbums = formatAlbums(searchData.albums); + + 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, + tracks: formattedTracks, + artists: formattedArtists, + albums: formattedAlbums, + playlists: formattedPlaylists, + total_results: { + tracks: formattedTracks.length, + 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 + ); + } + + 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; diff --git a/api/stickers.js b/api/stickers.js new file mode 100644 index 0000000..86d170c --- /dev/null +++ b/api/stickers.js @@ -0,0 +1,425 @@ +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 { Op } = require('sequelize'); + +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 preset stickers from cloudinary preset folder +router.get('/presets', async (req, res) => { + try { + const cloudinaryResult = await cloudinary.search + .expression('folder:"TTP-Capstone 2/Preset"/*') + .max_results(100) + .execute(); + 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 + }); + } +}); + +// 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', + uploadedBy: req.user.id + }, + order: [['createdAt', 'DESC']] + }); + // 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) { + res.status(500).json({ + error: 'Failed to fetch custom stickers', + details: error.message + }); + } +}); + +// 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 + }); + } +}); + +// 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.body; + if (!id) { + return res.status(400).json({ error: 'id is required in request body' }); + } + const userId = req.user.id; + const decodedId = decodeURIComponent(id); + const filenameOnly = decodedId.split('/').pop(); + 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: { + [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', + debug: { + searchedId: id, + decodedId, + filenameOnly, + allCloudinaryPublicIds: allIds, + allFilenames, + allUrls + } + }); + } + try { + await cloudinary.uploader.destroy(sticker.cloudinaryPublicId); + 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 + }); + } + } 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/app.js b/app.js index 5857036..a7e76d8 100644 --- a/app.js +++ b/app.js @@ -2,47 +2,60 @@ 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 http = require("http"); const PORT = process.env.PORT || 8080; -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", + "http://127.0.0.1:3000", + "http://127.0.0.1:15500", + "https://capstone-2-frontend-tan.vercel.app", + ], + credentials: true, + methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "Cookie"], +}; -// cookie parser middleware +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); +app.use("/api/messages", require("./api/messages")); -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 +/* --------------------- http server + socket attach ------------------ */ +const server = http.createServer(app); +// This line pulls all websocket/presence code out of app.js +require("./ws/realtime")(server, app, { corsOptions }); -// error handling middleware 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 () => { try { await db.sync(); console.log("✅ Connected to the database"); - app.listen(PORT, () => { - console.log(`🚀 Server is running on port ${PORT}`); + await db.sync({ alter: true }); + console.log("✅ Database synced successfully"); + server.listen(PORT, () => { + console.log(`🚀 API + WebSockets running on port ${PORT}`); }); } catch (err) { console.error("❌ Unable to connect to the database:", err); diff --git a/auth/index.js b/auth/index.js index 07968c5..f781be0 100644 --- a/auth/index.js +++ b/auth/index.js @@ -3,12 +3,25 @@ const jwt = require("jsonwebtoken"); const { User } = require("../database"); 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" }); @@ -23,7 +36,7 @@ const authenticateJWT = (req, res, next) => { }); }; -// Auth0 authentication route +//auth0 account creation route router.post("/auth0", async (req, res) => { try { const { auth0Id, email, username } = req.body; @@ -32,30 +45,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 } })) { @@ -67,7 +74,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, @@ -79,15 +85,11 @@ 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", + token: token, user: { id: user.id, username: user.username, @@ -96,39 +98,31 @@ 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 +//post route to create an account 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, @@ -140,46 +134,36 @@ 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 +//post route to login 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 } }); - user.checkPassword(password); 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, @@ -191,35 +175,36 @@ 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", + token: token, 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) +//get logged in user 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) => { @@ -230,4 +215,4 @@ router.get("/me", (req, res) => { }); }); -module.exports = { router, authenticateJWT }; +module.exports = { router, authenticateJWT }; \ No newline at end of file diff --git a/auth/spotify.js b/auth/spotify.js new file mode 100644 index 0000000..ecfe314 --- /dev/null +++ b/auth/spotify.js @@ -0,0 +1,743 @@ +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://127.0.0.1:3000"; +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +const cookieSettings = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 24 * 60 * 60 * 1000, + 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 { + 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"); + } +}; + +//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"); + } + + if (user.isSpotifyTokenValid()) { + return user.spotifyAccessToken; + } + + if (!user.spotifyRefreshToken) { + throw new Error("Spotify not connected"); + } + + 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"; + + baseUsername = baseUsername + .replace(/[^a-zA-Z0-9_]/g, "_") + .substring(0, 15) + .toLowerCase(); + + if (baseUsername.length < 3) { + baseUsername = `spotify_${spotifyProfile.id.substring(0, 10)}`; + } + + if (baseUsername.length > 20) { + baseUsername = baseUsername.substring(0, 20); + } + + 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; + } + } + + return finalUsername; +}; + +// uses client id to get login url for spotify +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", + "playlist-modify-public", + "playlist-modify-private", + ].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" }); + } +}); + +// Get route for the user who is logged in +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", + "playlist-modify-public", + "playlist-modify-private", + ].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" }); + } +}); + +// Post route for logging in with spotify +router.post("/login", async (req, res) => { + try { + const { code, state } = req.body; + + if (state !== "spotify_login") { + return res.status(400).json({ error: "Invalid state parameter" }); + } + + if (!code) { + return res.status(400).json({ error: "No authorization code provided" }); + } + + 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({ + 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; + + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.data; + + let user = await User.findOne({ where: { spotifyId: spotifyProfile.id } }); + + if (!user) { + 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 { + await user.update({ + 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, + }); + } + + 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); + + 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.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, + }); + } +}); + +// Callback url post route for spotify +router.post("/callback", authenticateJWT, async (req, res) => { + try { + const { code, state } = req.body; + const userId = parseInt(state); + + if (userId !== req.user.id) { + 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( + "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; + + const profileResponse = await axios.get("https://api.spotify.com/v1/me", { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + const spotifyProfile = profileResponse.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" }); + } catch (error) { + console.error("Spotify callback error:", error.message); + res.status(500).json({ error: "Failed to connect Spotify" }); + } +}); + +// Get Spotify for logged in user +router.get("/profile", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user.spotifyId) { + return res.json({ connected: false }); + } + + res.json({ + connected: true, + profile: { + id: user.spotifyId, + display_name: user.spotifyDisplayName, + images: user.spotifyProfileImage + ? [{ url: user.spotifyProfileImage }] + : [], + }, + }); + } catch (error) { + console.error("Error getting Spotify profile:", error.message); + res.status(500).json({ error: "Failed to get profile" }); + } +}); + +// Get top tracks for logged in user +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, + }, + } + ); + + 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" }); + } + } +); + +//route for top artist +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, + }, + } + ); + + 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, + 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, + }, + } + ); + + 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, + 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}`, + }, + } + ); + + 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, + 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, + }, + } + ); + + 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/ does not work at the moment +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, + requireSpotifyAuth, + 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; 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/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/db.js b/database/db.js index b251a9d..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-1"; +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/follows.js b/database/follows.js new file mode 100644 index 0000000..d589f75 --- /dev/null +++ b/database/follows.js @@ -0,0 +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 }, + 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", + }, + }, + { + tableName: "follows", + underscored: true, + indexes: [{ unique: true, fields: ["follower_id", "following_id"] }], + } +); + +module.exports = Follows; diff --git a/database/index.js b/database/index.js index e498df6..b55b045 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,124 @@ const db = require("./db"); const User = require("./user"); +const Posts = require("./posts"); +const Follows = require("./follows"); +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", + as: "posts", +}); + +Posts.belongsTo(User, { + foreignKey: "userId", + as: "author", +}); + +User.belongsToMany(User, { + through: Follows, + as: "following", + foreignKey: "follower_id", + otherKey: "following_id", +}); + +User.belongsToMany(User, { + through: Follows, + as: "followers", + foreignKey: "following_id", + otherKey: "follower_id", +}); + +Follows.belongsTo(User, { + foreignKey: "follower_id", + as: "follower", +}); + +Follows.belongsTo(User, { + foreignKey: "following_id", + 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", + as: "uploadedStickers", +}); + +Sticker.belongsTo(User, { + foreignKey: "uploaded_by", + 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", +}); + +// 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" }); + +//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, -}; + Posts, + Follows, + Sticker, + 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 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/posts.js b/database/posts.js new file mode 100644 index 0000000..cdc6bf3 --- /dev/null +++ b/database/posts.js @@ -0,0 +1,71 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Posts = db.define( + "posts", + { + 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", + }, + }, + spotifyId: { + type: DataTypes.STRING, + allowNull: true, + field: "spotify_id", + }, + + spotifyType: { + type: DataTypes.ENUM("track", "album", "playlist", "artist"), + allowNull: true, + field: "spotify_type", + }, + + spotifyEmbedUrl: { + type: DataTypes.STRING, + allowNull: true, + field: "spotify_embed_url", + }, + + likesCount: { + type: DataTypes.INTEGER, + defaultValue: 0, + field: "likes_count", + }, + isPublic: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: "is_public", + }, + }, + { + tableName: "posts", + underscored: true, + } +); + +module.exports = Posts; diff --git a/database/seed.js b/database/seed.js index e58b595..a7523fb 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,30 +1,393 @@ const db = require("./db"); -const { User } = require("./index"); +const { User, Posts, Follows, PostLike, Comments } = 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") }, + { + 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", + }, + { + 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", + }, + { + 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", + }, + { + 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: "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", + }, ]); + const followData = [ + // Admin follows user1, user2, user3, user4 + { 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 + { 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 + { 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 + { 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 + { 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); + console.log(`✅ Created ${followData.length} follow relationships`); + 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([ + // 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", + isPublic: false, + likesCount: 0, + }, + { + 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", + isPublic: true, + likesCount: 23, + }, + { + 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", + isPublic: false, + likesCount: 0, + }, + { + 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", + isPublic: true, + likesCount: 45, + }, + { + title: "Calm Mornings", + description: "Wake up gently.", + status: "published", + userId: 1, + spotifyId: "3KkXRkHbMCARz0aVfEt68P", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/3KkXRkHbMCARz0aVfEt68P", + isPublic: true, + likesCount: 18, + }, + + // 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", + isPublic: true, + likesCount: 67, + }, + { + title: "Hip-Hop Energy", + description: "Stay pumped.", + status: "published", + userId: 2, + spotifyId: "37i9dQZF1DX0XUsuxWHRQd", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX0XUsuxWHRQd", + isPublic: true, + likesCount: 89, + }, + { + title: "The Weekend Wave", + description: "Weekend vibes incoming.", + status: "published", + userId: 2, + spotifyId: "3U4isOIWM3VvDubwSI3y7a", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/3U4isOIWM3VvDubwSI3y7a", + isPublic: true, + likesCount: 34, + }, + { + 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", + isPublic: true, + likesCount: 112, + }, + { + title: "Mellow Mood", + description: "For rainy evenings.", + status: "published", + userId: 2, + spotifyId: "2Fxmhks0bxGSBdJ92vM42m", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/2Fxmhks0bxGSBdJ92vM42m", + isPublic: true, + likesCount: 28, + }, + + // 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", + isPublic: true, + likesCount: 156, + }, + { + title: "Study Mode On", + description: "Helps me focus.", + status: "published", + userId: 3, + spotifyId: "37i9dQZF1DX8Uebhn9wzrS", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DX8Uebhn9wzrS", + isPublic: true, + likesCount: 73, + }, + { + title: "Classic Chill", + description: "Old school but gold.", + 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.", + 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!", + status: "published", + userId: 3, + spotifyId: "4uLU6hMCjMI75M1A2tKUQC", + spotifyType: "track", + spotifyEmbedUrl: + "https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC", + isPublic: true, + likesCount: 64, + }, + + // User 4 + { + 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", + isPublic: true, + likesCount: 37, + }, + { + title: "Favorite Banger", + description: "Crank it loud!", + 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.", + 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.", + status: "published", + userId: 4, + spotifyId: "37i9dQZF1DXcBWIGoYBM5M", + spotifyType: "playlist", + spotifyEmbedUrl: + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + isPublic: true, + likesCount: 127, + }, + ]); + + console.log(`📝 Created ${posts.length} posts`); + + 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("🌱 Seeded the database"); + console.log(`💬 Created ${comments.length} comments`); + console.log("🌱 Seeded the database successfully!"); + } 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(); +seed(); \ No newline at end of file diff --git a/database/sticker.js b/database/sticker.js new file mode 100644 index 0000000..2c4126e --- /dev/null +++ b/database/sticker.js @@ -0,0 +1,88 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); +// this is for stickers streaming from Cloudinary +const Sticker = db.define("sticker", { + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: true, + len: [1, 100] + } + }, + url: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: true, + len: [1, 500] + } + }, + cloudinaryPublicId: { + type: DataTypes.STRING, + allowNull: false, + field: 'cloudinary_public_id', + validate: { + notEmpty: true, + len: [1, 200] + } + }, + type: { + type: DataTypes.ENUM('preset', 'custom'), + allowNull: false, + defaultValue: 'preset' + }, + category: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'general', + validate: { + len: [1, 50] + } + }, + uploadedBy: { + type: DataTypes.INTEGER, + allowNull: true, + 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', + underscored: true, + indexes: [ + { + fields: ['type'] + }, + { + fields: ['category'] + }, + { + fields: ['uploaded_by'] + }, + { + fields: ['type', 'category'] + } + ] +}); + +module.exports = Sticker; diff --git a/database/user.js b/database/user.js index 755c757..7343b86 100644 --- a/database/user.js +++ b/database/user.js @@ -9,6 +9,7 @@ const User = db.define("user", { unique: true, validate: { len: [3, 20], + notEmpty: true, }, }, email: { @@ -16,31 +17,160 @@ const User = db.define("user", { allowNull: true, unique: true, validate: { - isEmail: true, + 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' + }, + profileTheme: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'default', + field: 'profile_theme', + validate: { + len: [1, 50] // Allow themes up to 50 characters + } + }, + spotifyItems: { + type: DataTypes.JSON, + allowNull: true, + 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, + // validate: { + // mustHaveAuthMethod() { + // if (!this.passwordHash && !this.spotifyId && !this.auth0Id) { + // throw new Error('User must have at least one authentication method'); + // } + // } + //} }); -// 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); }; -module.exports = User; +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/database/userProfileSticker.js b/database/userProfileSticker.js new file mode 100644 index 0000000..6eee7a9 --- /dev/null +++ b/database/userProfileSticker.js @@ -0,0 +1,91 @@ +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, + 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; diff --git a/package-lock.json b/package-lock.json index af0cf82..5e10542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,31 @@ { - "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", + "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" + "sequelize": "^6.37.7", + "sharp": "^0.34.3", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" }, "devDependencies": { "nodemon": "^3.1.10" @@ -26,6 +34,496 @@ "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/@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", + "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/@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", @@ -50,6 +548,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", @@ -83,6 +592,29 @@ "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", + "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", @@ -90,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", @@ -156,9 +697,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": { @@ -185,6 +726,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", @@ -248,6 +806,72 @@ "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/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", + "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", @@ -255,6 +879,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", @@ -343,6 +982,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", @@ -352,6 +1000,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", @@ -408,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", @@ -438,6 +1214,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", @@ -502,27 +1293,84 @@ "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "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": ">=8" + "node": ">= 6" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "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": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/forwarded": { @@ -651,6 +1499,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", @@ -722,6 +1585,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", @@ -929,6 +1798,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", @@ -951,16 +1841,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" @@ -999,6 +1889,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", @@ -1101,9 +2052,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" @@ -1290,6 +2241,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", @@ -1297,6 +2254,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", @@ -1336,6 +2304,43 @@ "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/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", @@ -1397,6 +2402,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", @@ -1523,6 +2535,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", @@ -1595,6 +2649,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", @@ -1608,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", @@ -1626,6 +2856,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", @@ -1677,6 +2924,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", @@ -1691,6 +2945,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", @@ -1713,6 +2973,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", @@ -1773,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 7e0a0af..379885c 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,23 @@ "license": "ISC", "description": "", "dependencies": { + "@auth0/auth0-react": "^2.4.0", + "@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" + "sequelize": "^6.37.7", + "sharp": "^0.34.3", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/socket-server.js b/socket-server.js new file mode 100644 index 0000000..d887933 --- /dev/null +++ b/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 1b0a308..8be8eb5 100644 --- a/vercel.json +++ b/vercel.json @@ -9,7 +9,8 @@ "routes": [ { "src": "/(.*)", - "dest": "app.js" + "dest": "app.js", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] } ] } 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;