Compare commits

..
Author SHA1 Message Date
jared f342e446d3 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
- Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
- Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
2026-09-11 12:44:06 -04:00
jaredandClaude Sonnet 5 18c213ebd7 Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 34s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m25s
Lint / Deploy (push) Successful in 8s
create_ticket_api.php's dedup-reopen path wrote status = 'Open' via a
raw SQL UPDATE, completely bypassing TicketModel::updateTicket() and
WorkflowModel::isTransitionAllowed() — the one status-write path in
the app that never consulted the workflow engine at all. If an admin
configured the Workflow Designer to disallow a direct Closed->Open
transition, this automated path still forced it unconditionally.

Now checks isTransitionAllowed('Closed', 'Open', false) first (false
since this is an unattended system account, not admin-elevated). If
not allowed, falls back to any transition the Workflow Designer does
allow from Closed that requires neither a comment nor admin privilege
(both of which this unattended automation can't satisfy), and applies
it via TicketModel::updateTicket() instead of raw SQL. If no such
transition exists at all, the ticket is deliberately left Closed
(rather than forcing an unconfigured state) with a comment and audit
entry explaining why, so the recurrence is still visible to a human
without silently violating workflow rules.

Verified against real MariaDB across all three branches: direct
Closed->Open allowed (reopens to Open), disallowed but Closed->'In
Progress' available unattended (falls back correctly), and no usable
transition configured at all (ticket correctly stays Closed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:46 -04:00
jaredandClaude Sonnet 5 6adbb29964 Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
BulkOperationsModel's bulk_close/bulk_status paths had zero references
to NotificationHelper — the exact same status transition (e.g.
Open->Closed) silently produced no Matrix/watcher notification when
performed via bulk actions, while the single-ticket edit page and
Bearer API both notify on every status change. Separately, their
audit_log entries used a bare ['status' => 'Closed', ...] shape
instead of the {'status': {'from': X, 'to': Y}} shape every other
status-change path uses, which broke two downstream consumers:
TicketView.php's timeline fell back to a generic "updated this
ticket" instead of "updated status", and notifications.php's
$details['status']['from'] on a string produced a broken "? -> ?"
notification title.

Fixed the audit_log shape for both operation types, and added a
notification queue collected during the per-ticket loop and flushed
only after a successful commit (so atomic-mode rollback correctly
sends zero notifications, matching how nothing else about a rolled-
back batch takes effect either). Also fixed an incidental bug found
while matching this to the single-ticket path: update_ticket.php's
notifyWatchers() call never passed the ticket's visibility, silently
defaulting to 'public' and always including the shared notify list
even for confidential/internal tickets — the exact leak #71 fixed
elsewhere in NotificationHelper itself, just never reaching this
call site.

Verified against real MariaDB with a real local webhook-capturing
server: bulk_close correctly fires sendStatusChangeNotification() +
notifyWatchers() with the right old/new status and a redacted title
for a confidential ticket; audit_log rows show the correct {from,to}
shape; and an atomic-mode rollback (one ticket's transition invalid)
sends zero notifications and leaves both tickets unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:37 -04:00
3 changed files with 133 additions and 26 deletions
+2 -1
View File
@@ -276,7 +276,8 @@ try {
$updateData['title'], $updateData['title'],
'status_changed', 'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId (int)$this->userId,
$currentTicket['visibility'] ?? 'public'
); );
} }
+59 -12
View File
@@ -42,6 +42,8 @@ try {
require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php'; require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.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'; require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn); $apiKeyAuth = new ApiKeyAuth($conn);
@@ -338,17 +340,52 @@ if ($existing) {
exit; exit;
} }
// Ticket was closed — reopen it and add a recurrence comment // Ticket was closed — reopen it and add a recurrence comment. Route
$reopenStmt = $conn->prepare( // through the Workflow Designer like every other status-write path in
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?" // the app, rather than forcing status='Open' via raw SQL regardless of
); // configured transition rules.
$reopenStmt->bind_param("is", $userId, $existingId); $workflowModel = new WorkflowModel($conn);
$reopenStmt->execute(); $reopenStatus = 'Open';
$reopenStmt->close(); 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" . $commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"hwmonDaemon detected this condition again. The ticket description reflects the " "hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred."; . "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( $commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" "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->execute();
$commentStmt->close(); $commentStmt->close();
if ($reopenStatus !== null) {
$auditLog->log($userId, 'update', 'ticket', $existingId, [ $auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'], 'status' => ['from' => 'Closed', 'to' => $reopenStatus],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]); ]);
// Ticket reopened (Closed → Open) — refresh dashboard stats. // Ticket reopened — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache(); (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(); Database::close();
if ($reopenStatus !== null) {
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [ NotificationHelper::sendTicketNotification($existingId, [
'title' => $title, 'title' => $title,
'priority' => $priority, 'priority' => $priority,
'category' => $category, 'category' => $category,
'type' => $type, 'type' => $type,
'status' => 'Open', 'status' => $reopenStatus,
], 'automated'); ], 'automated');
}
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'ticket_id' => $existingId, 'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened', 'message' => $reopenStatus !== null
'action' => 'reopened', ? 'Existing closed ticket reopened'
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
]); ]);
exit; exit;
} }
+61 -2
View File
@@ -125,13 +125,23 @@ class BulkOperationsModel
$processed = 0; $processed = 0;
$failed = 0; $failed = 0;
$errors = []; $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 // Load required models
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.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); $ticketModel = new TicketModel($this->conn);
$auditLogModel = new AuditLogModel($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 // Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds); $ticketsById = $ticketModel->getTicketsByIds($ticketIds);
@@ -221,8 +231,18 @@ class BulkOperationsModel
'update', 'update',
'ticket', 'ticket',
$ticketId, $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; break;
@@ -291,8 +311,18 @@ class BulkOperationsModel
'update', 'update',
'ticket', 'ticket',
$ticketId, $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); @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) { } catch (Exception $e) {
// Rollback on any unexpected error // Rollback on any unexpected error
$this->conn->rollback(); $this->conn->rollback();