Files
tinker_tickets/models/CommentModel.php
T
jaredandClaude Sonnet 5 5709c3134f
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 47s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m31s
Lint / Deploy (push) Successful in 4s
Verify mentioned-user access before sending @mention notifications (#69)
sendMentionNotification(), called from add_comment.php, had no
visibility check at all — unlike sendCommentNotification()/
notifyWatchers() which redact the comment preview for non-public
tickets. Mentioning a user with zero standing access to a confidential
ticket (not creator/assignee/admin, not in visibility_groups) sent
them a Matrix DM with the full ticket title AND comment text — worse
than #46 since it's delivered directly to an individual rather than
diluted into a shared list.

add_comment.php now filters mentioned users through
canUserAccessTicket() before resolving Matrix IDs, skipping the
notification entirely for anyone without access (one of the two
options the issue names as acceptable). getMentionedUsers() needed to
start selecting is_admin and groups alongside user_id/username/
display_name, since canUserAccessTicket() requires them. Verified
against real MariaDB: a user mentioned on a confidential ticket they
don't own/aren't assigned to is correctly denied, a user in the
matching visibility_groups for an internal ticket is correctly
allowed, and the same user is correctly denied on a different internal
ticket whose group they're not in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:42 -04:00

437 lines
15 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, is_admin, `groups` 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;
}
/**
* Get total comment count for a ticket
*/
public function getCommentCount(string $ticketId): int
{
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return (int)($row['total'] ?? 0);
}
/**
* @param int $ticketId
* @param bool $threaded Build nested reply structure (threading)
* @param int $limit Max root-level comments to return (0 = all)
* @param int $offset Root-level comment offset for pagination
*/
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0)
{
$hasThreading = $this->hasThreadingSupport();
// When paginating with threading we fetch root comments page first,
// then pull all their replies in a second query.
if ($hasThreading && $threaded && $limit > 0) {
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
}
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";
}
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("sii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("s", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
$commentMap = [];
while ($row = $result->fetch_assoc()) {
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;
}
$stmt->close();
// Build threaded structure if threading is enabled (no pagination — all loaded)
if ($hasThreading && $threaded) {
$rootComments = [];
foreach ($commentMap as $id => $comment) {
if ($comment['parent_comment_id'] === null) {
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
}
}
return $rootComments;
}
return array_values($commentMap);
}
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(string $ticketId, int $limit, int $offset): array
{
// Page of root comments
$rootSql = "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 = ? AND tc.parent_comment_id IS NULL
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("sii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
$commentMap = [];
$rootIds = [];
while ($row = $rootResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['parent_comment_id'] = null;
$row['thread_depth'] = 0;
$commentMap[$row['comment_id']] = $row;
$rootIds[] = $row['comment_id'];
}
if (empty($rootIds)) {
return [];
}
// Load replies level-by-level under this page's roots. A single
// "parent_comment_id IN (rootIds)" only fetches DIRECT children, so
// grandchildren/great-grandchildren (addComment allows up to depth 3)
// would be missing from the map and dropped by buildCommentThread.
// Expand iteratively until no new replies (bounded by max depth 3).
$parentIds = $rootIds;
$depth = 0;
while (!empty($parentIds) && $depth < 3) {
$placeholders = implode(',', array_fill(0, count($parentIds), '?'));
$replySql = "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 = ?
AND tc.parent_comment_id IN ($placeholders)
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 's' . str_repeat('i', count($parentIds));
$replyStmt->bind_param($types, $ticketId, ...$parentIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
$nextParentIds = [];
while ($row = $replyResult->fetch_assoc()) {
if (isset($commentMap[$row['comment_id']])) {
continue; // guard against cycles / duplicates
}
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $depth + 1;
$commentMap[$row['comment_id']] = $row;
$nextParentIds[] = $row['comment_id'];
}
$parentIds = $nextParentIds;
$depth++;
}
$rootComments = [];
foreach ($rootIds as $rid) {
if (isset($commentMap[$rid])) {
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
}
}
return $rootComments;
}
/**
* 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 (
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
&& isset($allComments[$c['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'] !== (int)$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 {
error_log('CommentModel::updateComment failed: ' . $this->conn->error);
return ['success' => false, 'error' => 'Failed to update comment'];
}
}
/**
* 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'] !== (int)$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 {
error_log('CommentModel::deleteComment failed: ' . $this->conn->error);
return ['success' => false, 'error' => 'Failed to delete comment'];
}
}
}