Files
tinker_tickets/models/CommentModel.php
Jared Vititoe a8738fdf57 Add comment threading and fix fetch authentication
- Add comment threading/reply functionality with nested display
  - Database migration for parent_comment_id and thread_depth columns
  - Recursive comment rendering with depth-based indentation
  - Reply form with inline UI and smooth animations
  - Thread collapse/expand capability
  - Max thread depth of 3 levels

- Fix 401 authentication errors on API calls
  - Add credentials: 'same-origin' to all fetch calls
  - Affects settings.js, ticket.js, dashboard.js, advanced-search.js
  - Ensures session cookies are sent with requests

- Enhanced comment styling
  - Thread connector lines for visual hierarchy
  - Reply button on comments (up to depth 3)
  - Quote block styling for replies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:43:36 -05:00

309 lines
10 KiB
PHP

<?php
class CommentModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Extract @mentions from comment text
*
* @param string $text Comment text
* @return array Array of mentioned usernames
*/
public function extractMentions($text) {
$mentions = [];
// Match @username patterns (alphanumeric, underscores, hyphens)
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
$mentions = array_unique($matches[1]);
}
return $mentions;
}
/**
* Get user IDs for mentioned usernames
*
* @param array $usernames Array of usernames
* @return array Array of user records with user_id, username, display_name
*/
public function getMentionedUsers($usernames) {
if (empty($usernames)) {
return [];
}
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($usernames));
$stmt->bind_param($types, ...$usernames);
$stmt->execute();
$result = $stmt->get_result();
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$stmt->close();
return $users;
}
public function getCommentsByTicketId($ticketId, $threaded = true) {
// Check if threading columns exist
$hasThreading = $this->hasThreadingSupport();
if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
ORDER BY
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
} else {
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
ORDER BY tc.created_at DESC";
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$comments = [];
$commentMap = [];
while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name'];
} else {
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
}
$row['replies'] = [];
$row['parent_comment_id'] = $row['parent_comment_id'] ?? null;
$row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row;
}
// Build threaded structure if threading is enabled
if ($hasThreading && $threaded) {
$rootComments = [];
foreach ($commentMap as $id => $comment) {
if ($comment['parent_comment_id'] === null) {
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
}
}
return $rootComments;
}
// Flat list
return array_values($commentMap);
}
/**
* Check if threading columns exist
*/
private function hasThreadingSupport() {
static $hasSupport = null;
if ($hasSupport !== null) {
return $hasSupport;
}
$result = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'parent_comment_id'");
$hasSupport = ($result && $result->num_rows > 0);
return $hasSupport;
}
/**
* Recursively build comment thread
*/
private function buildCommentThread($comment, &$allComments) {
$comment['replies'] = [];
foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id']) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
}
}
// Sort replies by date ascending
usort($comment['replies'], function($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
return $comment;
}
/**
* Get flat list of comments (for backward compatibility)
*/
public function getCommentsByTicketIdFlat($ticketId) {
return $this->getCommentsByTicketId($ticketId, false);
}
public function addComment($ticketId, $commentData, $userId = null) {
// Check if threading is supported
$hasThreading = $this->hasThreadingSupport();
// Set default username (kept for backward compatibility)
$username = $commentData['user_name'] ?? 'User';
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
$commentText = $commentData['comment_text'];
$parentCommentId = $commentData['parent_comment_id'] ?? null;
$threadDepth = 0;
// Calculate thread depth if replying to a comment
if ($hasThreading && $parentCommentId) {
$parentComment = $this->getCommentById($parentCommentId);
if ($parentComment) {
$threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3
}
}
if ($hasThreading) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sissiii",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled,
$parentCommentId,
$threadDepth
);
} else {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sissi",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled
);
}
if ($stmt->execute()) {
$commentId = $this->conn->insert_id;
return [
'success' => true,
'comment_id' => $commentId,
'user_name' => $username,
'created_at' => date('M d, Y H:i'),
'markdown_enabled' => $markdownEnabled,
'comment_text' => $commentText,
'parent_comment_id' => $parentCommentId,
'thread_depth' => $threadDepth
];
} else {
return [
'success' => false,
'error' => $this->conn->error
];
}
}
/**
* Get a single comment by ID
*/
public function getCommentById($commentId) {
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.comment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $commentId);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc();
}
/**
* Update an existing comment
* Only the comment owner or an admin can update
*/
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
if (!$comment) {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
}
// Check if updated_at column exists
$hasUpdatedAt = false;
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
if ($colCheck && $colCheck->num_rows > 0) {
$hasUpdatedAt = true;
}
if ($hasUpdatedAt) {
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
} else {
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
}
$stmt = $this->conn->prepare($sql);
$markdownInt = $markdownEnabled ? 1 : 0;
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
if ($stmt->execute()) {
return [
'success' => true,
'comment_id' => $commentId,
'comment_text' => $commentText,
'markdown_enabled' => $markdownInt,
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
];
} else {
return ['success' => false, 'error' => $this->conn->error];
}
}
/**
* Delete a comment
* Only the comment owner or an admin can delete
*/
public function deleteComment($commentId, $userId, $isAdmin = false) {
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
if (!$comment) {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
}
$ticketId = $comment['ticket_id'];
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $commentId);
if ($stmt->execute()) {
return [
'success' => true,
'comment_id' => $commentId,
'ticket_id' => $ticketId
];
} else {
return ['success' => false, 'error' => $this->conn->error];
}
}
}
?>