RecurringTicketModel::claimForRun() deliberately advances next_run_at before TicketModel::createTicket() runs, to prevent duplicate-ticket floods if creation fails partway and the cron retries. The tradeoff: if createTicket() then fails, that specific occurrence is gone forever with no record anywhere an admin would normally look — the catch block only wrote a line to stdout/the cron log. Added recordMissedOccurrence(), called from both the "createTicket() returned success:false" branch and the exception catch, which writes an audit_log entry (entity_type='recurring_ticket', action_type='error') and fires a new NotificationHelper::sendSystemAlert() — a generic operational alert (unlike the ticket-specific notification methods, it has no associated ticket) sent to the shared MATRIX_NOTIFY_USERS list regardless of any per-event toggle, so a silently-skipped recurring ticket surfaces immediately instead of requiring someone to grep cron logs. Verified against real MariaDB and a real webhook-capturing server: calling the recorder writes the audit_log row with the failure reason and schedule details, and fires the Matrix alert with the same information. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
188 lines
6.9 KiB
PHP
188 lines
6.9 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Recurring Tickets Cron Job
|
|
*
|
|
* Run this script via cron to automatically create tickets from recurring schedules.
|
|
* Recommended: run every 5-15 minutes.
|
|
*
|
|
* Example crontab entry (minute 10 of every hour):
|
|
* 10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.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";
|
|
}
|
|
|
|
/**
|
|
* Record a recurring-ticket occurrence that was claimed (next_run_at already
|
|
* advanced to the next future run) but then failed to actually produce a
|
|
* ticket. That claim-then-fail ordering is deliberate — it stops a failing
|
|
* creation from re-firing and flooding duplicates on every subsequent cron
|
|
* tick — but means this specific occurrence has no other record anywhere an
|
|
* admin would normally look: no audit_log entry (nothing was created), no
|
|
* Matrix "ticket created" alert, no failure table. Without this, it's simply
|
|
* gone, silently, forever.
|
|
*/
|
|
function recordMissedOccurrence($auditLog, $recurring, $reason)
|
|
{
|
|
$auditLog->log(
|
|
$recurring['created_by'],
|
|
'error',
|
|
'recurring_ticket',
|
|
(string)$recurring['recurring_id'],
|
|
[
|
|
'reason' => $reason,
|
|
'title_template' => $recurring['title_template'],
|
|
'schedule_type' => $recurring['schedule_type'],
|
|
]
|
|
);
|
|
|
|
NotificationHelper::sendSystemAlert(
|
|
"Recurring ticket occurrence lost: schedule #{$recurring['recurring_id']} "
|
|
. "(\"{$recurring['title_template']}\") was claimed for this run but ticket "
|
|
. "creation failed, so this occurrence will not be created or retried.",
|
|
['reason' => $reason, 'recurring_id' => $recurring['recurring_id']]
|
|
);
|
|
}
|
|
|
|
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 {
|
|
$reason = $result['error'] ?? 'Unknown error';
|
|
logMessage("ERROR: Failed to create ticket - " . $reason);
|
|
recordMissedOccurrence($auditLog, $recurring, $reason);
|
|
$errors++;
|
|
}
|
|
} catch (Exception $e) {
|
|
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
|
// claimForRun() already advanced next_run_at before this point, so
|
|
// this occurrence is permanently gone unless recorded somewhere an
|
|
// admin would actually look — a cron log line alone doesn't count.
|
|
recordMissedOccurrence($auditLog, $recurring, $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");
|