diff --git a/api/export_tickets.php b/api/export_tickets.php index 68e19c7..5c8d1b7 100644 --- a/api/export_tickets.php +++ b/api/export_tickets.php @@ -19,6 +19,8 @@ try { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.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 if (session_status() === PHP_SESSION_NONE) { session_start(); } @@ -39,8 +41,9 @@ try { $category = isset($_GET['category']) ? $_GET['category'] : null; $type = isset($_GET['type']) ? $_GET['type'] : 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; + $singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : null; // Initialize model $ticketModel = new TicketModel($conn); @@ -149,10 +152,86 @@ try { ], JSON_PRETTY_PRINT); 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 { header('Content-Type: application/json'); 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; } diff --git a/api/update_ticket.php b/api/update_ticket.php index f4f3ac1..c01f4d6 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -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) @@ -197,10 +208,11 @@ try { } return [ - 'success' => true, - 'status' => $updateData['status'], - 'priority' => $updateData['priority'], - 'message' => 'Ticket updated successfully' + 'success' => true, + 'status' => $updateData['status'], + 'priority' => $updateData['priority'], + 'updated_at' => date('Y-m-d H:i:s'), + 'message' => 'Ticket updated successfully' ]; } } diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 81d4e3c..2ae1b8b 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -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 lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data }) - .then(data => { - if (data.success) { + .then(resp => { + if (resp.success) { const statusDisplay = document.getElementById('statusDisplay'); if (statusDisplay) { - statusDisplay.className = `status-${data.status}`; - statusDisplay.textContent = data.status; + statusDisplay.className = `status-${resp.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'); + } 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 { - lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error')); + lt.toast.error('Error saving ticket: ' + (resp.error || 'Unknown error')); } }) .catch(error => { diff --git a/views/TicketView.php b/views/TicketView.php index c7a0d87..12fd78f 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -71,20 +71,22 @@ $visUserModel = new UserModel($conn); $allAvailableGroups = $visUserModel->getAllGroups(); // JSON-encode ticket fields for the inline script -$json_ticket_id = json_encode($ticket['ticket_id'], JSON_HEX_TAG); -$json_title = json_encode($ticket['title'], JSON_HEX_TAG); -$json_status = json_encode($ticket['status'], JSON_HEX_TAG); -$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG); -$json_category = json_encode($ticket['category'], JSON_HEX_TAG); -$json_type = json_encode($ticket['type'], JSON_HEX_TAG); +$json_ticket_id = json_encode($ticket['ticket_id'], JSON_HEX_TAG); +$json_title = json_encode($ticket['title'], JSON_HEX_TAG); +$json_status = json_encode($ticket['status'], JSON_HEX_TAG); +$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG); +$json_category = json_encode($ticket['category'], JSON_HEX_TAG); +$json_type = json_encode($ticket['type'], JSON_HEX_TAG); +$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG); $pageInlineScript = << + EXPORT