All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
138 lines
4.9 KiB
JavaScript
138 lines
4.9 KiB
JavaScript
import { Router } from 'express';
|
|
import crypto from 'crypto';
|
|
import { getDb } from '../config/database.js';
|
|
import { authenticateToken } from '../middleware/auth.js';
|
|
import { log } from '../config/logger.js';
|
|
import { getAnalyticsToken } from '../config/bbb.js';
|
|
|
|
const router = Router();
|
|
|
|
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
|
|
router.post('/callback/:uid', async (req, res) => {
|
|
try {
|
|
const { token } = req.query;
|
|
const expectedToken = getAnalyticsToken(req.params.uid);
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
if (!token || token.length !== expectedToken.length ||
|
|
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
|
|
return res.status(403).json({ error: 'Invalid token' });
|
|
}
|
|
|
|
const db = getDb();
|
|
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
|
|
|
|
if (!room) {
|
|
return res.status(404).json({ error: 'Room not found' });
|
|
}
|
|
|
|
if (!room.learning_analytics) {
|
|
return res.status(403).json({ error: 'Learning analytics not enabled for this room' });
|
|
}
|
|
|
|
const data = req.body;
|
|
if (!data || typeof data !== 'object') {
|
|
return res.status(400).json({ error: 'Invalid analytics data' });
|
|
}
|
|
|
|
// Extract meeting info from BBB learning analytics payload
|
|
// Format: { meeting_id, internal_meeting_id, data: { metadata: { meeting_name }, duration, attendees, ... } }
|
|
const meetingId = data.internal_meeting_id || data.meeting_id || room.uid;
|
|
const meetingName = data.data?.metadata?.meeting_name || data.meeting_id || room.uid;
|
|
|
|
// Upsert: update if same meeting already exists (BBB sends updates during the meeting)
|
|
const existing = await db.get(
|
|
'SELECT id FROM learning_analytics_data WHERE room_id = ? AND meeting_id = ?',
|
|
[room.id, meetingId]
|
|
);
|
|
|
|
const jsonData = JSON.stringify(data);
|
|
|
|
if (existing) {
|
|
await db.run(
|
|
'UPDATE learning_analytics_data SET data = ?, meeting_name = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[jsonData, meetingName, existing.id]
|
|
);
|
|
} else {
|
|
await db.run(
|
|
'INSERT INTO learning_analytics_data (room_id, meeting_id, meeting_name, data) VALUES (?, ?, ?, ?)',
|
|
[room.id, meetingId, meetingName, jsonData]
|
|
);
|
|
}
|
|
|
|
log.server.info(`Analytics callback received for room ${room.uid} (meeting: ${meetingId})`);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
log.server.error(`Analytics callback error: ${err.message}`);
|
|
res.status(500).json({ error: 'Error processing analytics data' });
|
|
}
|
|
});
|
|
|
|
// GET /api/analytics/room/:uid - Get analytics for a room (authenticated)
|
|
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
|
try {
|
|
const db = getDb();
|
|
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [req.params.uid]);
|
|
|
|
if (!room) {
|
|
return res.status(404).json({ error: 'Room not found' });
|
|
}
|
|
|
|
// Check access: owner, shared, or admin
|
|
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 analytics for this room' });
|
|
}
|
|
}
|
|
|
|
const rows = await db.all(
|
|
'SELECT id, meeting_id, meeting_name, data, created_at FROM learning_analytics_data WHERE room_id = ? ORDER BY created_at DESC',
|
|
[room.id]
|
|
);
|
|
|
|
const analytics = rows.map(row => ({
|
|
id: row.id,
|
|
meetingId: row.meeting_id,
|
|
meetingName: row.meeting_name,
|
|
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
|
|
createdAt: row.created_at,
|
|
}));
|
|
|
|
res.json({ analytics });
|
|
} catch (err) {
|
|
log.server.error(`Get analytics error: ${err.message}`);
|
|
res.status(500).json({ error: 'Error fetching analytics' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/analytics/:id - Delete analytics entry (authenticated, owner only)
|
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
|
try {
|
|
const db = getDb();
|
|
const entry = await db.get(
|
|
`SELECT la.id, la.room_id, r.user_id
|
|
FROM learning_analytics_data la
|
|
JOIN rooms r ON la.room_id = r.id
|
|
WHERE la.id = ?`,
|
|
[req.params.id]
|
|
);
|
|
|
|
if (!entry) {
|
|
return res.status(404).json({ error: 'Analytics entry not found' });
|
|
}
|
|
|
|
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
|
|
return res.status(403).json({ error: 'No permission to delete this entry' });
|
|
}
|
|
|
|
await db.run('DELETE FROM learning_analytics_data WHERE id = ?', [req.params.id]);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
log.server.error(`Delete analytics error: ${err.message}`);
|
|
res.status(500).json({ error: 'Error deleting analytics entry' });
|
|
}
|
|
});
|
|
|
|
export default router;
|