toggleActive() flipped is_active without touching next_run_at. If a schedule was disabled while next_run_at was still in the future, then re-enabled after that date had passed, the next cron tick saw next_run_at <= NOW() and fired immediately — surprising for an admin expecting a re-enabled "daily" schedule to wait until its next natural occurrence. Now recomputes next_run_at from the current time when transitioning to active, matching what a fresh schedule creation would produce; disabling is unchanged. Verified against a local MariaDB instance: re-enabling a schedule whose next_run_at was in 2020 recomputed it to tomorrow at the scheduled time; disabling leaves next_run_at untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
266 lines
8.5 KiB
PHP
266 lines
8.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* RecurringTicketModel - Manages recurring ticket schedules
|
|
*/
|
|
|
|
class RecurringTicketModel
|
|
{
|
|
private $conn;
|
|
|
|
public function __construct($conn)
|
|
{
|
|
$this->conn = $conn;
|
|
}
|
|
|
|
/**
|
|
* Get all recurring tickets
|
|
*/
|
|
public function getAll($includeInactive = false)
|
|
{
|
|
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
|
u2.display_name as creator_name, u2.username as creator_username
|
|
FROM recurring_tickets rt
|
|
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
|
|
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
|
|
|
|
if (!$includeInactive) {
|
|
$sql .= " WHERE rt.is_active = 1";
|
|
}
|
|
|
|
$sql .= " ORDER BY rt.next_run_at ASC";
|
|
|
|
$result = $this->conn->query($sql);
|
|
$items = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$items[] = $row;
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Get a single recurring ticket by ID
|
|
*/
|
|
public function getById($recurringId)
|
|
{
|
|
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param('i', $recurringId);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$row = $result->fetch_assoc();
|
|
$stmt->close();
|
|
return $row;
|
|
}
|
|
|
|
/**
|
|
* Create a new recurring ticket
|
|
*/
|
|
public function create($data)
|
|
{
|
|
$sql = "INSERT INTO recurring_tickets
|
|
(title_template, description_template, category, type, priority, assigned_to,
|
|
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
|
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param(
|
|
'ssssiissssii',
|
|
$data['title_template'],
|
|
$data['description_template'],
|
|
$data['category'],
|
|
$data['type'],
|
|
$data['priority'],
|
|
$data['assigned_to'],
|
|
$data['schedule_type'],
|
|
$data['schedule_day'],
|
|
$data['schedule_time'],
|
|
$data['next_run_at'],
|
|
$data['is_active'],
|
|
$data['created_by']
|
|
);
|
|
|
|
if ($stmt->execute()) {
|
|
$id = $this->conn->insert_id;
|
|
$stmt->close();
|
|
return ['success' => true, 'recurring_id' => $id];
|
|
}
|
|
|
|
$error = $stmt->error;
|
|
$stmt->close();
|
|
return ['success' => false, 'error' => $error];
|
|
}
|
|
|
|
/**
|
|
* Update a recurring ticket
|
|
*/
|
|
public function update($recurringId, $data)
|
|
{
|
|
$sql = "UPDATE recurring_tickets SET
|
|
title_template = ?, description_template = ?, category = ?, type = ?,
|
|
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
|
schedule_time = ?, next_run_at = ?, is_active = ?
|
|
WHERE recurring_id = ?";
|
|
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param(
|
|
'ssssiissssii',
|
|
$data['title_template'],
|
|
$data['description_template'],
|
|
$data['category'],
|
|
$data['type'],
|
|
$data['priority'],
|
|
$data['assigned_to'],
|
|
$data['schedule_type'],
|
|
$data['schedule_day'],
|
|
$data['schedule_time'],
|
|
$data['next_run_at'],
|
|
$data['is_active'],
|
|
$recurringId
|
|
);
|
|
|
|
$success = $stmt->execute();
|
|
$stmt->close();
|
|
return ['success' => $success];
|
|
}
|
|
|
|
/**
|
|
* Delete a recurring ticket
|
|
*/
|
|
public function delete($recurringId)
|
|
{
|
|
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param('i', $recurringId);
|
|
$success = $stmt->execute();
|
|
$stmt->close();
|
|
return ['success' => $success];
|
|
}
|
|
|
|
/**
|
|
* Get recurring tickets due for execution
|
|
*/
|
|
public function getDueRecurringTickets()
|
|
{
|
|
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
|
$result = $this->conn->query($sql);
|
|
$items = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$items[] = $row;
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Atomically claim a due schedule for processing.
|
|
*
|
|
* Advances next_run_at (and stamps last_run_at) in a single conditional
|
|
* UPDATE gated on the row still being active and due. Returns true only if
|
|
* THIS call won the claim. This must be done BEFORE creating the ticket so
|
|
* that:
|
|
* - two overlapping cron runs can't both process the same schedule, and
|
|
* - a failure in a later step (ticket create, assignment, audit) can't
|
|
* leave next_run_at in the past, which would re-fire — and re-create a
|
|
* duplicate ticket — on every subsequent cron run.
|
|
*
|
|
* @return bool true if the schedule was claimed by this call
|
|
*/
|
|
public function claimForRun($recurringId)
|
|
{
|
|
$recurring = $this->getById($recurringId);
|
|
if (!$recurring) {
|
|
return false;
|
|
}
|
|
|
|
$nextRun = $this->calculateNextRunTime(
|
|
$recurring['schedule_type'],
|
|
$recurring['schedule_day'],
|
|
$recurring['schedule_time']
|
|
);
|
|
|
|
$sql = "UPDATE recurring_tickets
|
|
SET last_run_at = NOW(), next_run_at = ?
|
|
WHERE recurring_id = ? AND is_active = 1 AND next_run_at <= NOW()";
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param('si', $nextRun, $recurringId);
|
|
$stmt->execute();
|
|
$claimed = $stmt->affected_rows > 0;
|
|
$stmt->close();
|
|
return $claimed;
|
|
}
|
|
|
|
/**
|
|
* Calculate the next run time based on schedule
|
|
*/
|
|
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime)
|
|
{
|
|
$now = new DateTime();
|
|
$time = new DateTime($scheduleTime);
|
|
|
|
switch ($scheduleType) {
|
|
case 'daily':
|
|
$next = new DateTime('tomorrow ' . $scheduleTime);
|
|
break;
|
|
|
|
case 'weekly':
|
|
$dayNames = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
$dayName = $dayNames[(int)$scheduleDay] ?? 'Monday';
|
|
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
|
break;
|
|
|
|
case 'monthly':
|
|
$day = max(1, min(31, $scheduleDay));
|
|
$next = new DateTime();
|
|
$next->modify('first day of next month');
|
|
// Clamp to the last day of the target month (handles Feb, 30-day months, etc.)
|
|
$daysInMonth = (int)$next->format('t');
|
|
$day = min($day, $daysInMonth);
|
|
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
|
|
$next->setTime($time->format('H'), $time->format('i'), 0);
|
|
break;
|
|
|
|
default:
|
|
$next = new DateTime('tomorrow ' . $scheduleTime);
|
|
}
|
|
|
|
return $next->format('Y-m-d H:i:s');
|
|
}
|
|
|
|
/**
|
|
* Toggle active status
|
|
*/
|
|
public function toggleActive($recurringId)
|
|
{
|
|
$recurring = $this->getById($recurringId);
|
|
if (!$recurring) {
|
|
return ['success' => false];
|
|
}
|
|
|
|
$newActive = $recurring['is_active'] ? 0 : 1;
|
|
|
|
if ($newActive) {
|
|
// Re-enabling: recompute next_run_at from now, as if the schedule
|
|
// were freshly created. Otherwise a schedule paused while
|
|
// next_run_at was still in the future, then re-enabled after that
|
|
// date has passed, would fire immediately on the next cron tick
|
|
// instead of waiting for its next natural occurrence.
|
|
$nextRun = $this->calculateNextRunTime(
|
|
$recurring['schedule_type'],
|
|
$recurring['schedule_day'],
|
|
$recurring['schedule_time']
|
|
);
|
|
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
|
|
} else {
|
|
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param('ii', $newActive, $recurringId);
|
|
}
|
|
|
|
$success = $stmt->execute();
|
|
$stmt->close();
|
|
return ['success' => $success];
|
|
}
|
|
}
|