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,
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,

View file

@ -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);