Skip to content
Open
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
34 changes: 34 additions & 0 deletions backend/src/controllers/poll.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import mongoose from "mongoose";
import Group from "../models/group.model.js";
import Poll from "../models/poll.model.js";
import { io } from "../lib/socket.js";
import { computePollResults } from "../lib/pollResults.js";

// Helper — check if user is a member of the group
const isGroupMember = (group, userId) =>
Expand Down Expand Up @@ -97,6 +98,39 @@ export const getGroupPolls = async (req, res) => {
}
};

// GET /api/polls/:pollId
export const getPollById = 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).populate(
"creatorId",
"fullName profilePic",
);
if (!poll) {
return res.status(404).json({ message: "Poll not found" });
}

const group = await Group.findById(poll.groupId);
if (!group || !isGroupMember(group, userId)) {
return res.status(403).json({ message: "Access denied" });
}

res.status(200).json({
...poll.toObject(),
results: computePollResults(poll),
});
} catch (error) {
console.error("Get poll by id error:", error);
res.status(500).json({ message: "Server error" });
}
};

// POST /api/polls/:pollId/vote
export const votePoll = async (req, res) => {
try {
Expand Down
64 changes: 64 additions & 0 deletions backend/src/lib/pollResults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Authoritative poll results with percentages that always sum to 100.
//
// The client computes percentages naively (round each independently), so three
// even options render as 33/33/33 = 99. This computes them server-side with the
// largest-remainder (Hamilton) method, which distributes the leftover points to
// the largest fractional remainders so the total is exactly 100. Pure — no DB,
// no socket, no request.

/**
* @param {{ options?: Array<{ _id: any, text?: string, votes?: any[] }> }} poll
* @returns {{ totalVotes: number, options: Array<{ _id: string, text: string, voteCount: number, percentage: number }>, leadingOptionId: string|null }}
*/
export const computePollResults = (poll) => {
const rawOptions = Array.isArray(poll?.options) ? poll.options : [];

const options = rawOptions.map((opt) => ({
_id: opt?._id != null ? opt._id.toString() : "",
text: opt?.text ?? "",
voteCount: Array.isArray(opt?.votes) ? opt.votes.length : 0,
}));

const totalVotes = options.reduce((sum, o) => sum + o.voteCount, 0);

if (totalVotes === 0) {
return {
totalVotes: 0,
options: options.map((o) => ({ ...o, percentage: 0 })),
leadingOptionId: null,
};
}

// Largest-remainder: floor each share, then hand the remaining points to the
// options with the biggest fractional parts (ties broken by original order).
const shares = options.map((o, index) => {
const exact = (o.voteCount / totalVotes) * 100;
const floor = Math.floor(exact);
return { index, floor, remainder: exact - floor };
});

let remaining = 100 - shares.reduce((sum, s) => sum + s.floor, 0);
const byRemainder = [...shares].sort(
(a, b) => b.remainder - a.remainder || a.index - b.index,
);
for (const share of byRemainder) {
if (remaining <= 0) break;
share.floor += 1;
remaining -= 1;
}

const percentages = shares
.sort((a, b) => a.index - b.index)
.map((s) => s.floor);

let leading = options[0];
for (const o of options) {
if (o.voteCount > leading.voteCount) leading = o;
}

return {
totalVotes,
options: options.map((o, i) => ({ ...o, percentage: percentages[i] })),
leadingOptionId: leading._id,
};
};
2 changes: 2 additions & 0 deletions backend/src/routes/poll.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { protectRoute } from "../middleware/auth.middleware.js";
import {
createPoll,
getGroupPolls,
getPollById,
votePoll,
closePoll,
} from "../controllers/poll.controller.js";
Expand All @@ -11,6 +12,7 @@ const router = express.Router();

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

Expand Down
40 changes: 40 additions & 0 deletions backend/test/poll/pollResults.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { computePollResults } from "../../src/lib/pollResults.js";

const opt = (id, votes) => ({ _id: id, text: id.toUpperCase(), votes: Array(votes).fill("v") });

describe("computePollResults", () => {
it("returns zeroes and no leader when there are no votes", () => {
const r = computePollResults({ options: [opt("a", 0), opt("b", 0)] });
expect(r.totalVotes).toBe(0);
expect(r.options.map((o) => o.percentage)).toEqual([0, 0]);
expect(r.leadingOptionId).toBeNull();
});

it("splits an even two-way vote 50/50", () => {
const r = computePollResults({ options: [opt("a", 1), opt("b", 1)] });
expect(r.options.map((o) => o.percentage)).toEqual([50, 50]);
expect(r.options.reduce((s, o) => s + o.percentage, 0)).toBe(100);
expect(r.leadingOptionId).toBe("a");
});

it("makes three even options sum to exactly 100 (largest-remainder)", () => {
const r = computePollResults({ options: [opt("a", 1), opt("b", 1), opt("c", 1)] });
expect(r.options.reduce((s, o) => s + o.percentage, 0)).toBe(100);
expect(r.options.map((o) => o.percentage)).toEqual([34, 33, 33]);
});

it("makes six even options sum to exactly 100", () => {
const r = computePollResults({
options: ["a", "b", "c", "d", "e", "f"].map((id) => opt(id, 1)),
});
expect(r.options.reduce((s, o) => s + o.percentage, 0)).toBe(100);
});

it("identifies the leading option and vote counts", () => {
const r = computePollResults({ options: [opt("a", 3), opt("b", 1)] });
expect(r.leadingOptionId).toBe("a");
expect(r.options.find((o) => o._id === "a").voteCount).toBe(3);
expect(r.totalVotes).toBe(4);
expect(r.options.reduce((s, o) => s + o.percentage, 0)).toBe(100);
});
});