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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-12 13:33:56 -05:00 committed by screentinker
parent fab4ae909a
commit dce0d22763
2 changed files with 31 additions and 6 deletions

View file

@ -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);

View file

@ -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' });