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:
+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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
Reference in New Issue
Block a user