diff --git a/api/update_ticket.php b/api/update_ticket.php index 67e42a8..dded313 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -276,7 +276,8 @@ try { $updateData['title'], 'status_changed', ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], - (int)$this->userId + (int)$this->userId, + $currentTicket['visibility'] ?? 'public' ); } diff --git a/create_ticket_api.php b/create_ticket_api.php index d8e8b21..a723104 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -42,6 +42,8 @@ try { require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/models/AuditLogModel.php'; require_once __DIR__ . '/models/StatsModel.php'; +require_once __DIR__ . '/models/TicketModel.php'; +require_once __DIR__ . '/models/WorkflowModel.php'; require_once __DIR__ . '/helpers/UrlHelper.php'; $apiKeyAuth = new ApiKeyAuth($conn); @@ -338,17 +340,52 @@ if ($existing) { exit; } - // Ticket was closed — reopen it and add a recurrence comment - $reopenStmt = $conn->prepare( - "UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?" - ); - $reopenStmt->bind_param("is", $userId, $existingId); - $reopenStmt->execute(); - $reopenStmt->close(); + // Ticket was closed — reopen it and add a recurrence comment. Route + // through the Workflow Designer like every other status-write path in + // the app, rather than forcing status='Open' via raw SQL regardless of + // configured transition rules. + $workflowModel = new WorkflowModel($conn); + $reopenStatus = 'Open'; + if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) { + // Direct Closed->Open isn't configured — fall back to any transition + // the Workflow Designer does allow from Closed that this unattended, + // non-admin automation can actually satisfy (no comment prompt, no + // admin elevation). If even that doesn't exist, leave the ticket + // Closed rather than force an unconfigured state. + $reopenStatus = null; + foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) { + if (!$transition['requires_comment'] && !$transition['requires_admin']) { + $reopenStatus = $transition['to_status']; + break; + } + } + } + + if ($reopenStatus !== null) { + $ticketModel = new TicketModel($conn); + $ticketModel->updateTicket([ + 'ticket_id' => $existingId, + 'title' => $title, + 'description' => $description, + 'category' => $category, + 'type' => $type, + 'status' => $reopenStatus, + 'priority' => $priority, + ], $userId); + } else { + error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId — " + . "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed"); + } $commentText = "**Issue recurred — ticket reopened automatically.**\n\n" . "hwmonDaemon detected this condition again. The ticket description reflects the " . "original report; see this comment's timestamp for when the issue recurred."; + if ($reopenStatus === null) { + $commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n" + . "hwmonDaemon detected this condition again. No Workflow Designer transition from " + . "Closed is configured that this automation can perform unattended (no comment/admin " + . "requirement); the ticket remains Closed. Please review and reopen manually if appropriate."; + } $commentStmt = $conn->prepare( "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" ); @@ -356,30 +393,40 @@ if ($existing) { $commentStmt->execute(); $commentStmt->close(); - $auditLog->log($userId, 'update', 'ticket', $existingId, [ - 'status' => ['from' => 'Closed', 'to' => 'Open'], - 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', - ]); + if ($reopenStatus !== null) { + $auditLog->log($userId, 'update', 'ticket', $existingId, [ + 'status' => ['from' => 'Closed', 'to' => $reopenStatus], + 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', + ]); - // Ticket reopened (Closed → Open) — refresh dashboard stats. - (new StatsModel($conn))->invalidateCache(); + // Ticket reopened — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); + } else { + $auditLog->log($userId, 'update', 'ticket', $existingId, [ + 'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed', + ]); + } Database::close(); - require_once __DIR__ . '/helpers/NotificationHelper.php'; - NotificationHelper::sendTicketNotification($existingId, [ - 'title' => $title, - 'priority' => $priority, - 'category' => $category, - 'type' => $type, - 'status' => 'Open', - ], 'automated'); + if ($reopenStatus !== null) { + require_once __DIR__ . '/helpers/NotificationHelper.php'; + NotificationHelper::sendTicketNotification($existingId, [ + 'title' => $title, + 'priority' => $priority, + 'category' => $category, + 'type' => $type, + 'status' => $reopenStatus, + ], 'automated'); + } echo json_encode([ 'success' => true, 'ticket_id' => $existingId, - 'message' => 'Existing closed ticket reopened', - 'action' => 'reopened', + 'message' => $reopenStatus !== null + ? 'Existing closed ticket reopened' + : 'Recurrence noted; ticket left Closed (no valid workflow transition configured)', + 'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted', ]); exit; } diff --git a/models/BulkOperationsModel.php b/models/BulkOperationsModel.php index 0b601d4..a5d68ef 100644 --- a/models/BulkOperationsModel.php +++ b/models/BulkOperationsModel.php @@ -125,13 +125,23 @@ class BulkOperationsModel $processed = 0; $failed = 0; $errors = []; + // Status-change notifications collected during the loop below and + // sent only after a successful commit, matching how the single-ticket + // and Bearer API paths never notify for a change that didn't durably + // land (and how an atomic-mode rollback must not fire any at all). + $notificationQueue = []; // Load required models require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; + require_once dirname(__DIR__) . '/models/UserModel.php'; + require_once dirname(__DIR__) . '/helpers/NotificationHelper.php'; $ticketModel = new TicketModel($this->conn); $auditLogModel = new AuditLogModel($this->conn); + $userModel = new UserModel($this->conn); + $actor = $operation['performed_by'] ? $userModel->getUserById((int)$operation['performed_by']) : null; + $changedByDisplay = $actor['display_name'] ?? $actor['username'] ?? null; // Batch load all tickets in one query to eliminate N+1 problem $ticketsById = $ticketModel->getTicketsByIds($ticketIds); @@ -221,8 +231,18 @@ class BulkOperationsModel 'update', 'ticket', $ticketId, - ['status' => 'Closed', 'bulk_operation_id' => $operationId] + [ + 'status' => ['from' => $currentTicket['status'], 'to' => 'Closed'], + 'bulk_operation_id' => $operationId, + ] ); + $notificationQueue[] = [ + 'ticketId' => $ticketId, + 'title' => $currentTicket['title'], + 'visibility' => $currentTicket['visibility'] ?? 'public', + 'oldStatus' => $currentTicket['status'], + 'newStatus' => 'Closed', + ]; } } break; @@ -291,8 +311,18 @@ class BulkOperationsModel 'update', 'ticket', $ticketId, - ['status' => $parameters['status'], 'bulk_operation_id' => $operationId] + [ + 'status' => ['from' => $currentTicket['status'], 'to' => $parameters['status']], + 'bulk_operation_id' => $operationId, + ] ); + $notificationQueue[] = [ + 'ticketId' => $ticketId, + 'title' => $currentTicket['title'], + 'visibility' => $currentTicket['visibility'] ?? 'public', + 'oldStatus' => $currentTicket['status'], + 'newStatus' => $parameters['status'], + ]; } } } @@ -364,6 +394,35 @@ class BulkOperationsModel @unlink($path); } } + + // Fire the same Matrix/watcher notifications the single-ticket and + // Bearer API status-change paths send, now that every change in + // this batch is durably committed. Best-effort: a notification + // failure must never turn an otherwise-successful bulk operation + // into an error. + foreach ($notificationQueue as $n) { + try { + NotificationHelper::sendStatusChangeNotification( + $n['ticketId'], + $n['oldStatus'], + $n['newStatus'], + $n['title'], + $changedByDisplay, + $n['visibility'] + ); + NotificationHelper::notifyWatchers( + $this->conn, + $n['ticketId'], + $n['title'], + 'status_changed', + ['old_status' => $n['oldStatus'], 'new_status' => $n['newStatus'], 'changed_by' => $changedByDisplay], + (int)$operation['performed_by'], + $n['visibility'] + ); + } catch (Throwable $e) { + error_log("Bulk operation $operationId: notification failed for ticket {$n['ticketId']}: " . $e->getMessage()); + } + } } catch (Exception $e) { // Rollback on any unexpected error $this->conn->rollback();