Merge development into main: bulk-op notification/audit fixes + workflow-validated auto-reopen (#67, #68, #74)
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
- Fire notifications and fix audit_log shape for bulk status changes (#67, #74) - Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
This commit is contained in:
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+70
-23
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user