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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 22:37:53 -04:00
parent 727c5171ff
commit 55a3d2945c
5 changed files with 106 additions and 66 deletions
+2 -1
View File
@@ -157,9 +157,10 @@ try {
}, $mentionedUsers); }, $mentionedUsers);
} }
// Add user display name to result for frontend // Add user info to result for frontend avatar rendering
if ($result['success']) { if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
} }
// Discard any unexpected output // Discard any unexpected output
+2 -10
View File
@@ -93,16 +93,8 @@ try {
]; ];
} }
// Authorization: admins can edit any ticket; others only their own or assigned // Any authenticated team member can update tickets.
if (!$this->isAdmin // Admin-only operations (delete, bulk actions) are enforced separately.
&& (int)$currentTicket['created_by'] !== (int)$this->userId
&& (int)$currentTicket['assigned_to'] !== (int)$this->userId
) {
return [
'success' => false,
'error' => 'Permission denied'
];
}
// Merge current data with updates, keeping existing values for missing fields // Merge current data with updates, keeping existing values for missing fields
$updateData = [ $updateData = [
+98 -53
View File
@@ -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
? '<img src="/api/user_avatar.php?user_id=' + opts.userId + '" alt="" class="lt-avatar-img">'
: '';
var threadLine = opts.parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var replyBtn = depth < 3
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
' data-action="reply-comment" data-comment-id="' + opts.commentId + '"' +
' data-user="' + lt.escHtml(opts.displayName) + '" aria-label="Reply to comment">Reply</button>'
: '';
var modBtns = opts.canModify !== false
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + opts.commentId + '" aria-label="Edit comment">Edit</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
' data-action="delete-comment" data-comment-id="' + opts.commentId + '" aria-label="Delete comment">Del</button>'
: '';
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 +
'<div class="comment-content">' +
'<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">' +
'<div class="lt-avatar lt-avatar--xs ' + color + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(opts.displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' + lt.escHtml(opts.createdAt) + '</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text" id="comment-text-' + opts.commentId + '"' +
(opts.isMarkdown ? ' data-markdown data-rendered="1"' : '') + '>' +
opts.commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"' +
' id="comment-raw-' + opts.commentId + '" aria-hidden="true">' +
lt.escHtml(opts.rawText) +
'</textarea>' +
'</div>';
return div;
}
function addComment() { function addComment() {
const newComment = document.getElementById('newComment'); const newComment = document.getElementById('newComment');
if (!newComment) return; if (!newComment) return;
@@ -226,32 +301,19 @@ function addComment() {
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
} }
// 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 commentsList = document.querySelector('.comments-list');
const commentDiv = buildCommentElement({
const commentDiv = document.createElement('div'); commentId: data.comment_id,
commentDiv.className = 'comment'; userId: data.user_id,
displayName: data.user_name,
const headerDiv = document.createElement('div'); createdAt: data.created_at,
headerDiv.className = 'comment-header'; commentText: displayText,
rawText: commentText,
const userSpan = document.createElement('span'); isMarkdown: isMarkdownEnabled,
userSpan.className = 'comment-user'; depth: 0,
userSpan.textContent = data.user_name; // Safe - auto-escapes parentId: null,
});
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);
commentsList.insertBefore(commentDiv, commentsList.firstChild); commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else { } else {
lt.toast.error(data.error || 'Failed to add comment'); lt.toast.error(data.error || 'Failed to add comment');
@@ -1550,34 +1612,17 @@ function submitReply(parentCommentId) {
} }
// Create the new reply element // Create the new reply element
const replyDiv = document.createElement('div'); const replyDiv = buildCommentElement({
replyDiv.className = `comment thread-depth-${newDepth} comment-reply`; commentId: data.comment_id,
replyDiv.dataset.commentId = data.comment_id; userId: data.user_id,
replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0'; displayName: data.user_name,
replyDiv.dataset.threadDepth = newDepth; createdAt: data.created_at,
replyDiv.dataset.parentId = parentCommentId; commentText: displayText,
rawText: commentText,
replyDiv.innerHTML = ` isMarkdown: isMarkdownEnabled,
<div class="thread-line"></div> depth: newDepth,
<div class="comment-content"> parentId: parentCommentId,
<div class="comment-header"> });
<span class="comment-user">${lt.escHtml(data.user_name)}</span>
<span class="comment-date">${lt.escHtml(data.created_at)}</span>
<div class="comment-actions">
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${lt.escHtml(data.user_name)}" title="Reply">↩</button>` : ''}
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">[ EDIT ]</button>
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">[ DEL ]</button>
</div>
</div>
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
${displayText}
</div>
<textarea class="comment-edit-raw is-hidden" id="comment-raw-${data.comment_id}">${lt.escHtml(commentText)}</textarea>
</div>
`;
// Add animation
replyDiv.classList.add('animate-fadein');
repliesContainer.appendChild(replyDiv); repliesContainer.appendChild(replyDiv);
} }
+1 -1
View File
@@ -369,7 +369,7 @@ class AuditLogModel {
public function logCommentCreate($userId, $commentId, $ticketId) { public function logCommentCreate($userId, $commentId, $ticketId) {
return $this->log( return $this->log(
$userId, $userId,
'create', 'comment',
'comment', 'comment',
(string)$commentId, (string)$commentId,
['ticket_id' => $ticketId] ['ticket_id' => $ticketId]
+3 -1
View File
@@ -37,7 +37,9 @@ function getEventIcon(string $actionType): string {
function formatAction(array $event): string { function formatAction(array $event): string {
$det = $event['details'] ?? []; $det = $event['details'] ?? [];
switch ($event['action_type']) { 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 'comment': return 'posted a comment';
case 'view': return 'viewed this ticket'; case 'view': return 'viewed this ticket';
case 'attachment': return 'uploaded a file'; case 'attachment': return 'uploaded a file';