Make a content-only schedule actually put that content on the screen

The schedule dialog offers "Content (single item, optional)". The value was cross-tenancy validated
and stored faithfully, and then read by nothing. services/scheduler.js acts on exactly two columns,
layout_id and playlist_id; content_id is consulted nowhere in the codebase. So picking a file and
saving produced a schedule that fired and changed nothing — while the calendar drew a block labelled
with that filename, as confirmation that it would.

Rather than thread a third override type through the engine and every player, the schedule now gets
a playlist containing that one item. That is the shape the entire pipeline already understands:
publish, assign, push, snapshot, offline cache and all four players work on it unchanged.

It is published through the shared publishPlaylist path rather than by hand-rolling the snapshot,
because players read denormalized fields out of published_snapshot (filename, mime_type, filepath,
remote_url, per-item schedules) and a second copy of that shape here would rot the first time it
changed.

An explicit playlist override still wins and no throwaway playlist is created; a schedule with
neither content nor playlist is untouched.

5 tests covering all of those, including that the generated playlist lands in the right workspace and
that its snapshot carries the fields the players need rather than just the id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
Claude 2026-07-30 22:26:58 -05:00
parent 3c1b2f62ee
commit e3b01a5c7f
2 changed files with 126 additions and 1 deletions

View file

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

View file

@ -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 (_) {} });