From bf5f71aa354befeaae63ed42a1959b0e3ce457ee Mon Sep 17 00:00:00 2001 From: Jan Willem Mannaerts Date: Thu, 5 Mar 2026 11:21:45 +0100 Subject: [PATCH] Remove vote on leave/disconnect, require 2+ members, restore vote on sync - Add removeVote (only during VOTING state, preserves revealed votes) - Remove user's vote on disconnect, room:leave, and kick - After vote removal, check auto-reveal for remaining members - Send poker:my-vote on sync so card selection is restored - Disable voting cards until at least 2 participants are present Co-Authored-By: Claude Opus 4.6 --- backend/src/index.js | 35 +++++++++++++++++++++++++++ backend/src/services/pokerService.js | 11 +++++++++ frontend/src/components/PokerRoom.jsx | 21 +++++++++++++--- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index d4cc779..0d12ba0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -17,6 +17,7 @@ import { getSessionSnapshot, revealIfComplete, forceReveal, + removeVote, saveScopedEstimate, submitVote } from './services/pokerService.js'; @@ -193,6 +194,25 @@ function socketThrottle(socket, limitPerMinute = 60) { }; } +async function handleVoteRemoval(roomId, sessionId, tenantCloudId, userKey) { + await removeVote({ sessionId, tenantCloudId, userKey }); + + const members = await getRoomMembers(roomId, tenantCloudId); + const memberKeys = new Set(members.map((m) => m.userKey)); + const reveal = await revealIfComplete(sessionId, tenantCloudId, memberKeys); + + await emitSessionState(roomId, sessionId, tenantCloudId); + + if (reveal?.allVoted) { + io.to(`room:${roomId}`).emit('poker:revealed', { + sessionId, + votes: reveal.votesByUser, + average: reveal.average, + suggestedEstimate: reveal.suggestedEstimate + }); + } +} + io.on('connection', (socket) => { const user = getSocketUser(socket); if (!user || !user.jiraCloudId) { @@ -211,6 +231,9 @@ io.on('connection', (socket) => { const roomId = socket.roomId; if (!roomId) return; try { + if (socket.activeSessionId) { + await handleVoteRemoval(roomId, socket.activeSessionId, socket.user.jiraCloudId, socket.user.jiraAccountId); + } await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId); await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch { @@ -246,6 +269,10 @@ io.on('connection', (socket) => { throttled('room:leave', async ({ roomId }) => { try { if (!roomId) return; + if (socket.activeSessionId) { + await handleVoteRemoval(roomId, socket.activeSessionId, socket.user.jiraCloudId, socket.user.jiraAccountId); + socket.activeSessionId = null; + } await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId); socket.leave(`room:${roomId}`); socket.roomId = null; @@ -259,9 +286,13 @@ io.on('connection', (socket) => { throttled('poker:sync', async ({ sessionId }) => { try { if (!sessionId) return; + socket.activeSessionId = sessionId; const snapshot = await getSessionSnapshot(sessionId, socket.user.jiraCloudId); if (!snapshot) return; await emitSessionState(snapshot.session.roomId, sessionId, socket.user.jiraCloudId); + // Send the user's own vote back so their card selection is restored + const myVote = snapshot.votesByUser[socket.user.jiraAccountId] ?? null; + socket.emit('poker:my-vote', { sessionId, vote: myVote }); } catch (error) { console.error('[socket] poker:sync failed:', error); } @@ -286,6 +317,7 @@ io.on('connection', (socket) => { return; } + socket.activeSessionId = sessionId; const voteResult = await submitVote({ sessionId, tenantCloudId: socket.user.jiraCloudId, @@ -418,6 +450,9 @@ io.on('connection', (socket) => { socket.emit('poker:error', { error: 'roomId and userKey are required.' }); return; } + if (socket.activeSessionId) { + await handleVoteRemoval(roomId, socket.activeSessionId, socket.user.jiraCloudId, userKey); + } await leaveRoom(roomId, socket.user.jiraCloudId, userKey); await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch (error) { diff --git a/backend/src/services/pokerService.js b/backend/src/services/pokerService.js index 8d60354..426ade7 100644 --- a/backend/src/services/pokerService.js +++ b/backend/src/services/pokerService.js @@ -247,6 +247,17 @@ export async function forceReveal(sessionId, tenantCloudId, roomMemberKeys) { return getSnapshot(result); } +export async function removeVote({ sessionId, tenantCloudId, userKey }) { + const result = await withSessionCas(tenantCloudId, sessionId, (session) => { + if (session.state !== 'VOTING') return undefined; + if (!session.votes.has(userKey)) return undefined; + session.votes.delete(userKey); + return session; + }); + if (!result) return null; + return getSnapshot(result); +} + export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) { const result = await withSessionCas(tenantCloudId, sessionId, (session) => { if (session.tenantCloudId !== tenantCloudId) return undefined; diff --git a/frontend/src/components/PokerRoom.jsx b/frontend/src/components/PokerRoom.jsx index 6373afa..0c34531 100644 --- a/frontend/src/components/PokerRoom.jsx +++ b/frontend/src/components/PokerRoom.jsx @@ -60,21 +60,32 @@ export default function PokerRoom({ session, issue, user, members, roomId, onSav setError(payload?.error || 'Unexpected poker error'); } + function onMyVote(payload) { + if (payload.sessionId !== session.id) return; + if (payload.vote !== null && payload.vote !== undefined) { + setMyVote(payload.vote); + } + } + socket.on('poker:vote-update', onVoteUpdate); socket.on('poker:revealed', onRevealed); socket.on('poker:saved', onSavedPayload); socket.on('poker:error', onError); + socket.on('poker:my-vote', onMyVote); return () => { socket.off('poker:vote-update', onVoteUpdate); socket.off('poker:revealed', onRevealed); socket.off('poker:saved', onSavedPayload); socket.off('poker:error', onError); + socket.off('poker:my-vote', onMyVote); }; }, [session.id, socket]); + const canVote = !revealed && members.length >= 2; + function handleVote(value) { - if (revealed) return; + if (!canVote) return; setMyVote(value); socket.emit('poker:vote', { sessionId: session.id, @@ -162,14 +173,18 @@ export default function PokerRoom({ session, issue, user, members, roomId, onSav ) : (
-
+ {!canVote && members.length < 2 && ( +

Waiting for at least 2 participants to start voting...

+ )} +
{CARDS.map((card) => { const isSelected = myVote === card.value; return (