mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Deleting a device group converts its group schedules into per-device ones so the screens keep their programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted row landed with workspace_id = NULL. A null workspace does not merely look untidy — it makes the row unreachable in three directions at once, and they compound into the worst possible combination: invisible the schedule list and the all-screens calendar both filter on workspace_id undeletable PUT and DELETE refuse a row with no workspace (403) still live services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds "I deleted the group but the screens still switch content at 9am, and there is nothing in the calendar to remove." The only way out was direct database access. The conversion now carries the workspace, preferring the schedule's own and falling back to the group's so a legacy group schedule that itself predates workspace_id still converts into a reachable row. A boot migration repairs rows already orphaned in the field by recovering the workspace from the device each one targets; anything still unresolvable is left alone rather than guessed at. 4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use, preserves the actual programming rather than just the ownership, and the repair recovers a row orphaned before this fix existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
386 lines
19 KiB
JavaScript
386 lines
19 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|
// Phase 2.2i: workspace-aware access. Same pattern as devices/content/widgets.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
// #public-api: operational fleet commands (reboot/shutdown/...) need the 'full' token
|
|
// scope. No-op for JWT sessions; for tokens a read/write scope is rejected.
|
|
const { requireScope } = require('../middleware/apiToken');
|
|
|
|
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
|
const ALLOWED_COMMANDS = [
|
|
'screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown',
|
|
// #161 Tier-2 (owner-gated on the panel; STPolicy no-ops off-tier so a stray send is inert):
|
|
'power_menu', 'lock_now', 'kiosk_lock', 'kiosk_unlock',
|
|
'set_time', 'set_timezone', 'status_bar', 'block_uninstall', 'unblock_uninstall',
|
|
// #161 device-owner tooling: remote shell (app-UID diagnostics) + push/install an APK from a URL.
|
|
'shell', 'install_apk',
|
|
// #160 Track-A system control (no device owner): media volume + per-window brightness (Tier 0),
|
|
// system brightness + screen-off timeout (Tier 1 / WRITE_SETTINGS). Panel no-ops if unsupported.
|
|
'set_volume', 'set_brightness', 'set_system_brightness', 'set_screen_timeout',
|
|
];
|
|
|
|
// Phase 2.2i: split read/write access checks. Both attach req.group on success.
|
|
function loadGroupAccessCtx(req, res) {
|
|
const group = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
|
|
if (!group) { res.status(404).json({ error: 'group not found' }); return null; }
|
|
if (!group.workspace_id) { res.status(403).json({ error: 'Group not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(group.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
return { group, ctx };
|
|
}
|
|
|
|
function requireGroupRead(req, res, next) {
|
|
const access = loadGroupAccessCtx(req, res);
|
|
if (!access) return;
|
|
req.group = access.group;
|
|
next();
|
|
}
|
|
|
|
function requireGroupWrite(req, res, next) {
|
|
const access = loadGroupAccessCtx(req, res);
|
|
if (!access) return;
|
|
if (!access.ctx.actingAs && access.ctx.workspaceRole === 'workspace_viewer') {
|
|
return res.status(403).json({ error: 'Read-only access' });
|
|
}
|
|
req.group = access.group;
|
|
next();
|
|
}
|
|
|
|
// List groups in the caller's current workspace.
|
|
router.get('/', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
const groups = db.prepare(`
|
|
SELECT g.*, COUNT(dgm.device_id) as device_count
|
|
FROM device_groups g
|
|
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
|
|
WHERE g.workspace_id = ?
|
|
GROUP BY g.id
|
|
ORDER BY g.name ASC
|
|
`).all(req.workspaceId);
|
|
res.json(groups);
|
|
});
|
|
|
|
// Create group in the caller's current workspace.
|
|
router.post('/', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating groups.' });
|
|
const { name, color } = req.body;
|
|
if (!name) return res.status(400).json({ error: 'name required' });
|
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
|
const id = uuidv4();
|
|
db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
|
|
.run(id, req.user.id, req.workspaceId, name, color || '#3B82F6');
|
|
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
|
|
});
|
|
|
|
// Update group
|
|
router.put('/:id', requireGroupWrite, (req, res) => {
|
|
const { name, color, sync_enabled, leader_device_id, reboot_schedule } = req.body;
|
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
|
// #12 scheduled reboot: group-level default nightly-reboot time ("HH:MM" or null/'' = off).
|
|
// A member device's own reboot_schedule overrides this in the scheduler.
|
|
if (reboot_schedule !== undefined) {
|
|
let val = null;
|
|
if (reboot_schedule !== null && reboot_schedule !== '') {
|
|
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(String(reboot_schedule))) {
|
|
return res.status(400).json({ error: 'reboot_schedule must be "HH:MM" (24h) or null' });
|
|
}
|
|
val = String(reboot_schedule);
|
|
}
|
|
db.prepare('UPDATE device_groups SET reboot_schedule = ? WHERE id = ?').run(val, req.params.id);
|
|
}
|
|
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
|
|
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id);
|
|
// #group-sync: enable synchronized playback + optional pinned leader.
|
|
if (sync_enabled !== undefined) {
|
|
db.prepare('UPDATE device_groups SET sync_enabled = ? WHERE id = ?').run(sync_enabled ? 1 : 0, req.params.id);
|
|
}
|
|
if (leader_device_id !== undefined) {
|
|
if (leader_device_id !== null) {
|
|
const isMember = db.prepare('SELECT 1 FROM device_group_members WHERE group_id = ? AND device_id = ?').get(req.params.id, leader_device_id);
|
|
if (!isMember) return res.status(400).json({ error: 'leader_device_id must be a member of this group' });
|
|
}
|
|
db.prepare('UPDATE device_groups SET leader_device_id = ? WHERE id = ?').run(leader_device_id || null, req.params.id);
|
|
}
|
|
// Re-push to every member so they enter/exit sync mode and refresh their is_leader flag.
|
|
if (sync_enabled !== undefined || leader_device_id !== undefined) {
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
for (const m of members) pushPlaylistToDevice(req, m.device_id);
|
|
}
|
|
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// #group-sync: manual "Resync now" — nudge every member to re-snap to the shared schedule
|
|
// immediately. Sync is clock/schedule based (no leader), so this just forces an instant re-align
|
|
// (handy after a content change or if an operator wants to eyeball alignment).
|
|
router.post('/:id/resync', requireGroupWrite, (req, res) => {
|
|
const io = req.app.get('io');
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
if (io) {
|
|
const deviceNs = io.of('/device');
|
|
for (const m of members) deviceNs.to(m.device_id).emit('group:resync', { group_id: req.params.id });
|
|
}
|
|
res.json({ ok: true, notified: members.length });
|
|
});
|
|
|
|
// Delete group — converts group schedules to per-device schedules first
|
|
router.delete('/:id', requireGroupWrite, (req, res) => {
|
|
const groupId = req.params.id;
|
|
|
|
const convert = db.transaction(() => {
|
|
// Find group schedules that need conversion
|
|
const groupSchedules = db.prepare('SELECT * FROM schedules WHERE group_id = ?').all(groupId);
|
|
|
|
// Find current group members
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(groupId);
|
|
|
|
let converted = 0;
|
|
|
|
if (groupSchedules.length > 0 && members.length > 0) {
|
|
// workspace_id MUST be carried over. It is nullable with no default, so omitting it landed
|
|
// every converted schedule with workspace_id = NULL — and a null workspace does not merely
|
|
// look untidy, it makes the row unreachable in three directions at once:
|
|
// - the schedule list and the all-screens calendar filter on workspace_id: invisible
|
|
// - PUT and DELETE refuse a row with no workspace (403): undeletable
|
|
// - services/scheduler.js has NO workspace filter: it keeps firing every 60 seconds
|
|
// i.e. "I deleted the group but the screens still switch at 9am and there is nothing in the
|
|
// calendar to remove". The only way out was direct database access.
|
|
const insert = db.prepare(`
|
|
INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id,
|
|
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
|
|
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
for (const schedule of groupSchedules) {
|
|
for (const member of members) {
|
|
insert.run(
|
|
// Prefer the schedule's own workspace, falling back to the group's, so a legacy
|
|
// group schedule predating workspace_id still converts into a reachable row.
|
|
uuidv4(), schedule.user_id, schedule.workspace_id || req.group.workspace_id, member.device_id,
|
|
schedule.zone_id, schedule.content_id, schedule.widget_id,
|
|
schedule.layout_id, schedule.playlist_id, schedule.title,
|
|
schedule.start_time, schedule.end_time, schedule.timezone,
|
|
schedule.recurrence, schedule.recurrence_end, schedule.priority,
|
|
schedule.enabled, schedule.color, schedule.created_at, schedule.updated_at
|
|
);
|
|
}
|
|
converted++;
|
|
}
|
|
}
|
|
|
|
// Delete group schedules explicitly (before group delete turns group_id to NULL via ON DELETE SET NULL)
|
|
db.prepare('DELETE FROM schedules WHERE group_id = ?').run(groupId);
|
|
|
|
// Delete the group (cascades to device_group_members)
|
|
db.prepare('DELETE FROM device_groups WHERE id = ?').run(groupId);
|
|
|
|
return { converted, devices: members.length };
|
|
});
|
|
|
|
const result = convert();
|
|
res.json({ success: true, schedules_converted: result.converted, devices: result.devices });
|
|
});
|
|
|
|
// Get devices in a group
|
|
router.get('/:id/devices', requireGroupRead, (req, res) => {
|
|
const devices = db.prepare(`
|
|
SELECT d.* FROM devices d
|
|
JOIN device_group_members dgm ON d.id = dgm.device_id
|
|
WHERE dgm.group_id = ?
|
|
ORDER BY d.name ASC
|
|
`).all(req.params.id);
|
|
res.json(devices);
|
|
});
|
|
|
|
// Add device to group. If the group has a playlist set (via the assign-playlist
|
|
// dropdown on the dashboard), the new device inherits it — both for drag-drop
|
|
// onto the group section and for the Manage modal's checkboxes, which both
|
|
// hit this endpoint. Without this, joining a group never auto-assigned the
|
|
// group's playlist, leaving the new device on whatever it had before.
|
|
//
|
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
|
// checked device.user_id == caller; a workspace_admin who happened to own a
|
|
// device in another workspace could add it to a group in this workspace.
|
|
// Now: the device must belong to the same workspace as the group.
|
|
router.post('/:id/devices', requireGroupWrite, (req, res) => {
|
|
const { device_id } = req.body;
|
|
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
|
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
|
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
|
if (device.workspace_id !== req.group.workspace_id) {
|
|
return res.status(403).json({ error: 'Device is not in this group\'s workspace' });
|
|
}
|
|
try {
|
|
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
|
|
|
|
// Sync device's playlist to the group's: a defined playlist is inherited,
|
|
// a group with no playlist clears the device's. The user's mental model
|
|
// is "joining a group means using its playlist (or none)" — staying on a
|
|
// stale playlist after joining a no-playlist group was the bug we just hit.
|
|
const group = db.prepare('SELECT playlist_id FROM device_groups WHERE id = ?').get(req.params.id);
|
|
const newPlaylist = group?.playlist_id || null;
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, device_id);
|
|
pushPlaylistToDevice(req, device_id);
|
|
res.status(201).json({ success: true, playlist_id: newPlaylist });
|
|
} catch (e) {
|
|
res.status(400).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Remove device from group. Sync the device's playlist to whatever its
|
|
// current group membership implies — symmetric with the join sync above.
|
|
// - No remaining groups → clear playlist (Ungrouped).
|
|
// - Remaining group with a playlist → adopt that playlist.
|
|
// - Remaining group(s) but none have a playlist → clear playlist.
|
|
// Without this, a device dragged out of a group keeps stale playlist state
|
|
// from the group it just left.
|
|
router.delete('/:id/devices/:deviceId', requireGroupWrite, (req, res) => {
|
|
const deviceId = req.params.deviceId;
|
|
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
|
|
|
|
const remaining = db.prepare(`
|
|
SELECT g.playlist_id FROM device_groups g
|
|
JOIN device_group_members dgm ON g.id = dgm.group_id
|
|
WHERE dgm.device_id = ?
|
|
ORDER BY g.playlist_id IS NULL, g.name ASC
|
|
LIMIT 1
|
|
`).get(deviceId);
|
|
const newPlaylist = remaining?.playlist_id || null;
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, deviceId);
|
|
pushPlaylistToDevice(req, deviceId);
|
|
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Ensure a device has a playlist; auto-create one if missing.
|
|
// Phase 2.2i: pre-emptive loop-closer for the future playlists.js migration.
|
|
// The auto-created playlist lives in the same workspace as the device, so
|
|
// once playlists.js scopes by workspace_id this helper's rows remain visible.
|
|
function ensureDevicePlaylist(deviceId, userId) {
|
|
const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
|
|
if (device?.playlist_id) return device.playlist_id;
|
|
const playlistId = uuidv4();
|
|
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
|
|
.run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
|
|
return playlistId;
|
|
}
|
|
|
|
// Mark playlist as draft (called after any item mutation)
|
|
function markDraft(playlistId) {
|
|
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
|
}
|
|
|
|
// Push playlist update to a device (used by assign-playlist which doesn't modify items)
|
|
function pushPlaylistToDevice(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 */ }
|
|
}
|
|
|
|
// Bulk assign content to all devices in a group (adds to each device's playlist).
|
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
|
// checked content.user_id == caller; the content could live in any workspace
|
|
// the caller had any reach into. Now: content must live in the group's
|
|
// workspace (or be a platform-template content row, workspace_id IS NULL).
|
|
router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
|
|
const { content_id, duration_sec } = req.body;
|
|
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
|
|
|
// Verify content lives in the same workspace as the group (or is a
|
|
// platform-template row).
|
|
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
|
|
if (!content) return res.status(404).json({ error: 'Content not found' });
|
|
if (content.workspace_id && content.workspace_id !== req.group.workspace_id) {
|
|
return res.status(403).json({ error: 'Content is not in this group\'s workspace' });
|
|
}
|
|
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
|
|
const transaction = db.transaction(() => {
|
|
for (const m of members) {
|
|
const playlistId = ensureDevicePlaylist(m.device_id, req.user.id);
|
|
const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId);
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
|
|
.run(playlistId, content_id, max.next, duration_sec || 10);
|
|
markDraft(playlistId);
|
|
}
|
|
});
|
|
transaction();
|
|
|
|
res.json({ success: true, devices_updated: members.length });
|
|
});
|
|
|
|
// Assign an existing playlist to all devices in a group, and persist the
|
|
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
|
|
//
|
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
|
// checked playlist.user_id == caller; the playlist could live in any
|
|
// workspace the caller could reach. Now: playlist must live in the group's
|
|
// workspace. Playlists don't currently have a NULL/template path - playlists.js
|
|
// migration is deferred, so this check uses the raw workspace_id column that
|
|
// 2.2i's ensureDevicePlaylist loop-closer also writes to.
|
|
router.post('/:id/assign-playlist', requireGroupWrite, (req, res) => {
|
|
const { playlist_id } = req.body;
|
|
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
|
|
|
|
const playlist = db.prepare('SELECT id, workspace_id FROM playlists WHERE id = ?').get(playlist_id);
|
|
if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
|
|
if (playlist.workspace_id && playlist.workspace_id !== req.group.workspace_id) {
|
|
return res.status(403).json({ error: 'Playlist is not in this group\'s workspace' });
|
|
}
|
|
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
|
|
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
|
|
const transaction = db.transaction(() => {
|
|
db.prepare('UPDATE device_groups SET playlist_id = ? WHERE id = ?').run(playlist_id, req.params.id);
|
|
for (const m of members) stmt.run(playlist_id, m.device_id);
|
|
});
|
|
transaction();
|
|
|
|
for (const m of members) pushPlaylistToDevice(req, m.device_id);
|
|
res.json({ success: true, devices_updated: members.length });
|
|
});
|
|
|
|
// Send command to all devices in a group (reboot/shutdown/screen on/off etc.)
|
|
router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) => {
|
|
const { type, payload } = req.body;
|
|
if (!type) return res.status(400).json({ error: 'command type required' });
|
|
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
|
|
|
|
const devices = db.prepare(`
|
|
SELECT d.id, d.name, d.status FROM devices d
|
|
JOIN device_group_members dgm ON d.id = dgm.device_id
|
|
WHERE dgm.group_id = ?
|
|
`).all(req.params.id);
|
|
|
|
const deviceNs = req.app.get('io').of('/device');
|
|
const results = [];
|
|
|
|
for (const device of devices) {
|
|
const room = deviceNs.adapter.rooms.get(device.id);
|
|
if (room && room.size > 0) {
|
|
deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} });
|
|
results.push({ device_id: device.id, name: device.name, status: 'sent' });
|
|
} else {
|
|
results.push({ device_id: device.id, name: device.name, status: 'offline' });
|
|
}
|
|
}
|
|
|
|
const sent = results.filter(r => r.status === 'sent').length;
|
|
const offline = results.filter(r => r.status === 'offline').length;
|
|
console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline`);
|
|
res.json({ success: true, sent, offline, total: devices.length, results });
|
|
});
|
|
|
|
module.exports = router;
|