diff --git a/backend/src/index.js b/backend/src/index.js index c17429d..6f72a8b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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() { diff --git a/backend/src/services/pokerService.js b/backend/src/services/pokerService.js index b947a2b..7c511b5 100644 --- a/backend/src/services/pokerService.js +++ b/backend/src/services/pokerService.js @@ -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; diff --git a/backend/src/services/roomService.js b/backend/src/services/roomService.js index 1b9fa26..910859c 100644 --- a/backend/src/services/roomService.js +++ b/backend/src/services/roomService.js @@ -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)); diff --git a/frontend/public/logo-256.png b/frontend/public/logo-256.png new file mode 100644 index 0000000..2309dc5 Binary files /dev/null and b/frontend/public/logo-256.png differ diff --git a/frontend/src/components/PokerRoom.jsx b/frontend/src/components/PokerRoom.jsx index c3586a2..be3022d 100644 --- a/frontend/src/components/PokerRoom.jsx +++ b/frontend/src/components/PokerRoom.jsx @@ -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 */}
{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 (
) : ( -
- {CARDS.map((card) => { - const isSelected = myVote === card.value; - return ( - - ); - })} + + {card.label} + + + ); + })} +
+ {votedUserKeys.length > 0 && ( + + )} )} @@ -212,7 +206,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
- {participants.map((participant) => { + {members.map((participant) => { const hasVoted = revealed ? votes[participant.userKey] !== undefined : votedUserKeys.includes(participant.userKey); diff --git a/frontend/src/components/Room.jsx b/frontend/src/components/Room.jsx index 15bcc9f..41bcddb 100644 --- a/frontend/src/components/Room.jsx +++ b/frontend/src/components/Room.jsx @@ -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(() => { + 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 () => { - const sessionId = activeSessionIdRef.current; - if (sessionId) { - socket.emit('poker:leave', { sessionId }); - } + 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} />