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:
+81
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+17
-5
@@ -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'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user