Initial commit: Pokerface sprint planning poker for Jira
Full-stack app with Express/Socket.io backend, React frontend, NATS JetStream for state, and Atlassian Jira OAuth integration. Includes security hardening: NATS auth support, KV bucket TTL enforcement, CAS retry for race conditions, error message sanitization, and OAuth state stored in NATS KV. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fdd9ba8d56
36 changed files with 7596 additions and 0 deletions
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
PORT=4010
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# Jira OAuth (Atlassian 3LO)
|
||||
JIRA_CLIENT_ID=
|
||||
JIRA_CLIENT_SECRET=
|
||||
JIRA_OAUTH_REDIRECT_URI=http://localhost:4010/api/jira/oauth/callback
|
||||
JIRA_SCOPES="offline_access read:jira-work write:jira-work read:me"
|
||||
JWT_SECRET=change-me-to-a-random-secret
|
||||
JIRA_STORY_POINTS_FIELD=customfield_10016
|
||||
JIRA_MOCK_FALLBACK=true
|
||||
1648
backend/package-lock.json
generated
Normal file
1648
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
backend/package.json
Normal file
24
backend/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "pokerface-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mickl/socket.io-nats-adapter": "^2.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nats": "^2.28.2",
|
||||
"socket.io": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.7"
|
||||
}
|
||||
}
|
||||
294
backend/src/index.js
Normal file
294
backend/src/index.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import dotenv from 'dotenv';
|
||||
import { Server } from 'socket.io';
|
||||
import natsAdapter from '@mickl/socket.io-nats-adapter';
|
||||
const { createAdapter } = natsAdapter;
|
||||
import { connectNats, getNatsConnection } from './lib/nats.js';
|
||||
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,
|
||||
saveScopedEstimate,
|
||||
submitVote
|
||||
} from './services/pokerService.js';
|
||||
import { updateIssueEstimate } from './services/jiraService.js';
|
||||
import { getSocketUser } from './middleware/auth.js';
|
||||
import { safeError } from './lib/errors.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
|
||||
const port = Number(process.env.PORT || 4010);
|
||||
if (isProd && !process.env.FRONTEND_URL) {
|
||||
throw new Error('FRONTEND_URL must be set in production.');
|
||||
}
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5174';
|
||||
const corsOptions = { origin: frontendUrl, credentials: true };
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
if (!origin) return !isProd;
|
||||
return origin === frontendUrl;
|
||||
}
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
if (isProd) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/api/poker', pokerRoutes);
|
||||
app.use('/api/jira', jiraRoutes);
|
||||
app.use('/api/poker/rooms', roomRoutes);
|
||||
|
||||
if (isProd) {
|
||||
const distPath = path.resolve(__dirname, '../../frontend/dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
cors: corsOptions,
|
||||
allowRequest: (req, callback) => {
|
||||
if (isAllowedOrigin(req.headers.origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback('Origin not allowed', false);
|
||||
}
|
||||
});
|
||||
|
||||
async function emitSessionState(sessionId) {
|
||||
const snapshot = await getSessionSnapshot(sessionId);
|
||||
if (!snapshot) return;
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:participants', {
|
||||
sessionId,
|
||||
participants: snapshot.participants,
|
||||
votedUserKeys: snapshot.votedUserKeys,
|
||||
voteCount: snapshot.voteCount,
|
||||
participantCount: snapshot.participantCount
|
||||
});
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:vote-update', {
|
||||
sessionId,
|
||||
voteCount: snapshot.voteCount,
|
||||
participantCount: snapshot.participantCount,
|
||||
votedUserKeys: snapshot.votedUserKeys,
|
||||
allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount
|
||||
});
|
||||
|
||||
if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') {
|
||||
io.to(`poker:${sessionId}`).emit('poker:revealed', {
|
||||
sessionId,
|
||||
votes: snapshot.votesByUser,
|
||||
average: snapshot.session.averageEstimate,
|
||||
suggestedEstimate: snapshot.session.suggestedEstimate,
|
||||
savedEstimate: snapshot.session.savedEstimate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = getSocketUser(socket);
|
||||
if (!user || !user.jiraCloudId) {
|
||||
socket.emit('poker:error', { error: 'Not authenticated' });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
socket.user = user;
|
||||
|
||||
socket.on('poker:join', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId 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,
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:join failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:vote', async ({ sessionId, vote }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session before voting.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const voteResult = await submitVote({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId,
|
||||
vote
|
||||
});
|
||||
if (!voteResult) {
|
||||
socket.emit('poker:error', { error: 'Unable to submit vote for this session.' });
|
||||
return;
|
||||
}
|
||||
const reveal = await revealIfComplete(sessionId);
|
||||
|
||||
await emitSessionState(sessionId);
|
||||
|
||||
if (reveal?.allVoted) {
|
||||
io.to(`poker:${sessionId}`).emit('poker:revealed', {
|
||||
sessionId,
|
||||
votes: reveal.votesByUser,
|
||||
average: reveal.average,
|
||||
suggestedEstimate: reveal.suggestedEstimate
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:vote failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:save', async ({ sessionId, estimate }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session before saving.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const numericEstimate = Number(estimate);
|
||||
if (!Number.isFinite(numericEstimate)) {
|
||||
socket.emit('poker:error', { error: 'estimate must be a number.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await saveScopedEstimate({
|
||||
sessionId,
|
||||
estimate: numericEstimate,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId
|
||||
});
|
||||
if (!saved) {
|
||||
socket.emit('poker:error', { error: 'Unable to save estimate for this session.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const issueRef = saved.session.issueId || saved.session.issueKey;
|
||||
try {
|
||||
await updateIssueEstimate(socket.user.jiraAccountId, issueRef, numericEstimate, saved.session.boardId);
|
||||
} catch (_jiraError) {
|
||||
// Jira update is best-effort so poker flow continues even when Jira is unavailable.
|
||||
}
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:saved', {
|
||||
sessionId,
|
||||
estimate: numericEstimate,
|
||||
issueKey: saved.session.issueKey
|
||||
});
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId });
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:save failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('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);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:leave failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function start() {
|
||||
const nc = await connectNats();
|
||||
io.adapter(createAdapter(nc));
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Pokerface backend listening on :${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
5
backend/src/lib/errors.js
Normal file
5
backend/src/lib/errors.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export function safeError(error) {
|
||||
return isProd ? 'Internal server error.' : error.message;
|
||||
}
|
||||
78
backend/src/lib/nats.js
Normal file
78
backend/src/lib/nats.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { connect } from 'nats';
|
||||
|
||||
const natsUrl = process.env.NATS_URL || 'nats://localhost:4222';
|
||||
const BUCKET_TTL_MS = 24 * 60 * 60 * 1000; // 24h in milliseconds
|
||||
|
||||
let nc = null;
|
||||
let js = null;
|
||||
let kvOAuth = null;
|
||||
let kvRooms = null;
|
||||
let kvSprintIndex = null;
|
||||
let kvActiveRooms = null;
|
||||
let kvSessions = null;
|
||||
let kvSessionIndex = null;
|
||||
let kvOAuthState = null;
|
||||
|
||||
export async function connectNats() {
|
||||
const opts = { servers: natsUrl };
|
||||
if (process.env.NATS_TOKEN) {
|
||||
opts.token = process.env.NATS_TOKEN;
|
||||
} else if (process.env.NATS_USER) {
|
||||
opts.user = process.env.NATS_USER;
|
||||
opts.pass = process.env.NATS_PASS || '';
|
||||
}
|
||||
nc = await connect(opts);
|
||||
js = nc.jetstream();
|
||||
|
||||
async function getOrCreateKv(name, ttl = BUCKET_TTL_MS) {
|
||||
const kv = await js.views.kv(name, { ttl });
|
||||
// Enforce TTL on existing buckets whose config may be stale
|
||||
const jsm = await nc.jetstreamManager();
|
||||
const info = await jsm.streams.info(`KV_${name}`);
|
||||
if (info.config.max_age !== ttl * 1_000_000) { // NATS uses nanoseconds
|
||||
info.config.max_age = ttl * 1_000_000;
|
||||
await jsm.streams.update(info.config.name, info.config);
|
||||
}
|
||||
return kv;
|
||||
}
|
||||
|
||||
kvOAuth = await getOrCreateKv('oauth');
|
||||
kvRooms = await getOrCreateKv('rooms');
|
||||
kvSprintIndex = await getOrCreateKv('rooms-sprint-index');
|
||||
kvActiveRooms = await getOrCreateKv('active-rooms');
|
||||
kvSessions = await getOrCreateKv('sessions');
|
||||
kvSessionIndex = await getOrCreateKv('session-issue-index');
|
||||
kvOAuthState = await getOrCreateKv('oauth-state', 10 * 60 * 1000); // 10min TTL
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Connected to NATS at', natsUrl);
|
||||
|
||||
return nc;
|
||||
}
|
||||
|
||||
export function getNatsConnection() {
|
||||
return nc;
|
||||
}
|
||||
|
||||
export async function withCasRetry(kv, key, transformFn, maxRetries = 5) {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const entry = await kv.get(key);
|
||||
const current = entry ? entry.json() : null;
|
||||
const revision = entry ? entry.revision : 0;
|
||||
const result = await transformFn(current, revision);
|
||||
if (result === undefined) return current; // no-op
|
||||
try {
|
||||
if (revision === 0) {
|
||||
await kv.create(key, JSON.stringify(result));
|
||||
} else {
|
||||
await kv.update(key, JSON.stringify(result), revision);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) throw err;
|
||||
// CAS conflict — retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { nc, js, kvOAuth, kvRooms, kvSprintIndex, kvActiveRooms, kvSessions, kvSessionIndex, kvOAuthState };
|
||||
74
backend/src/middleware/auth.js
Normal file
74
backend/src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const COOKIE_NAME = 'pokerface_session';
|
||||
const JWT_EXPIRY = '24h';
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function getSecret() {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new Error('Missing JWT_SECRET env var');
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function createSessionToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
jiraAccountId: user.jiraAccountId,
|
||||
jiraCloudId: user.jiraCloudId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
getSecret(),
|
||||
{ expiresIn: JWT_EXPIRY }
|
||||
);
|
||||
}
|
||||
|
||||
export function setSessionCookie(res, token) {
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_MAX_AGE_MS
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res) {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, getSecret());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const token = req.cookies?.[COOKIE_NAME];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const user = verifyToken(token);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
if (!user.jiraAccountId || !user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Invalid session tenant context' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
export function getSocketUser(socket) {
|
||||
const cookieHeader = socket.handshake.headers.cookie;
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
|
||||
if (!match) return null;
|
||||
|
||||
return verifyToken(match[1]);
|
||||
}
|
||||
175
backend/src/routes/jira.js
Normal file
175
backend/src/routes/jira.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import crypto from 'crypto';
|
||||
import express from 'express';
|
||||
import {
|
||||
buildOAuthUrl,
|
||||
exchangeCodeForToken,
|
||||
getProjects,
|
||||
getBoards,
|
||||
getBoardSprints,
|
||||
getSprintIssues,
|
||||
saveOAuthConnection,
|
||||
updateIssueEstimate
|
||||
} from '../services/jiraService.js';
|
||||
import {
|
||||
createSessionToken,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
requireAuth
|
||||
} from '../middleware/auth.js';
|
||||
import { kvOAuthState } from '../lib/nats.js';
|
||||
import { safeError } from '../lib/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/oauth/start', async (_req, res) => {
|
||||
try {
|
||||
const state = crypto.randomBytes(24).toString('hex');
|
||||
await kvOAuthState.put(state, 'valid');
|
||||
|
||||
const url = buildOAuthUrl(state);
|
||||
res.json({ url });
|
||||
} catch (error) {
|
||||
console.error('[oauth] Start failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/oauth/callback', async (req, res) => {
|
||||
const { state, code } = req.query;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const frontendUrl = process.env.FRONTEND_URL || (isProd ? '' : 'http://localhost:5174');
|
||||
|
||||
try {
|
||||
const entry = await kvOAuthState.get(String(state));
|
||||
if (!state || !entry) {
|
||||
return res.status(400).send('Invalid OAuth state.');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).send('Missing OAuth code.');
|
||||
}
|
||||
|
||||
await kvOAuthState.delete(String(state));
|
||||
|
||||
const tokenPayload = await exchangeCodeForToken(String(code));
|
||||
const { connection, profile } = await saveOAuthConnection(tokenPayload);
|
||||
if (!connection.cloudId) {
|
||||
throw new Error('No Jira cloud site available for this account.');
|
||||
}
|
||||
|
||||
const jwt = createSessionToken({
|
||||
jiraAccountId: profile.accountId,
|
||||
jiraCloudId: connection.cloudId,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl
|
||||
});
|
||||
setSessionCookie(res, jwt);
|
||||
|
||||
res.redirect(`${frontendUrl}?auth=success`);
|
||||
} catch (error) {
|
||||
console.error('[oauth] Callback failed:', error.message);
|
||||
const safeMessage = isProd ? 'Jira authentication failed.' : error.message;
|
||||
res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({
|
||||
jiraAccountId: req.user.jiraAccountId,
|
||||
jiraCloudId: req.user.jiraCloudId,
|
||||
displayName: req.user.displayName,
|
||||
avatarUrl: req.user.avatarUrl
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (_req, res) => {
|
||||
clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/projects', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const projects = await getProjects(req.user.jiraAccountId);
|
||||
res.json({ projects });
|
||||
} catch (error) {
|
||||
console.error('[jira] Projects failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/boards', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { projectKeyOrId } = req.query;
|
||||
if (!projectKeyOrId) {
|
||||
return res.status(400).json({ error: 'projectKeyOrId query parameter is required.' });
|
||||
}
|
||||
const boards = await getBoards(req.user.jiraAccountId, projectKeyOrId);
|
||||
res.json({ boards });
|
||||
} catch (error) {
|
||||
console.error('[jira] Boards failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/boards/:boardId/sprints', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sprints = await getBoardSprints(req.user.jiraAccountId, req.params.boardId);
|
||||
res.json({ sprints });
|
||||
} catch (error) {
|
||||
console.error('[jira] Sprints failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
res.json({ connected: true, jiraAccountId: req.user.jiraAccountId });
|
||||
} catch (error) {
|
||||
console.error('[jira] Status failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sprints/:sprintId/issues', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { sprintId } = req.params;
|
||||
const { boardId } = req.query;
|
||||
const issues = await getSprintIssues(req.user.jiraAccountId, String(sprintId), boardId ? String(boardId) : null);
|
||||
res.json({ issues, source: 'jira' });
|
||||
} catch (error) {
|
||||
if (process.env.JIRA_MOCK_FALLBACK === 'true') {
|
||||
const sprintId = req.params.sprintId;
|
||||
const issues = [
|
||||
{ id: `mock-${sprintId}-1`, key: `MOCK-${sprintId}1`, title: 'Authentication hardening', description: 'Harden authentication flow against common attacks.', estimate: 0, status: 'To Do', assignee: 'Alice' },
|
||||
{ id: `mock-${sprintId}-2`, key: `MOCK-${sprintId}2`, title: 'Webhook retries', description: 'Add retry logic with exponential backoff for failed webhooks.', estimate: 0, status: 'To Do', assignee: 'Bob' },
|
||||
{ id: `mock-${sprintId}-3`, key: `MOCK-${sprintId}3`, title: 'Billing summary endpoint', description: 'Create GET /billing/summary endpoint.', estimate: 5, status: 'In Progress', assignee: 'Eve' }
|
||||
];
|
||||
return res.json({ issues, source: 'mock' });
|
||||
}
|
||||
|
||||
console.error('[jira] Sprint issues failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/issues/:issueIdOrKey/estimate', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { issueIdOrKey } = req.params;
|
||||
const { estimate, boardId } = req.body;
|
||||
|
||||
if (estimate === undefined || estimate === null) {
|
||||
return res.status(400).json({ error: 'estimate is required.' });
|
||||
}
|
||||
if (!boardId) {
|
||||
return res.status(400).json({ error: 'boardId is required.' });
|
||||
}
|
||||
|
||||
await updateIssueEstimate(req.user.jiraAccountId, issueIdOrKey, Number(estimate), String(boardId));
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[jira] Estimate update failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
65
backend/src/routes/poker.js
Normal file
65
backend/src/routes/poker.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { canAccessSession, createScopedSession, getSessionSnapshot } from '../services/pokerService.js';
|
||||
import { getRoomById } from '../services/roomService.js';
|
||||
import { safeError } from '../lib/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/sessions', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { issueKey, issueId, issueTitle, roomId } = req.body;
|
||||
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
if (!issueKey || !issueTitle) {
|
||||
return res.status(400).json({ error: 'issueKey and issueTitle are required.' });
|
||||
}
|
||||
if (!roomId) {
|
||||
return res.status(400).json({ error: 'roomId is required.' });
|
||||
}
|
||||
|
||||
const room = await getRoomById(String(roomId), req.user.jiraCloudId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found.' });
|
||||
}
|
||||
|
||||
const snapshot = await createScopedSession({
|
||||
issueKey,
|
||||
issueId,
|
||||
issueTitle,
|
||||
roomId: String(roomId),
|
||||
boardId: room.boardId,
|
||||
tenantCloudId: req.user.jiraCloudId
|
||||
});
|
||||
return res.json({ session: snapshot.session });
|
||||
} catch (error) {
|
||||
console.error('[poker] Create session failed:', error);
|
||||
return res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
if (!await canAccessSession(req.params.sessionId, req.user.jiraCloudId)) {
|
||||
return res.status(404).json({ error: 'Session not found.' });
|
||||
}
|
||||
|
||||
const snapshot = await getSessionSnapshot(req.params.sessionId);
|
||||
if (!snapshot) {
|
||||
return res.status(404).json({ error: 'Session not found.' });
|
||||
}
|
||||
res.json(snapshot);
|
||||
} catch (error) {
|
||||
console.error('[poker] Get session failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
98
backend/src/routes/rooms.js
Normal file
98
backend/src/routes/rooms.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createRoom, getActiveRooms, getRoomById, deleteRoom } from '../services/roomService.js';
|
||||
import { safeError } from '../lib/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const { boardId, projectKey, projectName, sprintId, sprintName } = req.body;
|
||||
|
||||
if (!boardId || !sprintId || !sprintName) {
|
||||
return res.status(400).json({ error: 'boardId, sprintId, and sprintName are required.' });
|
||||
}
|
||||
|
||||
const numericBoardId = Number(boardId);
|
||||
const numericSprintId = Number(sprintId);
|
||||
if (!Number.isInteger(numericBoardId) || !Number.isInteger(numericSprintId) || numericBoardId <= 0 || numericSprintId <= 0) {
|
||||
return res.status(400).json({ error: 'boardId and sprintId must be valid integers.' });
|
||||
}
|
||||
|
||||
const room = await createRoom({
|
||||
cloudId: req.user.jiraCloudId,
|
||||
boardId: numericBoardId,
|
||||
projectKey: projectKey || '',
|
||||
projectName: projectName || '',
|
||||
sprintId: numericSprintId,
|
||||
sprintName,
|
||||
createdByAccountId: req.user.jiraAccountId,
|
||||
createdByName: req.user.displayName
|
||||
});
|
||||
|
||||
res.json({ room });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Create room failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const rooms = await getActiveRooms(req.user.jiraCloudId);
|
||||
res.json({ rooms });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Get active rooms failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:roomId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found.' });
|
||||
}
|
||||
res.json({ room });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Get room failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:roomId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found.' });
|
||||
}
|
||||
|
||||
if (room.createdByAccountId !== req.user.jiraAccountId) {
|
||||
return res.status(403).json({ error: 'Only the room creator can delete this room.' });
|
||||
}
|
||||
|
||||
await deleteRoom(req.params.roomId, req.user.jiraCloudId);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Delete room failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
285
backend/src/services/jiraService.js
Normal file
285
backend/src/services/jiraService.js
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { kvOAuth } from '../lib/nats.js';
|
||||
|
||||
const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
|
||||
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
|
||||
const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources';
|
||||
const ATLASSIAN_ME_URL = 'https://api.atlassian.com/me';
|
||||
|
||||
function getRequiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing required env var: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function getExpiresAt(expiresInSeconds) {
|
||||
if (!expiresInSeconds) return null;
|
||||
return Date.now() + (Number(expiresInSeconds) - 60) * 1000;
|
||||
}
|
||||
|
||||
export function buildOAuthUrl(state) {
|
||||
const clientId = getRequiredEnv('JIRA_CLIENT_ID');
|
||||
const redirectUri = getRequiredEnv('JIRA_OAUTH_REDIRECT_URI');
|
||||
const scope = process.env.JIRA_SCOPES || 'offline_access read:jira-work write:jira-work read:me';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
audience: 'api.atlassian.com',
|
||||
client_id: clientId,
|
||||
scope,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
response_type: 'code',
|
||||
prompt: 'consent'
|
||||
});
|
||||
|
||||
return `${ATLASSIAN_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(code) {
|
||||
const body = {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
|
||||
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
|
||||
code,
|
||||
redirect_uri: getRequiredEnv('JIRA_OAUTH_REDIRECT_URI')
|
||||
};
|
||||
|
||||
const response = await fetch(ATLASSIAN_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getAccessibleResources(accessToken) {
|
||||
const response = await fetch(ATLASSIAN_RESOURCES_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to load Jira resources: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchJiraProfile(accessToken) {
|
||||
const response = await fetch(ATLASSIAN_ME_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to fetch Jira profile: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
accountId: data.account_id,
|
||||
displayName: data.name || data.displayName || 'Unknown',
|
||||
avatarUrl: data.picture || null,
|
||||
email: data.email || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveOAuthConnection(tokenPayload) {
|
||||
const profile = await fetchJiraProfile(tokenPayload.access_token);
|
||||
const resources = await getAccessibleResources(tokenPayload.access_token);
|
||||
const primary = resources[0] || {};
|
||||
|
||||
const connection = {
|
||||
jiraAccountId: profile.accountId,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
email: profile.email,
|
||||
cloudId: primary.id || null,
|
||||
siteUrl: primary.url || null,
|
||||
accessToken: tokenPayload.access_token,
|
||||
refreshToken: tokenPayload.refresh_token || null,
|
||||
scope: tokenPayload.scope || null,
|
||||
expiresAt: getExpiresAt(tokenPayload.expires_in)
|
||||
};
|
||||
|
||||
await kvOAuth.put(`oauth.${profile.accountId}`, JSON.stringify(connection));
|
||||
|
||||
return { connection, profile };
|
||||
}
|
||||
|
||||
async function refreshAccessToken(connection) {
|
||||
if (!connection.refreshToken) {
|
||||
throw new Error('Jira refresh token is missing. Reconnect Jira OAuth.');
|
||||
}
|
||||
|
||||
const body = {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
|
||||
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
|
||||
refresh_token: connection.refreshToken
|
||||
};
|
||||
|
||||
const response = await fetch(ATLASSIAN_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const refreshed = await response.json();
|
||||
|
||||
const updated = {
|
||||
...connection,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || connection.refreshToken,
|
||||
scope: refreshed.scope || connection.scope,
|
||||
expiresAt: getExpiresAt(refreshed.expires_in)
|
||||
};
|
||||
|
||||
await kvOAuth.put(`oauth.${connection.jiraAccountId}`, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function getValidConnection(jiraAccountId) {
|
||||
const entry = await kvOAuth.get(`oauth.${jiraAccountId}`);
|
||||
if (!entry) throw new Error('Jira is not connected for this account.');
|
||||
|
||||
const connection = entry.json();
|
||||
if (!connection.accessToken || !connection.cloudId) {
|
||||
throw new Error('Jira is not connected for this account.');
|
||||
}
|
||||
|
||||
if (!connection.expiresAt || connection.expiresAt > Date.now()) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
return refreshAccessToken(connection);
|
||||
}
|
||||
|
||||
async function jiraFetch(jiraAccountId, path, options = {}) {
|
||||
const connection = await getValidConnection(jiraAccountId);
|
||||
const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Jira API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function normalizeIssue(issue) {
|
||||
return {
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
title: issue.fields?.summary || issue.key,
|
||||
description: issue.fields?.description || null,
|
||||
estimate: issue.fields?.[process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016'] || 0,
|
||||
status: issue.fields?.status?.name || 'Unknown',
|
||||
reporter: issue.fields?.reporter?.displayName || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSprintIssues(jiraAccountId, sprintId, boardId) {
|
||||
let spField = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016';
|
||||
if (boardId) {
|
||||
try {
|
||||
const config = await jiraFetch(jiraAccountId, `/rest/agile/1.0/board/${boardId}/configuration`);
|
||||
const estField = config?.estimation?.field?.fieldId;
|
||||
if (estField) spField = estField;
|
||||
} catch (_e) {
|
||||
// fall back to default
|
||||
}
|
||||
}
|
||||
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
`/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=200&jql=${encodeURIComponent('resolution = Unresolved AND issuetype not in subtaskIssueTypes()')}&fields=summary,status,reporter,description,${spField}`
|
||||
);
|
||||
|
||||
return (response?.issues || []).map((issue) => ({
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
title: issue.fields?.summary || issue.key,
|
||||
description: issue.fields?.description || null,
|
||||
estimate: issue.fields?.[spField] || 0,
|
||||
status: issue.fields?.status?.name || 'Unknown',
|
||||
reporter: issue.fields?.reporter?.displayName || null
|
||||
})).filter((issue) => !issue.estimate);
|
||||
}
|
||||
|
||||
export async function updateIssueEstimate(jiraAccountId, issueIdOrKey, estimate, boardId) {
|
||||
await jiraFetch(jiraAccountId, `/rest/agile/1.0/issue/${issueIdOrKey}/estimation?boardId=${boardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(estimate) })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjects(jiraAccountId) {
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
'/rest/api/3/project/search?maxResults=100&orderBy=name'
|
||||
);
|
||||
return (response?.values || []).map((project) => ({
|
||||
id: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
avatarUrl: project.avatarUrls?.['24x24'] || null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBoards(jiraAccountId, projectKeyOrId) {
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
`/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}&maxResults=100`
|
||||
);
|
||||
return (response?.values || [])
|
||||
.filter((board) => board.type === 'scrum' || board.type === 'simple')
|
||||
.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
type: board.type,
|
||||
projectKey: board.location?.projectKey || null,
|
||||
projectName: board.location?.projectName || null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBoardSprints(jiraAccountId, boardId) {
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
`/rest/agile/1.0/board/${boardId}/sprint?state=active,future`
|
||||
);
|
||||
return (response?.values || []).map((sprint) => ({
|
||||
id: sprint.id,
|
||||
name: sprint.name,
|
||||
state: sprint.state,
|
||||
startDate: sprint.startDate || null,
|
||||
endDate: sprint.endDate || null
|
||||
}));
|
||||
}
|
||||
250
backend/src/services/pokerService.js
Normal file
250
backend/src/services/pokerService.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import crypto from 'crypto';
|
||||
import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js';
|
||||
|
||||
const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100];
|
||||
|
||||
function parseVote(vote) {
|
||||
if (vote === null || vote === undefined) return { rawValue: '', numericValue: null };
|
||||
if (typeof vote === 'number') return { rawValue: String(vote), numericValue: vote };
|
||||
if (typeof vote === 'string') {
|
||||
const normalized = vote.trim();
|
||||
const numeric = Number(normalized);
|
||||
return {
|
||||
rawValue: normalized,
|
||||
numericValue: Number.isFinite(numeric) ? numeric : null
|
||||
};
|
||||
}
|
||||
return { rawValue: String(vote), numericValue: null };
|
||||
}
|
||||
|
||||
function nearestFibonacci(value) {
|
||||
return FIBONACCI.reduce((best, candidate) =>
|
||||
Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function getSession(sessionId) {
|
||||
const entry = await kvSessions.get(sessionId);
|
||||
if (!entry) return null;
|
||||
return deserializeSession(entry.json());
|
||||
}
|
||||
|
||||
async function putSession(session) {
|
||||
await kvSessions.put(session.id, JSON.stringify(serializeSession(session)));
|
||||
}
|
||||
|
||||
async function withSessionCas(sessionId, transformFn) {
|
||||
return withCasRetry(kvSessions, sessionId, (raw) => {
|
||||
const session = deserializeSession(raw);
|
||||
if (!session) return undefined;
|
||||
const result = transformFn(session);
|
||||
if (result === undefined) return undefined;
|
||||
return serializeSession(result);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
issueKey: session.issueKey,
|
||||
issueId: session.issueId,
|
||||
issueTitle: session.issueTitle,
|
||||
boardId: session.boardId,
|
||||
state: session.state,
|
||||
averageEstimate: session.averageEstimate,
|
||||
suggestedEstimate: session.suggestedEstimate,
|
||||
savedEstimate: session.savedEstimate
|
||||
},
|
||||
participants,
|
||||
votesByUser: votes,
|
||||
voteCount: votesMap.size,
|
||||
participantCount: participantsMap.size,
|
||||
votedUserKeys
|
||||
};
|
||||
}
|
||||
|
||||
function issueIndexKey(cloudId, issueKey) {
|
||||
return `${cloudId}.${issueKey}`;
|
||||
}
|
||||
|
||||
export async function createScopedSession({ issueKey, issueId, issueTitle, roomId, boardId, tenantCloudId }) {
|
||||
// Check for existing active session via issue index
|
||||
const indexEntry = await kvSessionIndex.get(issueIndexKey(tenantCloudId, issueKey));
|
||||
if (indexEntry) {
|
||||
const existingId = indexEntry.string();
|
||||
const existing = await getSession(existingId);
|
||||
if (existing && existing.tenantCloudId === tenantCloudId) {
|
||||
if (existing.state === 'VOTING') {
|
||||
return getSnapshot(existing);
|
||||
}
|
||||
// Clean up stale revealed/saved sessions
|
||||
await kvSessions.delete(existingId);
|
||||
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, issueKey));
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const session = {
|
||||
id,
|
||||
issueKey,
|
||||
issueId,
|
||||
issueTitle,
|
||||
roomId,
|
||||
boardId,
|
||||
tenantCloudId,
|
||||
createdAt: Date.now(),
|
||||
state: 'VOTING',
|
||||
participants: new Map(),
|
||||
votes: new Map(),
|
||||
averageEstimate: null,
|
||||
suggestedEstimate: null,
|
||||
savedEstimate: null
|
||||
};
|
||||
await putSession(session);
|
||||
await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id);
|
||||
return getSnapshot(session);
|
||||
}
|
||||
|
||||
export async function getSessionSnapshot(sessionId) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return null;
|
||||
return getSnapshot(session);
|
||||
}
|
||||
|
||||
export async function canAccessSession(sessionId, tenantCloudId) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return false;
|
||||
return session.tenantCloudId === tenantCloudId;
|
||||
}
|
||||
|
||||
export async function isSessionParticipant(sessionId, userKey) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return false;
|
||||
return session.participants.has(userKey);
|
||||
}
|
||||
|
||||
export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) {
|
||||
const result = await withSessionCas(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(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(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;
|
||||
});
|
||||
if (!result) {
|
||||
// Return current snapshot for REVEALED/SAVED states
|
||||
const current = await getSession(sessionId);
|
||||
if (current && (current.state === 'REVEALED' || current.state === 'SAVED')) {
|
||||
return getSnapshot(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return getSnapshot(result);
|
||||
}
|
||||
|
||||
export async function revealIfComplete(sessionId) {
|
||||
const result = await withSessionCas(sessionId, (session) => {
|
||||
const allVoted = session.participants.size > 0 &&
|
||||
session.votes.size === session.participants.size;
|
||||
|
||||
if (!allVoted) return undefined; // no mutation needed
|
||||
|
||||
const numericVotes = [...session.votes.values()]
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
|
||||
const average = numericVotes.length
|
||||
? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length
|
||||
: 0;
|
||||
|
||||
const suggestedEstimate = nearestFibonacci(average);
|
||||
|
||||
session.state = 'REVEALED';
|
||||
session.averageEstimate = average;
|
||||
session.suggestedEstimate = suggestedEstimate;
|
||||
return session;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
// Not all voted — return current snapshot
|
||||
const current = await getSession(sessionId);
|
||||
if (!current) return null;
|
||||
return { ...getSnapshot(current), allVoted: false };
|
||||
}
|
||||
|
||||
const snapshot = getSnapshot(result);
|
||||
return {
|
||||
...snapshot,
|
||||
allVoted: true,
|
||||
average: snapshot.session.averageEstimate,
|
||||
suggestedEstimate: snapshot.session.suggestedEstimate
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.tenantCloudId !== tenantCloudId) return null;
|
||||
if (!session.participants.has(userKey)) return null;
|
||||
|
||||
session.savedEstimate = estimate;
|
||||
session.state = 'SAVED';
|
||||
|
||||
const snapshot = getSnapshot(session);
|
||||
// Clean up — session is done
|
||||
await kvSessions.delete(sessionId);
|
||||
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, session.issueKey));
|
||||
return snapshot;
|
||||
}
|
||||
124
backend/src/services/roomService.js
Normal file
124
backend/src/services/roomService.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import crypto from 'crypto';
|
||||
import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js';
|
||||
|
||||
function assertCloudId(cloudId) {
|
||||
if (!cloudId) {
|
||||
throw new Error('Missing Jira tenant (cloudId). Please reconnect Jira.');
|
||||
}
|
||||
}
|
||||
|
||||
function roomKey(cloudId, id) { return `${cloudId}.${id}`; }
|
||||
function sprintKey(cloudId, sprintId) { return `${cloudId}.${sprintId}`; }
|
||||
|
||||
export async function createRoom(payload) {
|
||||
const {
|
||||
cloudId,
|
||||
boardId,
|
||||
projectKey,
|
||||
projectName,
|
||||
sprintId,
|
||||
sprintName,
|
||||
createdByAccountId,
|
||||
createdByName
|
||||
} = payload;
|
||||
assertCloudId(cloudId);
|
||||
|
||||
// Return existing room for this sprint if one exists
|
||||
try {
|
||||
const existingEntry = await kvSprintIndex.get(sprintKey(cloudId, sprintId));
|
||||
if (existingEntry) {
|
||||
const existingId = existingEntry.string();
|
||||
if (existingId) {
|
||||
const roomEntry = await kvRooms.get(roomKey(cloudId, existingId));
|
||||
if (roomEntry) return roomEntry.json();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stale index data — continue to create a new room
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const room = {
|
||||
id,
|
||||
cloudId,
|
||||
boardId,
|
||||
projectKey,
|
||||
projectName,
|
||||
sprintId,
|
||||
sprintName,
|
||||
createdByAccountId,
|
||||
createdByName
|
||||
};
|
||||
|
||||
await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room));
|
||||
await kvSprintIndex.put(sprintKey(cloudId, sprintId), id);
|
||||
|
||||
// Update active rooms set with CAS retry
|
||||
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
|
||||
const ids = activeIds || [];
|
||||
if (ids.includes(id)) return undefined; // already present, no-op
|
||||
return [...ids, id];
|
||||
});
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
export async function getActiveRooms(cloudId) {
|
||||
assertCloudId(cloudId);
|
||||
|
||||
const activeEntry = await kvActiveRooms.get(cloudId);
|
||||
if (!activeEntry) return [];
|
||||
|
||||
const ids = activeEntry.json();
|
||||
if (!ids.length) return [];
|
||||
|
||||
const staleIds = [];
|
||||
const parsedRooms = [];
|
||||
|
||||
for (const id of ids) {
|
||||
if (!id) { staleIds.push(id); continue; }
|
||||
try {
|
||||
const entry = await kvRooms.get(roomKey(cloudId, id));
|
||||
if (!entry) {
|
||||
staleIds.push(id);
|
||||
continue;
|
||||
}
|
||||
parsedRooms.push(entry.json());
|
||||
} catch {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length) {
|
||||
await withCasRetry(kvActiveRooms, cloudId, (current) => {
|
||||
const currentIds = current || [];
|
||||
return currentIds.filter((id) => !staleIds.includes(id));
|
||||
});
|
||||
}
|
||||
|
||||
return parsedRooms;
|
||||
}
|
||||
|
||||
export async function getRoomById(roomId, cloudId) {
|
||||
assertCloudId(cloudId);
|
||||
const entry = await kvRooms.get(roomKey(cloudId, roomId));
|
||||
return entry ? entry.json() : null;
|
||||
}
|
||||
|
||||
export async function deleteRoom(roomId, cloudId) {
|
||||
assertCloudId(cloudId);
|
||||
const entry = await kvRooms.get(roomKey(cloudId, roomId));
|
||||
if (!entry) return;
|
||||
|
||||
const room = entry.json();
|
||||
await kvRooms.delete(roomKey(cloudId, roomId));
|
||||
await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId));
|
||||
|
||||
// Remove from active rooms set with CAS retry
|
||||
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
|
||||
if (!activeIds) return undefined;
|
||||
const filtered = activeIds.filter((id) => id !== roomId);
|
||||
if (filtered.length === activeIds.length) return undefined; // not found, no-op
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue