diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 9d31b36..fed226b 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -179,6 +179,62 @@ router.post('/', authenticateToken, (req, res) => { } }); +// Bulk session operations (admin only) +router.post('/bulk', authenticateToken, (req, res) => { + try { + const { action, ids } = req.body; + + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'ids must be a non-empty array' }); + } + + const validActions = ['archive', 'unarchive', 'delete']; + if (!validActions.includes(action)) { + return res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); + } + + const placeholders = ids.map(() => '?').join(','); + const sessions = db.prepare( + `SELECT id, is_active FROM sessions WHERE id IN (${placeholders})` + ).all(...ids); + + if (sessions.length !== ids.length) { + const foundIds = sessions.map(s => s.id); + const missingIds = ids.filter(id => !foundIds.includes(id)); + return res.status(404).json({ error: 'Some sessions not found', missingIds }); + } + + if (action === 'archive' || action === 'delete') { + const activeIds = sessions.filter(s => s.is_active === 1).map(s => s.id); + if (activeIds.length > 0) { + return res.status(400).json({ + error: `Cannot ${action} active sessions. Close them first.`, + activeIds + }); + } + } + + const bulkOperation = db.transaction(() => { + if (action === 'archive') { + db.prepare(`UPDATE sessions SET archived = 1 WHERE id IN (${placeholders})`).run(...ids); + } else if (action === 'unarchive') { + db.prepare(`UPDATE sessions SET archived = 0 WHERE id IN (${placeholders})`).run(...ids); + } else if (action === 'delete') { + db.prepare(`DELETE FROM chat_logs WHERE session_id IN (${placeholders})`).run(...ids); + db.prepare(`DELETE FROM live_votes WHERE session_id IN (${placeholders})`).run(...ids); + db.prepare(`DELETE FROM session_games WHERE session_id IN (${placeholders})`).run(...ids); + db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids); + } + }); + + bulkOperation(); + + res.json({ success: true, affected: ids.length }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Close/finalize session (admin only) router.post('/:id/close', authenticateToken, (req, res) => { try { diff --git a/tests/api/session-archive.test.js b/tests/api/session-archive.test.js index d6ae450..ecccf26 100644 --- a/tests/api/session-archive.test.js +++ b/tests/api/session-archive.test.js @@ -171,3 +171,134 @@ describe('POST /api/sessions/:id/unarchive', () => { expect(res.status).toBe(404); }); }); + +describe('POST /api/sessions/bulk', () => { + beforeEach(() => { + cleanDb(); + }); + + test('bulk archive multiple sessions', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + const s2 = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [s1.id, s2.id] }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.affected).toBe(2); + + const list = await request(app).get('/api/sessions?filter=archived'); + expect(list.body).toHaveLength(2); + }); + + test('bulk unarchive multiple sessions', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + const s2 = seedSession({ is_active: 0, notes: null }); + const db = require('../helpers/test-utils').db; + db.prepare('UPDATE sessions SET archived = 1 WHERE id IN (?, ?)').run(s1.id, s2.id); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'unarchive', ids: [s1.id, s2.id] }); + + expect(res.status).toBe(200); + expect(res.body.affected).toBe(2); + + const list = await request(app).get('/api/sessions?filter=all'); + expect(list.body.every(s => s.archived === 0)).toBe(true); + }); + + test('bulk delete multiple sessions', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + const s2 = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'delete', ids: [s1.id, s2.id] }); + + expect(res.status).toBe(200); + expect(res.body.affected).toBe(2); + + const list = await request(app).get('/api/sessions?filter=all'); + expect(list.body).toHaveLength(0); + }); + + test('rejects archive of active sessions', async () => { + const active = seedSession({ is_active: 1, notes: null }); + const closed = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [active.id, closed.id] }); + + expect(res.status).toBe(400); + expect(res.body.activeIds).toContain(active.id); + + const list = await request(app).get('/api/sessions?filter=all'); + expect(list.body).toHaveLength(2); + expect(list.body.every(s => s.archived === 0)).toBe(true); + }); + + test('rejects delete of active sessions', async () => { + const active = seedSession({ is_active: 1, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'delete', ids: [active.id] }); + + expect(res.status).toBe(400); + }); + + test('returns 400 for empty ids array', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [] }); + + expect(res.status).toBe(400); + }); + + test('returns 400 for invalid action', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'nuke', ids: [1] }); + + expect(res.status).toBe(400); + }); + + test('returns 400 for non-array ids', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: 'not-array' }); + + expect(res.status).toBe(400); + }); + + test('returns 404 if any session ID does not exist', async () => { + const s1 = seedSession({ is_active: 0, notes: null }); + + const res = await request(app) + .post('/api/sessions/bulk') + .set('Authorization', getAuthHeader()) + .send({ action: 'archive', ids: [s1.id, 9999] }); + + expect(res.status).toBe(404); + }); + + test('returns 401 without auth', async () => { + const res = await request(app) + .post('/api/sessions/bulk') + .send({ action: 'archive', ids: [1] }); + + expect(res.status).toBe(401); + }); +});