Remove vote on leave/disconnect, require 2+ members, restore vote on sync
All checks were successful
Build & Push Container Image / build (push) Successful in 10s

- 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 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-03-05 11:21:45 +01:00
parent 0b713d3858
commit bf5f71aa35
3 changed files with 64 additions and 3 deletions

View file

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

View file

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