Files
tinker_tickets/models/RecurringTicketModel.php
T
jaredandClaude Opus 4.8 b3bc3ab159
Lint / PHP (phpcs PSR-12) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 8s
Security / PHP Security (semgrep) (push) Successful in 1m17s
Lint / Deploy (push) Successful in 13s
Lint / Notify on failure (push) Has been skipped
Harden recurring cron, bulk delete, error handling; fix semgrep CI
Continued fixes from the multi-agent review:

- recurring tickets cron: now that the parse error is fixed the job runs,
  exposing two latent bugs. (1) next_run_at was only advanced after the
  full success path, so any failure (e.g. a NULL created_by passed to the
  non-nullable assignTicket() $assignedBy -> TypeError) left it in the past
  and re-created a duplicate ticket every cron cycle. Added an atomic
  claimForRun() (conditional UPDATE gated on still-due) called BEFORE
  creation, which also prevents overlapping runs from double-creating.
  (2) The cron used a raw mysqli with no utf8mb4, corrupting non-ASCII
  content; it now uses Database::getConnection(). Also guard the assignment
  so created_by NULL falls back to the assignee.
- bulk delete: attachment files were unlinked inside the DB transaction, so
  an atomic-mode rollback restored rows but the files were already gone.
  deleteTicket() can now defer file removal to the caller, and
  BulkOperationsModel deletes files only after a successful commit.
- UserModel: back-tick the `groups` column (reserved word on MySQL 8.0.2+).
- create_ticket_api.php: stop leaking raw DB/exception messages to callers;
  log server-side and return a generic error. (Also includes a pre-existing
  working-tree tweak that adds title to the manual-ticket dedupe hash.)
- CI: semgrep install failed under PEP 668; add --break-system-packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:34:57 -04:00

266 lines
8.2 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(
'ssssiiisssii',
$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;
}
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($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 = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* 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)
{
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
}