From dce0d227637eb4d2a4aaae6d98a629f3205c2b8c Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 12 Jun 2026 13:33:56 -0500 Subject: [PATCH] fix(api): expose zone_id + layout_id on the public write paths - playlists: accept zone_id on item create + update, validated against a template or a layout in the playlist's workspace (no cross-tenant zone reference). - devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route), validated the same way; null clears it. Both are already returned in the GET SELECTs. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/routes/devices.js | 12 +++++++++++- server/routes/playlists.js | 25 ++++++++++++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/server/routes/devices.js b/server/routes/devices.js index 67a62f4..5312a47 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -146,7 +146,7 @@ router.put('/:id', (req, res) => { const device = checkDeviceOwnership(req, res); if (!device) return; - const { name, notes, timezone, orientation, default_content_id } = req.body; + const { name, notes, timezone, orientation, default_content_id, layout_id } = req.body; // Whitelist allowed fields to prevent SQL injection via field names const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id']; const updates = []; @@ -157,6 +157,16 @@ router.put('/:id', (req, res) => { values.push(val); } }); + // #public-api: allow setting the device's layout here too (symmetry with + // PUT /api/layouts/device/:id). Validate it's a template or in the device's + // workspace; null clears it (fullscreen). + if (layout_id !== undefined) { + if (layout_id !== null) { + const layout = db.prepare('SELECT id FROM layouts WHERE id = ? AND (is_template = 1 OR workspace_id = ?)').get(layout_id, device.workspace_id); + if (!layout) return res.status(400).json({ error: 'layout_id not found in this workspace' }); + } + updates.push('layout_id = ?'); values.push(layout_id || null); + } if (updates.length > 0) { values.push(req.params.id); db.prepare(`UPDATE devices SET ${updates.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...values); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 0b861c7..2544255 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -352,7 +352,7 @@ router.put('/:id/items/:itemId/schedules', requirePlaylistWrite, (req, res) => { // 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; + const { content_id, widget_id, sort_order, zone_id } = req.body; let { duration_sec } = req.body; if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' }); @@ -380,6 +380,13 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => { } } + // #public-api: optional multi-zone placement. Validate the zone belongs to a + // template or a layout in this playlist's workspace (the agency portal needs this). + if (zone_id) { + const zone = db.prepare('SELECT lz.id FROM layout_zones lz JOIN layouts l ON l.id = lz.layout_id WHERE lz.id = ? AND (l.is_template = 1 OR l.workspace_id = ?)').get(zone_id, req.playlist.workspace_id); + if (!zone) return res.status(400).json({ error: 'zone_id not found in this workspace' }); + } + // Auto-increment sort_order if not specified let order = sort_order; if (order === undefined || order === null) { @@ -389,9 +396,9 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => { } 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); + INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) + VALUES (?, ?, ?, ?, ?, ?) + `).run(req.params.id, content_id || null, widget_id || null, zone_id || null, order, duration_sec); // Mark as draft (items changed since last publish) markDraft(req.params.id); @@ -421,11 +428,19 @@ router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => { .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 { sort_order, duration_sec, zone_id } = req.body; const updates = []; const values = []; if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); } + // #public-api: multi-zone placement (zone_id null clears it). Undefined = no change. + if (zone_id !== undefined) { + if (zone_id !== null) { + const zone = db.prepare('SELECT lz.id FROM layout_zones lz JOIN layouts l ON l.id = lz.layout_id WHERE lz.id = ? AND (l.is_template = 1 OR l.workspace_id = ?)').get(zone_id, req.playlist.workspace_id); + if (!zone) return res.status(400).json({ error: 'zone_id not found in this workspace' }); + } + updates.push('zone_id = ?'); values.push(zone_id || null); + } 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' });