screentinker/server/routes/schedules.js
ScreenTinker 0030acc526 Store a schedule in the timezone its screen runs in
Creation and playback disagreed about which clock a schedule's hours are on.
The player evaluated blocks in the device's zone — an operator override, else
whatever the player's OS reported. Creation defaulted to a bare 'UTC', because
the dialog never asked for a zone and the server filled the silence with one.

So hours typed as "09:00 to 17:00" were stored as UTC and evaluated somewhere
else. For anyone outside UTC the schedule was correct and appeared to do
nothing, opening hours later than intended, with nothing on screen to explain
why. A user in Asia/Tokyo hit exactly this and reported it as "I added
something and it didn't appear".

Both sides now resolve through lib/device-timezone, so they cannot drift: an
explicit device override wins, then the OS-reported zone, then null. A legacy
'UTC' override counts as unset, since that was the old default rather than a
deliberate choice and a genuine UTC deployment is indistinguishable from an
unconfigured one.

A new schedule inherits its target's zone — the device's, or for a group its
leader's, falling back to the oldest member that reports one. A zone named
explicitly by the caller still wins; this only fills the silence. A target that
has never reported one still lands on UTC, which is the previous behaviour made
explicit rather than assumed.

The dialog now states which clock the hours are on, and says so differently when
that clock is not the operator's own. Stating it is the other half of the fix:
the server can pick the right zone, but the user still has to be able to see it.

Tests pin both directions and, most importantly, that creation and playback
resolve identically from the same device row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 09:43:46 -05:00

