Log watch/unwatch actions to the audit trail (#93)
api/watch_ticket.php performed the ticket_watchers INSERT IGNORE/DELETE
directly with no AuditLogModel call, unlike every other ticket-adjacent
mutation (comments, attachments, dependencies, status/field changes),
so watching/unwatching never showed up in a ticket's timeline.
Added AuditLogModel::log() calls to both the watch and unwatch paths,
gated on the DB statement's affected_rows so a no-op (already watching,
already not watching) doesn't produce a duplicate timeline entry. Added
'watch'/'unwatch' to AuditLogModel's VALID_ACTION_TYPES, and timeline
rendering in views/TicketView.php ("started watching this ticket" /
"stopped watching this ticket").
Verified against real MariaDB: watch -> unwatch -> watch again produces
exactly 2 timeline entries (not 4) since the two no-op repeats correctly
produced zero rows changed and were not logged; confirmed formatAction()/
getEventIcon() render both action types correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
);
|
);
|
||||||
$stmt->bind_param("si", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
$rowsChanged = $stmt->affected_rows;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
} else {
|
} else {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
@@ -50,9 +52,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
);
|
);
|
||||||
$stmt->bind_param("si", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
$rowsChanged = $stmt->affected_rows;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only log an actual state change — INSERT IGNORE/DELETE are no-ops when
|
||||||
|
// the user was already watching/not watching, and that shouldn't show up
|
||||||
|
// in the ticket's timeline as a new event.
|
||||||
|
if ($rowsChanged > 0) {
|
||||||
|
(new AuditLogModel($conn))->log($userId, $action, 'ticket', $ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
// Return updated state
|
// Return updated state
|
||||||
$countStmt = $conn->prepare(
|
$countStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class AuditLogModel
|
|||||||
private const VALID_ACTION_TYPES = [
|
private const VALID_ACTION_TYPES = [
|
||||||
'create', 'update', 'delete', 'view', 'security_event',
|
'create', 'update', 'delete', 'view', 'security_event',
|
||||||
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
|
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
|
||||||
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update'
|
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update',
|
||||||
|
'watch', 'unwatch'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @var array Allowed entity types for filtering */
|
/** @var array Allowed entity types for filtering */
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ function getEventIcon(string $actionType): string
|
|||||||
'status_change' => '[!]',
|
'status_change' => '[!]',
|
||||||
'attachment' => '[^]',
|
'attachment' => '[^]',
|
||||||
'delete' => '[x]',
|
'delete' => '[x]',
|
||||||
|
'watch' => '[o]',
|
||||||
|
'unwatch' => '[o]',
|
||||||
default => '[*]',
|
default => '[*]',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,10 @@ function formatAction(array $event): string
|
|||||||
return 'uploaded a file';
|
return 'uploaded a file';
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return 'deleted a comment';
|
return 'deleted a comment';
|
||||||
|
case 'watch':
|
||||||
|
return 'started watching this ticket';
|
||||||
|
case 'unwatch':
|
||||||
|
return 'stopped watching this ticket';
|
||||||
case 'assign':
|
case 'assign':
|
||||||
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
||||||
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
||||||
|
|||||||
Reference in New Issue
Block a user