27f50468a8
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 14s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 32s
Generic: Project CI / Lint & Validate (pull_request) Successful in 34s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 34s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Merges the full element rename from main into dev, resolving conflicts across 53 files. Migrates new dev-only extensions (com_mokosuite ticketsettings, plg_system_mokosuite_dbip) to MokoSuiteClient naming.
576 lines
14 KiB
PHP
576 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* @package MokoSuiteClient
|
|
* @subpackage com_mokosuiteclient
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Log\Log;
|
|
use Joomla\CMS\Uri\Uri;
|
|
|
|
/**
|
|
* Helpdesk email notification service.
|
|
*
|
|
* Sends emails for ticket events to Joomla users (by ID) and/or
|
|
* raw email addresses. Uses Joomla's configured mailer.
|
|
*
|
|
* @since 02.32.00
|
|
*/
|
|
class NotificationService
|
|
{
|
|
/**
|
|
* Send a ticket notification email.
|
|
*
|
|
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
|
|
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
|
|
* @param array $extra Extra context (reply body, old status, etc.)
|
|
*/
|
|
public static function notify(string $event, object $ticket, array $extra = []): void
|
|
{
|
|
try
|
|
{
|
|
$recipients = self::getRecipients($event, $ticket);
|
|
|
|
if (empty($recipients))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$subject = self::buildSubject($event, $ticket);
|
|
$body = self::buildBody($event, $ticket, $extra);
|
|
|
|
$mailer = Factory::getMailer();
|
|
$mailer->isHtml(false);
|
|
$mailer->setSubject($subject);
|
|
$mailer->setBody($body);
|
|
|
|
foreach ($recipients as $email)
|
|
{
|
|
$email = trim($email);
|
|
|
|
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
$mailer->clearAddresses();
|
|
$mailer->addRecipient($email);
|
|
$mailer->Send();
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
// Push notification via ntfy
|
|
self::pushNtfy($event, $ticket, $subject);
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine recipients based on event type and ticket data.
|
|
*/
|
|
private static function getRecipients(string $event, object $ticket): array
|
|
{
|
|
$emails = [];
|
|
|
|
// Get notification config from component params
|
|
$config = self::getNotificationConfig();
|
|
|
|
// Always notify configured admin emails
|
|
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
|
$emails = array_merge($emails, $adminEmails);
|
|
|
|
// Always notify configured admin user IDs
|
|
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
|
|
|
foreach ($adminUserIds as $uid)
|
|
{
|
|
$email = self::getUserEmail($uid);
|
|
|
|
if ($email)
|
|
{
|
|
$emails[] = $email;
|
|
}
|
|
}
|
|
|
|
switch ($event)
|
|
{
|
|
case 'ticket_created':
|
|
// Notify assigned user if any
|
|
if (!empty($ticket->assigned_to))
|
|
{
|
|
$email = self::getUserEmail((int) $ticket->assigned_to);
|
|
|
|
if ($email)
|
|
{
|
|
$emails[] = $email;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'ticket_replied':
|
|
// Notify ticket creator (customer gets notified of staff reply)
|
|
if (!empty($ticket->created_by))
|
|
{
|
|
$email = self::getUserEmail((int) $ticket->created_by);
|
|
|
|
if ($email)
|
|
{
|
|
$emails[] = $email;
|
|
}
|
|
}
|
|
|
|
// Notify assigned user
|
|
if (!empty($ticket->assigned_to))
|
|
{
|
|
$email = self::getUserEmail((int) $ticket->assigned_to);
|
|
|
|
if ($email)
|
|
{
|
|
$emails[] = $email;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'status_changed':
|
|
// Notify ticket creator
|
|
if (!empty($ticket->created_by))
|
|
{
|
|
$email = self::getUserEmail((int) $ticket->created_by);
|
|
|
|
if ($email)
|
|
{
|
|
$emails[] = $email;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'ticket_assigned':
|
|
// Notify newly assigned user
|
|
if (!empty($ticket->assigned_to))
|
|
{
|
|
$email = self::getUserEmail((int) $ticket->assigned_to);
|
|
|
|
if ($email)
|
|
{
|
|
$emails[] = $email;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
return array_unique($emails);
|
|
}
|
|
|
|
/**
|
|
* Build email subject line.
|
|
*/
|
|
private static function buildSubject(string $event, object $ticket): string
|
|
{
|
|
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
|
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
|
|
|
|
switch ($event)
|
|
{
|
|
case 'ticket_created':
|
|
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
|
|
|
|
case 'ticket_replied':
|
|
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
|
|
|
|
case 'status_changed':
|
|
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
|
|
|
|
case 'ticket_assigned':
|
|
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
|
|
|
|
default:
|
|
return $prefix . ($ticket->subject ?? '');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build email body.
|
|
*/
|
|
private static function buildBody(string $event, object $ticket, array $extra): string
|
|
{
|
|
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
|
$siteUrl = rtrim(Uri::root(), '/');
|
|
$ticketUrl = $siteUrl . '/index.php?option=com_mokosuiteclient&view=ticket&id=' . $ticket->id;
|
|
|
|
$lines = [];
|
|
$lines[] = $siteName . ' Support';
|
|
$lines[] = str_repeat('-', 40);
|
|
$lines[] = '';
|
|
|
|
switch ($event)
|
|
{
|
|
case 'ticket_created':
|
|
$lines[] = 'A new support ticket has been created.';
|
|
$lines[] = '';
|
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
|
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
|
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
|
|
$lines[] = '';
|
|
|
|
if (!empty($ticket->body))
|
|
{
|
|
$lines[] = 'Description:';
|
|
$lines[] = strip_tags($ticket->body);
|
|
$lines[] = '';
|
|
}
|
|
break;
|
|
|
|
case 'ticket_replied':
|
|
$lines[] = 'A new reply has been added to your ticket.';
|
|
$lines[] = '';
|
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
|
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
|
$lines[] = '';
|
|
|
|
if (!empty($extra['reply_body']))
|
|
{
|
|
$lines[] = 'Reply:';
|
|
$lines[] = strip_tags($extra['reply_body']);
|
|
$lines[] = '';
|
|
}
|
|
break;
|
|
|
|
case 'status_changed':
|
|
$lines[] = 'Your ticket status has been updated.';
|
|
$lines[] = '';
|
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
|
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
|
|
|
if (!empty($extra['old_status']))
|
|
{
|
|
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
|
|
}
|
|
|
|
$lines[] = '';
|
|
break;
|
|
|
|
case 'ticket_assigned':
|
|
$lines[] = 'A ticket has been assigned to you.';
|
|
$lines[] = '';
|
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
|
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
|
$lines[] = '';
|
|
break;
|
|
}
|
|
|
|
$lines[] = 'View ticket: ' . $ticketUrl;
|
|
$lines[] = '';
|
|
$lines[] = '-- ';
|
|
$lines[] = $siteName . ' | Powered by MokoSuiteClient';
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Get email address for a Joomla user ID.
|
|
*/
|
|
private static function getUserEmail(int $userId): ?string
|
|
{
|
|
if ($userId <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('email'))
|
|
->from($db->quoteName('#__users'))
|
|
->where($db->quoteName('id') . ' = ' . $userId)
|
|
);
|
|
|
|
return $db->loadResult() ?: null;
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get notification configuration from component params.
|
|
*/
|
|
private static function getNotificationConfig(): array
|
|
{
|
|
try
|
|
{
|
|
$db = Factory::getDbo();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('params'))
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
|
);
|
|
|
|
$params = json_decode($db->loadResult() ?? '{}', true);
|
|
|
|
return $params['notifications'] ?? [];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ==================================================================
|
|
// Ntfy Push Notifications (#205)
|
|
// ==================================================================
|
|
|
|
/**
|
|
* Send a push notification via ntfy for ticket events.
|
|
*/
|
|
private static function pushNtfy(string $event, object $ticket, string $title): void
|
|
{
|
|
$config = self::getNotificationConfig();
|
|
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
|
|
|
if (!$ntfyEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
|
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets';
|
|
$ntfyToken = $config['ntfy_token'] ?? '';
|
|
|
|
$tagMap = [
|
|
'ticket_created' => 'ticket,new',
|
|
'ticket_replied' => 'speech_balloon',
|
|
'status_changed' => 'arrows_counterclockwise',
|
|
'ticket_assigned' => 'bust_in_silhouette',
|
|
];
|
|
|
|
$priorityMap = [
|
|
'ticket_created' => '4',
|
|
'ticket_replied' => '3',
|
|
'status_changed' => '3',
|
|
'ticket_assigned' => '3',
|
|
];
|
|
|
|
$siteUrl = rtrim(Uri::root(), '/');
|
|
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0);
|
|
|
|
$message = self::buildNtfyMessage($event, $ticket);
|
|
|
|
$headers = [
|
|
'Title: ' . $title,
|
|
'Priority: ' . ($priorityMap[$event] ?? '3'),
|
|
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
|
|
'Click: ' . $ticketUrl,
|
|
];
|
|
|
|
if ($ntfyToken !== '')
|
|
{
|
|
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
|
}
|
|
|
|
$url = $ntfyServer . '/' . $ntfyTopic;
|
|
|
|
try
|
|
{
|
|
$ch = curl_init($url);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
|
curl_exec($ch);
|
|
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode < 200 || $httpCode >= 300)
|
|
{
|
|
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a short ntfy message body for ticket events.
|
|
*/
|
|
private static function buildNtfyMessage(string $event, object $ticket): string
|
|
{
|
|
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
|
|
|
|
switch ($event)
|
|
{
|
|
case 'ticket_created':
|
|
$priority = ucfirst($ticket->priority ?? 'normal');
|
|
return "New ticket: {$subject}\nPriority: {$priority}";
|
|
|
|
case 'ticket_replied':
|
|
return "Reply on: {$subject}";
|
|
|
|
case 'status_changed':
|
|
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
|
return "Status → {$status}: {$subject}";
|
|
|
|
case 'ticket_assigned':
|
|
return "Assigned to you: {$subject}";
|
|
|
|
default:
|
|
return $subject;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a push notification via ntfy for security events.
|
|
*/
|
|
public static function pushNtfySecurity(string $event, string $title, string $body): void
|
|
{
|
|
$config = self::getNotificationConfig();
|
|
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
|
|
|
if (!$ntfyEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
|
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security';
|
|
$ntfyToken = $config['ntfy_token'] ?? '';
|
|
|
|
$headers = [
|
|
'Title: [Security] ' . $title,
|
|
'Priority: 5',
|
|
'Tags: warning,shield',
|
|
];
|
|
|
|
if ($ntfyToken !== '')
|
|
{
|
|
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
|
}
|
|
|
|
$url = $ntfyServer . '/' . $ntfyTopic;
|
|
|
|
try
|
|
{
|
|
$ch = curl_init($url);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
|
curl_exec($ch);
|
|
curl_close($ch);
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
// ==================================================================
|
|
// Security Event Notifications (#131)
|
|
// ==================================================================
|
|
|
|
/**
|
|
* Send a security alert to admin emails.
|
|
*/
|
|
public static function securityAlert(string $event, string $subject, string $body): void
|
|
{
|
|
try
|
|
{
|
|
$config = self::getNotificationConfig();
|
|
$enabled = $config['security_alerts'] ?? '1';
|
|
|
|
if (!$enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
|
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
|
|
|
$recipients = $adminEmails;
|
|
|
|
foreach ($adminUserIds as $uid)
|
|
{
|
|
$email = self::getUserEmail($uid);
|
|
|
|
if ($email)
|
|
{
|
|
$recipients[] = $email;
|
|
}
|
|
}
|
|
|
|
$recipients = array_unique($recipients);
|
|
|
|
if (empty($recipients))
|
|
{
|
|
return;
|
|
}
|
|
|
|
$siteName = Factory::getConfig()->get('sitename', 'Site');
|
|
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
|
|
|
|
$lines = [
|
|
$siteName . ' Security Alert',
|
|
str_repeat('-', 40),
|
|
'',
|
|
'Event: ' . $event,
|
|
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
|
|
'',
|
|
$body,
|
|
'',
|
|
'-- ',
|
|
$siteName . ' | MokoSuiteClient Security',
|
|
];
|
|
|
|
$mailer = Factory::getMailer();
|
|
$mailer->isHtml(false);
|
|
$mailer->setSubject($fullSubject);
|
|
$mailer->setBody(implode("\n", $lines));
|
|
|
|
foreach ($recipients as $email)
|
|
{
|
|
try
|
|
{
|
|
$mailer->clearAddresses();
|
|
$mailer->addRecipient(trim($email));
|
|
$mailer->Send();
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
// Also push via ntfy
|
|
self::pushNtfySecurity($event, $subject, $body);
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
}
|