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/ballot.js b/api/ballot.js new file mode 100644 index 0000000..04d3c42 --- /dev/null +++ b/api/ballot.js @@ -0,0 +1,35 @@ +const express = require("express"); +const router = express.Router(); +const { Ballot, Vote, Polls, PollOption, User } = require("../database"); + +router.post("/", async (req, res) => { + try { + const { pollId, userId, votes } = req.body; + + if (!pollId || !votes || votes.length < 2) { + return res.status(400).json({ error: "Poll ID and at least 2 ranked votes are required." }); + } + + const poll = await Polls.findByPk(pollId); + if (!poll) { + return res.status(404).json({ error: "Poll not found." }); + } + + if (userId) { + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).json({ error: "User not found." }); + } + + const existingBallot = await Ballot.findOne({ where: { poll_id: pollId, user_id: userId } }); + if (existingBallot) { + return res.status(400).json({ error: "User has already submitted a ballot for this poll." }); + } + } + } catch (err) { + console.error(err); + res.status(500).json({ error: "Failed to submit ballot." }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/api/index.js b/api/index.js index f08162e..05b1282 100644 --- a/api/index.js +++ b/api/index.js @@ -1,7 +1,15 @@ const express = require("express"); const router = express.Router(); + const testDbRouter = require("./test-db"); +const ballotsRouter = require("./ballot"); +const usersRouter = require("./user"); +const pollsRouter = require("./poll"); +router.use("/Polls", pollsRouter); +router.use("/ballots", ballotsRouter); +router.use("/users", usersRouter); router.use("/test-db", testDbRouter); module.exports = router; + diff --git a/api/poll.js b/api/poll.js new file mode 100644 index 0000000..fef9534 --- /dev/null +++ b/api/poll.js @@ -0,0 +1,242 @@ +const express = require("express"); +const router = express.Router(); +const { Polls, PollOption } = require("../database"); + +router.get("/", async (req, res) => { + try { + const polls = await Polls.findAll(); + res.status(200).send(polls); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to fetch polls" }); + } +}); + +// Adjusted poll id logic to include options, more info and vote count +router.get("/:id", async (req, res) => { + try { + const poll = await Polls.findByPk(req.params.id, { + include: [{ model: PollOption, as: "options" }], + }); + + if (!poll) return res.status(404).send({ error: "Poll not found" }); + + // Checking if the poll should transition to "ended" + const now = new Date(); + if (poll.status === "published" && poll.endTime && new Date(poll.endTime) <= now) { + poll.status = "ended"; + await poll.save(); + } + + //issue in creating the options from the polls themselves, do it as a separate functions in the router.post + // for options and not just for polls + + + const totalVotes = poll.options.reduce((sum, opt) => sum + (opt.votes || 0), 0); + + res.send({ + id: poll.id, + title: poll.title, + description: poll.description, + status: poll.status, + publishedAt: poll.publishedAt, // including this for clarity + endTime: poll.endTime, + options: poll.options, + totalVotes, + }); + } catch (err) { + console.error("Error getting poll:", err); + res.status(500).send({ error: "Server error" }); + } +}); + + +router.patch("/:id", async (req, res) => { + try { + const poll = await Polls.findByPk(req.params.id); + + if (!poll) { + return res.status(404).json({ error: "Poll not found" }); + } + + // Stop users from editing a poll after it's been published or ended + if (["published", "ended"].includes(poll.status)) { + return res.status(400).json({ error: "Cannot edit a published or ended poll" }); + } + + // Included validation fields + const { title, description } = req.body; + if (title !== undefined && !title.trim()) { + return res.status(400).json({ error: "Title cannot be empty" }); + } + if (description !== undefined && !description.trim()) { + return res.status(400).json({ error: "Description cannot be empty" }); + } + + const updatedPoll = await poll.update(req.body); + res.send(updatedPoll); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to update poll" }); + } +}); + + + +router.post("/", async (req, res) => { + try { + const createPoll = await Polls.create(req.body); + res.status(201).send(createPoll); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to create poll" }); + } +}); + +// Duplication logic +router.post("/:id/duplicate", async (req, res) => { + try { + const originalPoll = await Polls.findByPk(req.params.id, { + include: [{ model: PollOption, as: "options" }], + }); + + if (!originalPoll) return res.status(404).json({ error: "Poll not found" }); + + const duplicatedPoll = await Polls.create({ + user_id: originalPoll.user_id, // or req.user.id if we're using auth + title: originalPoll.title + " (Copy)", + description: originalPoll.description, + status: "draft", + }); + + const duplicatedOptions = await Promise.all( + originalPoll.options.map((opt) => + PollOption.create({ + text: opt.text, + votes: 0, + pollId: duplicatedPoll.id, + }) + ) + ); + + res.status(201).json({ + message: "Poll duplicated successfully", + poll: duplicatedPoll, + options: duplicatedOptions, + }); + } catch (err) { + console.error("Duplicate poll error:", err); + res.status(500).json({ error: "Failed to duplicate poll" }); + } +}); + +router.post("/:id/options", async (req, res) => { + try { + const { id: pollId } = req.params; + const { text } = req.body; + + // Validate text + if (!text || !text.trim()) { + return res.status(400).json({ error: "Option text is required." }); + } + + // Check that poll exists + const poll = await Polls.findByPk(pollId); + if (!poll) { + return res.status(404).json({ error: "Poll not found." }); + } + + // Check that poll is in draft state + if (poll.status !== "draft") { + return res.status(400).json({ error: "Cannot add options to a published or ended poll." }); + } + + // Create the option + const newOption = await PollOption.create({ + text, + pollId: poll.id, + }); + + res.status(201).json({ + message: "Option added successfully.", + option: newOption, + }); + } catch (err) { + console.error("Error adding poll option:", err); + res.status(500).json({ error: "Failed to add option." }); + } +}); + + +// Publishing logic and validation added +router.put("/publish/:id", async (req, res) => { + try { + const poll = await Polls.findByPk(req.params.id, { + include: [{ model: PollOption, as: "options" }], + }); + + if (!poll) return res.status(404).json({ error: "Poll not found" }); + if (poll.status !== "draft") return res.status(400).json({ error: "Only draft polls can be published" }); + + if (!poll.title || !poll.description) { + return res.status(400).json({ error: "Title and description are required to publish" }); + } + + if (!poll.options || poll.options.length < 2) { + return res.status(400).json({ error: "Poll must have at least two options to publish" }); + } + + poll.status = "published"; + poll.publishedAt = new Date(); + await poll.save(); + + res.send({ message: "Poll published successfully", poll }); + } catch (err) { + console.error("Publish error:", err); + res.status(500).json({ error: "Failed to publish poll" }); + } +}); + +// Manually close the polls +router.put("/:id/end", async (req, res) => { + try { + const poll = await Polls.findByPk(req.params.id); + + if (!poll) return res.status(404).json({ error: "Poll not found" }); + if (poll.status !== "published") { + return res.status(400).json({ error: "Only published polls can be ended" }); + } + + poll.status = "ended"; + await poll.save(); + + res.json({ message: "Poll ended successfully", poll }); + } catch (error) { + console.error("Error ending poll:", error); + res.status(500).json({ error: "Failed to end poll" }); + } +}); + + +router.delete("/:id", async (req, res) => { + try { + const deletePoll = await Polls.findByPk(req.params.id); + + if (!deletePoll) { + return res.status(404).json({ error: "Poll not found" }); + } + + // Protects against polls getting deleted once published + if (["published", "ended"].includes(deletePoll.status)) { + return res.status(400).json({ error: "Cannot delete a published or ended poll" }); + } + + await deletePoll.destroy(); + res.sendStatus(200); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to delete poll" }); + } +}); + +module.exports = router; diff --git a/api/user.js b/api/user.js new file mode 100644 index 0000000..9c08c55 --- /dev/null +++ b/api/user.js @@ -0,0 +1,102 @@ +const express = require("express"); +const router = express.Router(); +const { User } = require("../database"); +const authenticateJWT = require("../middleware"); + +router.get("/", async (req, res) => { + try { + const users = await User.findAll(); + res.status(200).send(users); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to fetch users" }); + } +}); + +// Get profile of logged-in user +router.get("/profile", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.userId); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + res.json(user); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to fetch profile" }); + } +}); + +// Get user by ID +router.get("/:id", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.params.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + res.status(200).send(user); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to fetch user by id" }); + } +}); + + +router.patch("/:id", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.params.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + if (req.user.userId !== user.id) { + return res.status(403).json({ error: "You are not allowed to edit this user." }); + } + + const updatedUser = await user.update(req.body); + res.json(updatedUser); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to update user" }); + } +}); + + +router.delete("/:id", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.params.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + await user.destroy(); + res.sendStatus(200); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to delete user" }); + } +}); + +router.post("/", authenticateJWT, async (req, res) => { + try { + const user = await User.create(req.body); + res.status(201).send(user); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to create user" }); + } +}); + +router.get("/profile", authenticateJWT, async (req, res) => { + try { + const user = await User.findByPk(req.user.userId); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + res.json(user); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to fetch profile" }); + } +}); + + +module.exports = router; diff --git a/app.js b/app.js index 5857036..511796d 100644 --- a/app.js +++ b/app.js @@ -13,6 +13,8 @@ const cors = require("cors"); const PORT = process.env.PORT || 8080; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; +// const pollRoutes = require("./routes/poll"); + // body parser middleware app.use(express.json()); @@ -30,6 +32,7 @@ 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("/polls", pollRoutes); // Added poll routes // error handling middleware app.use((err, req, res, next) => { diff --git a/auth/index.js b/auth/index.js index 07968c5..ea7a034 100644 --- a/auth/index.js +++ b/auth/index.js @@ -169,8 +169,8 @@ router.post("/login", async (req, res) => { // Find user const user = await User.findOne({ where: { username } }); - user.checkPassword(password); - if (!user) { + // Adjusted to make sure that we don't get null users + if (!user || !user.checkPassword(password)) { return res.status(401).send({ error: "Invalid credentials" }); } @@ -215,19 +215,35 @@ router.post("/logout", (req, res) => { }); // Get current user route (protected) -router.get("/me", (req, res) => { - const token = req.cookies.token; +router.put("/me", authenticateJWT, async (req, res) => { + try { + const { firstName, lastName, email, profilePicture } = req.body; + const user = await User.findByPk(req.user.id); - if (!token) { - return res.send({}); - } + if (!user) return res.status(404).send({ error: "User not found" }); - jwt.verify(token, JWT_SECRET, (err, user) => { - if (err) { - return res.status(403).send({ error: "Invalid or expired token" }); - } - res.send({ user: user }); - }); + if (firstName !== undefined) user.firstName = firstName; + if (lastName !== undefined) user.lastName = lastName; + if (email !== undefined) user.email = email; + if (profilePicture !== undefined) user.profilePicture = profilePicture; + + await user.save(); + + res.send({ + message: "Profile updated successfully", + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + profilePicture: user.profilePicture, + }, + }); + } catch (err) { + console.error("Update profile error:", err); + res.status(500).send({ error: "Failed to update profile" }); + } }); module.exports = { router, authenticateJWT }; diff --git a/database/Polls.js b/database/Polls.js new file mode 100644 index 0000000..1f30b5d --- /dev/null +++ b/database/Polls.js @@ -0,0 +1,43 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Polls = db.define( + "polls", + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: false, + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "draft", + validate: { + isIn: [["draft", "published", "ended"]], + }, + }, + }, + { + timestamps: true, + } +); + +module.exports = Polls; diff --git a/database/admin.js b/database/admin.js new file mode 100644 index 0000000..20dcdca --- /dev/null +++ b/database/admin.js @@ -0,0 +1,25 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); +const bcrypt = require("bcrypt"); + +const Admin = db.define ("admins", + { + id: { + type: DataTypes.INTEGER, + autoIncrement: false, + primaryKey: true, + }, + email: { + type: DataTypes.STRING, + allownull: false, + }, + passwordHash: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + timestamps: true, + }); + + module.exports = Admin; \ No newline at end of file diff --git a/database/ballot.js b/database/ballot.js new file mode 100644 index 0000000..c0183e5 --- /dev/null +++ b/database/ballot.js @@ -0,0 +1,26 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Ballot = db.define("ballot", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + poll_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "polls", key: "id" }, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { model: "users", key: "id" }, + }, + submitted_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, +}); + +module.exports = Ballot; diff --git a/database/db.js b/database/db.js index b251a9d..42ae341 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 = "Capstone1"; const db = new Sequelize( process.env.DATABASE_URL || `postgres://localhost:5432/${dbName}`, diff --git a/database/index.js b/database/index.js index e498df6..82154c5 100644 --- a/database/index.js +++ b/database/index.js @@ -1,7 +1,60 @@ const db = require("./db"); const User = require("./user"); +const Polls = require("./Polls"); +const PollOption = require("./poll_options"); +const Vote = require("./vote"); +const Ballot = require("./ballot"); + +//Bidirectional relationship logic +// USER ↔ POLLS +Polls.belongsTo(User, { foreignKey: "user_id" }); +User.hasMany(Polls, { foreignKey: "user_id" }); + +// POLLS ↔ OPTIONS +Polls.hasMany(PollOption, { + foreignKey: "pollId", + as: "options", + onDelete: "CASCADE", +}); +PollOption.belongsTo(Polls, { + foreignKey: "pollId", + as: "poll", +}); + +// BALLOT ↔ VOTE +Ballot.hasMany(Vote, { + foreignKey: "ballotId", + as: "votes", +}); +Vote.belongsTo(Ballot, { + foreignKey: "ballotId", + as: "ballot", +}); + +Ballot.belongsTo(Polls, { + foreignKey: "poll_id", + as: "poll", +}); + +Ballot.belongsTo(User, { + foreignKey: "user_id", + as: "user", +}); + +Vote.belongsTo(PollOption, { + foreignKey: "pollOptionId", + as: "option", +}); +PollOption.hasMany(Vote, { + foreignKey: "pollOptionId", + as: "votes", +}); module.exports = { db, User, + Polls, + PollOption, + Ballot, + Vote, }; diff --git a/database/poll_Invites.js b/database/poll_Invites.js new file mode 100644 index 0000000..dbf1396 --- /dev/null +++ b/database/poll_Invites.js @@ -0,0 +1,44 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const PollInvite = db.define("poll_invite", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + poll_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "polls", + key: "id", + }, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: "users", + key: "id", + }, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "pending", + validate: { + isIn: [["pending", "accepted", "declined"]], + }, + }, + sent_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, +}); + +module.exports = PollInvite; diff --git a/database/poll_options.js b/database/poll_options.js new file mode 100644 index 0000000..e82f6fc --- /dev/null +++ b/database/poll_options.js @@ -0,0 +1,24 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const PollOptions = db.define("poll_option", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + text: { + type: DataTypes.STRING, + allowNull: false, + }, + pollId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "polls", + key: "id", + }, + }, +}); + +module.exports = PollOptions; diff --git a/database/roles.js b/database/roles.js new file mode 100644 index 0000000..7ac89af --- /dev/null +++ b/database/roles.js @@ -0,0 +1,25 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); +const bcrypt = require("bcrypt"); + +const Roles = db.define ("roles", + { + id: + { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + name: + { + type: DataTypes.STRING, + allowNull: false + }, + description: + { + type: DataTypes.TEXT, + allowNull: true + } + }); + + module.exports = Roles; \ No newline at end of file diff --git a/database/seed.js b/database/seed.js index e58b595..b48fd96 100644 --- a/database/seed.js +++ b/database/seed.js @@ -1,30 +1,58 @@ const db = require("./db"); -const { User } = require("./index"); +const { User, Polls, PollOption } = require("./index"); const seed = async () => { try { db.logging = false; await db.sync({ force: true }); // Drop and recreate tables + // Create users const users = await User.bulkCreate([ { username: "admin", passwordHash: User.hashPassword("admin123") }, { username: "user1", passwordHash: User.hashPassword("user111") }, { username: "user2", passwordHash: User.hashPassword("user222") }, ]); - console.log(`šŸ‘¤ Created ${users.length} users`); + // Create polls + const polls = await Polls.bulkCreate([ + { + user_id: users[0].id, + title: "What is your favorite programming language?", + description: "Vote for the language you love the most!", + status: "published", // test view logic + isActive: true, + }, + { + user_id: users[1].id, + title: "Do you prefer remote or in-person work?", + description: "Let us settle this once and for all.", + isActive: true, + }, + { + user_id: users[2].id, + title: "What's your favorite restaurant?", + description: "Vote yes or no for an offsite trip.", + isActive: false, + }, + ]); - // Create more seed data here once you've created your models - // Seed files are a great way to test your database schema! + // Options for the first poll + await PollOption.bulkCreate([ + { text: "JavaScript", pollId: polls[0].id, votes: 3 }, + { text: "Python", pollId: polls[0].id, votes: 2 }, + { text: "C++", pollId: polls[0].id, votes: 1 }, + ]); + console.log(`šŸ‘¤ Created ${users.length} users and ${polls.length} polls`); console.log("🌱 Seeded the database"); } catch (error) { console.error("Error seeding database:", error); if (error.message.includes("does not exist")) { console.log("\nšŸ¤”šŸ¤”šŸ¤” Have you created your database??? šŸ¤”šŸ¤”šŸ¤”"); } + } finally { + db.close(); } - db.close(); }; seed(); diff --git a/database/user.js b/database/user.js index 755c757..ef7697a 100644 --- a/database/user.js +++ b/database/user.js @@ -28,6 +28,19 @@ const User = db.define("user", { type: DataTypes.STRING, allowNull: true, }, + firstName: { + type: DataTypes.STRING, + allowNull: true, + }, + lastName: { + type: DataTypes.STRING, + allowNull: true, + }, + profilePicture: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: "https://i.imgur.com/default-avatar.png", + }, }); // Instance method to check password diff --git a/database/vote.js b/database/vote.js new file mode 100644 index 0000000..b9d59c4 --- /dev/null +++ b/database/vote.js @@ -0,0 +1,26 @@ +const { DataTypes } = require("sequelize"); +const db = require("./db"); + +const Vote = db.define("vote", { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + ballot_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "ballots", key: "id" }, + }, + poll_option_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: "poll_options", key: "id" }, + }, + rank: { + type: DataTypes.INTEGER, + allowNull: true, + }, +}); + +module.exports = Vote; diff --git a/middleware.js b/middleware.js new file mode 100644 index 0000000..dd4898e --- /dev/null +++ b/middleware.js @@ -0,0 +1,20 @@ +const jwt = require("jsonwebtoken"); + +const JWT_SECRET = process.env.JWT_SECRET || "supersecret"; + +function authenticateJWT(req, res, next) { + const authHeader = req.headers.authorization; + + if (!authHeader) return res.sendStatus(401); + + const token = authHeader.split(" ")[1]; + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) return res.sendStatus(403); + + req.user = user; + next(); + }); +} + +module.exports = authenticateJWT; diff --git a/package-lock.json b/package-lock.json index af0cf82..1b30289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capstone-i-backend", + "name": "capstone-1-backend", "version": "1.0.0", "license": "ISC", "dependencies": {