361 lines
16 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
// Phase 2.2m: workspace-aware schedule access. Schedules inherit workspace_id
// from their target (device or device_group). All polymorphic references
// (content / widget / layout / playlist) must live in the same workspace as
// the target. This closes a long-standing leak where POST accepted those
// payload refs with no ownership check at all (only the target was checked).
const { accessContext } = require('../lib/tenancy');
const { effectiveDeviceTz } = require('../lib/device-timezone');
// Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() {
return `
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.enabled = 1
AND (
s.device_id = ?
OR s.group_id IN (
SELECT group_id FROM device_group_members WHERE device_id = ?
)
)
ORDER BY
CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
s.priority DESC,
s.created_at ASC
`;
}
// Load a schedule + access context, sending 403/404 on failure.
function loadScheduleAccess(req, res, requireWrite) {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) { res.status(404).json({ error: 'Schedule not found' }); return null; }
if (!schedule.workspace_id) { res.status(403).json({ error: 'Schedule not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(schedule.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.schedule = schedule;
req.scheduleCtx = ctx;
return schedule;
}
function requireScheduleWrite(req, res, next) {
if (!loadScheduleAccess(req, res, true)) return;
next();
}
// Verify caller has at least read access to the given workspace (used when
// resolving the target's workspace before stamping a new schedule).
function workspaceAccess(req, workspaceId) {
if (!workspaceId) return null;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
if (!ws) return null;
return accessContext(req.user.id, req.user.role, ws);
}
// Verify a referenced row exists and lives in the given workspace. Returns
// null on success, or { status, error } on failure. Used for content / widget
// / layout / playlist refs (where workspace_id IS NULL is the platform-template
// path and is always allowed) and for devices / device_groups (where
// workspace_id is required - those tables never carry template rows).
function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) {
const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id);
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
if (row.workspace_id === workspaceId) return null;
if (opts.allowNullWorkspace && row.workspace_id == null) return null;
return { status: 403, error: `${table.replace(/_/g, ' ').slice(0, -1)} is not in this workspace` };
}
// List schedules (filterable). Phase 2.2m: workspace-scoped.
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const { device_id, group_id, start, end } = req.query;
let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.workspace_id = ?`;
const params = [req.workspaceId];
if (device_id) {
sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`;
params.push(device_id, device_id);
}
if (group_id) { sql += ' AND s.group_id = ?'; params.push(group_id); }
if (start) { sql += ' AND s.end_time >= ?'; params.push(start); }
if (end) { sql += ' AND s.start_time <= ?'; params.push(end); }
sql += ' ORDER BY s.start_time ASC';
res.json(db.prepare(sql).all(...params));
});
// Get schedules for a device. Phase 2.2m: device access via workspace_id.
router.get('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ctx = workspaceAccess(req, device.workspace_id);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
res.json(schedules);
});
// Expanded week view (resolves recurrences). Phase 2.2m: device access via workspace.
router.get('/week', (req, res) => {
const { date, device_id } = req.query;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ctx = workspaceAccess(req, device.workspace_id);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const weekStart = date ? new Date(date) : new Date();
weekStart.setHours(0, 0, 0, 0);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
const events = [];
for (const s of schedules) {
const expanded = expandSchedule(s, weekStart, weekEnd);
events.push(...expanded);
}
res.json(events);
});
// Create schedule. Phase 2.2m: schedule.workspace_id is inherited from the
// target (device or group). Single workspace lookup also enforces caller's
// write access. Closes 4 pre-existing leaks: content / widget / layout /
// playlist were accepted with NO ownership check at all.
router.post('/', (req, res) => {
const { 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 } = req.body;
if (!start_time || !end_time) {
return res.status(400).json({ error: 'start_time and end_time required' });
}
if (device_id && group_id) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' });
}
if (!device_id && !group_id) {
return res.status(400).json({ error: 'Either device_id or group_id is required' });
}
// Resolve target's workspace_id and verify caller has write access there.
let targetWorkspaceId = null;
let targetTz = null;
if (device_id) {
const device = db.prepare('SELECT workspace_id, timezone, reported_timezone FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
targetWorkspaceId = device.workspace_id;
targetTz = effectiveDeviceTz(device);
}
if (group_id) {
const group = db.prepare('SELECT workspace_id, leader_device_id FROM device_groups WHERE id = ?').get(group_id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (!group.workspace_id) return res.status(403).json({ error: 'Group not assigned to a workspace' });
targetWorkspaceId = group.workspace_id;
// A group can span zones, so there is no single right answer. The leader defines the
// group's wall clock; failing that, the oldest member that reports one. The resolved
// value is stored explicitly so the caller can see which zone it landed on.
const leader = group.leader_device_id
? db.prepare('SELECT timezone, reported_timezone FROM devices WHERE id = ?').get(group.leader_device_id)
: null;
targetTz = effectiveDeviceTz(leader);
if (!targetTz) {
const member = db.prepare(`SELECT d.timezone, d.reported_timezone FROM devices d
JOIN device_group_members m ON m.device_id = d.id
WHERE m.group_id = ? AND COALESCE(d.timezone, d.reported_timezone) IS NOT NULL
ORDER BY d.created_at LIMIT 1`).get(group_id);
targetTz = effectiveDeviceTz(member);
}
}
const ctx = workspaceAccess(req, targetWorkspaceId);
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' });
}
// Payload refs must live in the same workspace. Platform templates
// (workspace_id IS NULL) on content / widget / layout / playlist are allowed.
const refChecks = [
['content', content_id, true],
['widgets', widget_id, true],
['layouts', layout_id, true],
['playlists', playlist_id, true],
];
for (const [table, id, allowNull] of refChecks) {
if (!id) continue;
const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error });
}
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',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id);
res.status(201).json(schedule);
});
// Update schedule. Phase 2.2m: every polymorphic target that is changing must
// live in the schedule's workspace. Closes the pre-existing leak where
// verifyOwnership keyed only on user_id (workspace-blind).
router.put('/:id', requireScheduleWrite, (req, res) => {
const schedule = req.schedule;
const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id;
if (newDeviceId && newGroupId) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id' });
}
if (!newDeviceId && !newGroupId) {
return res.status(400).json({ error: 'Either device_id or group_id is required' });
}
// For each field changing to a non-null value, verify the referenced row
// lives in the schedule's workspace. Devices and groups must match exactly
// (no NULL workspace path); content / widget / layout / playlist may be
// platform templates (NULL workspace_id).
const ownershipChecks = [
['devices', req.body.device_id, schedule.device_id, false],
['device_groups', req.body.group_id, schedule.group_id, false],
['content', req.body.content_id, schedule.content_id, true],
['widgets', req.body.widget_id, schedule.widget_id, true],
['layouts', req.body.layout_id, schedule.layout_id, true],
['playlists', req.body.playlist_id, schedule.playlist_id, true],
];
for (const [table, newVal, oldVal, allowNull] of ownershipChecks) {
if (newVal === undefined || newVal === oldVal || !newVal) continue;
const err = checkRefInWorkspace(table, newVal, schedule.workspace_id, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error });
}
const fields = ['device_id', 'group_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title',
'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color'];
const updates = [];
const values = [];
fields.forEach(f => {
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
});
if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) {
updates.push('device_id = ?'); values.push(null);
}
if (req.body.device_id && !updates.some(u => u.startsWith('group_id'))) {
updates.push('group_id = ?'); values.push(null);
}
if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id);
db.prepare(`UPDATE schedules SET ${updates.join(', ')} WHERE id = ?`).run(...values);
}
res.json(db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id));
});
// Delete schedule
router.delete('/:id', requireScheduleWrite, (req, res) => {
db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Helper: expand a schedule with recurrence into individual events for a date range
function expandSchedule(schedule, rangeStart, rangeEnd) {
const events = [];
const start = new Date(schedule.start_time);
const end = new Date(schedule.end_time);
const durationMs = end - start;
if (!schedule.recurrence) {
if (end >= rangeStart && start <= rangeEnd) {
events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time });
}
return events;
}
const rule = parseRRule(schedule.recurrence);
if (!rule) {
events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time });
return events;
}
const recEnd = schedule.recurrence_end ? new Date(schedule.recurrence_end) : rangeEnd;
let current = new Date(start);
let count = 0;
const maxIterations = 366;
while (current <= rangeEnd && current <= recEnd && count < maxIterations) {
const instanceEnd = new Date(current.getTime() + durationMs);
if (current >= rangeStart || instanceEnd >= rangeStart) {
const dayOfWeek = current.getDay();
const matchesDay = !rule.byDay || rule.byDay.includes(dayOfWeek);
if (matchesDay) {
events.push({
...schedule,
instance_start: current.toISOString(),
instance_end: instanceEnd.toISOString()
});
}
}
switch (rule.freq) {
case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break;
case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break;
case 'MONTHLY': current.setMonth(current.getMonth() + (rule.interval || 1)); break;
default: current.setDate(current.getDate() + 1);
}
count++;
}
return events;
}
function parseRRule(rrule) {
if (!rrule) return null;
const parts = rrule.split(';');
const rule = {};
const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
for (const part of parts) {
const [key, val] = part.split('=');
switch (key) {
case 'FREQ': rule.freq = val; break;
case 'INTERVAL': rule.interval = parseInt(val); break;
case 'BYDAY': rule.byDay = val.split(',').map(d => dayMap[d]).filter(d => d !== undefined); break;
case 'COUNT': rule.count = parseInt(val); break;
case 'UNTIL': rule.until = val; break;
}
}
return rule;
}
module.exports = router;