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
+81 -2
View File
@@ -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
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)
@@ -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'
];
}
}