mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Phase 2 (assignments -> playlist_items) dropped zone_id during the conversion: migrateAssignmentsToPlaylists INSERTed only (playlist_id, content_id, widget_id, sort_order, duration_sec), and the new playlist_items DDL omitted the zone_id column entirely. Every write path on top of playlist_items inherited that omission - the multi-zone layout assignment feature stopped working. Frontend always sent zone_id correctly (device-detail.js:1015,1072 POST and PUT both include it; api.addAssignment and api.updateAssignment forward the body verbatim). Server silently dropped it. The assignments.js PUT route was the most direct evidence: it destructured zone_id from req.body but never added it to the updates array. Schema: - schema.sql: add zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL to fresh-install DDL. - database.js migrations[]: add idempotent ALTER TABLE for existing installs (the surrounding try/catch loop handles duplicate-column). Backfill (new gated migration phase2_zone_id_backfill): - Pre-migration snapshot copied to db/remote_display.pre-zone-id- backfill-<ts>.db (one-off for this migration; the general every-migration-snapshot framework is a separate concern, not built here). - Best-effort UPDATE playlist_items.zone_id from surviving assignments rows via device.playlist_id + content_id/widget_id match, LIMIT 1 for the multi-match edge case. - Regenerates published_snapshot for every published playlist so the JSON the player consumes carries zone_id going forward. Even with zero rows backfilled (the common case post-Phase-2 cleanup) this closes the snapshot-staleness gap. - Stamps schema_migrations regardless so it won't re-run on next boot. - On the live local DB: 0 playlist_items backfilled, 18 published_snapshots regenerated. On the April 13 prod fixture (sandboxed copy): 0 backfilled, 7 regenerated. Expected and matches our pre-flight finding that assignments was effectively scrubbed of zone_id everywhere. Route wiring (7 sites + 1 shared constant): - assignments.js ITEM_SELECT: project pi.zone_id (read path so the frontend display at device-detail.js:500 surfaces the value). - assignments.js POST INSERT: include zone_id column + value. - assignments.js PUT: actually use the already-destructured zone_id in the updates allow-list. Treats undefined as "no change" so a PUT that omits zone_id leaves the existing value intact; any explicit value (including null) is written. - assignments.js copy-to INSERT: preserve a.zone_id during device-to-device playlist copy. - playlists.js buildSnapshotItems: project pi.zone_id so the snapshot JSON carries it. This is what the player's renderZones loop reads (player/index.html:1338 matches a.zone_id === zone.id). - playlists.js discard-revert INSERT: restore zone_id from snapshot item on revert. Out of scope (verified safe by SQL semantics + UI inspection): - playlists.js POST item-add and PUT item-update in the playlist-detail surface: the UI there doesn't expose zone editing, and their SQL leaves zone_id NULL on insert / untouched on update. No regression. - Other playlists.js SELECT projections (lines 141, 190, 240, 265, 334, 379, 419) all use SELECT pi.* and auto-pick zone_id once the column exists. - Kiosk-page assign at device-detail.js:1027 doesn't send zone_id; separate pre-existing gap, not part of this regression. Tests (all local, no push, no prod deploy): - Migration boot on live local DB: clean, idempotent (second boot skips the gated function). - Migration boot on April 13 prod fixture (sandboxed copy at /tmp/zone-fix-fixtures/test-run.db): cleanly runs the full migration stack (multi-tenancy + 5 other phases the fixture predated) then the new zone_id backfill. Live local DB untouched. - 8 SQL-level route behavior tests pass: INSERT stores zone_id, PUT changes/clears zone_id, ITEM_SELECT and buildSnapshotItems projections include zone_id, copy-to preserves, discard-revert restores from snapshot JSON, undefined zone_id in PUT leaves existing value intact. Not verified: end-to-end multi-zone playback on a real device. The SQL + snapshot JSON layer is correct (player consumes playlist.find(a => a.zone_id === zone.id) and now gets the right zone_id back from the snapshot); confirming render-to-correct-zone on actual hardware is the next step before prod deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
11 KiB
JavaScript
250 lines
11 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.2j: workspace-aware access. Underlying tables (devices, playlists)
|
|
// already carry workspace_id from Phase 1; this route can use them even
|
|
// though playlists.js itself isn't yet workspace-filtered.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Phase 2.2j: workspace-aware device access check. Returns access context
|
|
// (with workspaceRole/actingAs) or null. Caller decides if read or write.
|
|
function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
|
|
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.params[paramName]);
|
|
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
|
|
if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.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;
|
|
}
|
|
return { device, ctx };
|
|
}
|
|
|
|
// Ensure device has a playlist; auto-create one if missing.
|
|
// Phase 2.2j: stamps workspace_id on the auto-created playlist so it remains
|
|
// visible once playlists.js migrates. Mirrors the 2.2i fix in device-groups.js.
|
|
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;
|
|
}
|
|
|
|
// Standard item query with joined content/widget info
|
|
const ITEM_SELECT = `
|
|
SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
|
|
pi.created_at, pi.updated_at,
|
|
COALESCE(c.filename, w.name) as filename,
|
|
c.mime_type, c.filepath, c.thumbnail_path,
|
|
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
|
w.name as widget_name, w.widget_type, w.config as widget_config
|
|
FROM playlist_items pi
|
|
LEFT JOIN content c ON pi.content_id = c.id
|
|
LEFT JOIN widgets w ON pi.widget_id = w.id
|
|
`;
|
|
|
|
// Get assignments (playlist items) for a device
|
|
router.get('/device/:deviceId', (req, res) => {
|
|
if (!checkDeviceAccess(req, res, 'deviceId', false)) return;
|
|
const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!device?.playlist_id) return res.json([]);
|
|
|
|
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
|
|
.all(device.playlist_id);
|
|
res.json(items);
|
|
});
|
|
|
|
// Add content or widget to device playlist.
|
|
// Phase 2.2j: closes 2 pre-existing cross-tenant leaks:
|
|
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
|
|
// who happens to own content in another workspace could push it into a
|
|
// device in this workspace. Now: content must be in device's workspace
|
|
// (or be a platform-template, workspace_id IS NULL).
|
|
// 2. Widget gate: today checks ONLY existence - any user could attach any
|
|
// widget UUID to their own device's playlist. Now: widget must be in
|
|
// device's workspace (or be a platform-template).
|
|
router.post('/device/:deviceId', (req, res) => {
|
|
const access = checkDeviceAccess(req, res, 'deviceId', true);
|
|
if (!access) return;
|
|
const { content_id, widget_id, zone_id, duration_sec = 10, sort_order } = req.body;
|
|
|
|
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
|
|
|
|
if (content_id) {
|
|
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 !== access.device.workspace_id) {
|
|
return res.status(403).json({ error: 'Content is not in this device\'s workspace' });
|
|
}
|
|
}
|
|
if (widget_id) {
|
|
const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(widget_id);
|
|
if (!widget) return res.status(404).json({ error: 'Widget not found' });
|
|
if (widget.workspace_id && widget.workspace_id !== access.device.workspace_id) {
|
|
return res.status(403).json({ error: 'Widget is not in this device\'s workspace' });
|
|
}
|
|
}
|
|
|
|
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
|
|
|
|
let order = sort_order;
|
|
if (order === undefined || order === null) {
|
|
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
|
.get(playlistId);
|
|
order = (max.max_order || 0) + 1;
|
|
}
|
|
|
|
try {
|
|
const result = db.prepare(`
|
|
INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(playlistId, content_id || null, widget_id || null, zone_id || null, order, duration_sec);
|
|
|
|
markDraft(playlistId);
|
|
|
|
const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid);
|
|
res.status(201).json(item);
|
|
} catch (err) {
|
|
if (err.message.includes('UNIQUE')) {
|
|
return res.status(409).json({ error: 'Content already in playlist' });
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
// Helper: load a playlist item and check write access via the parent
|
|
// playlist's workspace. Returns the item row or null after sending 403/404.
|
|
function checkItemWrite(req, res) {
|
|
const item = db.prepare('SELECT pi.*, p.workspace_id AS pl_workspace_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id);
|
|
if (!item) { res.status(404).json({ error: 'Item not found' }); return null; }
|
|
if (!item.pl_workspace_id) { res.status(403).json({ error: 'Playlist not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(item.pl_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 (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return item;
|
|
}
|
|
|
|
// Update playlist item
|
|
router.put('/:id', (req, res) => {
|
|
const item = checkItemWrite(req, res);
|
|
if (!item) return;
|
|
|
|
const { sort_order, duration_sec, zone_id } = req.body;
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
|
|
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
|
|
// zone_id can be null (clear the zone) - treat undefined as "no change",
|
|
// any other value (including null) as "write this".
|
|
if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); }
|
|
|
|
if (updates.length > 0) {
|
|
updates.push("updated_at = strftime('%s','now')");
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
markDraft(item.playlist_id);
|
|
}
|
|
|
|
const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id);
|
|
res.json(updated);
|
|
});
|
|
|
|
// Delete playlist item
|
|
router.delete('/:id', (req, res) => {
|
|
const item = checkItemWrite(req, res);
|
|
if (!item) return;
|
|
|
|
db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.id);
|
|
markDraft(item.playlist_id);
|
|
|
|
res.json({ success: true, content_id: item.content_id });
|
|
});
|
|
|
|
// Reorder items for a device's playlist
|
|
router.post('/device/:deviceId/reorder', (req, res) => {
|
|
if (!checkDeviceAccess(req, res, 'deviceId', true)) return;
|
|
const { order } = req.body;
|
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' });
|
|
|
|
const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!device?.playlist_id) return res.json([]);
|
|
|
|
const updateStmt = db.prepare('UPDATE playlist_items SET sort_order = ? WHERE id = ? AND playlist_id = ?');
|
|
const transaction = db.transaction(() => {
|
|
order.forEach((itemId, index) => {
|
|
updateStmt.run(index, itemId, device.playlist_id);
|
|
});
|
|
});
|
|
transaction();
|
|
|
|
markDraft(device.playlist_id);
|
|
|
|
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
|
|
.all(device.playlist_id);
|
|
res.json(items);
|
|
});
|
|
|
|
// Copy playlist from one device to another.
|
|
// Phase 2.2j: closes a pre-existing cross-tenant leak. Today both deviceIds
|
|
// only got the user_id ownership check; a caller with reach into a foreign
|
|
// workspace could copy that workspace's playlist into a device in their own
|
|
// workspace (or vice versa). Now: both devices must be in the same workspace,
|
|
// and the caller must have write access there.
|
|
router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
|
|
const sourceAccess = checkDeviceAccess(req, res, 'deviceId', true);
|
|
if (!sourceAccess) return;
|
|
const targetAccess = checkDeviceAccess(req, res, 'targetDeviceId', true);
|
|
if (!targetAccess) return;
|
|
if (sourceAccess.device.workspace_id !== targetAccess.device.workspace_id) {
|
|
return res.status(403).json({ error: 'Source and target devices must be in the same workspace' });
|
|
}
|
|
|
|
const sourceDevice = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!sourceDevice?.playlist_id) return res.status(404).json({ error: 'Source device has no playlist' });
|
|
|
|
const sourceItems = db.prepare('SELECT * FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order')
|
|
.all(sourceDevice.playlist_id);
|
|
if (!sourceItems.length) return res.status(404).json({ error: 'Source playlist is empty' });
|
|
|
|
const target = db.prepare('SELECT id, user_id FROM devices WHERE id = ?').get(req.params.targetDeviceId);
|
|
if (!target) return res.status(404).json({ error: 'Target device not found' });
|
|
|
|
const targetPlaylistId = ensureDevicePlaylist(req.params.targetDeviceId, target.user_id || req.user.id);
|
|
|
|
if (req.body.replace) {
|
|
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(targetPlaylistId);
|
|
}
|
|
|
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM playlist_items WHERE playlist_id = ?')
|
|
.get(targetPlaylistId).m || 0;
|
|
const stmt = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?, ?)');
|
|
|
|
const transaction = db.transaction(() => {
|
|
sourceItems.forEach((a, i) => {
|
|
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, a.duration_sec);
|
|
});
|
|
});
|
|
transaction();
|
|
|
|
markDraft(targetPlaylistId);
|
|
res.json({ success: true, copied: sourceItems.length });
|
|
});
|
|
|
|
module.exports = router;
|