diff --git a/api/index.js b/api/index.js index f08162e..eb7fad7 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,9 @@ const express = require("express"); const router = express.Router(); +const pollRouter = require("./polls"); const testDbRouter = require("./test-db"); router.use("/test-db", testDbRouter); +router.use("/polls", pollRouter) module.exports = router; diff --git a/api/poll.js b/api/poll.js new file mode 100644 index 0000000..e69de29 diff --git a/api/polls.js b/api/polls.js new file mode 100644 index 0000000..e3d90d4 --- /dev/null +++ b/api/polls.js @@ -0,0 +1,583 @@ +const express = require("express"); +const router = express.Router(); +const { Poll, PollOption, Vote, VotingRank } = require("../database"); +const { authenticateJWT } = require("../auth"); +const { where } = require("sequelize"); + +// Get all users Polls---------------------------- +router.get("/", authenticateJWT, async (req, res) => { + const userId = req.user.id; + + try { + const userPolls = await Poll.findAll({ where: { userId } }); + res.json(userPolls); + } catch (error) { + res.status(500).json({ error: "Failed to get all polls" }); + } +}); + +//Get all draft polls by user-------------------- + +router.get("/draft", authenticateJWT, async (req, res) => { + const userId = req.user.id; + try { + const draftPolls = await Poll.findAll({ + where: { + userId, + status: "draft", + }, + }); + const specialDelivery = { + message: + draftPolls.length === 0 + ? "There no polls to display" + : "Polls successfully retrived", + polls: draftPolls, // polls is an array of objects + }; + + specialDelivery.polls.sort( + (a, b) => new Date(b.createdAt) - new Date(a.createdAt) + ); + specialDelivery.polls.map((poll) => { + console.log(poll.createdAt); + }); + + res.status(200).json(specialDelivery); + } catch (error) { + res.status(500).json({ error: "Failed to get drafted polls" }); + } +}); + +// Get polls by slug +router.get("/slug/:slug", authenticateJWT, async (req, res) => { + try { + const pollSlug = req.params.slug; + const poll = await Poll.findOne({ + where: { slug: pollSlug }, + include: [ + { + model: PollOption, + }, + ], + }); + + if (!poll) { + return res.status(404).json({ error: "Poll not found" }); + } + res.json(poll); + } catch (error) { + res.status(500).json({ error: "Failed to get poll" }); + } +}); + +//Get a users poll by id with options----------------- +router.get("/:pollId", authenticateJWT, async (req, res) => { + const userId = req.user.id; + const { pollId } = req.params; + console.log(pollId); + console.log(userId); + + try { + // fetch a spcific poll with options that belong to this user + const poll = await Poll.findOne({ + where: { + id: pollId, + userId: userId, + }, + include: { model: PollOption }, + }); + + if (!poll) { + return res.status(404).json({ error: "No polls found" }); + } + res.json(poll); + } catch (error) { + console.error("Error fetching poll:", error); + res.status(500).json({ error: "Failed to get poll by ID" }); + } +}); + +// Create polls--------------------------- +router.post("/", authenticateJWT, async (req, res) => { + const userId = req.user.id; + const { title, description, deadline, status, options = [] } = req.body; + + if (status === "published" && options.length < 2) { + return res.status(400).json({ + error: " 2 options are requires to publish a poll", + }); + } + try { + const newPoll = await Poll.create({ + title, + description, + deadline, + status, + userId, + }); + //[opttion1, option2, option3] + if (options.length > 0) { + const formattedOptions = options.map((text) => ({ + optionText: text, + pollId: newPoll.id, + })); + + await PollOption.bulkCreate(formattedOptions); + return res.status(201).json({ + message: "Poll and options created", + poll: newPoll, + }); + } + return res.json(newPoll); + } catch (error) { + console.error("Poll creation failed:", error); + res.status(500).json({ + error: "Failed to create poll", + message: "Check that API fields and data are correct", + }); + } +}); + +//Edit polls-------------------- +router.patch("/:pollId", authenticateJWT, async (req, res) => { + const userId = req.user.id; + const poll = req.body; + const { title, description, deadline, status, options = [] } = req.body; + const newBody = { + title, + description, + deadline, + status, + }; + const { pollId } = req.params; + + try { + const updatePoll = await Poll.findByPk(pollId); + + if (!updatePoll) { + return res.status(404).json({ error: "poll not found" }); + } else if (updatePoll.userId !== userId) { + return res + .status(403) + .json({ error: "poll does not belong to this user" }); + } + + if (updatePoll.status === "draft") { + const updatedPoll = await updatePoll.update(newBody); + const optionsToDestroy = await PollOption.destroy({ where: { pollId } }); + + // [option1, option2, option3] + // formattedOptions = [ + // { + // optionText: 'option1', + // pollId: pollId, + // }, + // { + // optionText: 'option2', + // pollId: pollId, + // }, + // { + // optionText: 'option3', + // pollId: pollId, + // } + // ]; + + const formattedOptions = await options.map((text) => ({ + optionText: text, + pollId: pollId, + })); + + const newPollOptions = await PollOption.bulkCreate(formattedOptions); + + return res.json(newBody); + } + + if (updatePoll.status === "published") { + const updateDeadline = await updatePoll.update({ deadline }); + return res.json(updateDeadline); + } + return res + .status(400) + .json({ error: "Invalid poll status string or update not allowed" }); + } catch (error) { + console.error("Update error:", error); + res.status(500).json({ + error: "Failed to update poll", + message: "Only deadline can be edited when poll is published", + }); + } +}); + +//delete draft poll------------------------- +router.delete("/:id", authenticateJWT, async (req, res) => { + try { + const pollId = req.params.id; + const userId = req.user.id; + + const poll = await Poll.findByPk(pollId); + + if (!poll) { + return res.status(404).json({ error: "Poll not found" }); + } + + if (poll.userId !== userId) { + return res.status(401).json({ error: "Unauthorized action: You do not own this poll" }); + } + + await poll.destroy(); + + res.json({ message: "Poll deleted successfully" }); + } catch (error) { + res.status(500).json({ error: "Failed to delete poll" }); + } +}); + +//-------------------------------------------------------------- Create A vote ballot -------------------------------------------- +router.post("/:pollId/vote", authenticateJWT, async (req, res) => { + // rankings = [ + // { optionId: 1, rank: 1 }, + // { optionId: 2, rank: 2 }, + // ]; + console.log("Vote route hit"); + const userId = req.user.id; + console.log("User Id", userId) + + const { pollId } = req.params; + console.log("Poll Id", pollId) + const { rankings } = req.body; + if (!userId) { + return res.status(404).json({ error: "Unathorized action" }); + } + + try { + // I know I am going to need the options that belong to this poll so I should query this poll and include the options + + const poll = await Poll.findOne({ + where: { id: pollId }, + include: { model: PollOption }, + }); + // return res.send(poll) this is returns the poll that I want + + if (!poll) { + return res.status(404).json({ error: "Poll not found" }); + } + + // I want to create a new vote only if a vote does not already exist + + // Now that we have the poll we are workgin with I want to look at the vote that belongs to this poll + const vote = await Vote.findOne({ + where: { userId: userId, pollId: pollId }, + include: { model: VotingRank }, + }); + if (vote) { + return res + .status(401) + .json({ error: "A vote for this poll aready exist" }); + } + + const newVote = await Vote.create({ userId, pollId, submitted: true }) + // return res.send(newVote) + // I created the a new vote and linked it to the user and the poll now i need to create a new vote_ranking and link it to this vote + const formattedVotingRank = rankings.map((rank) => { + return { pollOptionId: rank.optionId, voteId: newVote.id, rank: rank.rank } + }) + + console.log(formattedVotingRank) + const newVoteRanking = await VotingRank.bulkCreate(formattedVotingRank) + // return res.send(newVoteRanking) + + const completedVote = await Vote.findOne({ + where: { userId: userId, pollId: pollId }, + include: { model: VotingRank, include: { model: PollOption, attributes: ['id', 'optionText'] } }, + }); + // if (newVote.submitted === false) { + // vote.submitted = true; + // await newVotevote.save(); + // } + return res.send(completedVote); + + // Recap I have the target Poll then I featch the vote that belongs to the user and this poll .. after getting the vote + // i was able to fetech all rankings that belongs to this vote + + // I now know that i am able to fetch all the data the I need .. however I am fetchin a vote that already exist but has not been submitted + // if (vote.submitted === true) { + // return res.status(401).json({ + // error: "this user has already submitted a vote for this poll", + // }); + // } + + + // return res.send(vote); + + /// so from here i have succesfully submitted a vote now i need to go back to the top and create a new vote with new rankings since + // what I accomplish was to submit a vote predefined in the seed.js + } catch (error) { + console.log("Fatal error"); + return res.status(500).json({ error: "Failed to submit a vote" }); + } + + + +}); + + +//------------------------------------ Calculate results -------------------------------------------------------- + +router.get("/:pollId/results", authenticateJWT, async (req, res) => { + + + const userId = req.user.id; + const { pollId } = req.params; + + const votes = await Vote.findAll({ + where: { pollId: pollId }, + include: { model: VotingRank }, + }); + + const allBallots = votes.map(vote => vote.votingRanks); + + const ballots = allBallots.map(ballot => { + return ballot + .sort((a, b) => a.rank - b.rank) + .map((element) => element.pollOptionId) + }) + + const options = await PollOption.findAll({ where: { pollId: pollId } }) + + const optionsMap = {}; + + for (const option of options) { + optionsMap[option.id] = + { + name: option.optionText, + count: 0, + eliminated: false, + }; + } + + + const totalVotes = ballots.length; + console.log(totalVotes) + const majorityThreshhold = Math.floor(totalVotes / 2) + 1; + console.log(majorityThreshhold) + + + let foundWinner = false; + + while (!foundWinner) { + + for (const option of Object.values(optionsMap)) { + option.count = 0; + } + + for (const ballot of ballots) { + for (const optionId of ballot) { + const option = optionsMap[optionId]; + if (!option.eliminated) { + option.count += 1; + break; + } + } + } + + + for (const [id, option] of Object.entries(optionsMap)) { + if (option.count > majorityThreshhold) { + foundWinner = true; + return res.json({ + status: "winner", + optionId: id, + name: option.name, + voteCount: option.count, + totalVotes, + }); + } + } + + + let minCount = Infinity; + let optionToEliminate = []; + + + for (const [optionId, option] of Object.entries(optionsMap)) { + if (!option.eliminated) { + if (option.count < minCount) { + minCount = option.count; + optionToEliminate = [optionId]; + } else if (option.count === minCount) { + optionToEliminate.push(optionId) + } + } + } + console.log(optionToEliminate) + console.log(minCount); + + const remaining = Object.values(optionsMap).filter((option) => !option.eliminated); + + if (remaining.length === optionToEliminate.length) { + foundWinner = true + return res.json({ + status: "tie", + tiedOptions: optionToEliminate.map((id) => ({ + optionId: id, + name: optionsMap[id].name, + voteCount: optionsMap[id].count, + })), + totalVotes, + }); + } + + + for (const optionId of optionToEliminate) { + optionsMap[optionId].eliminated = true + } + } + + + + + + + // return res.send(allBallots) + // return res.send(ballots) + // [ + // [ + // 7, + // 8, + // 9, + // 10 + // ], + // [ + // 10, + // 9, + // 8, + // 7 + // ] + // ] + // return res.send(options) + return res.send(optionsMap) + + // { + // "7": { + // "name": "Die Hard", + // "count": 0, + // "elimated": 0 + // }, + // "8": { + // "name": "Die Hard 2", + // "count": 0, + // "elimated": 0 + // }, + // "9": { + // "name": "Twilight", + // "count": 0, + // "elimated": 0 + // }, + // "10": { + // "name": "Spiderverse", + // "count": 0, + // "elimated": 0 + // } + // } +}) + +// duplicate poll endpoint +router.post('/:id/duplicate', authenticateJWT, async (req, res) => { + try { + const pollId = req.params.id; + const userId = req.user.id; + + // fetch poll and options + const poll = await Poll.findByPk(pollId, { + include: { model: PollOption } + }); + if (!poll) { + return res.status(404).json({ error: 'Poll not found' }); + } + if (poll.userId !== userId) { + return res.status(403).json({ error: 'Unauthorized: not your poll' }); + } + + // create new poll with same fields + const newPoll = await Poll.create({ + title: poll.title + ' (copy)', + description: poll.description, + status: 'draft', + userId: userId, + deadline: poll.deadline, + authRequired: poll.authRequired, + restricted: poll.restricted + }); + + // generate unique slug + await newPoll.update({ slug: `${poll.slug}-copy-${newPoll.id}` }); + + // copy options + const newOptions = poll.pollOptions.map((opt) => ({ + optionText: opt.optionText, + pollId: newPoll.id, + position: opt.position + })); + await PollOption.bulkCreate(newOptions); + + // fetch new poll with options + const pollWithOptions = await Poll.findByPk(newPoll.id, { + include: { model: PollOption } + }); + + res.status(201).json({ + message: 'Poll duplicated successfully', + poll: pollWithOptions + }); + } catch (error) { + res.status(500).json({ error: 'Failed to duplicate poll' }); + } +}); + +// duplicate poll by id--------------------------- +router.post('/:pollId/duplicate', authenticateJWT, async (req, res) => { + const userId = req.user.id; + const { pollId } = req.params; + try { + // fetch poll and options + const poll = await Poll.findOne({ + where: { id: pollId, userId }, + include: { model: PollOption } + }); + if (!poll) { + return res.status(404).json({ error: 'Poll not found' }); + } + // create new poll + const newPoll = await Poll.create({ + title: poll.title + ' (copy)', + description: poll.description, + status: 'draft', + userId, + deadline: poll.deadline, + authRequired: poll.authRequired, + restricted: poll.restricted + }); + await newPoll.update({ slug: `${poll.slug}-copy-${newPoll.id}` }); + // copy options + const newOptions = poll.pollOptions.map((opt) => ({ + optionText: opt.optionText, + pollId: newPoll.id, + position: opt.position + })); + await PollOption.bulkCreate(newOptions); + // fetch new poll with options + const pollWithOptions = await Poll.findOne({ + where: { id: newPoll.id, userId }, + include: { model: PollOption } + }); + res.status(201).json({ + message: 'Poll duplicated successfully', + poll: pollWithOptions + }); + } catch (error) { + res.status(500).json({ error: 'Failed to duplicate poll' }); + } +}); + + +module.exports = router; diff --git a/app.js b/app.js index 5857036..0c4a32a 100644 --- a/app.js +++ b/app.js @@ -4,11 +4,14 @@ const morgan = require("morgan"); const path = require("path"); const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); +const passport = require("passport"); +const session = require("express-session"); const app = express(); const apiRouter = require("./api"); const { router: authRouter } = require("./auth"); const { db } = require("./database"); const cors = require("cors"); +const oAuthRouter = require("./auth/routes") const PORT = process.env.PORT || 8080; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; @@ -26,10 +29,22 @@ app.use( // cookie parser middleware app.use(cookieParser()); +// Session middleware for passport +app.use(session({ + secret: process.env.JWT_SECRET || 'your-secret-key', + resave: false, + saveUninitialized: false +})); + +// Initialize passport +app.use(passport.initialize()); +app.use(passport.session()); + 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 +app.use("/auth", oAuthRouter); //mount oAuth router // error handling middleware app.use((err, req, res, next) => { @@ -39,7 +54,7 @@ app.use((err, req, res, next) => { const runApp = async () => { try { - await db.sync(); + await db.sync({ alter: true }); //update tables to match model console.log("โœ… Connected to the database"); app.listen(PORT, () => { console.log(`๐Ÿš€ Server is running on port ${PORT}`); diff --git a/auth/index.js b/auth/index.js index 07968c5..5faf45b 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,6 +1,7 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); +const { Op } = require("sequelize"); const router = express.Router(); @@ -167,9 +168,15 @@ router.post("/login", async (req, res) => { return; } - // Find user - const user = await User.findOne({ where: { username } }); - user.checkPassword(password); + // Find user by username or email + const user = await User.findOne({ + where: { + [Op.or]: [ + { username: username }, + { email: username } + ] + } + }); if (!user) { return res.status(401).send({ error: "Invalid credentials" }); } @@ -230,4 +237,173 @@ router.get("/me", (req, res) => { }); }); +// Helper function to check if input looks like email +const looksLikeEmail = (input) => { + return input.includes("@"); +}; + +// Signup with username and password +router.post("/signup/username", async (req, res) => { + try { + const { username, password, firstName, lastName } = req.body; + + if (!username || !password) { + 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" }); + } + + // Check if input looks like email + if (looksLikeEmail(username)) { + return res + .status(400) + .send({ + error: + "Username cannot be an email address. Please use a regular username.", + }); + } + + // Check if username already exists (check both username and email fields to prevent duplicates) + const existingUser = await User.findOne({ + where: { + [Op.or]: [{ username }, { email: username }], + }, + }); + + if (existingUser) { + return res.status(409).send({ error: "Username already exists" }); + } + + // Create new user + const passwordHash = User.hashPassword(password); + const userData = { + username, + passwordHash, + firstName: firstName || null, + lastName: lastName || null, + }; + + const user = await User.create(userData); + + // Generate JWT token + const token = jwt.sign( + { + id: user.id, + username: user.username, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }); + + res.send({ + message: "User created successfully with username", + user: { + id: user.id, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + }, + }); + } catch (error) { + console.error("Username signup error:", error); + res.status(500).send({ error: "Internal server error" }); + } +}); + +// Helper function to validate email format +const isValidEmail = (email) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +// Signup with email and password +router.post("/signup/email", async (req, res) => { + try { + const { email, password, firstName, lastName } = req.body; + + if (!email || !password) { + return res.status(400).send({ error: "Email and password are required" }); + } + + if (password.length < 6) { + return res.status(400).send({ error: "Password must be at least 6 characters long" }); + } + + // Validate email format + if (!isValidEmail(email)) { + return res.status(400).send({ error: "Please provide a valid email address" }); + } + + // Check if email already exists (check both email and username fields to prevent duplicates) + const existingUser = await User.findOne({ + where: { + [Op.or]: [ + { email: email }, + { username: email } + ] + } + }); + + if (existingUser) { + return res.status(409).send({ error: "Email already exists" }); + } + + // Create new user + const passwordHash = User.hashPassword(password); + const userData = { + email: email, + passwordHash, + firstName: firstName || null, + lastName: lastName || null + }; + + const user = await User.create(userData); + + // Generate JWT token + const token = jwt.sign( + { + id: user.id, + username: user.username, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }); + + res.send({ + message: "User created successfully with email", + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + }); + } catch (error) { + console.error("Email signup error:", error); + res.status(500).send({ error: "Internal server error" }); + } +}); + module.exports = { router, authenticateJWT }; diff --git a/auth/routes.js b/auth/routes.js new file mode 100644 index 0000000..e4095a3 --- /dev/null +++ b/auth/routes.js @@ -0,0 +1,112 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const passport = require('passport'); +const GoogleStrategy = require('passport-google-oauth20').Strategy; +const { User } = require("../database"); + + +const router = express.Router(); + +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +// Passport serialization +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +passport.deserializeUser(async (id, done) => { + try { + const user = await User.findByPk(id); + done(null, user); + } catch (error) { + done(error, null); + } +}); + +passport.use(new GoogleStrategy({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL + }, async (accessToken, refreshToken, profile, done) => { + try { + let user = await User.findOne({where: {googleId: profile.id}}); + + if (!user) { + // Try by email + const email = profile.emails?.[0]?.value; + if (email) { + user = await User.findOne({ where: { email } }); + + if (user) { + user.googleId = profile.id; + await user.save(); + } + } + } + + if (!user) { + // Create new user + const email = profile.emails?.[0]?.value || null; + const username = email ? email.split('@')[0] : `user_${Date.now()}`; + + // Ensure username is unique + let finalUsername = username; + let counter = 1; + while (await User.findOne({ where: { username: finalUsername } })) { + finalUsername = `${username}_${counter}`; + counter++; + } + + user = await User.create({ + googleId: profile.id, + email, + username: finalUsername, + passwordHash: null, + }); + } + + return done(null, user); + } catch (error) { + return done(error, null); + } + })); + + //OAuth flow + router.get("/google", passport.authenticate("google", { + scope: ["profile", "email"] + })); + + //Handle callback + router.get("/google/callback", passport.authenticate("google", {session: false}), + async (req,res) => { + try { + let user = req.user; + + // Generate JWT token + const token = jwt.sign( + { + id: user.id, + username: user.username, + auth0Id: user.auth0Id, + email: user.email, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }); + + res.redirect(`${process.env.FRONTEND_URL}/dashboard`); + } catch (error) { + res.redirect(`${process.env.FRONTEND_URL}/login?error=google&message=Google%20login%20failed`); + + } + + }); + + module.exports = router; \ No newline at end of file diff --git a/database/db.js b/database/db.js index b251a9d..bba1d5f 100644 --- a/database/db.js +++ b/database/db.js @@ -3,7 +3,7 @@ const { Sequelize } = require("sequelize"); const pg = require("pg"); // Feel free to rename the database to whatever you want! -const dbName = "capstone-1"; +const dbName = "capstone_1"; const db = new Sequelize( process.env.DATABASE_URL || `postgres://localhost:5432/${dbName}`, diff --git a/database/index.js b/database/index.js index e498df6..f046fab 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,111 @@ const db = require("./db"); -const User = require("./user"); +const User = require("./models/user"); +const Poll = require("./models/poll"); +const PollOption = require("./models/pollOption"); +const VotingRank = require("./models/votingRank"); +const Vote = require("./models/vote"); +const RestrictedPollAccess = require('./models/restrictedPollAccess') + +//----- User Model-------- + +//One to many - user has many polls +User.hasMany(Poll, { + foreignKey: 'userId', + // onDelete: 'CASCADE' deletes poll is user is deleted +}); + + + +//----- Poll Model-------- + +// many to one - Each Poll belongs to one user +Poll.belongsTo(User, { + foreignKey: 'userId', +}); + + + +//----- PollOption Model-------- + +// One to many - one Poll has many options +Poll.hasMany(PollOption, { + foreignKey: 'pollId', + onDelete: "CASCADE", // delete poll_options if poll is deleted +}); + +// many to one - Each pollOption belongs to one Poll +PollOption.belongsTo(Poll, { + foreignKey: "pollId" +}); + + + +//----- Vote Model-------- + +// one to many- each user can submit many votes +User.hasMany(Vote, { + foreignKey: "userId", +}) + +// one to one- Each vote(ballot) belongs to a user +Vote.belongsTo(User, { + foreignKey: "userId", +}) + +// many to one - Each vote(ballot) belongs to a poll +Vote.belongsTo(Poll, { + foreignKey: "pollId" +}) + + + +//----- Voting Rank Model-------- + +// one to many- each vote(ballot) can have many ranked options +Vote.hasMany(VotingRank, { + foreignKey: "voteId", +}) + + +// many to one - each rank entry belongs to one vote(ballot) +VotingRank.belongsTo(Vote, { + foreignKey: "voteId", +}) + + +// many to one- Each voteRank belongs to one Polloption +VotingRank.belongsTo(PollOption, { + foreignKey: 'pollOptionId', +}) + + + +//----- Restricted Access Model-------- + +//A user can be restricted to many polls +User.belongsToMany(Poll, { + through: RestrictedPollAccess, + as: "restrictedPolls", + foreignKey: "userId" +}) + +// A poll can belong to many users through Restrictred access +Poll.belongsToMany(User, { + through: RestrictedPollAccess, + as: "restictedUsers", + foreignKey: "pollId", +}) + +// + + module.exports = { db, User, + Poll, + PollOption, + Vote, + VotingRank, + RestrictedPollAccess, }; diff --git a/database/models/poll.js b/database/models/poll.js new file mode 100644 index 0000000..bae6812 --- /dev/null +++ b/database/models/poll.js @@ -0,0 +1,88 @@ +const { DataTypes } = require('sequelize'); +const db = require('../db'); + +// define the Poll model + +const Poll = db.define("poll", { + title: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + // allowNull: true, + }, + participants: { + type: DataTypes.INTEGER, + defaultValue: 0, + }, + status: { + type: DataTypes.ENUM("draft", "published", "ended"), + allowNull: false, + }, + deadline: { + type: DataTypes.DATE, + // allowNull: false, + }, + authRequired: { + type: DataTypes.BOOLEAN, // allow only user votes if true + default: false, + }, + isDisabled: { + type: DataTypes.BOOLEAN, // if true poll is disabled by admin + default: false, + }, + restricted: { + type: DataTypes.BOOLEAN, // only specic users can parcipate if true + default: false, + }, + slug: { + type: DataTypes.STRING, + unique: true, + allowNull: false, + }, + +}, + { + timestamps: true, + } +); + +//slug creation + +function slugify(text) { + return text + .toString() + .toLowerCase() + .trim() + .replace(/\s+/g, "-") + .replace(/[^\w\-]+/g, "") + .replace(/\-\-+/g, "-"); + } + + // generate a random string + function generateRandomString(length = 6) { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; + } + + //auto-generate slug + Poll.beforeValidate(async (poll) => { + if (!poll.slug) { + const baseSlug = slugify(poll.title); + let uniqueSlug = baseSlug; + + while (await Poll.findOne({where: {slug: uniqueSlug}})){ + uniqueSlug = `${baseSlug}-${generateRandomString()}`; + } + poll.slug = uniqueSlug; + } + }) + + + +module.exports = Poll; \ No newline at end of file diff --git a/database/models/pollOption.js b/database/models/pollOption.js new file mode 100644 index 0000000..e6f3612 --- /dev/null +++ b/database/models/pollOption.js @@ -0,0 +1,30 @@ +const { DataTypes } = require('sequelize'); +const db = require('../db'); + +// Table Poll_Option { +// id PK +// option_text string +// position integer ("?") +// poll_id FK +// created_at timestamp +// } +const pollOption = db.define("pollOption", { + optionText: { + type: DataTypes.STRING, + allowNull: false, + }, + position: { + type: DataTypes.INTEGER, + allowNull: true, + }, + pollId: { + type: DataTypes.INTEGER, + allowNull: false, + } +}, + { + timestamps: true, + } +) + +module.exports = pollOption; \ No newline at end of file diff --git a/database/models/restrictedPollAccess.js b/database/models/restrictedPollAccess.js new file mode 100644 index 0000000..3c70587 --- /dev/null +++ b/database/models/restrictedPollAccess.js @@ -0,0 +1,25 @@ +const { DataTypes } = require("sequelize"); +const db = require("../db"); + +// Table Restricted_Poll_Access { +// id PK +// poll_id FK [ref: > Poll.id] +// user_id FK [ref: > User.id] +// created_at timestamp +// } +const RestrictedPollAccess = db.define("restrictedPollAccess", { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + pollId: { + type: DataTypes.INTEGER, + allowNull: false, + }, +}, + { + timestamps: true, + } +) + +module.exports = RestrictedPollAccess; \ No newline at end of file diff --git a/database/user.js b/database/models/user.js similarity index 51% rename from database/user.js rename to database/models/user.js index 755c757..4e3876b 100644 --- a/database/user.js +++ b/database/models/user.js @@ -1,22 +1,46 @@ const { DataTypes } = require("sequelize"); -const db = require("./db"); +const db = require("../db"); const bcrypt = require("bcrypt"); const User = db.define("user", { username: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, unique: true, validate: { - len: [3, 20], + len: [1, 50] + } + }, + firstName: { + type: DataTypes.STRING, + // allowNull: false, + validate: { + notEmpty: true, + len: [1, 50], + }, + }, + lastName: { + type: DataTypes.STRING, + // allowNull: false, + validate: { + notEmpty: true, + len: [1, 50], }, }, email: { type: DataTypes.STRING, - allowNull: true, + // allowNull: false, unique: true, validate: { isEmail: true, + notEmpty: true, + }, + }, + passwordHash: { + type: DataTypes.STRING, + allowNull: true, + validate: { + notEmpty: true, }, }, auth0Id: { @@ -24,11 +48,34 @@ const User = db.define("user", { allowNull: true, unique: true, }, - passwordHash: { + googleId: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, + img: { type: DataTypes.STRING, allowNull: true, + validate: { + isUrl: true, + }, + }, + isAdmin: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, }, -}); + isDisable: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, +}, + { + timestamps: true, + createdAt: 'created_at', + } +); // Instance method to check password User.prototype.checkPassword = function (password) { diff --git a/database/models/vote.js b/database/models/vote.js new file mode 100644 index 0000000..e078ef9 --- /dev/null +++ b/database/models/vote.js @@ -0,0 +1,31 @@ +const { DataTypes } = require('sequelize'); +const db = require('../db'); + +// define the Vote model + +const Vote = db.define("vote", { + submitted: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + voterToken: { + type: DataTypes.STRING, + allowNull: true, + }, + ipAddress: { + type: DataTypes.STRING, + allowNull: true, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + pollId: { + type: DataTypes.INTEGER, + allowNull: false, + }, +}, { + timestamps: true, +}); + +module.exports = Vote; \ No newline at end of file diff --git a/database/models/votingRank.js b/database/models/votingRank.js new file mode 100644 index 0000000..d5a03c2 --- /dev/null +++ b/database/models/votingRank.js @@ -0,0 +1,21 @@ +const { DataTypes } = require("sequelize"); +const db = require("../db"); + +// define the votingRank model + +const VotingRank = db.define("votingRank", { + voteId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + pollOptionId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + rank: { + type: DataTypes.INTEGER, + allowNull: false, + } +}) + +module.exports = VotingRank; \ No newline at end of file diff --git a/database/seed.js b/database/seed.js index e58b595..b1f47ef 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,5 +1,6 @@ +const { Pool } = require("pg"); const db = require("./db"); -const { User } = require("./index"); +const { User, Poll, PollOption, Vote, VotingRank } = require("./index"); const seed = async () => { try { @@ -7,13 +8,259 @@ const seed = async () => { await db.sync({ force: true }); // Drop and recreate tables 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"), + }, + { + username: "user1", + passwordHash: User.hashPassword("user111") + }, + { + username: "user2", + passwordHash: User.hashPassword("user222") + }, ]); - console.log(`๐Ÿ‘ค Created ${users.length} users`); + // deadline: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + + const pollData = [ + { + key: "anime", + title: "Best Anime?", + description: "Rank your favorite animes!", + participants: 0, + status: "draft", + userKey: "user1", + + }, + { + key: "movie", + title: "Best Movie?", + description: "Rank your favorite movies!", + participants: 0, + status: "published", + userKey: "user2", + }, + { + key: "bbq", + title: "Best BBQ Item?", + description: "Rank your favorite BBQ food!", + status: "published", + userKey: "user1" + }, + { + key: "authRequired", + title: "authRequired true", + description: "?", + participants: 0, + status: "published", + authRequired: true, + userKey: "user2" + + }, + { + key: "restricited", + title: "restricted true", + description: "Rank your favorite anime of all time!", + participants: 0, + status: "published", + restricted: true, + userKey: "user1" + + }, + ]; + + const createdPolls = {}; + const deadline = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // 3days + + const userMap = { + admin: users[0], + user1: users[1], + user2: users[2], + }; + + for (const poll of pollData) { + const created = await Poll.create({ + ...poll, + deadline, + userId: userMap[poll.userKey].id, + }) + createdPolls[poll.key] = created; + // console.log(createdPolls.anime.id) + }; + + + + const PollOptions = await PollOption.bulkCreate([ + { + optionText: "Demon Slayer", + position: 1, + pollId: createdPolls.anime.id, + }, + { + optionText: "One Piece", + position: 2, + pollId: createdPolls.anime.id, + }, + { + optionText: "AOT", + position: 3, + pollId: createdPolls.anime.id, + }, + { + optionText: "Naruto", + position: 4, + pollId: createdPolls.anime.id, + }, + { + optionText: "Devil May Cry", + position: 5, + pollId: createdPolls.anime.id, + }, + { + optionText: "Castlevania", + position: 6, + pollId: createdPolls.anime.id, + }, + { + optionText: "Die Hard", + pollId: createdPolls.movie.id + }, + { + optionText: "Die Hard 2", + pollId: createdPolls.movie.id, + }, + { + optionText: "Twilight", + pollId: createdPolls.movie.id, + }, + { + optionText: "Spiderverse", + pollId: createdPolls.movie.id, + }, + { + optionText: "Pork Ribs", + pollId: createdPolls.bbq.id, + }, + { + optionText: "Hot Dog", + pollId: createdPolls.bbq.id, + }, + { + optionText: "Cheeseburger", + pollId: createdPolls.bbq.id, + }, + { + optionText: "Suasage", + pollId: createdPolls.bbq.id, + }, + { + optionText: "a", + pollId: createdPolls.authRequired.id, + }, + { + optionText: "b", + pollId: createdPolls.authRequired.id, + }, + { + optionText: "c", + pollId: createdPolls.authRequired.id, + }, + { + optionText: "d", + pollId: createdPolls.authRequired.id, + }, + { + optionText: "1", + pollId: createdPolls.restricited.id, + }, + { + optionText: "2", + pollId: createdPolls.restricited.id, + }, + { + optionText: "3", + pollId: createdPolls.restricited.id, + + }, + { + optionText: "4", + pollId: createdPolls.restricited.id, + }, + + ]); + + + // vote ---> envelope + // const votes = await Vote.bulkCreate([ + // { + // userId: users[1].id, + // pollId: createdPolls.anime.id + // }, + // { + // userId: users[2].id, + // pollId: createdPolls.movie.id + // }, + // { + // userId: users[1].id, + // pollId: createdPolls.bbq.id + // }, + // { + // userId: users[2].id, + // pollId: createdPolls.authRequired.id + // }, + // { + // userId: users[1].id, + // pollId: createdPolls.restricited.id + // }, + // ]) + + + // const optionMap = {}; + // PollOptions.forEach((option) => { + // optionMap[option.optionText] = option; + // }); + + // const ranks = await VotingRank.bulkCreate([ + // { + // voteId: votes[0].id, + // pollOptionId: optionMap["Demon Slayer"].id, + // rank: 1, + // }, + // { + // voteId: votes[0].id, + // pollOptionId: optionMap["One Piece"].id, + // rank: 3, + // }, + // { + // voteId: votes[0].id, + // pollOptionId: optionMap["AOT"].id, + // rank: 4, + // }, + // { + // voteId: votes[0].id, + // pollOptionId: optionMap["Devil May Cry"].id, + // rank: 6 + // }, + // { + // voteId: votes[0].id, + // pollOptionId: optionMap["Castlevania"].id, + // rank: 5 + // }, + // { + // voteId: votes[0].id, + // pollOptionId: optionMap["Naruto"].id, + // rank: 2 + // }, + // ]); + + + + console.log(`๐Ÿ‘ค Created ${users.length} users`); + console.log(`Created ${Object.keys(createdPolls).length} polls`); + console.log(`๐Ÿงพ Created ${PollOptions.length} poll options`); // Create more seed data here once you've created your models // Seed files are a great way to test your database schema! diff --git a/middleware/checkDuplicateVote.js b/middleware/checkDuplicateVote.js new file mode 100644 index 0000000..acfd3a9 --- /dev/null +++ b/middleware/checkDuplicateVote.js @@ -0,0 +1,50 @@ +const { Vote } = require("../database"); +const {Op} = require("sequelize"); //operator to specify multiple conditions + +const checkDuplicateVote = async (req, res, next) => { + const userId = req.user ? req.user.id : null; + const pollId = req.body.pollId; + + try { + if (userId) { + //check if user already voted + const existingVote = await Vote.findOne({ + where: { + userId: userId, + pollId: pollId, + }, + }); + + if (existingVote) { + return res + .status(409) + .json({ error: "You have already voted on this poll." }); + } + } + else { + const guestUser = req.body.voterToken; + const ipAddress = req.ip; + //check if guest user already voted + const existingGuestVote = await Vote.findOne({ + where: { + pollId: pollId, + [Op.or]: [ + { voterToken: guestUser }, + { ipAddress: ipAddress } + ], + }, + }); + if (existingGuestVote) { + return res + .status(409) + .json({ error: "You have already voted on this poll." }); + } + } + return next(); // proceed to the next middleware or route handler + } catch (error) { + console.error("Duplicate vote check failed:", error); + res.status(500).json({ error: "Server error checking vote." }); + } +}; + +module.exports = checkDuplicateVote; diff --git a/package-lock.json b/package-lock.json index af0cf82..a8d821b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,25 @@ { - "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": { + "@react-oauth/google": "^0.12.2", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-session": "^1.18.1", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.16.2", "sequelize": "^6.37.7" }, @@ -26,6 +30,16 @@ "win-node-env": "^0.6.1" } }, + "node_modules/@react-oauth/google": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz", + "integrity": "sha512-d1GVm2uD4E44EJft2RbKtp8Z1fp/gK8Lb6KHgs3pHlM0PxCXGLaq8LLYQYENnN4xPWO1gkL4apBtlPKzpLvZwg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -90,6 +104,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -495,6 +518,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1067,6 +1130,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1127,6 +1196,64 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-to-regexp": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", @@ -1136,6 +1263,11 @@ "node": ">=16" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.16.2", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz", @@ -1312,6 +1444,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1336,6 +1477,29 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1397,6 +1561,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", @@ -1691,6 +1862,24 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1713,6 +1902,15 @@ "node": ">= 0.8" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index 7e0a0af..cc6c7b5 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,17 @@ "license": "ISC", "description": "", "dependencies": { + "@react-oauth/google": "^0.12.2", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", + "express-session": "^1.18.1", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.16.2", "sequelize": "^6.37.7" },