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);
try {
await leaveSession({
sessionId,
tenantCloudId: socket.user.jiraCloudId,
userKey: socket.user.jiraAccountId
});
await emitSessionState(sessionId, socket.user.jiraCloudId);
} catch {
// best-effort cleanup
}
const roomId = socket.roomId;
if (!roomId) return;
try {
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() {