From 55a3d2945ca22917a3a27cdafedf37449dfb620a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 6 Apr 2026 22:37:53 -0400 Subject: [PATCH] Fix comment avatar, activity log labels, and ticket update permissions - add_comment.php: include user_id in response for avatar rendering - ticket.js: add buildCommentElement() helper that matches server-rendered comment structure (avatar, edit/delete buttons, textarea); use it in addComment() and submitReply() so new comments show the avatar immediately - AuditLogModel: logCommentCreate uses action_type='comment' not 'create' - TicketView: formatAction handles entity_type='comment' with action_type='create' for existing DB records; prevents "created this ticket" showing for comments - update_ticket.php: remove owner/assignee restriction so any authenticated team member can update ticket status and fields Co-Authored-By: Claude Sonnet 4.6 --- api/add_comment.php | 3 +- api/update_ticket.php | 12 +--- assets/js/ticket.js | 151 +++++++++++++++++++++++++-------------- models/AuditLogModel.php | 2 +- views/TicketView.php | 4 +- 5 files changed, 106 insertions(+), 66 deletions(-) diff --git a/api/add_comment.php b/api/add_comment.php index 1454ac5..8737dae 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -157,9 +157,10 @@ try { }, $mentionedUsers); } - // Add user display name to result for frontend + // Add user info to result for frontend avatar rendering if ($result['success']) { $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; + $result['user_id'] = $userId; } // Discard any unexpected output diff --git a/api/update_ticket.php b/api/update_ticket.php index ab82e17..ccc76bb 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -93,16 +93,8 @@ try { ]; } - // Authorization: admins can edit any ticket; others only their own or assigned - if (!$this->isAdmin - && (int)$currentTicket['created_by'] !== (int)$this->userId - && (int)$currentTicket['assigned_to'] !== (int)$this->userId - ) { - return [ - 'success' => false, - 'error' => 'Permission denied' - ]; - } + // Any authenticated team member can update tickets. + // Admin-only operations (delete, bulk actions) are enforced separately. // Merge current data with updates, keeping existing values for missing fields $updateData = [ diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 790e199..a218435 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -182,6 +182,81 @@ function toggleEditMode() { } } +/** + * Compute avatar color class from display name (mirrors PHP crc32 % 4 logic) + */ +function avatarColorClass(displayName) { + var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; + var h = 0; + for (var i = 0; i < displayName.length; i++) { + h = ((h << 5) - h + displayName.charCodeAt(i)) | 0; + } + return colors[Math.abs(h) % 4]; +} + +/** + * Build a comment/reply DOM element matching the server-rendered structure + */ +function buildCommentElement(opts) { + // opts: { commentId, userId, displayName, createdAt, commentText, isMarkdown, + // depth, parentId, canModify } + var depth = opts.depth || 0; + var depthClass = 'thread-depth-' + Math.min(depth, 3); + var threadClass = opts.parentId ? 'comment-reply' : 'comment-root'; + + var words = (opts.displayName || '').trim().split(/\s+/).filter(Boolean); + var initials = words.slice(0, 2).map(function(w) { return w[0].toUpperCase(); }).join(''); + var color = avatarColorClass(opts.displayName || ''); + var avatarImg = opts.userId > 0 + ? '' + : ''; + + var threadLine = opts.parentId ? '' : ''; + + var replyBtn = depth < 3 + ? '' + : ''; + var modBtns = opts.canModify !== false + ? '' + + '' + : ''; + + var div = document.createElement('div'); + div.className = 'comment ' + depthClass + ' ' + threadClass + ' animate-fadein'; + div.dataset.commentId = opts.commentId; + div.dataset.markdownEnabled = opts.isMarkdown ? '1' : '0'; + div.dataset.threadDepth = depth; + div.dataset.parentId = opts.parentId || ''; + + div.innerHTML = + threadLine + + '
' + + '
' + + '' + + '' + lt.escHtml(opts.displayName) + '' + + '' + lt.escHtml(opts.createdAt) + '' + + '
' + replyBtn + modBtns + '
' + + '
' + + '
' + + opts.commentText + + '
' + + '' + + '
'; + + return div; +} + function addComment() { const newComment = document.getElementById('newComment'); if (!newComment) return; @@ -226,32 +301,19 @@ function addComment() { .replace(/\n/g, '
'); } - // Add new comment to the list (using safe DOM API to prevent XSS) + // Add new comment to the list const commentsList = document.querySelector('.comments-list'); - - const commentDiv = document.createElement('div'); - commentDiv.className = 'comment'; - - const headerDiv = document.createElement('div'); - headerDiv.className = 'comment-header'; - - const userSpan = document.createElement('span'); - userSpan.className = 'comment-user'; - userSpan.textContent = data.user_name; // Safe - auto-escapes - - const dateSpan = document.createElement('span'); - dateSpan.className = 'comment-date'; - dateSpan.textContent = data.created_at; // Safe - auto-escapes - - const textDiv = document.createElement('div'); - textDiv.className = 'comment-text'; - textDiv.innerHTML = displayText; // displayText already sanitized above - - headerDiv.appendChild(userSpan); - headerDiv.appendChild(dateSpan); - commentDiv.appendChild(headerDiv); - commentDiv.appendChild(textDiv); - + const commentDiv = buildCommentElement({ + commentId: data.comment_id, + userId: data.user_id, + displayName: data.user_name, + createdAt: data.created_at, + commentText: displayText, + rawText: commentText, + isMarkdown: isMarkdownEnabled, + depth: 0, + parentId: null, + }); commentsList.insertBefore(commentDiv, commentsList.firstChild); } else { lt.toast.error(data.error || 'Failed to add comment'); @@ -1550,34 +1612,17 @@ function submitReply(parentCommentId) { } // Create the new reply element - const replyDiv = document.createElement('div'); - replyDiv.className = `comment thread-depth-${newDepth} comment-reply`; - replyDiv.dataset.commentId = data.comment_id; - replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0'; - replyDiv.dataset.threadDepth = newDepth; - replyDiv.dataset.parentId = parentCommentId; - - replyDiv.innerHTML = ` -
-
-
- ${lt.escHtml(data.user_name)} - ${lt.escHtml(data.created_at)} -
- ${newDepth < 3 ? `` : ''} - - -
-
-
- ${displayText} -
- -
- `; - - // Add animation - replyDiv.classList.add('animate-fadein'); + const replyDiv = buildCommentElement({ + commentId: data.comment_id, + userId: data.user_id, + displayName: data.user_name, + createdAt: data.created_at, + commentText: displayText, + rawText: commentText, + isMarkdown: isMarkdownEnabled, + depth: newDepth, + parentId: parentCommentId, + }); repliesContainer.appendChild(replyDiv); } diff --git a/models/AuditLogModel.php b/models/AuditLogModel.php index 565041c..4f0acef 100644 --- a/models/AuditLogModel.php +++ b/models/AuditLogModel.php @@ -369,7 +369,7 @@ class AuditLogModel { public function logCommentCreate($userId, $commentId, $ticketId) { return $this->log( $userId, - 'create', + 'comment', 'comment', (string)$commentId, ['ticket_id' => $ticketId] diff --git a/views/TicketView.php b/views/TicketView.php index 37589bf..1a4095e 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -37,7 +37,9 @@ function getEventIcon(string $actionType): string { function formatAction(array $event): string { $det = $event['details'] ?? []; switch ($event['action_type']) { - case 'create': return 'created this ticket'; + case 'create': + if (($event['entity_type'] ?? '') === 'comment') return 'posted a comment'; + return 'created this ticket'; case 'comment': return 'posted a comment'; case 'view': return 'viewed this ticket'; case 'attachment': return 'uploaded a file';