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
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:
parent
0b713d3858
commit
bf5f71aa35
3 changed files with 64 additions and 3 deletions
|
|
@ -17,6 +17,7 @@ import {
|
||||||
getSessionSnapshot,
|
getSessionSnapshot,
|
||||||
revealIfComplete,
|
revealIfComplete,
|
||||||
forceReveal,
|
forceReveal,
|
||||||
|
removeVote,
|
||||||
saveScopedEstimate,
|
saveScopedEstimate,
|
||||||
submitVote
|
submitVote
|
||||||
} from './services/pokerService.js';
|
} 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) => {
|
io.on('connection', (socket) => {
|
||||||
const user = getSocketUser(socket);
|
const user = getSocketUser(socket);
|
||||||
if (!user || !user.jiraCloudId) {
|
if (!user || !user.jiraCloudId) {
|
||||||
|
|
@ -211,6 +231,9 @@ io.on('connection', (socket) => {
|
||||||
const roomId = socket.roomId;
|
const roomId = socket.roomId;
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
try {
|
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 leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId);
|
||||||
await emitRoomMembers(roomId, socket.user.jiraCloudId);
|
await emitRoomMembers(roomId, socket.user.jiraCloudId);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -246,6 +269,10 @@ io.on('connection', (socket) => {
|
||||||
throttled('room:leave', async ({ roomId }) => {
|
throttled('room:leave', async ({ roomId }) => {
|
||||||
try {
|
try {
|
||||||
if (!roomId) return;
|
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);
|
await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId);
|
||||||
socket.leave(`room:${roomId}`);
|
socket.leave(`room:${roomId}`);
|
||||||
socket.roomId = null;
|
socket.roomId = null;
|
||||||
|
|
@ -259,9 +286,13 @@ io.on('connection', (socket) => {
|
||||||
throttled('poker:sync', async ({ sessionId }) => {
|
throttled('poker:sync', async ({ sessionId }) => {
|
||||||
try {
|
try {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
socket.activeSessionId = sessionId;
|
||||||
const snapshot = await getSessionSnapshot(sessionId, socket.user.jiraCloudId);
|
const snapshot = await getSessionSnapshot(sessionId, socket.user.jiraCloudId);
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
await emitSessionState(snapshot.session.roomId, sessionId, socket.user.jiraCloudId);
|
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) {
|
} catch (error) {
|
||||||
console.error('[socket] poker:sync failed:', error);
|
console.error('[socket] poker:sync failed:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -286,6 +317,7 @@ io.on('connection', (socket) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socket.activeSessionId = sessionId;
|
||||||
const voteResult = await submitVote({
|
const voteResult = await submitVote({
|
||||||
sessionId,
|
sessionId,
|
||||||
tenantCloudId: socket.user.jiraCloudId,
|
tenantCloudId: socket.user.jiraCloudId,
|
||||||
|
|
@ -418,6 +450,9 @@ io.on('connection', (socket) => {
|
||||||
socket.emit('poker:error', { error: 'roomId and userKey are required.' });
|
socket.emit('poker:error', { error: 'roomId and userKey are required.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (socket.activeSessionId) {
|
||||||
|
await handleVoteRemoval(roomId, socket.activeSessionId, socket.user.jiraCloudId, userKey);
|
||||||
|
}
|
||||||
await leaveRoom(roomId, socket.user.jiraCloudId, userKey);
|
await leaveRoom(roomId, socket.user.jiraCloudId, userKey);
|
||||||
await emitRoomMembers(roomId, socket.user.jiraCloudId);
|
await emitRoomMembers(roomId, socket.user.jiraCloudId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,17 @@ export async function forceReveal(sessionId, tenantCloudId, roomMemberKeys) {
|
||||||
return getSnapshot(result);
|
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 }) {
|
export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) {
|
||||||
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
|
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
|
||||||
if (session.tenantCloudId !== tenantCloudId) return undefined;
|
if (session.tenantCloudId !== tenantCloudId) return undefined;
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,32 @@ export default function PokerRoom({ session, issue, user, members, roomId, onSav
|
||||||
setError(payload?.error || 'Unexpected poker error');
|
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:vote-update', onVoteUpdate);
|
||||||
socket.on('poker:revealed', onRevealed);
|
socket.on('poker:revealed', onRevealed);
|
||||||
socket.on('poker:saved', onSavedPayload);
|
socket.on('poker:saved', onSavedPayload);
|
||||||
socket.on('poker:error', onError);
|
socket.on('poker:error', onError);
|
||||||
|
socket.on('poker:my-vote', onMyVote);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('poker:vote-update', onVoteUpdate);
|
socket.off('poker:vote-update', onVoteUpdate);
|
||||||
socket.off('poker:revealed', onRevealed);
|
socket.off('poker:revealed', onRevealed);
|
||||||
socket.off('poker:saved', onSavedPayload);
|
socket.off('poker:saved', onSavedPayload);
|
||||||
socket.off('poker:error', onError);
|
socket.off('poker:error', onError);
|
||||||
|
socket.off('poker:my-vote', onMyVote);
|
||||||
};
|
};
|
||||||
}, [session.id, socket]);
|
}, [session.id, socket]);
|
||||||
|
|
||||||
|
const canVote = !revealed && members.length >= 2;
|
||||||
|
|
||||||
function handleVote(value) {
|
function handleVote(value) {
|
||||||
if (revealed) return;
|
if (!canVote) return;
|
||||||
setMyVote(value);
|
setMyVote(value);
|
||||||
socket.emit('poker:vote', {
|
socket.emit('poker:vote', {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
|
|
@ -162,14 +173,18 @@ export default function PokerRoom({ session, issue, user, members, roomId, onSav
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-7 gap-2">
|
{!canVote && members.length < 2 && (
|
||||||
|
<p className="text-sm text-slate-400 text-center m-0">Waiting for at least 2 participants to start voting...</p>
|
||||||
|
)}
|
||||||
|
<div className={`grid grid-cols-7 gap-2${!canVote ? ' opacity-50 pointer-events-none' : ''}`}>
|
||||||
{CARDS.map((card) => {
|
{CARDS.map((card) => {
|
||||||
const isSelected = myVote === card.value;
|
const isSelected = myVote === card.value;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={card.value}
|
key={card.value}
|
||||||
onClick={() => handleVote(card.value)}
|
onClick={() => handleVote(card.value)}
|
||||||
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent"
|
disabled={!canVote}
|
||||||
|
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent disabled:cursor-not-allowed"
|
||||||
style={{
|
style={{
|
||||||
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
|
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
|
||||||
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
|
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue