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:
@@ -29,6 +29,8 @@ try {
|
||||
require_once $auditLogModelPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.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
|
||||
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
|
||||
$result['mentions'] = array_map(function($u) {
|
||||
return $u['username'];
|
||||
|
||||
@@ -3,6 +3,8 @@ require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.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
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -60,6 +62,21 @@ if ($assignedTo === null || $assignedTo === '') {
|
||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||
if ($success) {
|
||||
$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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -26,6 +26,7 @@ try {
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once $workflowModelPath;
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
|
||||
// Check authentication via session
|
||||
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 [
|
||||
'success' => true,
|
||||
'status' => $updateData['status'],
|
||||
|
||||
+13
-3
@@ -55,9 +55,19 @@ $GLOBALS['config'] = [
|
||||
'API_URL' => '/api', // API URL
|
||||
|
||||
// Matrix webhook (hookshot generic webhook URL)
|
||||
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
||||
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org)
|
||||
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
|
||||
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
||||
// 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 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.)
|
||||
// Set APP_DOMAIN in .env to override
|
||||
|
||||
@@ -49,8 +49,10 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get comments for this ticket using CommentModel
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||
// Load first page of comments; show "load more" if ticket has many
|
||||
$commentPageSize = 50;
|
||||
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
||||
|
||||
// Get timeline for this ticket
|
||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||
|
||||
+142
-31
@@ -1,41 +1,17 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||
|
||||
class NotificationHelper {
|
||||
/**
|
||||
* Send a Matrix webhook notification for a new ticket.
|
||||
*
|
||||
* @param string $ticketId Ticket ID (9-digit string)
|
||||
* @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') {
|
||||
|
||||
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||
|
||||
private static function fire(array $payload): void {
|
||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||
if (empty($webhookUrl)) {
|
||||
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);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
@@ -48,11 +24,146 @@ class NotificationHelper {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$id = $payload['ticket_id'] ?? '?';
|
||||
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) {
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -115,6 +115,10 @@ switch (true) {
|
||||
require_once 'api/get_users.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/get_comments.php':
|
||||
require_once 'api/get_comments.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/assign_ticket.php':
|
||||
require_once 'api/assign_ticket.php';
|
||||
break;
|
||||
|
||||
+102
-8
@@ -50,10 +50,35 @@ class CommentModel {
|
||||
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();
|
||||
|
||||
// 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
|
||||
@@ -70,16 +95,21 @@ class CommentModel {
|
||||
ORDER BY tc.created_at DESC";
|
||||
}
|
||||
|
||||
if ($limit > 0) {
|
||||
$sql .= " LIMIT ? OFFSET ?";
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $ticketId);
|
||||
if ($limit > 0) {
|
||||
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
||||
} else {
|
||||
$stmt->bind_param("i", $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 {
|
||||
@@ -90,8 +120,9 @@ class CommentModel {
|
||||
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
||||
$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) {
|
||||
$rootComments = [];
|
||||
foreach ($commentMap as $id => $comment) {
|
||||
@@ -102,10 +133,73 @@ class CommentModel {
|
||||
return $rootComments;
|
||||
}
|
||||
|
||||
// Flat list
|
||||
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
|
||||
*/
|
||||
|
||||
+190
-13
@@ -10,12 +10,13 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
|
||||
$activeNav = 'dashboard';
|
||||
$pageStyles = ['/assets/css/ticket.css?v=20260327'];
|
||||
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||
$pageStyles = ["/assets/css/ticket.css?v={$_v}"];
|
||||
$pageScripts = [
|
||||
'/assets/js/markdown.js?v=20260327',
|
||||
'/assets/js/ticket.js?v=20260327',
|
||||
'/assets/js/keyboard-shortcuts.js?v=20260327',
|
||||
'/assets/js/settings.js?v=20260327',
|
||||
"/assets/js/markdown.js?v={$_v}",
|
||||
"/assets/js/ticket.js?v={$_v}",
|
||||
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
||||
"/assets/js/settings.js?v={$_v}",
|
||||
];
|
||||
|
||||
// Helper functions
|
||||
@@ -77,16 +78,25 @@ $json_status = json_encode($ticket['status'], JSON_HEX_TAG);
|
||||
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
||||
$json_category = json_encode($ticket['category'], 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
|
||||
window.ticketData = {
|
||||
ticket_id: {$json_ticket_id},
|
||||
title: {$json_title},
|
||||
status: {$json_status},
|
||||
priority: {$json_priority},
|
||||
category: {$json_category},
|
||||
type: {$json_type},
|
||||
updated_at: {$json_updated_at},
|
||||
ticket_id: {$json_ticket_id},
|
||||
title: {$json_title},
|
||||
status: {$json_status},
|
||||
priority: {$json_priority},
|
||||
category: {$json_category},
|
||||
type: {$json_type},
|
||||
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;
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
@@ -450,6 +460,16 @@ include __DIR__ . '/layout_header.php';
|
||||
}
|
||||
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 ?>
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user