Files
redlight/server/routes/analytics.js
Michelle cae84754e4
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m11s
feat: add analytics visibility settings and export functionality
- Added `analytics_visibility` column to `rooms` table to control who can view analytics data.
- Updated analytics routes to check visibility settings before allowing access and export of analytics data.
- Implemented export functionality for analytics in CSV, XLSX, and PDF formats.
- Enhanced `AnalyticsList` component to include export options for analytics entries.
- Updated room detail page to allow setting analytics visibility when creating or editing rooms.
- Added translations for new analytics visibility options and export messages.
2026-03-13 22:36:07 +01:00

310 lines
11 KiB
JavaScript

import { Router } from 'express';
import crypto from 'crypto';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
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, analytics_visibility FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner, shared (if visibility allows), or admin
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
if (room.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to view analytics for this room' });
}
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' });
}
});
// Helper: extract flat attendee rows from analytics entry
function extractRows(entry) {
const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data;
const attendees = data?.data?.attendees || [];
const meetingName = data?.data?.metadata?.meeting_name || entry.meeting_name || '';
const meetingDuration = data?.data?.duration || 0;
const meetingStart = data?.data?.start || '';
const meetingFinish = data?.data?.finish || '';
return attendees.map(a => ({
meetingName,
meetingStart,
meetingFinish,
meetingDuration,
name: a.name || '',
role: a.moderator ? 'Moderator' : 'Viewer',
duration: a.duration || 0,
talkTime: a.engagement?.talk_time || 0,
chats: a.engagement?.chats || 0,
talks: a.engagement?.talks || 0,
raiseHand: a.engagement?.raisehand || 0,
emojis: a.engagement?.emojis || 0,
pollVotes: a.engagement?.poll_votes || 0,
}));
}
const COLUMNS = [
{ header: 'Meeting', key: 'meetingName', width: 25 },
{ header: 'Start', key: 'meetingStart', width: 20 },
{ header: 'End', key: 'meetingFinish', width: 20 },
{ header: 'Meeting Duration (s)', key: 'meetingDuration', width: 18 },
{ header: 'Name', key: 'name', width: 25 },
{ header: 'Role', key: 'role', width: 12 },
{ header: 'Duration (s)', key: 'duration', width: 14 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 14 },
{ header: 'Chats', key: 'chats', width: 8 },
{ header: 'Talks', key: 'talks', width: 8 },
{ header: 'Raise Hand', key: 'raiseHand', width: 12 },
{ header: 'Emojis', key: 'emojis', width: 8 },
{ header: 'Poll Votes', key: 'pollVotes', width: 10 },
];
// GET /api/analytics/:id/export/:format - Export a single analytics entry (csv, xlsx, pdf)
router.get('/:id/export/:format', authenticateToken, async (req, res) => {
try {
const format = req.params.format;
if (!['csv', 'xlsx', 'pdf'].includes(format)) {
return res.status(400).json({ error: 'Unsupported format. Use csv, xlsx, or pdf.' });
}
const db = getDb();
const entry = await db.get(
`SELECT la.*, r.user_id, r.analytics_visibility
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') {
if (entry.analytics_visibility !== 'shared') {
return res.status(403).json({ error: 'No permission to export this entry' });
}
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [entry.room_id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to export this entry' });
}
}
const rows = extractRows(entry);
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
if (format === 'csv') {
const header = COLUMNS.map(c => c.header).join(',');
const csvRows = rows.map(r =>
COLUMNS.map(c => {
const val = r[c.key];
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(',')
);
const csv = [header, ...csvRows].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.csv"`);
return res.send('\uFEFF' + csv); // BOM for Excel UTF-8
}
if (format === 'xlsx') {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Analytics');
sheet.columns = COLUMNS;
rows.forEach(r => sheet.addRow(r));
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.xlsx"`);
await workbook.xlsx.write(res);
return res.end();
}
if (format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`);
doc.pipe(res);
// Title
doc.fontSize(16).text(entry.meeting_name || 'Learning Analytics', { align: 'center' });
doc.moveDown(0.5);
// Table header columns for PDF (subset for readability)
const pdfCols = [
{ header: 'Name', key: 'name', width: 120 },
{ header: 'Role', key: 'role', width: 65 },
{ header: 'Duration (s)', key: 'duration', width: 75 },
{ header: 'Talk Time (s)', key: 'talkTime', width: 75 },
{ header: 'Chats', key: 'chats', width: 50 },
{ header: 'Talks', key: 'talks', width: 50 },
{ header: 'Raise Hand', key: 'raiseHand', width: 65 },
{ header: 'Emojis', key: 'emojis', width: 50 },
{ header: 'Poll Votes', key: 'pollVotes', width: 60 },
];
const startX = doc.x;
let y = doc.y;
// Header
doc.fontSize(8).font('Helvetica-Bold');
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(col.header, x, y, { width: col.width, align: 'left' });
});
y += 14;
doc.moveTo(startX, y).lineTo(startX + pdfCols.reduce((s, c) => s + c.width, 0), y).stroke();
y += 4;
// Rows
doc.font('Helvetica').fontSize(8);
rows.forEach(r => {
if (y > doc.page.height - 50) {
doc.addPage();
y = 30;
}
pdfCols.forEach((col, i) => {
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
doc.text(String(r[col.key]), x, y, { width: col.width, align: 'left' });
});
y += 14;
});
doc.end();
return;
}
} catch (err) {
log.server.error(`Export analytics error: ${err.message}`);
res.status(500).json({ error: 'Error exporting analytics data' });
}
});
export default router;