mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
#236: the wall canvas was secretly framebuffer space rather than the wall as the audience sees it. Invisible while every panel is the normal way up, and actively misleading the moment one isn't — two portrait-mounted panels standing side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy of every video, before the output came out right. It worked, but only after trial and error, and it meant a portrait wall could never reuse content as-is. Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same convention as the per-device orientation setting), the canvas means the physical wall, and the player works out the mapping. The geometry lives in one place, server/lib/wall-geometry.js, because four players have to agree on it to the pixel across a seam. Existing walls need no migration and do not move. Every wall in the field is rotation 0, and that case takes the original expression verbatim on all three players rather than the algebraically-equal centre-based one — the two differ in the last float bit, and a float's worth of disagreement between two panels is a hairline seam down a wall that was aligned yesterday. Pinned by the first test in wall-geometry.test.js and by wall-payload.test.js. While a display is in a wall its panel rotation replaces its own orientation: both describe the same physical fact, so honouring both turned the content twice. #235: a wall replaced its members' cards, so one dead panel of a four-panel wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the live wall and putting it back. The wall screen now lists its panels with live online state, a per-panel screenshot request, and a link to each device's page; the dashboard wall card carries per-member status chips that track socket updates. Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts each panel's viewport maps onto exactly its own rect of wall space, for every rotation, plus a mixed wall and the Tizen player's hand-ported copy executed against the canonical rule. Full server suite green (1260). Not verified here: the Android and Tizen renders on real hardware. Kotlin compiles clean; the maths is shared/tested, the view plumbing is not.
331 lines
14 KiB
JavaScript
331 lines
14 KiB
JavaScript
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');
|
|
// #236: per-panel mounting rotation. Normalised on the way IN as well as out, so a bad value from a
|
|
// scripted API caller is rejected at the door instead of persisting and confusing every later read.
|
|
const { normalizeWallRotation } = require('../lib/wall-geometry');
|
|
|
|
// 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');
|
|
const commandQueue = require('../lib/command-queue');
|
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), deviceId, buildPlaylistPayload);
|
|
} 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, normalizeWallRotation(d.rotation),
|
|
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: normalizeWallRotation(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;
|