feat(analytics): implement learning analytics feature with data collection and display
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
125
server/routes/analytics.js
Normal file
125
server/routes/analytics.js
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user