import { Router } from 'express'; import { authenticateToken } from '../middleware/auth.js'; import { getDb } from '../config/database.js'; import { log } from '../config/logger.js'; import { getRecordings, getRecordingByRecordId, deleteRecording, publishRecording, } from '../config/bbb.js'; const router = Router(); /** * Format a raw BBB recording into a normalised object. */ function formatBbbRecording(rec, fallbackName) { const playback = rec.playback?.format; let formats = []; if (playback) { formats = Array.isArray(playback) ? playback : [playback]; } return { recordID: rec.recordID, meetingID: rec.meetingID, name: rec.name || fallbackName || 'Recording', state: rec.state, published: rec.published === 'true', startTime: rec.startTime, endTime: rec.endTime, participants: rec.participants, size: rec.size, formats: formats.map(f => ({ type: f.type, url: f.url, length: f.length, size: f.size, })), metadata: rec.metadata || {}, }; } /** * Format a DB row into the same shape the frontend expects. */ function formatDbRecording(row) { return { recordID: row.record_id, meetingID: row.meeting_id, name: row.name || 'Recording', state: row.state, published: row.published === 1, startTime: row.start_time, endTime: row.end_time, participants: row.participants, size: row.size, formats: JSON.parse(row.formats || '[]'), metadata: JSON.parse(row.metadata || '{}'), fromCache: true, }; } /** * Upsert a single formatted recording into the DB. * - If the record_id already exists AND is the same recording, merge new formats. * - If it's a new recording or a different one with the same record_id, overwrite. */ async function upsertRecording(db, rec) { const existing = await db.get('SELECT * FROM recordings WHERE record_id = ?', [rec.recordID]); const formatsJson = JSON.stringify(rec.formats); const metadataJson = JSON.stringify(rec.metadata || {}); if (existing) { // Verify it's still the same recording (same startTime) if (String(rec.startTime) === String(existing.start_time)) { // Merge formats: keep existing formats, add any new types const existingFormats = JSON.parse(existing.formats || '[]'); const existingTypes = new Set(existingFormats.map(f => f.type)); const mergedFormats = [...existingFormats]; for (const f of rec.formats) { if (existingTypes.has(f.type)) { // Update URL for existing format type (server may have changed) const idx = mergedFormats.findIndex(ef => ef.type === f.type); if (idx !== -1) mergedFormats[idx] = f; } else { // New format type added mergedFormats.push(f); } } await db.run( `UPDATE recordings SET name = ?, state = ?, published = ?, end_time = ?, participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?`, [rec.name, rec.state, rec.published ? 1 : 0, rec.endTime, rec.participants, rec.size, JSON.stringify(mergedFormats), metadataJson, rec.recordID] ); } else { // Different recording with same record_id – overwrite completely await db.run( `UPDATE recordings SET meeting_id = ?, name = ?, state = ?, published = ?, start_time = ?, end_time = ?, participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?`, [rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0, rec.startTime, rec.endTime, rec.participants, rec.size, formatsJson, metadataJson, rec.recordID] ); } } else { // Completely new recording – insert await db.run( `INSERT INTO recordings (record_id, meeting_id, name, state, published, start_time, end_time, participants, size, formats, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [rec.recordID, rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0, rec.startTime, rec.endTime, rec.participants, rec.size, formatsJson, metadataJson] ); } } /** * Core logic: fetch recordings from BBB, sync with DB, fall back to DB on error. * Returns an array of formatted recordings. */ async function fetchAndSyncRecordings(meetingID, fallbackName) { const db = getDb(); let bbbRecordings = null; try { const raw = await getRecordings(meetingID || undefined); bbbRecordings = raw.map(rec => formatBbbRecording(rec, fallbackName)); } catch (err) { log.recordings.warn(`BBB API unreachable, falling back to cached recordings: ${err.message}`); } if (bbbRecordings !== null) { // BBB API was reachable – sync each recording into the DB for (const rec of bbbRecordings) { try { await upsertRecording(db, rec); } catch (err) { log.recordings.error(`Failed to cache recording ${rec.recordID}: ${err.message}`); } } // For recordings in DB that were NOT returned by BBB (possibly deleted on server // or server changed), keep them in DB with their cached URLs – don't delete. // However, we return the BBB result merged with any DB-only recordings. const bbbIds = new Set(bbbRecordings.map(r => r.recordID)); const params = meetingID ? [meetingID] : []; const query = meetingID ? 'SELECT * FROM recordings WHERE meeting_id = ?' : 'SELECT * FROM recordings'; const dbRows = await db.all(query, params); // Add DB-only recordings (not on current BBB server) with cached URLs for (const row of dbRows) { if (!bbbIds.has(row.record_id)) { bbbRecordings.push(formatDbRecording(row)); } } return bbbRecordings; } else { // BBB API unreachable – serve everything from DB const params = meetingID ? [meetingID] : []; const query = meetingID ? 'SELECT * FROM recordings WHERE meeting_id = ?' : 'SELECT * FROM recordings'; const rows = await db.all(query, params); return rows.map(formatDbRecording); } } // GET /api/recordings - Get recordings for a room (by meetingID/uid) router.get('/', authenticateToken, async (req, res) => { try { const { meetingID } = req.query; // M11: verify user has access to the room if a meetingID is specified if (meetingID) { const db = getDb(); const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [meetingID]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } if (room.user_id !== req.user.id && req.user.role !== 'admin') { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission to view recordings for this room' }); } } } else if (req.user.role !== 'admin') { // Non-admins must specify a meetingID return res.status(400).json({ error: 'meetingID query parameter is required' }); } const formatted = await fetchAndSyncRecordings(meetingID); res.json({ recordings: formatted }); } catch (err) { log.recordings.error(`Get recordings error: ${err.message}`); res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] }); } }); // GET /api/recordings/room/:uid - Get recordings for a specific room router.get('/room/:uid', authenticateToken, async (req, res) => { try { const db = getDb(); const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } // H9: verify requesting user has access to this room if (room.user_id !== req.user.id && req.user.role !== 'admin') { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission to view recordings for this room' }); } } const formatted = await fetchAndSyncRecordings(room.uid, room.name); res.json({ recordings: formatted }); } catch (err) { log.recordings.error(`Get room recordings error: ${err.message}`); res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] }); } }); // DELETE /api/recordings/:recordID router.delete('/:recordID', authenticateToken, async (req, res) => { try { const db = getDb(); // M14 fix: look up the recording from BBB to find its meetingID (room UID), // then verify the user owns or shares that room. if (req.user.role !== 'admin') { // Try BBB first, fall back to DB cache let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null); if (!rec) { const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]); if (dbRow) { rec = { meetingID: dbRow.meeting_id }; } } if (!rec) { return res.status(404).json({ error: 'Recording not found' }); } const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } if (room.user_id !== req.user.id) { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission to delete this recording' }); } } } // Try to delete on BBB (may fail if server changed – that's OK) try { await deleteRecording(req.params.recordID); } catch (err) { log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`); } // Always remove from local cache await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]); res.json({ message: 'Recording deleted' }); } catch (err) { log.recordings.error(`Delete recording error: ${err.message}`); res.status(500).json({ error: 'Recording could not be deleted' }); } }); // PUT /api/recordings/:recordID/publish router.put('/:recordID/publish', authenticateToken, async (req, res) => { try { const db = getDb(); // M14 fix: look up the recording from BBB to find its meetingID (room UID), // then verify the user owns or shares that room. if (req.user.role !== 'admin') { let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null); if (!rec) { const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]); if (dbRow) { rec = { meetingID: dbRow.meeting_id }; } } if (!rec) { return res.status(404).json({ error: 'Recording not found' }); } const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } if (room.user_id !== req.user.id) { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission to update this recording' }); } } } const { publish } = req.body; // Try BBB API try { await publishRecording(req.params.recordID, publish); } catch (err) { log.recordings.warn(`BBB publishRecording failed: ${err.message}`); } // Update DB cache await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?', [publish ? 1 : 0, req.params.recordID]); res.json({ message: publish ? 'Recording published' : 'Recording unpublished' }); } catch (err) { log.recordings.error(`Publish recording error: ${err.message}`); res.status(500).json({ error: 'Recording could not be updated' }); } }); export default router;