mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
464 lines
22 KiB
JavaScript
464 lines
22 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
|
|
`;
|
|
}
|
|
|
|
// Every schedule in a workspace, each row carrying the NAME of what it targets.
|
|
//
|
|
// The per-device query answers "what plays on THIS screen". This answers "what is
|
|
// scheduled anywhere", which is what an operator actually needs to see: with a
|
|
// single-device calendar you cannot tell whether a gap is deliberate or whether you
|
|
// pointed the schedule at the wrong screen — the failure mode a real user hit.
|
|
function getWorkspaceSchedulesQuery() {
|
|
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,
|
|
d.name as device_name
|
|
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
|
|
LEFT JOIN devices d ON s.device_id = d.id
|
|
WHERE s.enabled = 1 AND s.workspace_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).
|
|
// layout_zones has no workspace_id of its own — a zone belongs to a layout, and the layout carries
|
|
// the workspace. zone_id was the one polymorphic reference missing from the ownership checks, so a
|
|
// schedule could be pointed at a zone in someone else's workspace.
|
|
function checkZoneInWorkspace(zoneId, workspaceId) {
|
|
const row = db.prepare(
|
|
'SELECT l.workspace_id FROM layout_zones z JOIN layouts l ON l.id = z.layout_id WHERE z.id = ?'
|
|
).get(zoneId);
|
|
if (!row) return { status: 404, error: 'zone not found' };
|
|
if (row.workspace_id === workspaceId) return null;
|
|
if (row.workspace_id == null) return null; // platform-template layout
|
|
return { status: 403, error: 'zone is not in this workspace' };
|
|
}
|
|
|
|
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, all } = req.query;
|
|
if (!device_id && !all) return res.status(400).json({ error: 'device_id or all=1 required' });
|
|
|
|
// all=1 -> every schedule on every screen, for the "all screens" calendar. The workspace
|
|
// comes from the caller's resolved tenancy, never from the query string: a client-supplied
|
|
// workspace_id here would be a cross-tenant read waiting to happen.
|
|
let scopeWorkspaceId = all ? req.workspaceId : null;
|
|
if (all && !scopeWorkspaceId) return res.json([]);
|
|
if (device_id) {
|
|
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' });
|
|
scopeWorkspaceId = device.workspace_id;
|
|
}
|
|
const ctx = workspaceAccess(req, scopeWorkspaceId);
|
|
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 = device_id
|
|
? db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id)
|
|
: db.prepare(getWorkspaceSchedulesQuery()).all(scopeWorkspaceId);
|
|
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 });
|
|
}
|
|
if (zone_id) {
|
|
const zErr = checkZoneInWorkspace(zone_id, targetWorkspaceId);
|
|
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, 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);
|
|
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;
|
|
|
|
// Walk DAY BY DAY across the visible range and draw every day the rule actually fires.
|
|
//
|
|
// The old loop stepped by the recurrence unit from the schedule's original start, which got both
|
|
// of the common presets wrong:
|
|
// - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
|
|
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day — one event a week, or
|
|
// none at all if it had been created on a weekend.
|
|
// - Starting from the original start with a 366-iteration cap meant a schedule begun more than
|
|
// a year ago never reached the current week, so it drew nothing whatsoever.
|
|
// The engine meanwhile evaluates day-of-week directly, so it ran Mon-Fri regardless. The calendar
|
|
// is the operator's only view of what is scheduled, and it disagreed with reality in both
|
|
// directions. Iterating the range instead means the drawing follows the same rule the engine
|
|
// applies, and the cost is bounded by the window being displayed rather than by history.
|
|
const dayMs = 24 * 60 * 60 * 1000;
|
|
const startTimeOfDay = { h: start.getHours(), m: start.getMinutes(), s: start.getSeconds() };
|
|
|
|
// First candidate day: the later of the schedule's start and the window's start.
|
|
let cursor = new Date(Math.max(start.getTime(), rangeStart.getTime()));
|
|
cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0);
|
|
if (cursor.getTime() + durationMs < rangeStart.getTime()) cursor = new Date(cursor.getTime() + dayMs);
|
|
|
|
const lastDay = new Date(Math.min(rangeEnd.getTime(), recEnd.getTime()));
|
|
const interval = Math.max(1, rule.interval || 1);
|
|
|
|
while (cursor <= lastDay) {
|
|
const instanceEnd = new Date(cursor.getTime() + durationMs);
|
|
let fires = false;
|
|
switch (rule.freq) {
|
|
case 'DAILY':
|
|
// Honour the interval by counting whole days from the original start.
|
|
fires = Math.floor((cursor - start) / dayMs) % interval === 0;
|
|
break;
|
|
case 'WEEKLY':
|
|
// byDay is what makes Mon-Fri work. Without it, weekly means "the start's weekday".
|
|
fires = rule.byDay ? rule.byDay.includes(cursor.getDay()) : cursor.getDay() === start.getDay();
|
|
break;
|
|
case 'MONTHLY':
|
|
fires = cursor.getDate() === start.getDate();
|
|
break;
|
|
default:
|
|
fires = true;
|
|
}
|
|
if (fires && (cursor >= rangeStart || instanceEnd >= rangeStart)) {
|
|
events.push({
|
|
...schedule,
|
|
instance_start: cursor.toISOString(),
|
|
instance_end: instanceEnd.toISOString(),
|
|
});
|
|
}
|
|
cursor = new Date(cursor.getTime() + dayMs);
|
|
cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0); // DST-safe re-anchor
|
|
}
|
|
|
|
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;
|
|
// Exported for testing, the same way playlists.js exports publishPlaylist. The calendar's
|
|
// correctness is arithmetic and deserves to be checked without standing up a server.
|
|
module.exports.expandSchedule = expandSchedule;
|