Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ MONGODB_URI=your_mongodb_connection_string

# Server
PORT=5001
NODE_ENV=development

# Authentication
NODE_ENV=development

# Set to true only for local testing. Never enable this in production.
BYPASS_EMAIL_VERIFICATION=false

# Authentication
JWT_SECRET=your_jwt_secret
REFRESH_TOKEN_SECRET=your_refresh_token_secret

Expand Down
44 changes: 44 additions & 0 deletions backend/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,50 @@ export const signup = async (req, res) => {
),
}))
);
// Test-only bypass. It requires an explicit opt-in and can never run in
// production, even if the environment flag is set accidentally.
const isEmailVerificationBypassed =
process.env.NODE_ENV === "development" &&
process.env.BYPASS_EMAIL_VERIFICATION === "true";

if (isEmailVerificationBypassed) {
// Auto-verify the user and log them in immediately
const newUser = await User.create({
fullName,
email: normalizedEmail,
password: hashedPassword,
securityQuestions: hashedQuestions,
role: "user",
isVerified: true, // skip email verification
});

const token = generateToken(newUser._id);
const refreshToken = generateRefreshToken(newUser._id);
const refreshTokenHash = crypto.createHash("sha256").update(refreshToken).digest("hex");
newUser.refreshTokenHash = refreshTokenHash;
await newUser.save();

res.cookie("refreshToken", refreshToken, {
httpOnly: true,
sameSite: "strict",
secure: false,
maxAge: 7 * 24 * 60 * 60 * 1000,
});

console.log("[DEV MODE] User auto-verified, no OTP email sent.");

return res.status(201).json({
_id: newUser._id,
fullName: newUser.fullName,
email: newUser.email,
profilePic: newUser.profilePic,
role: newUser.role,
token,
message: "[DEV] Account created and verified automatically (dev mode).",
});
}
// --- END DEV MODE BYPASS ---

const verificationOtp = crypto.randomInt(100000, 1000000).toString();

