diff --git a/server/routes/schedules.js b/server/routes/schedules.js index 3954223..9337060 100644 --- a/server/routes/schedules.js +++ b/server/routes/schedules.js @@ -261,13 +261,36 @@ router.post('/', (req, res) => { if (zErr) return res.status(zErr.status).json({ error: zErr.error }); } + // A content-only schedule is turned into a playlist holding that one item. + // + // The dialog offers "Content (single item, optional)" and the value was stored faithfully — but + // the engine only ever acts on layout_id and playlist_id, so content_id was read by nothing at + // all. The schedule fired, changed nothing, and the calendar drew a block labelled with the + // filename as confirmation that it would. Rather than add a third override path through the + // engine and every player, give the item the same shape everything already understands: its own + // playlist. That reuses the whole published/assign/push pipeline as-is. + let effectivePlaylistId = playlist_id || null; + if (!effectivePlaylistId && content_id) { + const c = db.prepare('SELECT filename FROM content WHERE id = ?').get(content_id); + const genId = uuidv4(); + db.prepare('INSERT INTO playlists (id, name, workspace_id, user_id, status) VALUES (?, ?, ?, ?, ?)') + .run(genId, `Scheduled: ${(c && c.filename) || 'item'}`, targetWorkspaceId, req.user.id, 'published'); + db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, 0, 10)') + .run(genId, content_id); + // Publish through the shared path rather than hand-rolling the snapshot: players read + // denormalized fields (filename, mime_type, filepath, remote_url, schedules...) out of + // published_snapshot, and duplicating that shape here would rot the moment it changes. + require('./playlists').publishPlaylist(genId); + effectivePlaylistId = genId; + } + const id = uuidv4(); 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, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null, - layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || targetTz || 'UTC', + layout_id || null, effectivePlaylistId, title || '', start_time, end_time, timezone || targetTz || 'UTC', recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6'); const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id); diff --git a/server/test/schedule-content-item.test.js b/server/test/schedule-content-item.test.js new file mode 100644 index 0000000..f5d982d --- /dev/null +++ b/server/test/schedule-content-item.test.js @@ -0,0 +1,102 @@ +'use strict'; + +// The schedule dialog offers "Content (single item, optional)". The value was validated for +// cross-tenancy and stored faithfully — and then read by nothing at all. services/scheduler.js acts +// on exactly two columns: +// +// if (active.layout_id && ...) { ...apply... } +// if (active.playlist_id && ...) { ...apply... } +// +// content_id, widget_id and zone_id are consulted nowhere. So a content-only schedule was a +// complete no-op, while the calendar drew a block labelled with the filename as confirmation that +// it would fire. +// +// Rather than thread a third override through the engine and every player, the schedule now gets a +// playlist holding that one item — the shape the whole pipeline already understands. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-schedcontent-')); +process.env.DATA_DIR = tmp; +process.env.JWT_SECRET = 'test-secret-sched-content'; + +const express = require('express'); +const { db } = require('../db/database'); +const { requireAuth, generateToken } = require('../middleware/auth'); +const { resolveTenancy } = require('../lib/tenancy'); + +const O = 'o-sc', WS = 'ws-sc', U = 'u-sc', DEV = 'dev-sc', C = 'c-sc'; +db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sc@t.local'); +db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U); +db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS'); +db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U); +db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at) + VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS); +db.prepare("INSERT OR IGNORE INTO content (id,workspace_id,user_id,filename,filepath,mime_type,file_size) VALUES (?,?,?,'promo.jpg','promo.jpg','image/jpeg',1234)").run(C, WS, U); + +const app = express(); +app.use(express.json()); +app.set('io', null); +app.use('/api/schedules', requireAuth, resolveTenancy, require('../routes/schedules')); +const server = app.listen(0); +const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS); + +async function createSchedule(body) { + await new Promise(r => (server.listening ? r() : server.once('listening', r))); + const res = await fetch(`http://127.0.0.1:${server.address().port}/api/schedules`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json().catch(() => null) }; +} + +const BASE = { device_id: DEV, title: 'Promo hour', start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T17:00:00' }; + +test('THE BUG: a content-only schedule now has something the engine can act on', async () => { + const { status } = await createSchedule({ ...BASE, content_id: C }); + assert.equal(status, 201); // created + const s = db.prepare('SELECT * FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV); + assert.ok(s.playlist_id, 'the engine reads playlist_id; without one the schedule did nothing'); +}); + +test('the generated playlist contains exactly that item, published', () => { + const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV); + const pl = db.prepare('SELECT * FROM playlists WHERE id = ?').get(s.playlist_id); + assert.equal(pl.workspace_id, WS, 'and it belongs to the right workspace'); + assert.equal(pl.status, 'published'); + const items = db.prepare('SELECT content_id FROM playlist_items WHERE playlist_id = ?').all(s.playlist_id); + assert.deepEqual(items.map(i => i.content_id), [C]); +}); + +test('the snapshot the players read is populated', () => { + const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV); + const pl = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(s.playlist_id); + const snap = JSON.parse(pl.published_snapshot || '[]'); + assert.equal(snap.length, 1); + assert.equal(snap[0].content_id, C); + assert.ok(snap[0].filename, 'players need the denormalized fields, not just the id'); +}); + +test('an explicit playlist override still wins and no playlist is invented', async () => { + db.prepare("INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES ('pl-explicit','Mine',?,?)").run(WS, U); + const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n; + await createSchedule({ ...BASE, content_id: C, playlist_id: 'pl-explicit' }); + const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV); + assert.equal(s.playlist_id, 'pl-explicit'); + assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before, 'no throwaway playlist created'); +}); + +test('a schedule with neither content nor playlist is unchanged', async () => { + const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n; + await createSchedule({ ...BASE, title: 'Layout only' }); + const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV); + assert.equal(s.playlist_id, null); + assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before); +}); + +test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });