#!/usr/bin/env php > /var/log/recurring_tickets.log 2>&1 */ // Change to project root directory chdir(dirname(__DIR__)); // Include required files require_once 'config/config.php'; require_once 'helpers/Database.php'; require_once 'helpers/NotificationHelper.php'; require_once 'models/RecurringTicketModel.php'; require_once 'models/TicketModel.php'; require_once 'models/AuditLogModel.php'; require_once 'models/StatsModel.php'; // Log function function logMessage($message) { echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n"; } logMessage("Starting recurring tickets cron job"); try { // Create database connection (Database::getConnection sets utf8mb4 so // non-ASCII titles/descriptions aren't corrupted on insert). $conn = Database::getConnection(); // Initialize models $recurringModel = new RecurringTicketModel($conn); $ticketModel = new TicketModel($conn); $auditLog = new AuditLogModel($conn); // Get all due recurring tickets $dueTickets = $recurringModel->getDueRecurringTickets(); logMessage("Found " . count($dueTickets) . " recurring tickets due for creation"); $created = 0; $errors = 0; foreach ($dueTickets as $recurring) { logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']); try { // Claim the schedule FIRST (atomic advance of next_run_at). If another // cron run already claimed it, or it's no longer due, skip it — this // prevents duplicate-ticket floods if a later step throws. if (!$recurringModel->claimForRun($recurring['recurring_id'])) { logMessage("Skipped (already claimed or not due): " . $recurring['recurring_id']); continue; } // Prepare ticket data $ticketData = [ 'title' => processTemplate($recurring['title_template']), 'description' => processTemplate($recurring['description_template']), 'category' => $recurring['category'], 'type' => $recurring['type'], 'priority' => $recurring['priority'], 'status' => 'Open' ]; // Create the ticket $result = $ticketModel->createTicket($ticketData, $recurring['created_by']); if ($result['success']) { $ticketId = $result['ticket_id']; logMessage("Created ticket: " . $ticketId); // Assign to user if specified. assignTicket() requires a non-null // "assigned_by"; fall back to the assignee when created_by is null // (recurring schedules may have no creator). if (!empty($recurring['assigned_to'])) { $assignedBy = (int)($recurring['created_by'] ?? $recurring['assigned_to']); $ticketModel->assignTicket($ticketId, (int)$recurring['assigned_to'], $assignedBy); } // Log to audit $auditLog->log( $recurring['created_by'], 'create', 'ticket', $ticketId, ['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']] ); // Fire the same Matrix "ticket created" notification the manual and // external-API create paths send, so recurring tickets aren't silent. NotificationHelper::sendTicketNotification($ticketId, $ticketData, 'automated'); $created++; } else { logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error')); $errors++; } } catch (Exception $e) { logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage()); $errors++; } } // Ticket counts changed — invalidate the cached dashboard stats once for the // whole run (mirrors the manual/API create paths, which invalidate per create). if ($created > 0) { (new StatsModel($conn))->invalidateCache(); } logMessage("Completed: Created $created tickets, $errors errors"); Database::close(); } catch (Exception $e) { logMessage("FATAL ERROR: " . $e->getMessage()); exit(1); } /** * Process template variables */ function processTemplate($template) { if (empty($template)) { return $template; } $replacements = [ '{{date}}' => date('Y-m-d'), '{{time}}' => date('H:i:s'), '{{datetime}}' => date('Y-m-d H:i:s'), '{{week}}' => date('W'), '{{month}}' => date('F'), '{{year}}' => date('Y'), '{{day_of_week}}' => date('l'), '{{day}}' => date('d'), ]; return str_replace(array_keys($replacements), array_values($replacements), $template); } logMessage("Cron job finished");