screentinker/server/routes/reports.js

180 lines
7.5 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
// Phase 2.2g: scope reports to the caller's current workspace.
// No platform_admin bypass - cross-workspace reporting comes from
// switch-workspace, not a magic role-based "see all" path. This matches
// the precedent set in devices.js.
function getWorkspaceDeviceFilter(req) {
if (!req.workspaceId) return { sql: ' AND 1=0', params: [] }; // no workspace -> empty result
return { sql: ' AND d.workspace_id = ?', params: [req.workspaceId] };
}
function getWorkspaceDeviceSubquery(req) {
if (!req.workspaceId) return { sql: ' AND device_id IN (SELECT id FROM devices WHERE 1=0)', params: [] };
return { sql: ' AND device_id IN (SELECT id FROM devices WHERE workspace_id = ?)', params: [req.workspaceId] };
}
// Query play logs
router.get('/plays', (req, res) => {
const { device_id, content_id, start, end, limit: lim } = req.query;
const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT pl.*, d.name as device_name
FROM play_logs pl
JOIN devices d ON pl.device_id = d.id
WHERE 1=1${scope.sql}`;
const params = [...scope.params];
if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); }
if (content_id) { sql += ' AND pl.content_id = ?'; params.push(content_id); }
if (start) { sql += ' AND pl.started_at >= ?'; params.push(Math.floor(new Date(start).getTime() / 1000)); }
if (end) { sql += ' AND pl.started_at <= ?'; params.push(Math.floor(new Date(end).getTime() / 1000)); }
sql += ' ORDER BY pl.started_at DESC LIMIT ?';
params.push(parseInt(lim) || 500);
res.json(db.prepare(sql).all(...params));
});
// Summary report
router.get('/summary', (req, res) => {
const { device_id, start, end, group_by } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
// Phase 2.2g: workspace-scope all summary queries, no admin bypass.
const wsScope = getWorkspaceDeviceSubquery(req);
let deviceFilter = wsScope.sql;
const params = [startEpoch, endEpoch, ...wsScope.params];
if (device_id) { deviceFilter += ' AND device_id = ?'; params.push(device_id); }
// Overall stats
const overall = db.prepare(`
SELECT COUNT(*) as total_plays,
COALESCE(SUM(duration_sec), 0) as total_duration_sec,
COUNT(DISTINCT content_id) as unique_content,
COUNT(DISTINCT device_id) as unique_devices,
AVG(duration_sec) as avg_duration_sec
FROM play_logs
WHERE started_at >= ? AND started_at <= ? ${deviceFilter}
`).get(...params);
// By content
const byContent = db.prepare(`
SELECT content_id, content_name, COUNT(*) as plays,
COALESCE(SUM(duration_sec), 0) as total_seconds,
SUM(completed) as completed_plays
FROM play_logs
WHERE started_at >= ? AND started_at <= ? ${deviceFilter}
GROUP BY content_id, content_name
ORDER BY plays DESC LIMIT 50
`).all(...params);
// By device
const byDevice = db.prepare(`
SELECT pl.device_id, d.name as device_name, COUNT(*) as plays,
COALESCE(SUM(pl.duration_sec), 0) as total_seconds
FROM play_logs pl
JOIN devices d ON pl.device_id = d.id
WHERE pl.started_at >= ? AND pl.started_at <= ? ${deviceFilter}
GROUP BY pl.device_id
ORDER BY plays DESC
`).all(...params);
// By hour of day
const byHour = db.prepare(`
SELECT CAST(strftime('%H', started_at, 'unixepoch', 'localtime') AS INTEGER) as hour,
COUNT(*) as plays
FROM play_logs
WHERE started_at >= ? AND started_at <= ? ${deviceFilter}
GROUP BY hour ORDER BY hour
`).all(...params);
// By day
const byDay = db.prepare(`
SELECT date(started_at, 'unixepoch', 'localtime') as day, COUNT(*) as plays,
COALESCE(SUM(duration_sec), 0) as total_seconds
FROM play_logs
WHERE started_at >= ? AND started_at <= ? ${deviceFilter}
GROUP BY day ORDER BY day
`).all(...params);
res.json({
period: { start: new Date(startEpoch * 1000).toISOString(), end: new Date(endEpoch * 1000).toISOString() },
overall: {
total_plays: overall.total_plays,
total_hours: Math.round(overall.total_duration_sec / 3600 * 10) / 10,
unique_content: overall.unique_content,
unique_devices: overall.unique_devices,
avg_duration_sec: Math.round(overall.avg_duration_sec || 0),
},
by_content: byContent,
by_device: byDevice,
by_hour: byHour,
by_day: byDay,
});
});
// Export CSV. Phase 2.2g: workspace-scoped. Previously this route had no scope
// filter at all - any authenticated user could export the entire platform's
// play_logs. The added WHERE clause closes that pre-existing cross-tenant leak.
router.get('/export', (req, res) => {
const { device_id, start, end } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : 0;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT pl.*, d.name as device_name FROM play_logs pl JOIN devices d ON pl.device_id = d.id WHERE pl.started_at >= ? AND pl.started_at <= ?${scope.sql}`;
const params = [startEpoch, endEpoch, ...scope.params];
if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); }
sql += ' ORDER BY pl.started_at ASC';
const rows = db.prepare(sql).all(...params);
const header = 'Device,Content,Started,Ended,Duration (sec),Completed\n';
const csv = header + rows.map(r => {
const started = new Date(r.started_at * 1000).toISOString();
const ended = r.ended_at ? new Date(r.ended_at * 1000).toISOString() : '';
return `"${r.device_name}","${r.content_name}","${started}","${ended}",${r.duration_sec || ''},${r.completed ? 'Yes' : 'No'}`;
}).join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=proof-of-play.csv');
res.send(csv);
});
// Device uptime report. Phase 2.2g: workspace-scoped. Previously this route
// had no scope filter at all - any authenticated user could see telemetry
// summaries for every device on the platform. The added WHERE clause closes
// that pre-existing cross-tenant leak.
router.get('/uptime', (req, res) => {
const { device_id, start, end } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT dt.device_id, d.name as device_name,
COUNT(*) as heartbeat_count,
MIN(dt.reported_at) as first_seen,
MAX(dt.reported_at) as last_seen
FROM device_telemetry dt
JOIN devices d ON dt.device_id = d.id
WHERE dt.reported_at >= ? AND dt.reported_at <= ?${scope.sql}`;
const params = [startEpoch, endEpoch, ...scope.params];
if (device_id) { sql += ' AND dt.device_id = ?'; params.push(device_id); }
sql += ' GROUP BY dt.device_id ORDER BY d.name';
const uptimeData = db.prepare(sql).all(...params);
// Estimate uptime: heartbeats are every 15s, so heartbeat_count * 15 / total_period
const totalPeriod = endEpoch - startEpoch;
uptimeData.forEach(d => {
d.estimated_uptime_pct = Math.min(100, Math.round((d.heartbeat_count * 15 / totalPeriod) * 100 * 10) / 10);
});
res.json(uptimeData);
});
module.exports = router;