Filter stale non-member votes from reveal logic
All checks were successful
Build & Push Container Image / build (push) Successful in 5s

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 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-03-04 10:30:50 +01:00
parent 2d78b9ff07
commit 36c8c5f6f4
2 changed files with 47 additions and 15 deletions

View file

@ -24,7 +24,6 @@ import {
joinRoom, joinRoom,
leaveRoom, leaveRoom,
getRoomMembers, getRoomMembers,
getRoomMemberCount,
isRoomMember isRoomMember
} from './services/roomService.js'; } from './services/roomService.js';
import { updateIssueEstimate } from './services/jiraService.js'; import { updateIssueEstimate } from './services/jiraService.js';
@ -147,19 +146,28 @@ async function emitSessionState(roomId, sessionId, tenantCloudId) {
const snapshot = await getSessionSnapshot(sessionId, tenantCloudId); const snapshot = await getSessionSnapshot(sessionId, tenantCloudId);
if (!snapshot) return; 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', { io.to(`room:${roomId}`).emit('poker:vote-update', {
sessionId, sessionId,
voteCount: snapshot.voteCount, voteCount: votedUserKeys.length,
votedUserKeys: snapshot.votedUserKeys, votedUserKeys,
memberCount memberCount: memberKeys.size
}); });
if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') { 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', { io.to(`room:${roomId}`).emit('poker:revealed', {
sessionId, sessionId,
votes: snapshot.votesByUser, votes,
average: snapshot.session.averageEstimate, average: snapshot.session.averageEstimate,
suggestedEstimate: snapshot.session.suggestedEstimate, suggestedEstimate: snapshot.session.suggestedEstimate,
savedEstimate: snapshot.session.savedEstimate savedEstimate: snapshot.session.savedEstimate
@ -279,8 +287,9 @@ io.on('connection', (socket) => {
} }
votesTotal.inc(); votesTotal.inc();
const memberCount = await getRoomMemberCount(roomId, socket.user.jiraCloudId); const members = await getRoomMembers(roomId, socket.user.jiraCloudId);
const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId, memberCount); 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); await emitSessionState(roomId, sessionId, socket.user.jiraCloudId);
@ -305,13 +314,22 @@ io.on('connection', (socket) => {
return; 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) { if (!snapshot) {
socket.emit('poker:error', { error: 'Unable to reveal votes.' }); socket.emit('poker:error', { error: 'Unable to reveal votes.' });
return; return;
} }
const roomId = snapshot.session.roomId;
io.to(`room:${roomId}`).emit('poker:revealed', { io.to(`room:${roomId}`).emit('poker:revealed', {
sessionId, sessionId,
votes: snapshot.votesByUser, votes: snapshot.votesByUser,

View file

@ -167,14 +167,23 @@ export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) {
return getSnapshot(result); return getSnapshot(result);
} }
export async function revealIfComplete(sessionId, tenantCloudId, roomMemberCount) { export async function revealIfComplete(sessionId, tenantCloudId, roomMemberKeys) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => { const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.state !== 'VOTING') return undefined; 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 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()] const numericVotes = [...session.votes.values()]
.map(Number) .map(Number)
.filter(Number.isFinite); .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) => { const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.state !== 'VOTING') return undefined; 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()] const numericVotes = [...session.votes.values()]
.map(Number) .map(Number)
.filter(Number.isFinite); .filter(Number.isFinite);
@ -225,7 +239,7 @@ export async function forceReveal(sessionId, tenantCloudId) {
return session; return session;
}); });
if (!result) { if (!result || result.state !== 'REVEALED') {
const current = await getSession(tenantCloudId, sessionId); const current = await getSession(tenantCloudId, sessionId);
if (!current) return null; if (!current) return null;
return getSnapshot(current); return getSnapshot(current);