From 36c8c5f6f44f684241ab98621138bed66aa1f5ea Mon Sep 17 00:00:00 2001 From: Jan Willem Mannaerts Date: Wed, 4 Mar 2026 10:30:50 +0100 Subject: [PATCH] Filter stale non-member votes from reveal logic revealIfComplete and forceReveal now accept a Set of room member keys and only count votes from current members. Stale votes from users who left the room are stripped before computing averages. emitSessionState also filters votedUserKeys and revealed votes to members only. Co-Authored-By: Claude Opus 4.6 --- backend/src/index.js | 38 ++++++++++++++++++++-------- backend/src/services/pokerService.js | 24 ++++++++++++++---- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index 6f72a8b..dfca288 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -24,7 +24,6 @@ import { joinRoom, leaveRoom, getRoomMembers, - getRoomMemberCount, isRoomMember } from './services/roomService.js'; import { updateIssueEstimate } from './services/jiraService.js'; @@ -147,19 +146,28 @@ async function emitSessionState(roomId, sessionId, tenantCloudId) { const snapshot = await getSessionSnapshot(sessionId, tenantCloudId); if (!snapshot) return; - const memberCount = await getRoomMemberCount(roomId, tenantCloudId); + const members = await getRoomMembers(roomId, tenantCloudId); + const memberKeys = new Set(members.map((m) => m.userKey)); + + // Filter to only votes from current room members + const votedUserKeys = snapshot.votedUserKeys.filter((k) => memberKeys.has(k)); io.to(`room:${roomId}`).emit('poker:vote-update', { sessionId, - voteCount: snapshot.voteCount, - votedUserKeys: snapshot.votedUserKeys, - memberCount + voteCount: votedUserKeys.length, + votedUserKeys, + memberCount: memberKeys.size }); if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') { + // Filter revealed votes to members only + const votes = {}; + for (const [k, v] of Object.entries(snapshot.votesByUser)) { + if (memberKeys.has(k)) votes[k] = v; + } io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, - votes: snapshot.votesByUser, + votes, average: snapshot.session.averageEstimate, suggestedEstimate: snapshot.session.suggestedEstimate, savedEstimate: snapshot.session.savedEstimate @@ -279,8 +287,9 @@ io.on('connection', (socket) => { } votesTotal.inc(); - const memberCount = await getRoomMemberCount(roomId, socket.user.jiraCloudId); - const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId, memberCount); + const members = await getRoomMembers(roomId, socket.user.jiraCloudId); + const memberKeys = new Set(members.map((m) => m.userKey)); + const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId, memberKeys); await emitSessionState(roomId, sessionId, socket.user.jiraCloudId); @@ -305,13 +314,22 @@ io.on('connection', (socket) => { return; } - const snapshot = await forceReveal(sessionId, socket.user.jiraCloudId); + const pre = await getSessionSnapshot(sessionId, socket.user.jiraCloudId); + if (!pre) { + socket.emit('poker:error', { error: 'Session not found.' }); + return; + } + const roomId = pre.session.roomId; + + const members = await getRoomMembers(roomId, socket.user.jiraCloudId); + const memberKeys = new Set(members.map((m) => m.userKey)); + + const snapshot = await forceReveal(sessionId, socket.user.jiraCloudId, memberKeys); if (!snapshot) { socket.emit('poker:error', { error: 'Unable to reveal votes.' }); return; } - const roomId = snapshot.session.roomId; io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, votes: snapshot.votesByUser, diff --git a/backend/src/services/pokerService.js b/backend/src/services/pokerService.js index 7c511b5..8d60354 100644 --- a/backend/src/services/pokerService.js +++ b/backend/src/services/pokerService.js @@ -167,14 +167,23 @@ export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) { return getSnapshot(result); } -export async function revealIfComplete(sessionId, tenantCloudId, roomMemberCount) { +export async function revealIfComplete(sessionId, tenantCloudId, roomMemberKeys) { const result = await withSessionCas(tenantCloudId, sessionId, (session) => { if (session.state !== 'VOTING') return undefined; - const allVoted = roomMemberCount > 0 && - session.votes.size === roomMemberCount; + + // Only count votes from current room members + const memberVoteCount = [...session.votes.keys()] + .filter((k) => roomMemberKeys.has(k)).length; + const allVoted = roomMemberKeys.size > 0 && + memberVoteCount === roomMemberKeys.size; if (!allVoted) return undefined; // no mutation needed + // Strip stale votes from non-members + for (const key of session.votes.keys()) { + if (!roomMemberKeys.has(key)) session.votes.delete(key); + } + const numericVotes = [...session.votes.values()] .map(Number) .filter(Number.isFinite); @@ -207,10 +216,15 @@ export async function revealIfComplete(sessionId, tenantCloudId, roomMemberCount }; } -export async function forceReveal(sessionId, tenantCloudId) { +export async function forceReveal(sessionId, tenantCloudId, roomMemberKeys) { const result = await withSessionCas(tenantCloudId, sessionId, (session) => { if (session.state !== 'VOTING') return undefined; + // Strip stale votes from non-members + for (const key of session.votes.keys()) { + if (!roomMemberKeys.has(key)) session.votes.delete(key); + } + const numericVotes = [...session.votes.values()] .map(Number) .filter(Number.isFinite); @@ -225,7 +239,7 @@ export async function forceReveal(sessionId, tenantCloudId) { return session; }); - if (!result) { + if (!result || result.state !== 'REVEALED') { const current = await getSession(tenantCloudId, sessionId); if (!current) return null; return getSnapshot(current);