Track participants at room level to fix premature auto-reveal
All checks were successful
Build & Push Container Image / build (push) Successful in 8s

Participants were tracked per-session, so each new issue started with 0
participants. The first user to join+vote saw 1/1 = all voted, triggering
premature reveal. Now members are tracked on the room object and persist
across issues. revealIfComplete compares votes against room member count.

Also fixes: disconnect handler was dead code (Socket.IO v4 empties
socket.rooms before firing disconnect) — replaced with disconnecting.

Added manual "Reveal Votes" button and poker:reveal socket handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-03-04 09:55:49 +01:00
parent 062510b6c6
commit 2d78b9ff07
6 changed files with 247 additions and 190 deletions

View file

@ -14,15 +14,19 @@ import pokerRoutes from './routes/poker.js';
import jiraRoutes from './routes/jira.js';
import roomRoutes from './routes/rooms.js';
import {
canAccessSession,
getSessionSnapshot,
isSessionParticipant,
joinSession,
leaveSession,
revealIfComplete,
forceReveal,
saveScopedEstimate,
submitVote
} from './services/pokerService.js';
import {
joinRoom,
leaveRoom,
getRoomMembers,
getRoomMemberCount,
isRoomMember
} from './services/roomService.js';
import { updateIssueEstimate } from './services/jiraService.js';
import { getSocketUser } from './middleware/auth.js';
import { safeError } from './lib/errors.js';
@ -134,28 +138,26 @@ const io = new Server(httpServer, {
}
});
async function emitSessionState(sessionId, tenantCloudId) {
async function emitRoomMembers(roomId, cloudId) {
const members = await getRoomMembers(roomId, cloudId);
io.to(`room:${roomId}`).emit('room:members', { roomId, members });
}
async function emitSessionState(roomId, sessionId, tenantCloudId) {
const snapshot = await getSessionSnapshot(sessionId, tenantCloudId);
if (!snapshot) return;
io.to(`poker:${sessionId}`).emit('poker:participants', {
sessionId,
participants: snapshot.participants,
votedUserKeys: snapshot.votedUserKeys,
voteCount: snapshot.voteCount,
participantCount: snapshot.participantCount
});
const memberCount = await getRoomMemberCount(roomId, tenantCloudId);
io.to(`poker:${sessionId}`).emit('poker:vote-update', {
io.to(`room:${roomId}`).emit('poker:vote-update', {
sessionId,
voteCount: snapshot.voteCount,
participantCount: snapshot.participantCount,
votedUserKeys: snapshot.votedUserKeys,
allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount
memberCount
});
if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') {
io.to(`poker:${sessionId}`).emit('poker:revealed', {
io.to(`room:${roomId}`).emit('poker:revealed', {
sessionId,
votes: snapshot.votesByUser,
average: snapshot.session.averageEstimate,
@ -194,52 +196,54 @@ io.on('connection', (socket) => {
websocketConnections.inc();
trackUniqueUser(user.jiraAccountId);
trackUniqueTenant(user.jiraCloudId);
socket.on('disconnect', async () => {
// Use 'disconnecting' — socket.rooms is still populated (unlike 'disconnect')
socket.on('disconnecting', async () => {
websocketConnections.dec();
for (const room of socket.rooms) {
if (!room.startsWith('poker:')) continue;
const sessionId = room.slice(6);
const roomId = socket.roomId;
if (!roomId) return;
try {
await leaveSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId
});
await emitSessionState(sessionId, socket.user.jiraCloudId);
await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId);
await emitRoomMembers(roomId, socket.user.jiraCloudId);
} catch {
// best-effort cleanup
}
}
});
const throttled = socketThrottle(socket);
throttled('poker:join', async ({ sessionId }) => {
throttled('room:join', async ({ roomId }) => {
try {
if (!sessionId) {
socket.emit('poker:error', { error: 'sessionId is required.' });
if (!roomId) {
socket.emit('poker:error', { error: 'roomId is required.' });
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
socket.join(`poker:${sessionId}`);
const snapshot = await joinSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
await joinRoom(roomId, socket.user.jiraCloudId, {
userKey: socket.user.jiraAccountId,
userName: socket.user.displayName,
avatarUrl: socket.user.avatarUrl || null
});
if (!snapshot) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
await emitSessionState(sessionId, socket.user.jiraCloudId);
socket.join(`room:${roomId}`);
socket.roomId = roomId;
await emitRoomMembers(roomId, socket.user.jiraCloudId);
} catch (error) {
console.error('[socket] poker:join failed:', error);
console.error('[socket] room:join failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
throttled('room:leave', async ({ roomId }) => {
try {
if (!roomId) return;
await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId);
socket.leave(`room:${roomId}`);
socket.roomId = null;
await emitRoomMembers(roomId, socket.user.jiraCloudId);
} catch (error) {
console.error('[socket] room:leave failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
@ -251,12 +255,15 @@ io.on('connection', (socket) => {
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
const snapshot = await getSessionSnapshot(sessionId, socket.user.jiraCloudId);
if (!snapshot) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
if (!await isSessionParticipant(sessionId, socket.user.jiraCloudId, socket.user.jiraAccountId)) {
socket.emit('poker:error', { error: 'Join the session before voting.' });
const roomId = snapshot.session.roomId;
if (!await isRoomMember(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId)) {
socket.emit('poker:error', { error: 'Join the room before voting.' });
return;
}
@ -271,12 +278,14 @@ io.on('connection', (socket) => {
return;
}
votesTotal.inc();
const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId);
await emitSessionState(sessionId, socket.user.jiraCloudId);
const memberCount = await getRoomMemberCount(roomId, socket.user.jiraCloudId);
const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId, memberCount);
await emitSessionState(roomId, sessionId, socket.user.jiraCloudId);
if (reveal?.allVoted) {
io.to(`poker:${sessionId}`).emit('poker:revealed', {
io.to(`room:${roomId}`).emit('poker:revealed', {
sessionId,
votes: reveal.votesByUser,
average: reveal.average,
@ -289,6 +298,34 @@ io.on('connection', (socket) => {
}
});
throttled('poker:reveal', async ({ sessionId }) => {
try {
if (!sessionId) {
socket.emit('poker:error', { error: 'sessionId is required.' });
return;
}
const snapshot = await forceReveal(sessionId, socket.user.jiraCloudId);
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,
average: snapshot.session.averageEstimate,
suggestedEstimate: snapshot.session.suggestedEstimate
});
await emitSessionState(roomId, sessionId, socket.user.jiraCloudId);
} catch (error) {
console.error('[socket] poker:reveal failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
throttled('poker:save', async ({ sessionId, estimate }) => {
try {
if (!sessionId) {
@ -296,12 +333,15 @@ io.on('connection', (socket) => {
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
const pre = await getSessionSnapshot(sessionId, socket.user.jiraCloudId);
if (!pre) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
if (!await isSessionParticipant(sessionId, socket.user.jiraCloudId, socket.user.jiraAccountId)) {
socket.emit('poker:error', { error: 'Join the session before saving.' });
const roomId = pre.session.roomId;
if (!await isRoomMember(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId)) {
socket.emit('poker:error', { error: 'Join the room before saving.' });
return;
}
@ -330,57 +370,32 @@ io.on('connection', (socket) => {
// Jira update is best-effort so poker flow continues even when Jira is unavailable.
}
io.to(`poker:${sessionId}`).emit('poker:saved', {
io.to(`room:${roomId}`).emit('poker:saved', {
sessionId,
estimate: numericEstimate,
issueKey: saved.session.issueKey
});
io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId });
io.to(`room:${roomId}`).emit('poker:ended', { sessionId });
} catch (error) {
console.error('[socket] poker:save failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
throttled('poker:kick', async ({ sessionId, userKey }) => {
throttled('poker:kick', async ({ roomId, userKey }) => {
try {
if (!sessionId || !userKey) {
socket.emit('poker:error', { error: 'sessionId and userKey are required.' });
if (!roomId || !userKey) {
socket.emit('poker:error', { error: 'roomId and userKey are required.' });
return;
}
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
socket.emit('poker:error', { error: 'Session not found.' });
return;
}
await leaveSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey
});
await emitSessionState(sessionId, socket.user.jiraCloudId);
await leaveRoom(roomId, socket.user.jiraCloudId, userKey);
await emitRoomMembers(roomId, socket.user.jiraCloudId);
} catch (error) {
console.error('[socket] poker:kick failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
throttled('poker:leave', async ({ sessionId }) => {
try {
if (!sessionId) return;
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return;
await leaveSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId
});
socket.leave(`poker:${sessionId}`);
await emitSessionState(sessionId, socket.user.jiraCloudId);
} catch (error) {
console.error('[socket] poker:leave failed:', error);
socket.emit('poker:error', { error: safeError(error) });
}
});
});
async function start() {

View file

@ -27,14 +27,12 @@ function nearestFibonacci(value) {
function serializeSession(session) {
return {
...session,
participants: Object.fromEntries(session.participants),
votes: Object.fromEntries(session.votes)
};
}
function deserializeSession(data) {
if (!data) return null;
data.participants = new Map(Object.entries(data.participants));
data.votes = new Map(Object.entries(data.votes));
return data;
}
@ -64,21 +62,17 @@ async function withSessionCas(cloudId, sessionId, transformFn) {
}
function getSnapshot(session) {
// Accept both Map-based and plain-object sessions
const participantsMap = session.participants instanceof Map
? session.participants
: new Map(Object.entries(session.participants));
const votesMap = session.votes instanceof Map
? session.votes
: new Map(Object.entries(session.votes));
const participants = [...participantsMap.values()];
const votes = Object.fromEntries(votesMap);
const votedUserKeys = [...votesMap.keys()];
return {
session: {
id: session.id,
roomId: session.roomId,
issueKey: session.issueKey,
issueId: session.issueId,
issueTitle: session.issueTitle,
@ -88,10 +82,8 @@ function getSnapshot(session) {
suggestedEstimate: session.suggestedEstimate,
savedEstimate: session.savedEstimate
},
participants,
votesByUser: votes,
voteCount: votesMap.size,
participantCount: participantsMap.size,
votedUserKeys
};
}
@ -132,7 +124,6 @@ export async function createScopedSession({ issueKey, issueId, issueTitle, roomI
tenantCloudId,
createdAt: Date.now(),
state: 'VOTING',
participants: new Map(),
votes: new Map(),
averageEstimate: null,
suggestedEstimate: null,
@ -156,40 +147,11 @@ export async function canAccessSession(sessionId, tenantCloudId) {
return session.tenantCloudId === tenantCloudId;
}
export async function isSessionParticipant(sessionId, tenantCloudId, userKey) {
const session = await getSession(tenantCloudId, sessionId);
if (!session) return false;
return session.participants.has(userKey);
}
export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
session.participants.set(userKey, { userKey, userName, avatarUrl });
session.votes.delete(userKey);
return session;
});
if (!result) return null;
return getSnapshot(result);
}
export async function leaveSession({ sessionId, tenantCloudId, userKey }) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
session.participants.delete(userKey);
session.votes.delete(userKey);
return session;
});
if (!result) return null;
return getSnapshot(result);
}
export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.state === 'REVEALED' || session.state === 'SAVED') return undefined;
if (session.state !== 'VOTING') return undefined;
if (session.tenantCloudId !== tenantCloudId) return undefined;
if (!session.participants.has(userKey)) return undefined;
const parsed = parseVote(vote);
session.votes.set(userKey, parsed.rawValue);
return session;
@ -205,10 +167,11 @@ export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) {
return getSnapshot(result);
}
export async function revealIfComplete(sessionId, tenantCloudId) {
export async function revealIfComplete(sessionId, tenantCloudId, roomMemberCount) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
const allVoted = session.participants.size > 0 &&
session.votes.size === session.participants.size;
if (session.state !== 'VOTING') return undefined;
const allVoted = roomMemberCount > 0 &&
session.votes.size === roomMemberCount;
if (!allVoted) return undefined; // no mutation needed
@ -228,8 +191,8 @@ export async function revealIfComplete(sessionId, tenantCloudId) {
return session;
});
if (!result) {
// Not all voted — return current snapshot
// withCasRetry returns current value on no-op — check if state actually changed
if (!result || result.state !== 'REVEALED') {
const current = await getSession(tenantCloudId, sessionId);
if (!current) return null;
return { ...getSnapshot(current), allVoted: false };
@ -244,10 +207,35 @@ export async function revealIfComplete(sessionId, tenantCloudId) {
};
}
export async function forceReveal(sessionId, tenantCloudId) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.state !== 'VOTING') return undefined;
const numericVotes = [...session.votes.values()]
.map(Number)
.filter(Number.isFinite);
const average = numericVotes.length
? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length
: 0;
session.state = 'REVEALED';
session.averageEstimate = average;
session.suggestedEstimate = nearestFibonacci(average);
return session;
});
if (!result) {
const current = await getSession(tenantCloudId, sessionId);
if (!current) return null;
return getSnapshot(current);
}
return getSnapshot(result);
}
export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) {
const result = await withSessionCas(tenantCloudId, sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
if (!session.participants.has(userKey)) return undefined;
session.savedEstimate = estimate;
session.state = 'SAVED';
return session;

View file

@ -48,7 +48,8 @@ export async function createRoom(payload) {
sprintId,
sprintName,
createdByAccountId,
createdByName
createdByName,
members: {}
};
await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room));
@ -107,6 +108,52 @@ export async function getRoomById(roomId, cloudId) {
return entry ? entry.json() : null;
}
export async function joinRoom(roomId, cloudId, { userKey, userName, avatarUrl }) {
assertCloudId(cloudId);
return withCasRetry(kvRooms, roomKey(cloudId, roomId), (raw) => {
if (!raw) return undefined;
const room = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!room.members) room.members = {};
room.members[userKey] = { userKey, userName, avatarUrl };
return room;
});
}
export async function leaveRoom(roomId, cloudId, userKey) {
assertCloudId(cloudId);
return withCasRetry(kvRooms, roomKey(cloudId, roomId), (raw) => {
if (!raw) return undefined;
const room = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!room.members || !room.members[userKey]) return undefined;
delete room.members[userKey];
return room;
});
}
export async function getRoomMembers(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
if (!entry) return [];
const room = entry.json();
return Object.values(room.members || {});
}
export async function getRoomMemberCount(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
if (!entry) return 0;
const room = entry.json();
return Object.keys(room.members || {}).length;
}
export async function isRoomMember(roomId, cloudId, userKey) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
if (!entry) return false;
const room = entry.json();
return !!(room.members && room.members[userKey]);
}
export async function deleteRoom(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { getSocket } from '../services/socket';
const CARDS = [
@ -17,8 +17,7 @@ const CARDS = [
{ value: '100', label: '100', color: '#dc2626' }
];
export default function PokerRoom({ session, issue, user, onSaved }) {
const [participants, setParticipants] = useState([]);
export default function PokerRoom({ session, issue, user, members, roomId, onSaved }) {
const [votedUserKeys, setVotedUserKeys] = useState([]);
const [revealed, setRevealed] = useState(false);
const [votes, setVotes] = useState({});
@ -29,15 +28,8 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
const [error, setError] = useState('');
const socket = useMemo(() => getSocket(), []);
const joinedRef = useRef(null);
useEffect(() => {
function onParticipants(payload) {
if (payload.sessionId !== session.id) return;
setParticipants(payload.participants || []);
setVotedUserKeys(payload.votedUserKeys || []);
}
function onVoteUpdate(payload) {
if (payload.sessionId !== session.id) return;
setVotedUserKeys(payload.votedUserKeys || []);
@ -63,30 +55,18 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
setError(payload?.error || 'Unexpected poker error');
}
socket.on('poker:participants', onParticipants);
socket.on('poker:vote-update', onVoteUpdate);
socket.on('poker:revealed', onRevealed);
socket.on('poker:saved', onSavedPayload);
socket.on('poker:error', onError);
// Guard against React strict mode double-invoking effects
if (joinedRef.current !== session.id) {
joinedRef.current = session.id;
socket.emit('poker:join', {
sessionId: session.id,
userKey: user.key,
userName: user.name
});
}
return () => {
socket.off('poker:participants', onParticipants);
socket.off('poker:vote-update', onVoteUpdate);
socket.off('poker:revealed', onRevealed);
socket.off('poker:saved', onSavedPayload);
socket.off('poker:error', onError);
};
}, [session.id, socket, user.key, user.name]);
}, [session.id, socket]);
function handleVote(value) {
if (revealed) return;
@ -99,7 +79,11 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
}
function handleKick(userKey) {
socket.emit('poker:kick', { sessionId: session.id, userKey });
socket.emit('poker:kick', { roomId, userKey });
}
function handleReveal() {
socket.emit('poker:reveal', { sessionId: session.id });
}
function handleSave() {
@ -145,7 +129,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
{/* Individual votes */}
<div className="flex flex-wrap gap-2">
{Object.entries(votes).map(([userKey, value]) => {
const name = participants.find((p) => p.userKey === userKey)?.userName || userKey;
const name = members.find((p) => p.userKey === userKey)?.userName || userKey;
return (
<span
key={userKey}
@ -176,6 +160,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-7 gap-2">
{CARDS.map((card) => {
const isSelected = myVote === card.value;
@ -199,6 +184,15 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
);
})}
</div>
{votedUserKeys.length > 0 && (
<button
onClick={handleReveal}
className="w-full bg-amber-500 hover:bg-amber-400 text-white font-semibold rounded-sm px-4 py-2 transition-colors cursor-pointer border-none text-sm"
>
Reveal Votes ({votedUserKeys.length}/{members.length})
</button>
)}
</div>
)}
</div>
</div>
@ -212,7 +206,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
</div>
<div className="flex-1 p-4 bg-slate-50 dark:bg-slate-800/50">
<div className="flex flex-col gap-2">
{participants.map((participant) => {
{members.map((participant) => {
const hasVoted = revealed
? votes[participant.userKey] !== undefined
: votedUserKeys.includes(participant.userKey);

View file

@ -11,21 +11,33 @@ export default function Room({ room, user, dark, toggleDark, onBack }) {
const [done, setDone] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [members, setMembers] = useState([]);
const issuesRef = useRef([]);
const pokerUser = { key: user.jiraAccountId, name: user.displayName };
const socket = useMemo(() => getSocket(), []);
const activeSessionIdRef = useRef(null);
// Emit poker:leave when the Room unmounts (user truly leaves the room)
// Room join/leave lifecycle + members listener
useEffect(() => {
return () => {
const sessionId = activeSessionIdRef.current;
if (sessionId) {
socket.emit('poker:leave', { sessionId });
function onMembers(payload) {
if (payload.roomId !== room.id) return;
setMembers(payload.members || []);
}
function onReconnect() {
socket.emit('room:join', { roomId: room.id });
}
socket.on('room:members', onMembers);
socket.io.on('reconnect', onReconnect);
socket.emit('room:join', { roomId: room.id });
return () => {
socket.off('room:members', onMembers);
socket.io.off('reconnect', onReconnect);
socket.emit('room:leave', { roomId: room.id });
};
}, [socket]);
}, [socket, room.id]);
useEffect(() => {
loadIssues();
@ -82,7 +94,6 @@ export default function Room({ room, user, dark, toggleDark, onBack }) {
const activeSessionRef = useRef(null);
activeSessionRef.current = activeSession;
activeSessionIdRef.current = activeSession?.session?.id || null;
const advanceToNext = useCallback((estimate) => {
if (!activeSessionRef.current) return;
@ -189,6 +200,8 @@ export default function Room({ room, user, dark, toggleDark, onBack }) {
session={activeSession.session}
issue={activeSession.issue}
user={pokerUser}
members={members}
roomId={room.id}
onSaved={advanceToNext}
/>
</div>