Wire optimistic locking, visibility audit log, full ticket export

Optimistic locking:
- TicketView now includes updated_at in window.ticketData
- ticket.js saveTicket() sends expected_updated_at on every save so
  the server can detect concurrent edits
- On conflict response, shows a clear toast: "ticket was modified by
  someone else while you were editing — reload to see latest version"
- On success, syncs window.ticketData.updated_at from server response
  so subsequent saves use the correct lock key
- update_ticket.php now returns updated_at in success response

Visibility audit log:
- updateVisibility() result is now checked; on success, logs a delta
  entry to the audit trail with from/to visibility and groups so the
  timeline shows who changed visibility and when

Full ticket export:
- export_tickets.php now accepts format=full with a single ticket_id
- Produces a JSON file containing ticket fields, flat comment list
  (with author, timestamps, text), and the full audit timeline
- Access-controlled: respects canUserAccessTicket() before exporting
- EXPORT button added to ticket toolbar linking directly to the endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:19:01 -04:00
parent 2fdd42b45b
commit cc3f667d4c
4 changed files with 132 additions and 24 deletions
+80 -1
View File
@@ -19,6 +19,8 @@ try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { session_start(); } if (session_status() === PHP_SESSION_NONE) { session_start(); }
@@ -41,6 +43,7 @@ try {
$search = isset($_GET['search']) ? trim($_GET['search']) : null; $search = isset($_GET['search']) ? trim($_GET['search']) : null;
$format = isset($_GET['format']) ? $_GET['format'] : 'csv'; $format = isset($_GET['format']) ? $_GET['format'] : 'csv';
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null; $ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
$singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : null;
// Initialize model // Initialize model
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
@@ -149,10 +152,86 @@ try {
], JSON_PRETTY_PRINT); ], JSON_PRETTY_PRINT);
exit; exit;
} elseif ($format === 'full') {
// Full single-ticket export: ticket + all comments + audit timeline
if (!$singleId) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
exit;
}
$ticket = $ticketModel->getTicketById($singleId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$auditLogModel = new AuditLogModel($conn);
// Load flat comment list (no threading nesting in export)
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
$comments = array_map(function($c) {
return [
'comment_id' => $c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
'created_at' => $c['created_at'],
'updated_at' => $c['updated_at'] ?? null,
'comment_text' => $c['comment_text'],
'parent_comment_id' => $c['parent_comment_id'] ?? null,
];
}, $rawComments);
$timelineOut = array_map(function($row) {
$details = $row['details'];
if (is_string($details)) {
$details = json_decode($details, true) ?? $details;
}
return [
'action' => $row['action_type'],
'entity' => $row['entity_type'],
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
'details' => $details,
'created_at' => $row['created_at'],
];
}, $timeline);
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
header('Cache-Control: no-cache, must-revalidate');
echo json_encode([
'exported_at' => date('c'),
'ticket' => [
'ticket_id' => $ticket['ticket_id'],
'title' => $ticket['title'],
'status' => $ticket['status'],
'priority' => 'P' . $ticket['priority'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'visibility' => $ticket['visibility'] ?? 'public',
'description' => $ticket['description'],
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
'created_at' => $ticket['created_at'],
'updated_at' => $ticket['updated_at'],
'closed_at' => $ticket['closed_at'] ?? null,
],
'comments' => $comments,
'comment_count' => count($comments),
'timeline' => $timelineOut,
], JSON_PRETTY_PRINT);
exit;
} else { } else {
header('Content-Type: application/json'); header('Content-Type: application/json');
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']); echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
exit; exit;
} }
+13 -1
View File
@@ -177,7 +177,18 @@ try {
]; ];
} }
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); $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
]
);
}
} }
// Log ticket update to audit log — only the changed fields (delta) // Log ticket update to audit log — only the changed fields (delta)
@@ -200,6 +211,7 @@ try {
'success' => true, 'success' => true,
'status' => $updateData['status'], 'status' => $updateData['status'],
'priority' => $updateData['priority'], 'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully' 'message' => 'Ticket updated successfully'
]; ];
} }
+16 -5
View File
@@ -47,18 +47,29 @@ function saveTicket() {
} }
} }
// Include optimistic lock timestamp so the server can detect concurrent edits
if (window.ticketData && window.ticketData.updated_at) {
data.expected_updated_at = window.ticketData.updated_at;
}
// Use the correct API path // Use the correct API path
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data }) lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => { .then(resp => {
if (data.success) { if (resp.success) {
const statusDisplay = document.getElementById('statusDisplay'); const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) { if (statusDisplay) {
statusDisplay.className = `status-${data.status}`; statusDisplay.className = `status-${resp.status}`;
statusDisplay.textContent = data.status; statusDisplay.textContent = resp.status;
}
// Keep local updated_at in sync so the next save uses the right lock key
if (resp.updated_at && window.ticketData) {
window.ticketData.updated_at = resp.updated_at;
} }
lt.toast.success('Ticket updated successfully'); lt.toast.success('Ticket updated successfully');
} else if (resp.conflict) {
lt.toast.error('This ticket was modified by someone else while you were editing. Reload to see the latest version.', 8000);
} else { } else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error')); lt.toast.error('Error saving ticket: ' + (resp.error || 'Unknown error'));
} }
}) })
.catch(error => { .catch(error => {
+6
View File
@@ -77,6 +77,7 @@ $json_status = json_encode($ticket['status'], JSON_HEX_TAG);
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG); $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG); $json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG); $json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$pageInlineScript = <<<JS $pageInlineScript = <<<JS
window.ticketData = { window.ticketData = {
ticket_id: {$json_ticket_id}, ticket_id: {$json_ticket_id},
@@ -85,6 +86,7 @@ window.ticketData = {
priority: {$json_priority}, priority: {$json_priority},
category: {$json_category}, category: {$json_category},
type: {$json_type}, type: {$json_type},
updated_at: {$json_updated_at},
}; };
window.ticketData.id = window.ticketData.ticket_id; window.ticketData.id = window.ticketData.ticket_id;
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();
@@ -122,6 +124,10 @@ include __DIR__ . '/layout_header.php';
</select> </select>
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button> <button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button> <button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
<a id="exportFullBtn"
href="/api/export_tickets.php?format=full&ticket_id=<?= (int)$ticket['ticket_id'] ?>"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Export this ticket with all comments and history as JSON">EXPORT</a>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button> <button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button>
</div> </div>
</div> </div>