Track participants at room level to fix premature auto-reveal
All checks were successful
Build & Push Container Image / build (push) Successful in 8s
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:
parent
062510b6c6
commit
2d78b9ff07
6 changed files with 247 additions and 190 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue