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:
+80
-1
@@ -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(); }
|
||||
@@ -41,6 +43,7 @@ try {
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -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)
|
||||
@@ -200,6 +211,7 @@ try {
|
||||
'success' => true,
|
||||
'status' => $updateData['status'],
|
||||
'priority' => $updateData['priority'],
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'message' => 'Ticket updated successfully'
|
||||
];
|
||||
}
|
||||
|
||||
+16
-5
@@ -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 => {
|
||||
|
||||
@@ -77,6 +77,7 @@ $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 = <<<JS
|
||||
window.ticketData = {
|
||||
ticket_id: {$json_ticket_id},
|
||||
@@ -85,6 +86,7 @@ window.ticketData = {
|
||||
priority: {$json_priority},
|
||||
category: {$json_category},
|
||||
type: {$json_type},
|
||||
updated_at: {$json_updated_at},
|
||||
};
|
||||
window.ticketData.id = window.ticketData.ticket_id;
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
@@ -122,6 +124,10 @@ include __DIR__ . '/layout_header.php';
|
||||
</select>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user