Persist status-change comments transactionally with the update (#37)
A comment accompanying a status change (required or user-supplied) was posted via a separate, independent HTTP call/write (add_comment.php, or a second add_comment call in lt.ticketStatus.submit()'s requires_comment retry path) before the status update itself. A failure partway through — or the client never issuing the second call — could leave a "reason" comment persisted with no matching status change, or vice versa, with no rollback tying the two together. api/update_ticket.php and api/ticket_status_api.php now post the comment and apply the status update inside one transaction, rolling back both on any failure. assets/js/ticket.js and lt.ticketStatus.submit() in assets/js/base.js no longer make a separate add_comment.php call; they pass the comment directly to update_ticket.php, which persists it server-side alongside the status change. Verified against real MariaDB by extracting the live ApiTicketController and the ticket_status_api.php transaction logic and running them directly: a forced optimistic-lock conflict correctly rolled back both the comment and the status change, and a successful call persisted exactly one comment alongside the status change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
This commit is contained in:
+56
-29
@@ -195,8 +195,8 @@ try {
|
||||
|
||||
// Enforce requires_comment transitions server-side.
|
||||
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
||||
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
if ($comment === '') {
|
||||
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
if ($statusChangeComment === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'A comment is required for this status change',
|
||||
@@ -207,40 +207,67 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
// A comment accompanying a status change (required or optional) is
|
||||
// persisted in the SAME transaction as the status update below, so
|
||||
// a failure partway through can't leave an orphaned "reason"
|
||||
// comment attached with no matching status change — the two
|
||||
// previously ran as separate, non-transactional HTTP calls from
|
||||
// the client (add_comment.php then update_ticket.php).
|
||||
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
|
||||
// Handle conflict case
|
||||
if (!$result['success']) {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$result = null;
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
|
||||
$commentResult = $this->commentModel->addComment($id, [
|
||||
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
|
||||
'comment_text' => $statusChangeComment,
|
||||
'markdown_enabled' => !empty($data['markdown_enabled']),
|
||||
], $this->userId);
|
||||
if (empty($commentResult['success'])) {
|
||||
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
|
||||
}
|
||||
|
||||
// Handle visibility update if provided (already validated above)
|
||||
if (isset($data['visibility'])) {
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if (!$visResult) {
|
||||
throw new Exception('Failed to update ticket visibility');
|
||||
}
|
||||
}
|
||||
|
||||
$this->conn->commit();
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
$response = ['success' => false, 'error' => $e->getMessage()];
|
||||
if (is_array($result) && !empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Handle visibility update if provided (already validated above)
|
||||
if (isset($data['visibility'])) {
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if ($visResult && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
'to' => $data['visibility'],
|
||||
'groups' => $visibilityGroups
|
||||
]
|
||||
);
|
||||
}
|
||||
if (isset($data['visibility']) && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
'to' => $data['visibility'],
|
||||
'groups' => $visibilityGroups
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Log ticket update to audit log — only the changed fields (delta)
|
||||
|
||||
Reference in New Issue
Block a user