diff --git a/backend/src/controllers/poll.controller.js b/backend/src/controllers/poll.controller.js index be6948bd..05ec69d3 100644 --- a/backend/src/controllers/poll.controller.js +++ b/backend/src/controllers/poll.controller.js @@ -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) => @@ -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 { diff --git a/backend/src/lib/pollResults.js b/backend/src/lib/pollResults.js new file mode 100644 index 00000000..4f771b56 --- /dev/null +++ b/backend/src/lib/pollResults.js @@ -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, + }; +}; diff --git a/backend/src/routes/poll.routes.js b/backend/src/routes/poll.routes.js index 561d1787..68731e1d 100644 --- a/backend/src/routes/poll.routes.js +++ b/backend/src/routes/poll.routes.js @@ -3,6 +3,7 @@ import { protectRoute } from "../middleware/auth.middleware.js"; import { createPoll, getGroupPolls, + getPollById, votePoll, closePoll, } from "../controllers/poll.controller.js"; @@ -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); diff --git a/backend/test/poll/pollResults.test.js b/backend/test/poll/pollResults.test.js new file mode 100644 index 00000000..e60254fe --- /dev/null +++ b/backend/test/poll/pollResults.test.js @@ -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); + }); +});