const hashedVerificationOtp = await bcrypt.hash(
Expand Down
199 changes: 199 additions & 0 deletions backend/src/controllers/poll.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import mongoose from "mongoose";
import Group from "../models/group.model.js";
import Poll from "../models/poll.model.js";
import { io } from "../lib/socket.js";

// Helper — check if user is a member of the group
const isGroupMember = (group, userId) =>
group.members.some((m) => m.userId.toString() === userId.toString());

// POST /api/polls/groups/:groupId
export const createPoll = async (req, res) => {
try {
const { groupId } = req.params;
const creatorId = req.user._id;
const { question, options, expiresAt } = req.body;

if (!mongoose.Types.ObjectId.isValid(groupId)) {
return res.status(400).json({ message: "Invalid group ID" });
}

const group = await Group.findById(groupId);
if (!group) {
return res.status(404).json({ message: "Group not found" });
}

if (!isGroupMember(group, creatorId)) {
return res.status(403).json({ message: "Not a group member" });
}

if (!question?.trim()) {
return res.status(400).json({ message: "Poll question is required" });
}

if (!Array.isArray(options) || options.length < 2 || options.length > 10) {
return res
.status(400)
.json({ message: "A poll must have between 2 and 10 options" });
}

const sanitizedOptions = options
.map((o) => ({ text: typeof o === "string" ? o.trim() : o?.text?.trim() }))
.filter((o) => o.text);

if (sanitizedOptions.length < 2) {
return res
.status(400)
.json({ message: "At least 2 non-empty options are required" });
}

const poll = await Poll.create({
groupId,
creatorId,
question: question.trim(),
options: sanitizedOptions,
expiresAt: expiresAt ? new Date(expiresAt) : null,
});

const populated = await poll.populate("creatorId", "fullName profilePic");

// Broadcast to all group members
io.to(groupId.toString()).emit("newGroupPoll", populated);

res.status(201).json(populated);
} catch (error) {
console.error("Create poll error:", error);
res.status(500).json({ message: "Server error" });
}
};

// GET /api/polls/groups/:groupId
export const getGroupPolls = async (req, res) => {
try {
const { groupId } = req.params;
const userId = req.user._id;

if (!mongoose.Types.ObjectId.isValid(groupId)) {
return res.status(400).json({ message: "Invalid group ID" });
}

const group = await Group.findById(groupId);
if (!group) {
return res.status(404).json({ message: "Group not found" });
}

if (!isGroupMember(group, userId)) {
return res.status(403).json({ message: "Access denied" });
}

const polls = await Poll.find({ groupId })
.populate("creatorId", "fullName profilePic")
.sort({ createdAt: -1 });

res.status(200).json(polls);
} catch (error) {
console.error("Get group polls error:", error);
res.status(500).json({ message: "Server error" });
}
};

// POST /api/polls/:pollId/vote
export const votePoll = async (req, res) => {
try {
const { pollId } = req.params;
const { optionId } = req.body;
const userId = req.user._id;

if (!mongoose.Types.ObjectId.isValid(pollId)) {
return res.status(400).json({ message: "Invalid poll ID" });
}

const poll = await Poll.findById(pollId);
if (!poll) {
return res.status(404).json({ message: "Poll not found" });
}

// Verify membership
const group = await Group.findById(poll.groupId);
if (!group || !isGroupMember(group, userId)) {
return res.status(403).json({ message: "Not a group member" });
}

// Check poll is still active (not closed / not expired)
if (poll.isClosed || (poll.expiresAt && new Date() > poll.expiresAt)) {
return res.status(400).json({ message: "This poll is no longer active" });
}

const targetOption = poll.options.id(optionId);
if (!targetOption) {
return res.status(400).json({ message: "Option not found" });
}

// Remove user's vote from all options first (enforce single-choice)
poll.options.forEach((opt) => {
opt.votes = opt.votes.filter((v) => v.toString() !== userId.toString());
});

// Toggle: if user already had this option, the vote is now removed (removed above).
// If not, add the vote.
const alreadyVoted = targetOption.votes.some(
(v) => v.toString() === userId.toString(),
);

if (!alreadyVoted) {
targetOption.votes.push(userId);
}

await poll.save();

const populated = await poll.populate("creatorId", "fullName profilePic");

// Broadcast updated poll to the whole group
io.to(poll.groupId.toString()).emit("pollUpdated", populated);

res.status(200).json(populated);
} catch (error) {
console.error("Vote poll error:", error);
res.status(500).json({ message: "Server error" });
}
};

// PATCH /api/polls/:pollId/close
export const closePoll = async (req, res) => {
try {
const { pollId } = req.params;
const userId = req.user._id;

if (!mongoose.Types.ObjectId.isValid(pollId)) {
return res.status(400).json({ message: "Invalid poll ID" });
}

const poll = await Poll.findById(pollId);
if (!poll) {
return res.status(404).json({ message: "Poll not found" });
}

// Only the creator can close the poll
if (poll.creatorId.toString() !== userId.toString()) {
return res
.status(403)
.json({ message: "Only the poll creator can close it" });
}

if (poll.isClosed) {
return res.status(400).json({ message: "Poll is already closed" });
}

poll.isClosed = true;
await poll.save();

const populated = await poll.populate("creatorId", "fullName profilePic");

io.to(poll.groupId.toString()).emit("pollUpdated", populated);

res.status(200).json(populated);
} catch (error) {
console.error("Close poll error:", error);
res.status(500).json({ message: "Server error" });
}
};
2 changes: 2 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import adminRoutes from "./routes/admin.routes.js";
import reportRoutes from "./routes/report.routes.js";
import gifRoutes from "./routes/gif.routes.js";
import userRoutes from "./routes/user.route.js";
import pollRoutes from "./routes/poll.routes.js";

dotenv.config();

Expand Down Expand Up @@ -48,6 +49,7 @@ app.use("/api/admin", adminRoutes);
app.use("/api/reports", reportRoutes);
app.use("/api/gif", gifRoutes);
app.use("/api/users", userRoutes);
app.use("/api/polls", pollRoutes);

if (process.env.NODE_ENV === "production") {
app.use(express.static(path.join(__dirname, "../frontend/dist")));
Expand Down
77 changes: 77 additions & 0 deletions backend/src/models/poll.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import mongoose from "mongoose";

const pollOptionSchema = new mongoose.Schema(
{
text: {
type: String,
required: true,
trim: true,
},
votes: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "User",
},
],
},
{ _id: true },
);

const pollSchema = new mongoose.Schema(
{
groupId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Group",
required: true,
index: true,
},

creatorId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
index: true,
},

question: {
type: String,
required: true,
trim: true,
},

options: {
type: [pollOptionSchema],
validate: {
validator: (opts) => opts.length >= 2 && opts.length <= 10,
message: "A poll must have between 2 and 10 options",
},
},

// Optional expiry — null means no expiry
expiresAt: {
type: Date,
default: null,
},

isClosed: {
type: Boolean,
default: false,
index: true,
},
},
{ timestamps: true },
);

// Virtual: is this poll currently active?
pollSchema.virtual("isActive").get(function () {
if (this.isClosed) return false;
if (this.expiresAt && new Date() > this.expiresAt) return false;
return true;
});

pollSchema.set("toObject", { virtuals: true });
pollSchema.set("toJSON", { virtuals: true });

const Poll = mongoose.models.Poll || mongoose.model("Poll", pollSchema);

export default Poll;
3 changes: 2 additions & 1 deletion backend/src/models/user.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ const userSchema = new mongoose.Schema(

googleId: {
type: String,
default: null,
unique: true,
sparse: true,
},

refreshTokenHash: {
Expand Down
17 changes: 17 additions & 0 deletions backend/src/routes/poll.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import express from "express";
import { protectRoute } from "../middleware/auth.middleware.js";
import {
createPoll,
getGroupPolls,
votePoll,
closePoll,
} from "../controllers/poll.controller.js";

const router = express.Router();

router.post("/groups/:groupId", protectRoute, createPoll);
router.get("/groups/:groupId", protectRoute, getGroupPolls);
router.post("/:pollId/vote", protectRoute, votePoll);
router.patch("/:pollId/close", protectRoute, closePoll);

export default router;
Loading
Loading