screentinker/server/routes/playlists.js
ScreenTinker 73f41c3288 fix(zone-id): restore zone-aware playlist_items wiring (issue #3 follow-up)
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>
2026-05-14 19:20:44 -05:00

463 lines
19 KiB
JavaScript

const express = require('express');
const router = express.Router();
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const config = require('../config');
// Phase 2.2k: workspace-aware access. requirePlaylistOwnership is replaced
// by read/write helpers gated on the playlist's workspace_id.
const { accessContext } = require('../lib/tenancy');
// Re-probe video duration with ffprobe if content.duration_sec is missing
async function probeAndUpdateDuration(content) {
if (content.duration_sec) return content.duration_sec;
if (!content.mime_type || !content.mime_type.startsWith('video/')) return null;
if (!content.filepath) return null;
try {
const { execFile } = require('child_process');
const fullPath = path.join(config.contentDir, content.filepath);
const probe = await new Promise((resolve, reject) => {
execFile('ffprobe', [
'-v', 'quiet', '-print_format', 'json', '-show_format', fullPath
], { timeout: 15000 }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout);
});
});
const info = JSON.parse(probe);
if (info.format?.duration) {
const dur = parseFloat(info.format.duration);
db.prepare('UPDATE content SET duration_sec = ? WHERE id = ?').run(dur, content.id);
return dur;
}
} catch (e) {
console.warn('ffprobe re-probe failed for', content.id, e.message);
}
return null;
}
// Phase 2.2k: workspace-aware playlist access. Returns the playlist row (with
// req.playlistCtx populated) or sends 403/404. requireWrite=false for reads.
function loadPlaylistAccess(req, res, requireWrite) {
const playlist = db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id);
if (!playlist) { res.status(404).json({ error: 'playlist not found' }); return null; }
if (!playlist.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(playlist.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.playlist = playlist;
req.playlistCtx = ctx;
return playlist;
}
function requirePlaylistRead(req, res, next) {
if (!loadPlaylistAccess(req, res, false)) return;
next();
}
function requirePlaylistWrite(req, res, next) {
if (!loadPlaylistAccess(req, res, true)) return;
next();
}
// Build the snapshot item list for a playlist (denormalized for device payload)
function buildSnapshotItems(playlistId) {
return db.prepare(`
SELECT pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, 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
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(playlistId);
}
// Mark playlist as draft (called after item mutations from the playlist detail UI)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Push playlist update to all devices using this playlist
function pushToDevices(playlistId, req) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
const deviceNs = io.of('/device');
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(playlistId);
for (const d of devices) {
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.id, buildPlaylistPayload);
}
} catch (e) { /* silent */ }
}
// Phase 2.2k: list scoped to caller's current workspace. No platform_admin
// bypass - cross-workspace view comes from switch-workspace, matching the
// precedent established across all other migrated routes.
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const playlists = db.prepare(`
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count
FROM playlists p
LEFT JOIN playlist_items pi ON p.id = pi.playlist_id
LEFT JOIN devices d ON d.playlist_id = p.id
WHERE p.workspace_id = ?
GROUP BY p.id
ORDER BY p.name ASC
`).all(req.workspaceId);
res.json(playlists);
});
// Phase 2.2k: create stamps workspace_id from req.workspaceId. Viewer-deny
// gate so workspace_viewers cannot create playlists in their workspace.
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, description } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name required' });
const id = uuidv4();
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description) VALUES (?, ?, ?, ?, ?)')
.run(id, req.user.id, req.workspaceId, name.trim(), (description || '').trim());
res.status(201).json(db.prepare(`
SELECT p.*, 0 as item_count, 0 as display_count FROM playlists p WHERE p.id = ?
`).get(id));
});
// Get single playlist with items
router.get('/:id', requirePlaylistRead, (req, res) => {
const items = db.prepare(`
SELECT pi.*,
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
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
const displayCount = db.prepare('SELECT COUNT(*) as count FROM devices WHERE playlist_id = ?').get(req.params.id).count;
res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount });
});
// Update playlist
router.put('/:id', requirePlaylistWrite, (req, res) => {
const { name, description } = req.body;
const updates = [];
const values = [];
if (name !== undefined) {
if (!name.trim()) return res.status(400).json({ error: 'name cannot be empty' });
updates.push('name = ?');
values.push(name.trim());
}
if (description !== undefined) {
updates.push('description = ?');
values.push(description.trim());
}
if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id);
db.prepare(`UPDATE playlists SET ${updates.join(', ')} WHERE id = ?`).run(...values);
}
res.json(db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id));
});
// Publish playlist — snapshot current items and push to devices
router.post('/:id/publish', requirePlaylistWrite, (req, res) => {
// Snapshot shape (no pi.id) is intentional — published_snapshot is consumed
// by devices and stored as JSON; row IDs there would be misleading.
const snapshotItems = buildSnapshotItems(req.params.id);
db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(JSON.stringify(snapshotItems), req.params.id);
pushToDevices(req.params.id, req);
// UI response shape must include pi.id so the post-publish render can wire
// per-row delete/duration listeners. TODO: refactor to share this SELECT
// with GET /:id (also duplicated in /discard and POST /:id/items/reorder).
const items = db.prepare(`
SELECT pi.*,
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
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
res.json({ ...db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id), items });
});
// Discard draft — revert playlist_items to match published_snapshot
router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
const playlist = req.playlist;
if (!playlist.published_snapshot) {
return res.status(400).json({ error: 'No published version to revert to' });
}
if (playlist.status === 'published') {
return res.status(400).json({ error: 'Playlist has no unpublished changes' });
}
let publishedItems;
try { publishedItems = JSON.parse(playlist.published_snapshot); } catch (e) {
return res.status(500).json({ error: 'Corrupt published snapshot' });
}
const transaction = db.transaction(() => {
// Clear current draft items
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(req.params.id);
// Re-insert from snapshot, skipping items whose content/widget was deleted
const insert = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?, ?)');
for (const item of publishedItems) {
try {
insert.run(req.params.id, item.content_id || null, item.widget_id || null, item.zone_id || null, item.sort_order, item.duration_sec);
} catch (e) {
if (e.message.includes('FOREIGN KEY')) {
console.warn(`Discard: skipping snapshot item (content_id=${item.content_id}, widget_id=${item.widget_id}) — referenced entity was deleted`);
continue;
}
throw e;
}
}
db.prepare("UPDATE playlists SET status = 'published', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
});
transaction();
const items = db.prepare(`
SELECT pi.*,
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
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
res.json({ ...db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id), items });
});
// Delete playlist
router.delete('/:id', requirePlaylistWrite, (req, res) => {
db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// --- Playlist Items ---
// List items
router.get('/:id/items', requirePlaylistRead, (req, res) => {
const items = db.prepare(`
SELECT pi.*,
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
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
res.json(items);
});
// Phase 2.2k: add item closes 2 pre-existing cross-tenant leaks:
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
// who owns content in another workspace could push it into a playlist
// in this workspace. Now: content must be in playlist'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 a playlist they could reach. Now: widget must be in
// playlist's workspace (or be a platform-template).
router.post('/:id/items', requirePlaylistWrite, async (req, res) => {
try {
const { content_id, widget_id, sort_order } = req.body;
let { duration_sec } = req.body;
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
if (duration_sec !== undefined && duration_sec !== null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
}
if (content_id) {
const content = db.prepare('SELECT id, workspace_id, duration_sec, mime_type, filepath 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.playlist.workspace_id) {
return res.status(403).json({ error: 'Content is not in this playlist\'s workspace' });
}
if (duration_sec === undefined || duration_sec === null) {
const contentDur = await probeAndUpdateDuration(content);
if (contentDur) duration_sec = Math.ceil(contentDur);
}
}
if (duration_sec === undefined || duration_sec === null) duration_sec = 10;
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 !== req.playlist.workspace_id) {
return res.status(403).json({ error: 'Widget is not in this playlist\'s workspace' });
}
}
// Auto-increment sort_order if not specified
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(req.params.id);
order = (max.max_order || 0) + 1;
}
const result = db.prepare(`
INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec)
VALUES (?, ?, ?, ?, ?)
`).run(req.params.id, content_id || null, widget_id || null, order, duration_sec);
// Mark as draft (items changed since last publish)
markDraft(req.params.id);
const item = db.prepare(`
SELECT pi.*,
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
WHERE pi.id = ?
`).get(result.lastInsertRowid);
res.status(201).json(item);
} catch (err) {
console.error('Failed to add playlist item:', err);
res.status(500).json({ error: 'Failed to add item' });
}
});
// Update item
router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?')
.get(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'item not found' });
const { sort_order, duration_sec } = req.body;
const updates = [];
const values = [];
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
if (duration_sec !== undefined) {
if (typeof duration_sec !== 'number' || duration_sec < 1) {
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
}
updates.push('duration_sec = ?');
values.push(duration_sec);
}
if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')");
values.push(req.params.itemId);
db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values);
markDraft(req.params.id);
}
const updated = db.prepare(`
SELECT pi.*,
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
WHERE pi.id = ?
`).get(req.params.itemId);
res.json(updated);
});
// Delete item
router.delete('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?')
.get(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'item not found' });
db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.itemId);
markDraft(req.params.id);
res.json({ success: true });
});
// Reorder items
router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' });
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, req.params.id);
});
});
transaction();
markDraft(req.params.id);
const items = db.prepare(`
SELECT pi.*,
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
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
res.json(items);
});
// Assign playlist to a device. Phase 2.2k: closes a pre-existing cross-tenant
// leak. Today checks device.user_id only; a caller with reach into a foreign
// workspace could assign their own playlist to a device in that workspace
// (or vice versa). Now: device must be in the playlist's workspace.
router.post('/:id/assign', requirePlaylistWrite, (req, res) => {
const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT id, 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.playlist.workspace_id) {
return res.status(403).json({ error: 'Device is not in this playlist\'s workspace' });
}
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(req.params.id, device_id);
// Push update to device
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), device_id, buildPlaylistPayload);
}
} catch (e) { /* silent */ }
res.json({ success: true });
});
module.exports = router;