import { db, id, json, now, parseJson, visibleClause } from '../db.js'; import { normalizeRunPlanSchedulePayload } from '../forms/schemas.js'; import { camelRunPlanSchedule, camelRunPlanScheduleRun } from './mappers.js'; import { loadVisibleRunPlan } from './runPlanModel.js'; const MAX_PREVIEW_OCCURRENCES = 12; export function listRunPlanSchedules(userId, isAdmin = false) { const scope = isAdmin ? '1=1' : visibleClause('rs'); return db.prepare(` SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name FROM runplan_schedules rs JOIN runplans r ON r.id = rs.runplan_id LEFT JOIN scripts s ON s.id = r.script_id LEFT JOIN groups g ON g.id = rs.group_id WHERE ${scope} ORDER BY COALESCE(rs.next_run_at, rs.updated_at) ASC, rs.name ASC `).all(...(isAdmin ? [] : [userId, userId])).map(withPreview).map(camelRunPlanSchedule); } export function getRunPlanSchedule(scheduleId, userId, isAdmin = false) { const scope = isAdmin ? '1=1' : visibleClause('rs'); const row = db.prepare(` SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name FROM runplan_schedules rs JOIN runplans r ON r.id = rs.runplan_id LEFT JOIN scripts s ON s.id = r.script_id LEFT JOIN groups g ON g.id = rs.group_id WHERE rs.id = ? AND ${scope} `).get(scheduleId, ...(isAdmin ? [] : [userId, userId])); return row ? camelRunPlanSchedule(withPreview(row)) : null; } export function createRunPlanSchedule(body, userId) { const payload = normalizeRunPlanSchedulePayload(body); if (!loadVisibleRunPlan(payload.runplanId, userId)) throw new Error('RunPlan not found or not visible'); const scheduleId = id('sched'); const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : ''; db.prepare(` INSERT INTO runplan_schedules ( id, name, description, runplan_id, enabled, schedule_type, start_at, end_at, time_of_day, interval_value, interval_unit, days_of_week, day_of_month, month_of_year, next_run_at, visibility, owner_user_id, group_id, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( scheduleId, payload.name, payload.description, payload.runplanId, payload.enabled ? 1 : 0, payload.scheduleType, payload.startAt, payload.endAt || null, payload.timeOfDay || null, payload.intervalValue, payload.intervalUnit, json(payload.daysOfWeek, []), payload.dayOfMonth, payload.monthOfYear, nextRunAt, payload.visibility, userId, payload.groupId, userId, now(), now() ); return getRunPlanSchedule(scheduleId, userId, false); } export function updateRunPlanSchedule(scheduleId, body, userId, isAdmin = false) { const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin); if (!existing) return null; const payload = normalizeRunPlanSchedulePayload({ ...camelRunPlanSchedule(withPreview(existing)), ...body, runplanId: body.runplanId || body.runplan_id || existing.runplan_id }); if (!loadVisibleRunPlan(payload.runplanId, userId) && !isAdmin) throw new Error('RunPlan not found or not visible'); const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : ''; db.prepare(` UPDATE runplan_schedules SET name = ?, description = ?, runplan_id = ?, enabled = ?, schedule_type = ?, start_at = ?, end_at = ?, time_of_day = ?, interval_value = ?, interval_unit = ?, days_of_week = ?, day_of_month = ?, month_of_year = ?, next_run_at = ?, visibility = ?, group_id = ?, updated_at = ? WHERE id = ? `).run( payload.name, payload.description, payload.runplanId, payload.enabled ? 1 : 0, payload.scheduleType, payload.startAt, payload.endAt || null, payload.timeOfDay || null, payload.intervalValue, payload.intervalUnit, json(payload.daysOfWeek, []), payload.dayOfMonth, payload.monthOfYear, nextRunAt, payload.visibility, payload.groupId, now(), scheduleId ); return getRunPlanSchedule(scheduleId, userId, isAdmin); } export function deleteRunPlanSchedule(scheduleId, userId, isAdmin = false) { const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin); if (!existing) return false; db.prepare('DELETE FROM runplan_schedules WHERE id = ?').run(scheduleId); return true; } export function setRunPlanScheduleEnabled(scheduleId, enabled, userId, isAdmin = false) { const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin); if (!existing) return null; const nextRunAt = enabled ? computeNextRun(rowToSchedule(existing), new Date(Date.now() - 1000)) : ''; db.prepare('UPDATE runplan_schedules SET enabled = ?, next_run_at = ?, updated_at = ? WHERE id = ?') .run(enabled ? 1 : 0, nextRunAt, now(), scheduleId); return getRunPlanSchedule(scheduleId, userId, isAdmin); } export function listDueRunPlanSchedules(nowIso = now()) { return db.prepare(` SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name FROM runplan_schedules rs JOIN runplans r ON r.id = rs.runplan_id LEFT JOIN scripts s ON s.id = r.script_id WHERE rs.enabled = 1 AND rs.next_run_at IS NOT NULL AND rs.next_run_at != '' AND rs.next_run_at <= ? ORDER BY rs.next_run_at ASC LIMIT 50 `).all(nowIso).map((row) => camelRunPlanSchedule(withPreview(row))); } export function recordRunPlanScheduleDispatch(schedule, { jobId = '', plannedFor = '', status = 'started', message = '' } = {}) { const raw = getRawSchedule(schedule.id); if (!raw) return null; const effectivePlannedFor = plannedFor || raw.next_run_at || now(); const nextRunAt = computeNextRun(rowToSchedule(raw), new Date(Date.now() + 1000)); const nextEnabled = raw.schedule_type === 'once' && !nextRunAt ? 0 : raw.enabled; db.prepare(` INSERT INTO runplan_schedule_runs (id, schedule_id, runplan_id, job_id, planned_for, status, message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run(id('srn'), raw.id, raw.runplan_id, jobId || null, effectivePlannedFor, status, message, now()); db.prepare(` UPDATE runplan_schedules SET last_run_at = ?, last_job_id = ?, next_run_at = ?, enabled = ?, updated_at = ? WHERE id = ? `).run(now(), jobId || null, nextRunAt, nextEnabled, now(), raw.id); return getRawSchedule(raw.id); } export function listRunPlanScheduleRuns(userId, isAdmin = false, limit = 100) { const scope = isAdmin ? '1=1' : visibleClause('rs'); return db.prepare(` SELECT sr.*, r.name AS runplan_name, j.status AS job_status FROM runplan_schedule_runs sr JOIN runplan_schedules rs ON rs.id = sr.schedule_id LEFT JOIN runplans r ON r.id = sr.runplan_id LEFT JOIN jobs j ON j.id = sr.job_id WHERE ${scope} ORDER BY sr.created_at DESC LIMIT ? `).all(...(isAdmin ? [limit] : [userId, userId, limit])).map(camelRunPlanScheduleRun); } export function previewRunPlanSchedule(body) { const payload = normalizeRunPlanSchedulePayload(body); return previewScheduleOccurrences(payload); } export function previewScheduleOccurrences(schedule, count = MAX_PREVIEW_OCCURRENCES, fromDate = new Date(Date.now() - 1000)) { const occurrences = []; let cursor = new Date(fromDate); for (let i = 0; i < count; i += 1) { const next = computeNextRun(schedule, cursor); if (!next) break; occurrences.push(next); cursor = new Date(Date.parse(next) + 1000); } return occurrences; } export function computeNextRun(schedule, fromDate = new Date()) { const start = validDate(schedule.startAt || schedule.start_at); if (!start) return ''; const end = validDate(schedule.endAt || schedule.end_at); const from = validDate(fromDate) || new Date(); const type = schedule.scheduleType || schedule.schedule_type || 'once'; const after = new Date(Math.max(from.getTime(), start.getTime() - 1000)); let next = null; if (type === 'once') next = start > after ? start : null; else if (type === 'interval') next = nextIntervalRun(schedule, start, after); else if (type === 'daily') next = nextDailyRun(schedule, start, after); else if (type === 'weekly') next = nextWeeklyRun(schedule, start, after); else if (type === 'monthly') next = nextMonthlyRun(schedule, start, after); else if (type === 'yearly') next = nextYearlyRun(schedule, start, after); if (!next) return ''; if (end && next > end) return ''; return next.toISOString(); } function nextIntervalRun(schedule, start, after) { const value = Math.max(1, Number(schedule.intervalValue ?? schedule.interval_value) || 1); const unit = schedule.intervalUnit || schedule.interval_unit || 'hours'; let candidate = new Date(start); for (let i = 0; i < 10000 && candidate <= after; i += 1) candidate = addInterval(candidate, value, unit); return candidate > after ? candidate : null; } function nextDailyRun(schedule, start, after) { return nextByDayScan(schedule, start, after, () => true); } function nextWeeklyRun(schedule, start, after) { const days = parseScheduleDays(schedule); const daySet = new Set(days.length ? days : [start.getDay()]); return nextByDayScan(schedule, start, after, (date) => daySet.has(date.getDay()), 370); } function nextMonthlyRun(schedule, start, after) { const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate())); let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime()))); for (let i = 0; i < 240; i += 1) { const candidate = withTime(clampMonthDate(cursor.getFullYear(), cursor.getMonth(), day), schedule, start); if (candidate >= start && candidate > after) return candidate; cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); } return null; } function nextYearlyRun(schedule, start, after) { const month = Math.min(12, Math.max(1, Number(schedule.monthOfYear ?? schedule.month_of_year) || start.getMonth() + 1)) - 1; const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate())); const firstYear = Math.max(start.getFullYear(), after.getFullYear()); for (let year = firstYear; year < firstYear + 25; year += 1) { const candidate = withTime(clampMonthDate(year, month, day), schedule, start); if (candidate >= start && candidate > after) return candidate; } return null; } function nextByDayScan(schedule, start, after, matches, maxDays = 730) { let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime()))); for (let i = 0; i < maxDays; i += 1) { if (matches(cursor)) { const candidate = withTime(cursor, schedule, start); if (candidate >= start && candidate > after) return candidate; } cursor = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1); } return null; } function parseScheduleDays(schedule) { const raw = schedule.daysOfWeek ?? schedule.days_of_week ?? []; const days = Array.isArray(raw) ? raw : parseJson(raw, []); return [...new Set(days.map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b); } function addInterval(date, value, unit) { const next = new Date(date); if (unit === 'minutes') next.setMinutes(next.getMinutes() + value); else if (unit === 'hours') next.setHours(next.getHours() + value); else if (unit === 'days') next.setDate(next.getDate() + value); else if (unit === 'months') next.setMonth(next.getMonth() + value); else if (unit === 'years') next.setFullYear(next.getFullYear() + value); return next; } function withTime(date, schedule, fallback) { const time = schedule.timeOfDay || schedule.time_of_day || `${String(fallback.getHours()).padStart(2, '0')}:${String(fallback.getMinutes()).padStart(2, '0')}`; const [hours, minutes] = String(time).split(':').map(Number); return new Date(date.getFullYear(), date.getMonth(), date.getDate(), Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0); } function clampMonthDate(year, month, day) { const lastDay = new Date(year, month + 1, 0).getDate(); return new Date(year, month, Math.min(day, lastDay)); } function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function validDate(value) { const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function withPreview(row) { return { ...row, preview_json: json(previewScheduleOccurrences(rowToSchedule(row)), []) }; } function rowToSchedule(row) { return { id: row.id, scheduleType: row.schedule_type, startAt: row.start_at, endAt: row.end_at || '', timeOfDay: row.time_of_day || '', intervalValue: row.interval_value || 1, intervalUnit: row.interval_unit || 'hours', daysOfWeek: parseJson(row.days_of_week, []), dayOfMonth: row.day_of_month || null, monthOfYear: row.month_of_year || null }; } function getRawVisibleSchedule(scheduleId, userId, isAdmin = false) { const scope = isAdmin ? '1=1' : visibleClause(); return db.prepare(`SELECT * FROM runplan_schedules WHERE id = ? AND ${scope}`) .get(scheduleId, ...(isAdmin ? [] : [userId, userId])); } function getRawSchedule(scheduleId) { return db.prepare('SELECT * FROM runplan_schedules WHERE id = ?').get(scheduleId); }