From 7ef173c49e743b5813a85b54d62e6b72c85c8d0d Mon Sep 17 00:00:00 2001 From: Michelle Date: Fri, 13 Mar 2026 09:46:15 +0100 Subject: [PATCH] feat(analytics): implement learning analytics feature with data collection and display --- server/config/bbb.js | 5 +- server/config/database.js | 34 ++++++ server/index.js | 2 + server/routes/analytics.js | 125 ++++++++++++++++++++++ server/routes/rooms.js | 12 ++- src/components/AnalyticsList.jsx | 178 +++++++++++++++++++++++++++++++ src/i18n/de.json | 23 +++- src/i18n/en.json | 23 +++- src/pages/RoomDetail.jsx | 29 ++++- 9 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 server/routes/analytics.js create mode 100644 src/components/AnalyticsList.jsx 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')}

+
+ ); + } + + return ( +
+ {analytics.map(entry => { + const users = getUserSummary(entry.data); + const isExpanded = expanded[entry.id]; + const totalParticipants = users.length; + const totalMessages = users.reduce((sum, u) => sum + u.messages, 0); + + return ( +
+
+
+
+

+ {entry.meetingName || entry.meetingId} +

+
+ +
+ + + {formatDate(entry.createdAt)} + + + + {totalParticipants} {t('analytics.participants')} + + + + {totalMessages} {t('analytics.messages')} + +
+
+ +
+ + +
+
+ + {isExpanded && users.length > 0 && ( +
+
+ + + + + + + + + + + + + {users.map((u, i) => ( + + + + + + + + + ))} + +
{t('analytics.userName')}{t('analytics.role')} + {t('analytics.talkTime')} + + + + {t('analytics.messages')} + {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}
+
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/i18n/de.json b/src/i18n/de.json index 937a9aa..057c481 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -255,7 +255,10 @@ "shareRemoved": "Freigabe entfernt", "shareFailed": "Freigabe fehlgeschlagen", "shareRemove": "Freigabe entfernen", - "defaultWelcome": "Willkommen zum Meeting!" + "defaultWelcome": "Willkommen zum Meeting!", + "analytics": "Lernanalyse", + "enableAnalytics": "Lernanalyse aktivieren", + "enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting." }, "recordings": { "title": "Aufnahmen", @@ -273,6 +276,24 @@ "publish": "Veröffentlichen", "loadFailed": "Aufnahmen konnten nicht geladen werden" }, + "analytics": { + "title": "Lernanalyse", + "noData": "Keine Analysedaten vorhanden", + "participants": "Teilnehmer", + "messages": "Nachrichten", + "expand": "Details anzeigen", + "collapse": "Details ausblenden", + "deleteConfirm": "Analysedaten wirklich löschen?", + "deleted": "Analysedaten gelöscht", + "deleteFailed": "Fehler beim Löschen", + "userName": "Name", + "role": "Rolle", + "moderator": "Moderator", + "viewer": "Teilnehmer", + "talkTime": "Sprechzeit", + "webcamTime": "Webcam-Zeit", + "reactions": "Reaktionen" + }, "settings": { "title": "Einstellungen", "subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen", diff --git a/src/i18n/en.json b/src/i18n/en.json index a29e978..b47f5fb 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -255,7 +255,10 @@ "shareRemoved": "Share removed", "shareFailed": "Share failed", "shareRemove": "Remove share", - "defaultWelcome": "Welcome to the meeting!" + "defaultWelcome": "Welcome to the meeting!", + "analytics": "Learning Analytics", + "enableAnalytics": "Enable learning analytics", + "enableAnalyticsHint": "Collects participant engagement data after each meeting." }, "recordings": { "title": "Recordings", @@ -273,6 +276,24 @@ "publish": "Publish", "loadFailed": "Recordings could not be loaded" }, + "analytics": { + "title": "Learning Analytics", + "noData": "No analytics data available", + "participants": "Participants", + "messages": "Messages", + "expand": "Show details", + "collapse": "Hide details", + "deleteConfirm": "Really delete analytics data?", + "deleted": "Analytics data deleted", + "deleteFailed": "Error deleting data", + "userName": "Name", + "role": "Role", + "moderator": "Moderator", + "viewer": "Viewer", + "talkTime": "Talk time", + "webcamTime": "Webcam time", + "reactions": "Reactions" + }, "settings": { "title": "Settings", "subtitle": "Manage your profile and settings", diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index 34d0821..d4c7dc2 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -4,13 +4,14 @@ import { ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio, Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck, Shield, Save, UserPlus, X, Share2, Globe, Send, - FileText, Upload, Trash2, Link, + FileText, Upload, Trash2, Link, BarChart3, } from 'lucide-react'; import Modal from '../components/Modal'; import api from '../services/api'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; import RecordingList from '../components/RecordingList'; +import AnalyticsList from '../components/AnalyticsList'; import toast from 'react-hot-toast'; export default function RoomDetail() { @@ -22,6 +23,7 @@ export default function RoomDetail() { const [room, setRoom] = useState(null); const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 }); const [recordings, setRecordings] = useState([]); + const [analytics, setAnalytics] = useState([]); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [activeTab, setActiveTab] = useState('overview'); @@ -93,10 +95,20 @@ export default function RoomDetail() { } }; + const fetchAnalytics = async () => { + try { + const res = await api.get(`/analytics/room/${uid}`); + setAnalytics(res.data.analytics || []); + } catch { + // Ignore + } + }; + useEffect(() => { fetchRoom(); fetchStatus(); fetchRecordings(); + fetchAnalytics(); const interval = setInterval(fetchStatus, 10000); return () => clearInterval(interval); }, [uid]); @@ -183,6 +195,7 @@ export default function RoomDetail() { record_meeting: !!editRoom.record_meeting, guest_access: !!editRoom.guest_access, moderator_code: editRoom.moderator_code, + learning_analytics: !!editRoom.learning_analytics, }); setRoom(res.data.room); setEditRoom(res.data.room); @@ -331,6 +344,7 @@ export default function RoomDetail() { const tabs = [ { id: 'overview', label: t('room.overview'), icon: Play }, { id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length }, + { id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length }, ...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []), ]; @@ -528,6 +542,10 @@ export default function RoomDetail() { )} + {activeTab === 'analytics' && ( + + )} + {activeTab === 'settings' && isOwner && editRoom && (
@@ -621,6 +639,15 @@ export default function RoomDetail() { /> {t('room.allowRecording')} +
{/* Guest access section */}