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/email.js b/api/email.js new file mode 100644 index 0000000..c9b0525 --- /dev/null +++ b/api/email.js @@ -0,0 +1,19 @@ +const express = require("express"); +const sendMail = require("../utils/sendMail"); +const router = express.Router(); + +router.post("/send-email", async (req, res) => { + const { to } = req.body; + + try{ + const result = await sendMail(to); + res.status(200).json({ message: "Email sent successfully", result }); + } + catch (error) { + console.error("Error sending email:", error); + res.status(500).json({ error: "Failed to send email" }); + } + +}); + +module.exports = router; \ No newline at end of file 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..10d0065 --- /dev/null +++ b/api/polls.js @@ -0,0 +1,979 @@ +const express = require("express"); +const router = express.Router(); +const { Poll, PollOption, Vote, VotingRank } = require("../database"); +const { authenticateJWT, blockIfDisabled, isAdmin, optionalAuth } = require("../auth"); +const { where, Model } = require("sequelize"); +const { Op } = require("sequelize"); // Op = Sequelize's operator tool (like AND, OR, NOT, etc.) + + +// Get all users Polls---------------------------- +router.get("/", authenticateJWT, async (req, res) => { + const userId = req.user.id; + + try { + // Get all votes submitted by this user + const userVotes = await Vote.findAll({ where: { userId } }); + + // Extract poll IDs that this user has voted in + const votedPollIds = [...new Set(userVotes.map(vote => vote.pollId))]; + console.log("User voted in poll IDs:", votedPollIds); + + // Get all polls created by this user + const createdPolls = await Poll.findAll({ + where: { userId }, + include: [{ model: PollOption }], // optional, fetches options if needed + }); + + // Get all polls the user voted in, but DIDN'T create + const votedPolls = votedPollIds.length > 0 + ? await Poll.findAll({ + where: { + id: votedPollIds, // poll ID is in the user's vote list + userId: { [Op.ne]: userId }, // but NOT created by this user (Op.ne = "not equal") + }, + include: [{ model: PollOption }], + }) + : []; // fallback in case user has no votes + + console.log("Created polls:", createdPolls.length); + console.log("Voted (not owned) polls:", votedPolls.length); + + //Format results to mark `created` or `participated` for frontend filters + const formattedCreated = createdPolls.map(poll => ({ + ...poll.toJSON(), + created: true, + participated: false, + })); + + const formattedVoted = votedPolls.map(poll => ({ + ...poll.toJSON(), + created: false, + participated: true, + })); + + //Combine both into one clean array + const allPolls = [...formattedCreated, ...formattedVoted]; + + res.json(allPolls); + } catch (error) { + console.error("Error in GET /api/polls:", error); + res.status(500).json({ error: "Failed to fetch 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", 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" }); + } + + if (poll.authRequired && !req.user) { + return res.status(401).json({ error: "Login required to view this poll" }); + } + + + 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("Fetching poll with ID:", pollId); + + 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 }, + { model: Vote }, + ], + + }); + + 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, blockIfDisabled, async (req, res) => { + const userId = req.user.id; + const { + title, + description, + deadline, + status, + options = [], + authRequired, + allowSharedLinks, + } = 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, + authRequired, + allowSharedLinks, + 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" }); + } else if (updatePoll.isDisabled) { + return res.status(403).json({ error: "Poll is disabled and cannot be edited" }); + } + + 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 Vote (Draft Only) --------------------- +router.post("/:pollId/vote", optionalAuth, blockIfDisabled, async (req, res) => { + const { pollId } = req.params; + const { email } = req.body; + const userId = req.user?.id || null; + + try { + // Block duplicate votes for authenticated users + if (userId) { + const existingVote = await Vote.findOne({ where: { pollId, userId } }); + if (existingVote) { + return res.status(409).json({ error: "Vote already exists" }); + } + } + + // Validate email for guests + if (!userId && (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))) { + return res.status(400).json({ error: "Valid email is required for guests" }); + } + + // Create a draft vote + const vote = await Vote.create({ + userId, + pollId, + email: userId ? null : email, + submitted: false, + }); + + return res.status(201).json(vote); + } catch (err) { + console.error("Vote creation error:", err); + return res.status(500).json({ error: "Failed to create vote" }); + } +}); + +// --------------------- Submit or Save Draft Vote --------------------- +router.patch("/:pollId/vote/:voteId", optionalAuth, async (req, res) => { + const { pollId, voteId } = req.params; + const { rankings, submitted } = req.body; + const userId = req.user?.id || null; + + try { + // Look up the vote โ€” must match pollId and either belong to logged-in user or be anonymous + const vote = await Vote.findOne({ + where: { + id: voteId, + pollId, + [Op.or]: [{ userId }, { userId: null }], + }, + }); + + if (!vote) return res.status(404).json({ error: "Vote not found" }); + if (vote.submitted) return res.status(403).json({ error: "Vote already submitted" }); + + // Update rankings if provided + if (Array.isArray(rankings)) { + await VotingRank.destroy({ where: { voteId } }); + + const newRanks = rankings.map((r) => ({ + voteId, + pollOptionId: r.optionId, + rank: r.rank, + })); + await VotingRank.bulkCreate(newRanks); + } + + // If submitted, finalize the vote + if (submitted === true) { + await vote.update({ submitted: true }); + + // Recount participants + const count = await Vote.count({ + where: { pollId, submitted: true }, + }); + await Poll.update({ participants: count }, { where: { id: pollId } }); + + return res.status(200).json({ message: "Vote submitted" }); + } + + // Otherwise, just saved as draft (only for logged-in users) + return res.status(200).json({ message: "Draft saved" }); + } catch (err) { + console.error("Vote update error:", err); + return res.status(500).json({ error: "Failed to update vote" }); + } +}); + +// get the current user's submission for a poll----------------------------- +router.get("/:pollId/vote", authenticateJWT, async (req, res) => { + const userId = req.user.id; + const { pollId } = req.params; + try { + const vote = await Vote.findOne({ + where: { userId, pollId }, + include: { + model: VotingRank, + include: { + model: PollOption, + attributes: ["id", "optionText"], + }, + }, + }); + if (!vote) { + return res.status(404).json({ error: "No submission found for this poll" }); + } + res.json(vote); + } catch (error) { + console.error("Error fetching user submission:", error); + res.status(500).json({ error: "Failed to fetch user submission" }); + } +}); + +//------------------------------------ Calculate results -------------------------------------------------------- + +router.get("/:pollId/results", authenticateJWT, blockIfDisabled, async (req, res) => { + + + const userId = req.user.id; + const { pollId } = req.params; + + const votes = await Vote.findAll({ + where: { pollId: pollId }, + include: { model: VotingRank }, + }); + // [ + // { + // "id": 1, + // "submitted": true, + // "voterToken": null, + // "ipAddress": null, + // "userId": 2, + // "pollId": 1, + // "createdAt": "2025-07-24T07:04:10.459Z", + // "updatedAt": "2025-07-24T07:04:10.459Z", + // "votingRanks": [ + // { + // "id": 1, + // "voteId": 1, + // "pollOptionId": 1, + // "rank": 1, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 2, + // "voteId": 1, + // "pollOptionId": 2, + // "rank": 2, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 3, + // "voteId": 1, + // "pollOptionId": 3, + // "rank": 3, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 4, + // "voteId": 1, + // "pollOptionId": 4, + // "rank": 4, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 5, + // "voteId": 1, + // "pollOptionId": 5, + // "rank": 5, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 6, + // "voteId": 1, + // "pollOptionId": 5, + // "rank": 5, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // } + // ] + // } + // ] + + const allBallots = votes.map(vote => vote.votingRanks); + // [ + // [ + // { + // "id": 1, + // "voteId": 1, + // "pollOptionId": 1, + // "rank": 1, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 2, + // "voteId": 1, + // "pollOptionId": 2, + // "rank": 2, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 3, + // "voteId": 1, + // "pollOptionId": 3, + // "rank": 3, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 4, + // "voteId": 1, + // "pollOptionId": 4, + // "rank": 4, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 5, + // "voteId": 1, + // "pollOptionId": 5, + // "rank": 5, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // }, + // { + // "id": 6, + // "voteId": 1, + // "pollOptionId": 5, + // "rank": 5, + // "createdAt": "2025-07-24T07:04:10.475Z", + // "updatedAt": "2025-07-24T07:04:10.475Z" + // } + // ] + // ] + + const ballots = allBallots.map(ballot => { + return ballot + .sort((a, b) => a.rank - b.rank) + .map((element) => element.pollOptionId) + }) + + // [ + // [ + // 7, + // 8, + // 9, + // 10 + // ], + // [ + // 10, + // 9, + // 8, + // 7 + // ] + // ] + const options = await PollOption.findAll({ where: { pollId: pollId } }) + // [ + // { + // "id": 1, + // "optionText": "Demon Slayer", + // "position": 1, + // "pollId": 1, + // "createdAt": "2025-07-24T07:01:09.263Z", + // "updatedAt": "2025-07-24T07:01:09.263Z" + // }, + // { + // "id": 2, + // "optionText": "One Piece", + // "position": 2, + // "pollId": 1, + // "createdAt": "2025-07-24T07:01:09.263Z", + // "updatedAt": "2025-07-24T07:01:09.263Z" + // }, + // { + // "id": 3, + // "optionText": "AOT", + // "position": 3, + // "pollId": 1, + // "createdAt": "2025-07-24T07:01:09.263Z", + // "updatedAt": "2025-07-24T07:01:09.263Z" + // }, + // { + // "id": 4, + // "optionText": "Naruto", + // "position": 4, + // "pollId": 1, + // "createdAt": "2025-07-24T07:01:09.263Z", + // "updatedAt": "2025-07-24T07:01:09.263Z" + // }, + // { + // "id": 5, + // "optionText": "Devil May Cry", + // "position": 5, + // "pollId": 1, + // "createdAt": "2025-07-24T07:01:09.263Z", + // "updatedAt": "2025-07-24T07:01:09.263Z" + // }, + // { + // "id": 6, + // "optionText": "Castlevania", + // "position": 6, + // "pollId": 1, + // "createdAt": "2025-07-24T07:01:09.263Z", + // "updatedAt": "2025-07-24T07:01:09.263Z" + // } + // ] + + const optionsMap = {}; + + for (const option of options) { + optionsMap[option.id] = + { + name: option.optionText, + count: 0, + eliminated: false, + }; + } + // { + // "7": { + // "name": "Die Hard", + // "count": 0, + // "elimated": + // }, + // "8": { + // "name": "Die Hard 2", + // "count": 0, + // "elimated": + // }, + // "9": { + // "name": "Twilight", + // "count": 0, + // "elimated": + // }, + // "10": { + // "name": "Spiderverse", + // "count": 0, + // "elimated": + // } + // } + + const totalVotes = ballots.length; + // console.log(totalVotes) + const majorityThreshhold = Math.floor(totalVotes / 2) + 1; + // console.log(majorityThreshhold) + + + let foundWinner = false; + + // Front end needs the options that were eliminated for each round to include their count ------> + let roundNumber = 1; + let roundResults = []; + + while (!foundWinner) { + + for (const option of Object.values(optionsMap)) { + option.count = 0; + } + // [ + // [ + // 1, + // 2, + // 3, + // 4 + // ], + // [ + // 10, + // 9, + // 8, + // 7 + // ] + // ] + for (const ballot of ballots) { + for (const optionId of ballot) { + const option = optionsMap[optionId]; + if (!option.eliminated) { + option.count += 1; + break; + } + } + } + // { + // '1': { name: 'Demon Slayer', count: 1, eliminated: false }, + // '2': { name: 'One Piece', count: 0, eliminated: true }, + // '3': { name: 'AOT', count: 0, eliminated: true }, + // '4': { name: 'Naruto', count: 0, eliminated: true }, + // '5': { name: 'Devil May Cry', count: 0, eliminated: true }, + // '6': { name: 'Castlevania', count: 0, eliminated: true } + // } + + + + // Front end needs the options that were eliminated for each round to include their count ------> + const currentRound = { + round: roundNumber, + results: {}, + }; + for (const [id, option] of Object.entries(optionsMap)) { + currentRound.results[id] = { + name: option.name, + count: option.count, + eliminated: option.eliminated + }; + } + roundResults.push(currentRound); + roundNumber++; + + 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, + rounds: roundResults + }); + } + } + + + 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) + } + } + } + + + 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, + rounds: roundResults + }); + } + + + for (const optionId of optionToEliminate) { + optionsMap[optionId].eliminated = true + } + } + + + + // return res.send(options) + // return res.send(votes) + // 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 + // } + // } +}) + +// admin route to fetch all polls--------------------------------------------------------------- + +router.get("/admin/all", authenticateJWT, isAdmin, async (req, res) => { + + try { + const polls = await Poll.findAll({ + include: [{ model: PollOption }], + attributes: { exclude: ["userId"] }, // exclude sensitive user info + }); + res.json(polls); + } catch (error) { + console.error("Error fetching all polls:", error); + res.status(500).json({ error: "Failed to fetch all polls" }); + } +}); + +// 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' }); + } +}); + +// PATCH /api/polls/:pollId/disable - admin only--------------------------------------------------------------- +router.patch('/:pollId/disable', authenticateJWT, isAdmin, async (req, res) => { + const { pollId } = req.params; + try { + const poll = await Poll.findByPk(pollId); + if (!poll) { + return res.status(404).json({ error: 'Poll not found' }); + } + poll.isDisabled = true; + await poll.save(); + res.json({ message: 'Poll disabled', pollId: poll.id }); + } catch (error) { + console.error('Error disabling poll:', error); + res.status(500).json({ error: 'Failed to disable poll' }); + } +}); + +// GET /api/polls/participated - Polls where current user has voted +router.get("/participated", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + // Find all votes by this user + const votes = await Vote.findAll({ where: { userId } }); + const pollIds = votes.map(v => v.pollId); + if (pollIds.length === 0) { + return res.json({ message: "No participated polls.", polls: [] }); + } + // find all polls where user has voted + const polls = await Poll.findAll({ where: { id: pollIds } }); + // label as participated + const labeled = polls.map(p => ({ ...p.toJSON(), participated: true })); + res.json({ message: "Participated polls retrieved.", polls: labeled }); + } catch (error) { + console.error("Error fetching participated polls:", error); + res.status(500).json({ error: "Failed to get participated polls" }); + } +}); + +// GET /api/polls/created - Polls created by current user +router.get("/created", authenticateJWT, async (req, res) => { + try { + const userId = req.user.id; + const polls = await Poll.findAll({ where: { userId } }); + // label as `created` + const labeled = polls.map(p => ({ ...p.toJSON(), created: true })); + res.json({ message: "Created polls retrieved.", polls: labeled }); + } catch (error) { + console.error("Error fetching created polls:", error); + res.status(500).json({ error: "Failed to get created polls" }); + } +}); + +module.exports = router; diff --git a/api/testcookie.js b/api/testcookie.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/testcookie.js @@ -0,0 +1 @@ + diff --git a/api/users.js b/api/users.js new file mode 100644 index 0000000..2845684 --- /dev/null +++ b/api/users.js @@ -0,0 +1,94 @@ +const express = require("express"); +const router = express.Router(); +const { User } = require("../database"); +const { authenticateJWT, isAdmin } = require("../auth"); + +// GET /api/users , admin only +router.get("/", authenticateJWT, isAdmin, async (req, res) => { + try { + const users = await User.findAll({ + + }); + res.json(users); + } catch (error) { + console.error("Error fetching users:", error); + res.status(500).json({ error: "Failed to fetch users" }); + } +}); + +// PATCH /api/users/:userId/disable - admin only (toggle disable status) +router.patch('/:userId/disable', authenticateJWT, isAdmin, async (req, res) => { + const { userId } = req.params; + try { + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + user.isDisable = !user.isDisable; + await user.save(); + res.json({ message: `User account ${user.isDisable ? 'disabled' : 'enabled'}`, userId: user.id, isDisable: user.isDisable }); + } catch (error) { + console.error('Error toggling user disable:', error); + res.status(500).json({ error: 'Failed to toggle user disable' }); + } +}); + +// GET /api/users/:userId - fetch user profile info +router.get('/:userId', authenticateJWT, async (req, res) => { + const { userId } = req.params; + try { + const user = await User.findByPk(userId, { + attributes: [ + 'id', + 'firstName', + 'lastName', + 'email', + 'username', + 'img', + 'isAdmin', + 'isDisable', + 'createdAt', + 'updatedAt' + ] + }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + res.json(user); + } catch (error) { + console.error('Error fetching user profile:', error); + res.status(500).json({ error: 'Failed to fetch user profile' }); + } +}); +// PATCH /api/users/:userId - update user profile info +router.patch('/:userId', authenticateJWT, async (req, res) => { + const { userId } = req.params; + const { firstName, lastName, email, username, img } = req.body; + // only allow user to edit their own account + if (parseInt(userId) !== req.user.id) { + return res.status(403).json({ error: 'you can only edit your own account' }); + } + try { + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + if (firstName) user.firstName = firstName; + if (lastName) user.lastName = lastName; + if (email) user.email = email; + if (username) user.username = username; + if (img) user.img = img + await user.save(); + res.json({ message: 'User profile updated successfully', user }); + } catch (error) { + // handle unique constraint errors for email and username + if (error.name === 'SequelizeUniqueConstraintError') { + return res.status(400).json({ error: 'Email or username already in use' }); + } + console.error('Error updating user profile:', error); + res.status(500).json({ error: 'Failed to update user profile' }); + } +}); + + +module.exports = router; diff --git a/app.js b/app.js index 5857036..0e5e7d9 100644 --- a/app.js +++ b/app.js @@ -4,11 +4,16 @@ 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 usersRouter = require("./api/users") +const emailRouter = require("./api/email"); const PORT = process.env.PORT || 8080; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; @@ -16,9 +21,29 @@ const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; // body parser middleware app.use(express.json()); +const allowedOrigins = [ + 'http://localhost:3000', + 'http://[::1]:3000', + 'https://rankzilla-frontend.vercel.app', +]; + + +const vercelPreviewPattern = /^https:\/\/rankzilla-frontend-[a-z0-9-]+\.vercel\.app$/; + + app.use( cors({ - origin: FRONTEND_URL, + origin: function (origin, callback) { + if ( + !origin || + allowedOrigins.includes(origin) || + vercelPreviewPattern.test(origin) + ) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, credentials: true, }) ); @@ -26,10 +51,23 @@ 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 +app.use("/api/users", usersRouter); // mount users router // error handling middleware app.use((err, req, res, next) => { @@ -37,9 +75,12 @@ app.use((err, req, res, next) => { res.sendStatus(500); }); +// email router +app.use("/api/email", emailRouter); // mount email router + 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..8193d68 100644 --- a/auth/index.js +++ b/auth/index.js @@ -1,11 +1,28 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { User } = require("../database"); +const { Op } = require("sequelize"); const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; +const optionalAuth = (req, res, next) => { + const token = req.cookies.token; + console.log("Token from cookies:", token); + if (!token) return next(); + console.log("Cookies:", req.cookies); + console.log("Token:", req.cookies?.token); + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (!err) { + req.user = user; + console.log("Authenticated user:", user); + } + next(); + }); +}; + // Middleware to authenticate JWT tokens const authenticateJWT = (req, res, next) => { const token = req.cookies.token; @@ -23,6 +40,22 @@ const authenticateJWT = (req, res, next) => { }); }; +// Admin check middleware +function isAdmin(req, res, next) { + if (req.user && req.user.isAdmin) { + return next(); + } + return res.status(403).json({ error: "Admin access required" }); +} + +// block disabled users from certain actions +function blockIfDisabled(req, res, next) { + if (req.user && req.user.isDisable) { + return res.status(403).json({ error: "Account disabled. Action not allowed." }); + } + next(); +} + // Auth0 authentication route router.post("/auth0", async (req, res) => { try { @@ -74,6 +107,7 @@ router.post("/auth0", async (req, res) => { username: user.username, auth0Id: user.auth0Id, email: user.email, + isAdmin: user.isAdmin, }, JWT_SECRET, { expiresIn: "24h" } @@ -82,7 +116,7 @@ router.post("/auth0", async (req, res) => { res.cookie("token", token, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", maxAge: 24 * 60 * 60 * 1000, // 24 hours }); @@ -101,7 +135,7 @@ router.post("/auth0", async (req, res) => { } }); -// Signup route +// Signup route-------------------------------------------- router.post("/signup", async (req, res) => { try { const { username, password } = req.body; @@ -135,6 +169,7 @@ router.post("/signup", async (req, res) => { username: user.username, auth0Id: user.auth0Id, email: user.email, + isAdmin: user.isAdmin, }, JWT_SECRET, { expiresIn: "24h" } @@ -143,7 +178,7 @@ router.post("/signup", async (req, res) => { res.cookie("token", token, { httpOnly: true, secure: true, - sameSite: "strict", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", maxAge: 24 * 60 * 60 * 1000, // 24 hours }); @@ -157,7 +192,7 @@ router.post("/signup", async (req, res) => { } }); -// Login route +// Login route------------------------------------------------------- router.post("/login", async (req, res) => { try { const { username, password } = req.body; @@ -167,13 +202,24 @@ 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" }); } + // check if the user is disabled + if (user.isDisable) { + return res.status(403).send({ error: "Account disabled. Please contact support." }); + } + // Check password if (!user.checkPassword(password)) { return res.status(401).send({ error: "Invalid credentials" }); @@ -186,6 +232,7 @@ router.post("/login", async (req, res) => { username: user.username, auth0Id: user.auth0Id, email: user.email, + isAdmin: user.isAdmin, }, JWT_SECRET, { expiresIn: "24h" } @@ -194,7 +241,7 @@ router.post("/login", async (req, res) => { res.cookie("token", token, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", maxAge: 24 * 60 * 60 * 1000, // 24 hours }); @@ -208,26 +255,215 @@ router.post("/login", async (req, res) => { } }); -// Logout route +// Logout route----------------------------------------------------- router.post("/logout", (req, res) => { res.clearCookie("token"); res.send({ message: "Logout successful" }); }); -// Get current user route (protected) -router.get("/me", (req, res) => { - const token = req.cookies.token; - - if (!token) { - return res.send({}); +// Get current user route (protected)------------------------------ +router.get("/me", authenticateJWT, async (req, res) => { + res.set("Cache-Control", "no-store, no-cache, must-revalidate, private"); + try { + const user = await User.findByPk(req.user.id, { + attributes: [ + "id", + "firstName", + "lastName", + "email", + "username", + "img", + "isAdmin", + "isDisable", + "createdAt", + "updatedAt" + ] + }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + res.json({ user }); + } catch (error) { + console.error("error fetching user info:", error); + res.status(500).json({ error: "fail to fetch user info" }); } +}); - jwt.verify(token, JWT_SECRET, (err, user) => { - if (err) { - return res.status(403).send({ error: "Invalid or expired token" }); +// 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" }); } - res.send({ user: user }); - }); + + 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, + isAdmin: user.isAdmin, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + 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, + isAdmin: user.isAdmin, + }, + JWT_SECRET, + { expiresIn: "24h" } + ); -module.exports = { router, authenticateJWT }; + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + 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, + isAdmin, + blockIfDisabled, + optionalAuth +}; 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..cb15ff4 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,113 @@ 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" +}); + + +Poll.hasMany(Vote, { + 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..e755233 --- /dev/null +++ b/database/models/poll.js @@ -0,0 +1,89 @@ +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 && poll.title) { // <- this prevents slugify(undefined) + 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..cd3b967 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: "createdAt", + } +); // 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..fc7244e --- /dev/null +++ b/database/models/vote.js @@ -0,0 +1,38 @@ +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, + }, + email: { + type: DataTypes.STRING, + allowNull: true, // guests and users can choose to leave it blank + validate: { + isEmail: true, + }, + }, + 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..02af672 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,288 @@ 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: "Tran", + passwordHash: User.hashPassword("tran123"), + email: "tran@example.com", + firstName: "Tran", + lastName: "Vo", + img: "https://cdn2.thecatapi.com/images/MTY3ODIyMQ.jpg", + isAdmin: true, + isDisable: false + }, + { + username: "Flo", + passwordHash: User.hashPassword("flo123"), + email: "flo@example.com", + firstName: "Florencio", + lastName: "Rendon", + img: "https://cdn2.thecatapi.com/images/MTY3ODIyMg.jpg", + isAdmin: false, + isDisable: false + }, + { + username: "Olivia", + passwordHash: User.hashPassword("olivia123"), + email: "olivia@example.com", + firstName: "Olivia", + lastName: "Wilson-Simmonds", + img: "https://cdn2.thecatapi.com/images/MTY3ODIyMw.jpg", + isAdmin: false, + isDisable: false + }, + { + username: "Hai", + passwordHash: User.hashPassword("hai123"), + email: "hai@example.com", + firstName: "Hailia", + lastName: "Sommerville", + img: "https://cdn2.thecatapi.com/images/MTY3ODIyNA.jpg", + isAdmin: false, + isDisable: false + } ]); - 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: "Tran", + + }, + { + key: "movie", + title: "Best Movie?", + description: "Rank your favorite movies!", + participants: 0, + status: "published", + userKey: "Flo", + }, + { + key: "bbq", + title: "Best BBQ Item?", + description: "Rank your favorite BBQ food!", + status: "published", + userKey: "Tran" + }, + { + key: "authRequired", + title: "authRequired true", + description: "?", + participants: 0, + status: "published", + authRequired: true, + userKey: "Flo" + }, + { + key: "restricited", + title: "restricted true", + description: "Rank your favorite anime of all time!", + participants: 0, + status: "published", + restricted: true, + userKey: "Tran" + }, + ]; + + const createdPolls = {}; + const deadline = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // 3days + + const userMap = { + Tran: users[0], + Flo: users[1], + Olivia: users[2], + Hai: users[3], + }; + + for (const poll of pollData) { + const created = await Poll.create({ + ...poll, + deadline, + userId: userMap[poll.userKey].id, + }) + createdPolls[poll.key] = created; + }; + + + + 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`); + users.forEach(u => { + console.log(`- ${u.username}: id=${u.id}, email=${u.email}, firstName=${u.firstName}, lastName=${u.lastName}, displayName=${u.displayName || "(none)"}, img=${u.img}, isAdmin=${u.isAdmin}, isDisable=${u.isDisable}`); + }); + 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..b4381fa --- /dev/null +++ b/middleware/checkDuplicateVote.js @@ -0,0 +1,53 @@ +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..51559e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,26 @@ { - "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", + "nodemailer": "^7.0.5", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.16.2", "sequelize": "^6.37.7" }, @@ -26,6 +31,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 +105,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 +519,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", @@ -1028,6 +1092,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -1067,6 +1140,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 +1206,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 +1273,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 +1454,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 +1487,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 +1571,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 +1872,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 +1912,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..54e1923 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,18 @@ "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", + "nodemailer": "^7.0.5", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.16.2", "sequelize": "^6.37.7" }, diff --git a/testMail.js b/testMail.js new file mode 100644 index 0000000..b4db141 --- /dev/null +++ b/testMail.js @@ -0,0 +1,12 @@ +const sendMail = require("./utils/sendMail"); // Adjust path if needed + +async function test() { + try { + const info = await sendMail("rankzilla.results@gmail.com"); // Replace with your real email + console.log("Test email sent successfully:", info); + } catch (error) { + console.error("Failed to send test email:", error); + } +} + +test(); \ No newline at end of file diff --git a/utils/sendMail.js b/utils/sendMail.js new file mode 100644 index 0000000..82ff12c --- /dev/null +++ b/utils/sendMail.js @@ -0,0 +1,32 @@ +require("dotenv").config(); +const nodemailer = require("nodemailer"); + + +const sendMail = async (to) => { + try{ + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, // email address + pass: process.env.EMAIL_PASSWORD, // app password + }, + }); + + const mailResults = { + from: `Rankzilla <${process.env.EMAIL_USER}>`, // sender address + to, + subject: "Poll Results Are Out!", + text: "Here are the results of the poll you voted on!", + }; + + const info = await transporter.sendMail(mailResults); + console.log("Email sent: ", info.messageId); + return info; + } + catch (error) { + console.error("Error sending email:", error); + throw error; + } +}; + +module.exports = sendMail; \ No newline at end of file