diff --git a/server/config/bbb.js b/server/config/bbb.js index a820c3c..0338e11 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -73,7 +73,7 @@ function getRoomPasswords(uid) { return { moderatorPW: modPw, attendeePW: attPw }; } -export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) { +export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) { const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); // Build welcome message with guest invite link @@ -111,6 +111,9 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati if (room.access_code) { params.lockSettingsLockOnJoin = 'true'; } + if (analyticsCallbackURL) { + params['meta_analytics-callback-url'] = analyticsCallbackURL; + } // Build optional presentation XML body - escape URL to prevent XML injection let xmlBody = null; diff --git a/server/config/database.js b/server/config/database.js index 8e87430..5f806da 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -768,6 +768,40 @@ export async function initDatabase() { await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL'); } + // ── Learning Analytics table ───────────────────────────────────────────── + if (!(await db.columnExists('rooms', 'learning_analytics'))) { + await db.exec('ALTER TABLE rooms ADD COLUMN learning_analytics INTEGER DEFAULT 0'); + } + + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS learning_analytics_data ( + id SERIAL PRIMARY KEY, + room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + meeting_id TEXT NOT NULL, + meeting_name TEXT, + data JSONB NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id); + CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS learning_analytics_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + meeting_id TEXT NOT NULL, + meeting_name TEXT, + data TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id); + CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id); + `); + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/index.js b/server/index.js index 9be08ef..8e09105 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import calendarRoutes from './routes/calendar.js'; import caldavRoutes from './routes/caldav.js'; import notificationRoutes from './routes/notifications.js'; import oauthRoutes from './routes/oauth.js'; +import analyticsRoutes from './routes/analytics.js'; import { startFederationSync } from './jobs/federationSync.js'; import { startCalendarReminders } from './jobs/calendarReminders.js'; @@ -73,6 +74,7 @@ async function start() { app.use('/api/calendar', calendarRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/oauth', oauthRoutes); + app.use('/api/analytics', analyticsRoutes); // CalDAV — mounted outside /api so calendar clients use a clean path app.use('/caldav', caldavRoutes); // Mount calendar federation receive also under /api/federation for remote instances diff --git a/server/routes/analytics.js b/server/routes/analytics.js new file mode 100644 index 0000000..c217912 --- /dev/null +++ b/server/routes/analytics.js @@ -0,0 +1,125 @@ +import { Router } from 'express'; +import { getDb } from '../config/database.js'; +import { authenticateToken } from '../middleware/auth.js'; +import { log } from '../config/logger.js'; + +const router = Router(); + +// POST /api/analytics/callback/:uid - BBB Learning Analytics callback (no auth, called by BBB) +router.post('/callback/:uid', async (req, res) => { + try { + 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 analytics payload + const meetingId = data.intId || data.extId || room.uid; + const meetingName = data.name || 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; diff --git a/server/routes/rooms.js b/server/routes/rooms.js index ee35163..3e43950 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -282,6 +282,7 @@ router.put('/:uid', authenticateToken, async (req, res) => { record_meeting = COALESCE(?, record_meeting), guest_access = COALESCE(?, guest_access), moderator_code = ?, + learning_analytics = COALESCE(?, learning_analytics), updated_at = CURRENT_TIMESTAMP WHERE uid = ? `, [ @@ -296,6 +297,7 @@ router.put('/:uid', authenticateToken, async (req, res) => { record_meeting !== undefined ? (record_meeting ? 1 : 0) : null, guest_access !== undefined ? (guest_access ? 1 : 0) : null, moderator_code !== undefined ? (moderator_code || null) : room.moderator_code, + learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null, req.params.uid, ]); @@ -480,7 +482,10 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { const presentationUrl = room.presentation_file ? `${baseUrl}/uploads/presentations/${room.presentation_file}` : null; - await createMeeting(room, baseUrl, loginURL, presentationUrl); + const analyticsCallbackURL = room.learning_analytics + ? `${baseUrl}/api/analytics/callback/${room.uid}` + : null; + await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL); const avatarURL = getUserAvatarURL(req, req.user); const displayName = req.user.display_name || req.user.name; const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); @@ -625,7 +630,10 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { if (!running && room.anyone_can_start) { const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; - await createMeeting(room, baseUrl, loginURL); + const analyticsCallbackURL = room.learning_analytics + ? `${baseUrl}/api/analytics/callback/${room.uid}` + : null; + await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL); } // Check moderator code diff --git a/src/components/AnalyticsList.jsx b/src/components/AnalyticsList.jsx new file mode 100644 index 0000000..3b39e95 --- /dev/null +++ b/src/components/AnalyticsList.jsx @@ -0,0 +1,178 @@ +import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp } from 'lucide-react'; +import { useState } from 'react'; +import api from '../services/api'; +import { useLanguage } from '../contexts/LanguageContext'; +import toast from 'react-hot-toast'; + +export default function AnalyticsList({ analytics, onRefresh }) { + const [loading, setLoading] = useState({}); + const [expanded, setExpanded] = useState({}); + const { t, language } = useLanguage(); + + const formatDate = (dateStr) => { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatDurationMs = (ms) => { + if (!ms || ms <= 0) return '0m'; + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; + }; + + const handleDelete = async (id) => { + if (!confirm(t('analytics.deleteConfirm'))) return; + setLoading(prev => ({ ...prev, [id]: 'deleting' })); + try { + await api.delete(`/analytics/${id}`); + toast.success(t('analytics.deleted')); + onRefresh?.(); + } catch { + toast.error(t('analytics.deleteFailed')); + } finally { + setLoading(prev => ({ ...prev, [id]: null })); + } + }; + + const toggleExpand = (id) => { + setExpanded(prev => ({ ...prev, [id]: !prev[id] })); + }; + + // Extract user summary from BBB learning analytics data + const getUserSummary = (data) => { + if (!data?.users) return []; + const usersObj = typeof data.users === 'object' ? data.users : {}; + return Object.values(usersObj).map(u => ({ + name: u.name || '—', + isModerator: u.isModerator || false, + totalTime: u.registeredOn && u.leftOn ? u.leftOn - u.registeredOn : 0, + talkTime: u.talk?.totalTime || 0, + webcamTime: Array.isArray(u.webcams) ? u.webcams.reduce((acc, w) => acc + ((w.stoppedSharingAt || Date.now()) - (w.startedSharingAt || 0)), 0) : 0, + messages: u.totalOfMessages || 0, + emojis: Array.isArray(u.emojis) ? u.emojis.length : 0, + raiseHand: Array.isArray(u.raiseHand) ? u.raiseHand.length : 0, + })); + }; + + if (!analytics || analytics.length === 0) { + return ( +
{t('analytics.noData')}
+| {t('analytics.userName')} | +{t('analytics.role')} | +
+ |
+ + {t('analytics.webcamTime')} + | +
+ |
+ {t('analytics.reactions')} | +
|---|---|---|---|---|---|
| {u.name} | ++ + {u.isModerator ? t('analytics.moderator') : t('analytics.viewer')} + + | +{formatDurationMs(u.talkTime)} | +{formatDurationMs(u.webcamTime)} | +{u.messages} | +{u.emojis + u.raiseHand} | +