const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); // Phase 2.2l: workspace-aware access. Drops the previous listVisibleWalls / // userCanAccessWall helpers - the admin/team_members branches there were // dead code after the Phase 2.1 role rename (no users carry role='admin' // anymore; team_members is a vestigial table from the pre-workspace model). const { accessContext } = require('../lib/tenancy'); // Load a wall + access context. Returns the wall row or null after sending // 403/404. requireWrite=true also denies workspace_viewer. function loadWallAccess(req, res, requireWrite) { const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); if (!wall) { res.status(404).json({ error: 'Wall not found' }); return null; } if (!wall.workspace_id) { res.status(403).json({ error: 'Wall not assigned to a workspace' }); return null; } const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(wall.workspace_id); const ctx = ws && accessContext(req.user.id, req.user.role, ws); if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; } if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') { res.status(403).json({ error: 'Read-only access' }); return null; } req.wall = wall; req.wallCtx = ctx; return wall; } function requireWallRead(req, res, next) { if (!loadWallAccess(req, res, false)) return; next(); } function requireWallWrite(req, res, next) { if (!loadWallAccess(req, res, true)) return; next(); } // List walls (with attached devices). Phase 2.2l: scoped to caller's // current workspace. router.get('/', (req, res) => { if (!req.workspaceId) return res.json([]); const walls = db.prepare('SELECT * FROM video_walls WHERE workspace_id = ? ORDER BY created_at DESC').all(req.workspaceId); const devStmt = db.prepare(` SELECT vwd.*, d.name as device_name, d.status as device_status FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col `); walls.forEach(w => { w.devices = devStmt.all(w.id); }); res.json(walls); }); // Notify dashboard clients to re-fetch walls/devices. Phase 2.3: scoped to // the wall's workspace room so other tenants don't get a stray refresh ping. function notifyDashboards(req, workspaceId) { try { const io = req.app.get('io'); if (!io || !workspaceId) return; const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms'); emitToWorkspace(io.of('/dashboard'), workspaceRoom(workspaceId), 'dashboard:wall-changed', null); } catch (e) { /* silent */ } } function loadWallWithDevices(id) { const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(id); if (!wall) return null; wall.devices = db.prepare(` SELECT vwd.*, d.name as device_name, d.status as device_status FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col `).all(id); return wall; } // Push a fresh wall-aware playlist payload to one device. function pushWallPayloadToDevice(req, deviceId) { try { const io = req.app.get('io'); if (!io) return; const { buildPlaylistPayload } = require('../ws/deviceSocket'); io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId)); } catch (e) { /* silent */ } } function pushToWallMembers(req, wallId) { const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wallId); for (const m of members) pushWallPayloadToDevice(req, m.device_id); } // Get wall with devices router.get('/:id', requireWallRead, (req, res) => { res.json(loadWallWithDevices(req.wall.id)); }); // Create wall. Phase 2.2l: stamps workspace_id; closes pre-existing leak // where playlist_id was accepted with NO cross-tenant check (caller could // embed a foreign workspace's playlist into a wall they create). router.post('/', (req, res) => { if (!req.workspaceId) return res.status(400).json({ error: 'No active workspace' }); const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.workspaceId); const ctx = ws && accessContext(req.user.id, req.user.role, ws); if (!ctx) return res.status(403).json({ error: 'Access denied' }); if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') { return res.status(403).json({ error: 'Read-only access' }); } const { name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, playlist_id } = req.body; if (!name) return res.status(400).json({ error: 'name required' }); if (playlist_id) { const pl = db.prepare('SELECT workspace_id FROM playlists WHERE id = ?').get(playlist_id); if (!pl) return res.status(404).json({ error: 'Playlist not found' }); if (pl.workspace_id !== req.workspaceId) { return res.status(403).json({ error: 'Playlist is not in this workspace' }); } } const id = uuidv4(); db.prepare(` INSERT INTO video_walls (id, user_id, workspace_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, playlist_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, req.user.id, req.workspaceId, name, grid_cols || 2, grid_rows || 1, bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null); const wall = loadWallWithDevices(id); notifyDashboards(req, req.workspaceId); res.status(201).json(wall); }); // Update wall (name, grid, bezels, playlist, leader, sync_mode). Phase 2.2l: // closes pre-existing leaks where playlist_id / content_id / leader_device_id // were accepted without any cross-tenant check. router.put('/:id', requireWallWrite, (req, res) => { const wall = req.wall; if (req.body.playlist_id) { const pl = db.prepare('SELECT workspace_id FROM playlists WHERE id = ?').get(req.body.playlist_id); if (!pl) return res.status(404).json({ error: 'Playlist not found' }); if (pl.workspace_id !== wall.workspace_id) { return res.status(403).json({ error: 'Playlist is not in this workspace' }); } } if (req.body.content_id) { const c = db.prepare('SELECT workspace_id FROM content WHERE id = ?').get(req.body.content_id); if (!c) return res.status(404).json({ error: 'Content not found' }); if (c.workspace_id && c.workspace_id !== wall.workspace_id) { return res.status(403).json({ error: 'Content is not in this workspace' }); } } if (req.body.leader_device_id) { const d = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.body.leader_device_id); if (!d) return res.status(404).json({ error: 'Leader device not found' }); if (d.workspace_id !== wall.workspace_id) { return res.status(403).json({ error: 'Leader device is not in this workspace' }); } } const fields = ['name', 'grid_cols', 'grid_rows', 'bezel_h_mm', 'bezel_v_mm', 'screen_w_mm', 'screen_h_mm', 'sync_mode', 'leader_device_id', 'content_id', 'playlist_id', 'player_x', 'player_y', 'player_width', 'player_height']; const updates = []; const values = []; for (const f of fields) { if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } } if (updates.length > 0) { updates.push("updated_at = strftime('%s','now')"); values.push(req.params.id); db.prepare(`UPDATE video_walls SET ${updates.join(', ')} WHERE id = ?`).run(...values); } // If playlist changed, propagate to every member device's playlist_id so the // existing buildPlaylistPayload picks up the right items. if (req.body.playlist_id !== undefined) { const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id); const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); for (const m of members) stmt.run(req.body.playlist_id || null, m.device_id); } pushToWallMembers(req, req.params.id); notifyDashboards(req, req.wall.workspace_id); res.json(loadWallWithDevices(req.params.id)); }); // Delete wall — clear playlists + wall_id on every former member (matches // group-dissolve semantics: leaving the wall returns devices to ungrouped). router.delete('/:id', requireWallWrite, (req, res) => { const wallWorkspaceId = req.wall.workspace_id; // capture before the DELETE const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id); const tx = db.transaction(() => { db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id); db.prepare('DELETE FROM video_walls WHERE id = ?').run(req.params.id); }); tx(); // Push fresh (now wall-less, playlist-less) payloads to ex-members so they // exit wall mode and clear content immediately. for (const m of members) pushWallPayloadToDevice(req, m.device_id); notifyDashboards(req, wallWorkspaceId); res.json({ success: true }); }); // Set device grid positions. Replaces the entire member set. // Devices removed lose their playlist (returned to ungrouped); devices added // inherit the wall's playlist. // Phase 2.2l: closes pre-existing leak. Old per-device check ran through // team_members (legacy table) and role==='admin' (dead since Phase 2.1) - // effectively only the device.user_id direct-ownership branch was active, // missing the workspace dimension. Now: every device must be in the wall's // workspace. router.put('/:id/devices', requireWallWrite, (req, res) => { const { devices } = req.body; if (!Array.isArray(devices)) return res.status(400).json({ error: 'devices array required' }); const wall = req.wall; for (const d of devices) { const dev = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(d.device_id); if (!dev) return res.status(404).json({ error: `Device ${d.device_id} not found` }); if (dev.workspace_id !== wall.workspace_id) { return res.status(403).json({ error: `Device ${d.device_id} is not in this workspace` }); } } const previous = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id); const previousIds = new Set(previous.map(p => p.device_id)); const incomingIds = new Set(devices.map(d => d.device_id)); const removedIds = [...previousIds].filter(id => !incomingIds.has(id)); const tx = db.transaction(() => { db.prepare('DELETE FROM video_wall_devices WHERE wall_id = ?').run(req.params.id); db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id); // Removed devices: clear playlist (they're returning to ungrouped state). for (const id of removedIds) { db.prepare("UPDATE devices SET playlist_id = NULL WHERE id = ?").run(id); } const insertPos = db.prepare(` INSERT INTO video_wall_devices (wall_id, device_id, grid_col, grid_row, rotation, canvas_x, canvas_y, canvas_width, canvas_height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const updateDevice = db.prepare("UPDATE devices SET wall_id = ?, playlist_id = ? WHERE id = ?"); for (const d of devices) { insertPos.run( req.params.id, d.device_id, d.grid_col, d.grid_row, d.rotation || 0, d.canvas_x ?? null, d.canvas_y ?? null, d.canvas_width ?? null, d.canvas_height ?? null, ); updateDevice.run(req.params.id, wall.playlist_id || null, d.device_id); // A device joining a wall leaves all of its groups (walls and groups // are mutually exclusive concepts in this UX). db.prepare('DELETE FROM device_group_members WHERE device_id = ?').run(d.device_id); } if (devices.length > 0) { // Prefer the device whose canvas rect is closest to the wall's top-left // (smallest canvas_x + canvas_y), falling back to grid 0,0, then first. const leader = [...devices].sort((a, b) => ((a.canvas_x ?? 0) + (a.canvas_y ?? 0)) - ((b.canvas_x ?? 0) + (b.canvas_y ?? 0)))[0] || devices.find(d => d.grid_col === 0 && d.grid_row === 0) || devices[0]; db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(leader.device_id, req.params.id); } else { db.prepare('UPDATE video_walls SET leader_device_id = NULL WHERE id = ?').run(req.params.id); } }); tx(); // Push wall-aware payload to current members, and a wall-less payload to // ex-members so they exit wall mode. for (const id of removedIds) pushWallPayloadToDevice(req, id); pushToWallMembers(req, req.params.id); notifyDashboards(req, req.wall.workspace_id); res.json(loadWallWithDevices(req.params.id)); }); // Set wall content (legacy single-video path — kept for back-compat). // Phase 2.2l: closes pre-existing leak where content_id was accepted with // NO cross-tenant check. router.put('/:id/content', requireWallWrite, (req, res) => { const wall = req.wall; const { content_id } = req.body; if (content_id) { const c = db.prepare('SELECT workspace_id FROM content WHERE id = ?').get(content_id); if (!c) return res.status(404).json({ error: 'Content not found' }); if (c.workspace_id && c.workspace_id !== wall.workspace_id) { return res.status(403).json({ error: 'Content is not in this workspace' }); } } db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?") .run(content_id || null, req.params.id); res.json({ success: true }); }); // Get wall config for a specific device (legacy fetch path) router.get('/:id/device-config/:deviceId', requireWallRead, (req, res) => { const wall = req.wall; const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?') .get(req.params.id, req.params.deviceId); if (!position) return res.status(404).json({ error: 'Device not in this wall' }); res.json({ wall_id: wall.id, grid_cols: wall.grid_cols, grid_rows: wall.grid_rows, grid_col: position.grid_col, grid_row: position.grid_row, rotation: position.rotation, bezel_h_px: wall.bezel_h_mm, bezel_v_px: wall.bezel_v_mm, sync_mode: wall.sync_mode, is_leader: wall.leader_device_id === req.params.deviceId, }); }); module.exports = router;