feat: comment pagination, Matrix integration, Synapse mention resolution

Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:34:16 -04:00
parent cc3f667d4c
commit c8181e8076
11 changed files with 645 additions and 57 deletions
+21
View File
@@ -29,6 +29,8 @@ try {
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
@@ -123,6 +125,25 @@ try {
); );
} }
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
}
// Add mentioned users to result for frontend // Add mentioned users to result for frontend
$result['mentions'] = array_map(function($u) { $result['mentions'] = array_map(function($u) {
return $u['username']; return $u['username'];
+17
View File
@@ -3,6 +3,8 @@ require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Get request data // Get request data
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
@@ -60,6 +62,21 @@ if ($assignedTo === null || $assignedTo === '') {
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) { if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
);
}
} }
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Get Comments API
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$total = $commentModel->getCommentCount($ticketId);
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
echo json_encode([
'success' => true,
'comments' => $comments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
+13
View File
@@ -26,6 +26,7 @@ try {
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once $workflowModelPath; require_once $workflowModelPath;
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
@@ -207,6 +208,18 @@ try {
} }
} }
// Notify on status change
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy
);
}
return [ return [
'success' => true, 'success' => true,
'status' => $updateData['status'], 'status' => $updateData['status'],
+11 -1
View File
@@ -56,8 +56,18 @@ $GLOBALS['config'] = [
// Matrix webhook (hookshot generic webhook URL) // Matrix webhook (hookshot generic webhook URL)
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null, 'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org) // Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '', 'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
// Set to '1' or 'true' to send a notification when any comment is posted
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Set to '1' or 'true' to send a notification when a ticket is assigned
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Domain settings for external integrations (webhooks, links, etc.) // Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override // Set APP_DOMAIN in .env to override
+4 -2
View File
@@ -49,8 +49,10 @@ class TicketController {
return; return;
} }
// Get comments for this ticket using CommentModel // Load first page of comments; show "load more" if ticket has many
$comments = $this->commentModel->getCommentsByTicketId($id); $commentPageSize = 50;
$totalComments = $this->commentModel->getCommentCount((int)$id);
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
// Get timeline for this ticket // Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id); $timeline = $this->auditLogModel->getTicketTimeline($id);
+142 -31
View File
@@ -1,41 +1,17 @@
<?php <?php
require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class NotificationHelper { class NotificationHelper {
/**
* Send a Matrix webhook notification for a new ticket. // ─── Internal: fire a webhook ─────────────────────────────────────────────
*
* @param string $ticketId Ticket ID (9-digit string) private static function fire(array $payload): void {
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
* @param string $trigger 'manual' (web UI) or 'automated' (API)
*/
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null; $webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) { if (empty($webhookUrl)) {
return; return;
} }
// Parse notify users from config (comma-separated Matrix user IDs)
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
// Extract hostname from [hostname] prefix in title
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
$payload = [
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => $notifyUsers,
];
$ch = curl_init($webhookUrl); $ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POST, 1);
@@ -48,11 +24,146 @@ class NotificationHelper {
$curlError = curl_error($ch); $curlError = curl_error($ch);
curl_close($ch); curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) { if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}"); error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
} elseif ($httpCode < 200 || $httpCode >= 300) { } elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}"); error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
} }
} }
private static function notifyUsers(): array {
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => self::notifyUsers(),
]);
}
/**
* Ticket status changed.
*
* @param string|int $ticketId
* @param string $oldStatus
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void {
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => self::notifyUsers(),
]);
}
/**
* New comment posted (non-mention; use sendMentionNotification for @mentions).
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void {
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
/**
* @mention detected in a comment.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText
* @param string|null $authorDisplay
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
*/
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void {
if (empty($mentionedMatrixIds)) {
return;
}
self::fire([
'event' => 'mention',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $mentionedMatrixIds,
]);
}
/**
* Ticket assigned (or reassigned) to a user.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void {
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
$notifyUsers[] = $assigneeMatrix;
}
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
} }
?> ?>
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* SynapseHelper
*
* Resolves local (SSO) usernames → Matrix user IDs by querying the
* Synapse Admin REST API directly. No caching — every call is live
* so results never go stale.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
*/
class SynapseHelper {
/**
* Resolve a local SSO username to its Matrix user ID.
*
* Uses the Synapse Admin API v2 endpoint:
* GET /_synapse/admin/v2/users/@{username}:{domain}
*
* If the account exists in Synapse the method returns the Matrix ID string.
* If the account does not exist, or if Synapse is unreachable / not configured,
* it returns null silently (notifications are best-effort).
*
* @param string $username Local username (e.g. "jared")
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
*/
public static function resolveUsername(string $username): ?string {
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$baseUrl || !$token || !$domain) {
return null;
}
$matrixId = '@' . rawurlencode($username) . ':' . $domain;
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($httpCode === 200) {
$data = json_decode($body, true);
// Confirm the response contains the name we expect
if (!empty($data['name'])) {
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
}
/**
* Resolve multiple usernames to Matrix IDs.
* Returns only those that were successfully confirmed in Synapse.
*
* @param string[] $usernames
* @return string[] Matrix user IDs
*/
public static function resolveUsernames(array $usernames): array {
$ids = [];
foreach ($usernames as $username) {
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
}
}
return $ids;
}
}
?>
+4
View File
@@ -115,6 +115,10 @@ switch (true) {
require_once 'api/get_users.php'; require_once 'api/get_users.php';
break; break;
case $requestPath == '/api/get_comments.php':
require_once 'api/get_comments.php';
break;
case $requestPath == '/api/assign_ticket.php': case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php'; require_once 'api/assign_ticket.php';
break; break;
+101 -7
View File
@@ -50,10 +50,35 @@ class CommentModel {
return $users; return $users;
} }
public function getCommentsByTicketId($ticketId, $threaded = true) { /**
// Check if threading columns exist * Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int {
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $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(); $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) { if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth $sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc FROM ticket_comments tc
@@ -70,16 +95,21 @@ class CommentModel {
ORDER BY tc.created_at DESC"; ORDER BY tc.created_at DESC";
} }
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId); $stmt->bind_param("i", $ticketId);
}
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$comments = [];
$commentMap = []; $commentMap = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) { if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name']; $row['display_name_formatted'] = $row['display_name'];
} else { } else {
@@ -90,8 +120,9 @@ class CommentModel {
$row['thread_depth'] = $row['thread_depth'] ?? 0; $row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row; $commentMap[$row['comment_id']] = $row;
} }
$stmt->close();
// Build threaded structure if threading is enabled // Build threaded structure if threading is enabled (no pagination — all loaded)
if ($hasThreading && $threaded) { if ($hasThreading && $threaded) {
$rootComments = []; $rootComments = [];
foreach ($commentMap as $id => $comment) { foreach ($commentMap as $id => $comment) {
@@ -102,10 +133,73 @@ class CommentModel {
return $rootComments; return $rootComments;
} }
// Flat list
return array_values($commentMap); return array_values($commentMap);
} }
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $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("iii", $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 [];
}
// All replies for these root comments (up to 3 levels deep)
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
$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)
AND tc.parent_comment_id IS NOT NULL
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 'i' . str_repeat('i', count($rootIds));
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
while ($row = $replyResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $row['thread_depth'] ?? 1;
$commentMap[$row['comment_id']] = $row;
}
$rootComments = [];
foreach ($rootIds as $rid) {
if (isset($commentMap[$rid])) {
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
}
}
return $rootComments;
}
/** /**
* Check if threading columns exist * Check if threading columns exist
*/ */
+182 -5
View File
@@ -10,12 +10,13 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? ''); $pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
$activeNav = 'dashboard'; $activeNav = 'dashboard';
$pageStyles = ['/assets/css/ticket.css?v=20260327']; $_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/ticket.css?v={$_v}"];
$pageScripts = [ $pageScripts = [
'/assets/js/markdown.js?v=20260327', "/assets/js/markdown.js?v={$_v}",
'/assets/js/ticket.js?v=20260327', "/assets/js/ticket.js?v={$_v}",
'/assets/js/keyboard-shortcuts.js?v=20260327', "/assets/js/keyboard-shortcuts.js?v={$_v}",
'/assets/js/settings.js?v=20260327', "/assets/js/settings.js?v={$_v}",
]; ];
// Helper functions // Helper functions
@@ -78,6 +79,10 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG); $json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG); $json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG); $json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
$json_is_admin = json_encode(!empty($currentUser['is_admin']), JSON_HEX_TAG);
$pageInlineScript = <<<JS $pageInlineScript = <<<JS
window.ticketData = { window.ticketData = {
ticket_id: {$json_ticket_id}, ticket_id: {$json_ticket_id},
@@ -87,6 +92,11 @@ window.ticketData = {
category: {$json_category}, category: {$json_category},
type: {$json_type}, type: {$json_type},
updated_at: {$json_updated_at}, updated_at: {$json_updated_at},
totalComments: {$json_total_comments},
commentOffset: {$json_comment_page},
commentPageSize:{$json_comment_page},
currentUserId: {$json_current_uid},
isAdmin: {$json_is_admin},
}; };
window.ticketData.id = window.ticketData.ticket_id; window.ticketData.id = window.ticketData.ticket_id;
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();
@@ -450,6 +460,16 @@ include __DIR__ . '/layout_header.php';
} }
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach; foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
?> ?>
<?php if ($totalComments > $commentPageSize): ?>
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
<button type="button" id="loadMoreBtn" class="lt-btn lt-btn-ghost lt-btn-sm">
Load more comments
<span class="lt-text-muted lt-text-xs" id="loadMoreCount">
(<?= (int)$totalComments - count($comments) ?> remaining)
</span>
</button>
</div>
<?php endif ?>
<?php endif ?> <?php endif ?>
</div> </div>
</div> </div>
@@ -809,6 +829,163 @@ document.addEventListener('DOMContentLoaded', function () {
} }
}); });
// Load more comments
var loadMoreBtn = document.getElementById('loadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function () {
var td = window.ticketData;
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading\u2026';
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
'&offset=' + td.commentOffset +
'&limit=' + td.commentPageSize;
lt.api.get(url).then(function (data) {
if (!data.success) {
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
return;
}
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
data.comments.forEach(function (c) {
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
});
td.commentOffset += data.comments.length;
var remaining = td.totalComments - td.commentOffset;
if (data.has_more && remaining > 0) {
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments <span class="lt-text-muted lt-text-xs">(' + remaining + ' remaining)</span>';
} else {
wrap.remove();
}
// Re-render markdown in newly added comments
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
}
});
}
}).catch(function (err) {
lt.toast.error('Failed to load comments');
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
});
});
}
/**
* Build a comment DOM element from a comment object returned by the API.
* Mirrors the PHP renderComment() output for root-level comments and replies.
*/
function buildCommentEl(c, currentUserId, isAdmin) {
var displayName = c.display_name_formatted || c.display_name || c.user_name || 'Unknown User';
var commentId = c.comment_id;
var isOwner = (parseInt(c.user_id, 10) === parseInt(currentUserId, 10));
var canModify = isOwner || isAdmin;
var mdEnabled = c.markdown_enabled == 1 || c.markdown_enabled === true;
var depth = parseInt(c.thread_depth, 10) || 0;
var parentId = c.parent_comment_id || null;
var depthClass = 'thread-depth-' + Math.min(depth, 3);
var threadClass = parentId ? 'comment-reply' : 'comment-root';
// Avatar initials
var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0;
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
// Format date
var dateStr = c.created_at || '';
try {
var d = new Date(c.created_at);
if (!isNaN(d)) {
dateStr = d.toLocaleString('en-US', { month:'short', day:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false }).replace(',', '');
}
} catch (e) {}
// Comment text
var rawText = c.comment_text || '';
var commentText;
if (mdEnabled) {
commentText = typeof parseMarkdown === 'function' ? parseMarkdown(rawText) : lt.escHtml(rawText);
} else {
var highlighted = lt.escHtml(rawText).replace(/\n/g, '<br>');
commentText = typeof highlightMentions === 'function' ? highlightMentions(highlighted) : highlighted;
}
var escapedRaw = lt.escHtml(rawText);
var editedHtml = c.updated_at ? '<span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
var userId = parseInt(c.user_id, 10) || 0;
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="' + commentId + '" data-user="' + lt.escHtml(displayName) + '"' +
' aria-label="Reply to comment">Reply</button>' : '';
var modBtns = canModify ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + 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="' + commentId + '" aria-label="Delete comment">Del</button>' : '';
var threadLine = parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var avatarImg = userId > 0 ?
'<img src="/api/user_avatar.php?user_id=' + userId + '" alt="" class="lt-avatar-img" onerror="this.style.display=\'none\'">' : '';
var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass;
div.dataset.commentId = commentId;
div.dataset.markdownEnabled = mdEnabled ? '1' : '0';
div.dataset.threadDepth = depth;
div.dataset.parentId = 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 ' + avatarColor + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' +
'<span class="ts-cell" data-ts="' + lt.escHtml(c.created_at || '') + '">' + lt.escHtml(dateStr) + '</span>' +
editedHtml +
'</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text" id="comment-text-' + commentId + '"' + (mdEnabled ? ' data-markdown data-rendered="1"' : '') + '>' +
commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw" id="comment-raw-' + commentId + '" style="display:none" aria-hidden="true">' +
escapedRaw +
'</textarea>' +
'</div>';
// Append replies if any (threaded)
if (c.replies && c.replies.length) {
var repliesDiv = document.createElement('div');
repliesDiv.className = 'comment-replies';
c.replies.forEach(function (r) {
repliesDiv.appendChild(buildCommentEl(r, currentUserId, isAdmin));
});
div.appendChild(repliesDiv);
}
return div;
}
}); });
</script> </script>