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.
1449 lines
41 KiB
PHP
1449 lines
41 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\Model;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
|
use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService;
|
|
|
|
class TicketsModel extends BaseDatabaseModel
|
|
{
|
|
/**
|
|
* Get ticket list with filters.
|
|
*/
|
|
public function getTickets(array $filters = []): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('t.id'),
|
|
$db->quoteName('t.subject'),
|
|
$db->quoteName('t.status_id'),
|
|
$db->quoteName('t.priority_id'),
|
|
$db->quoteName('t.created'),
|
|
$db->quoteName('t.modified'),
|
|
$db->quoteName('t.contact_id'),
|
|
$db->quoteName('t.sla_response_due'),
|
|
$db->quoteName('t.sla_resolution_due'),
|
|
$db->quoteName('t.sla_responded'),
|
|
$db->quoteName('c.title', 'category_title'),
|
|
$db->quoteName('u.name', 'created_by_name'),
|
|
$db->quoteName('ct.name', 'contact_name'),
|
|
$db->quoteName('st.title', 'status_title'),
|
|
$db->quoteName('st.alias', 'status_alias'),
|
|
$db->quoteName('st.color', 'status_color'),
|
|
$db->quoteName('pr.title', 'priority_title'),
|
|
$db->quoteName('pr.alias', 'priority_alias'),
|
|
$db->quoteName('pr.color', 'priority_color'),
|
|
$db->quoteName('st.is_closed', 'status_is_closed'),
|
|
])
|
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
|
->leftJoin($db->quoteName('#__contact_details', 'ct') . ' ON ct.id = t.contact_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 'st') . ' ON st.id = t.status_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'pr') . ' ON pr.id = t.priority_id');
|
|
|
|
if (!empty($filters['status_id']))
|
|
{
|
|
$query->where($db->quoteName('t.status_id') . ' = ' . (int) $filters['status_id']);
|
|
}
|
|
|
|
if (!empty($filters['priority_id']))
|
|
{
|
|
$query->where($db->quoteName('t.priority_id') . ' = ' . (int) $filters['priority_id']);
|
|
}
|
|
|
|
if (!empty($filters['assigned_to']))
|
|
{
|
|
$query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']);
|
|
}
|
|
|
|
if (!empty($filters['category_id']))
|
|
{
|
|
$query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']);
|
|
}
|
|
|
|
if (!empty($filters['contact_id']))
|
|
{
|
|
$query->where($db->quoteName('t.contact_id') . ' = ' . (int) $filters['contact_id']);
|
|
}
|
|
|
|
$query->order($db->quoteName('t.created') . ' DESC');
|
|
$query->setLimit(50);
|
|
|
|
$db->setQuery($query);
|
|
$tickets = $db->loadObjectList() ?: [];
|
|
|
|
// Load assignees for each ticket
|
|
foreach ($tickets as $ticket)
|
|
{
|
|
$ticket->assignees = $this->getTicketAssignees((int) $ticket->id);
|
|
}
|
|
|
|
return $tickets;
|
|
}
|
|
|
|
/**
|
|
* Get a single ticket with all replies.
|
|
*/
|
|
public function getTicket(int $id): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('t') . '.*',
|
|
$db->quoteName('c.title', 'category_title'),
|
|
$db->quoteName('u.name', 'created_by_name'),
|
|
$db->quoteName('u.email', 'created_by_email'),
|
|
$db->quoteName('ct.name', 'contact_name'),
|
|
$db->quoteName('ct.email_to', 'contact_email'),
|
|
$db->quoteName('ct.telephone', 'contact_phone'),
|
|
$db->quoteName('st.title', 'status_title'),
|
|
$db->quoteName('st.alias', 'status_alias'),
|
|
$db->quoteName('st.color', 'status_color'),
|
|
$db->quoteName('st.is_closed', 'status_is_closed'),
|
|
$db->quoteName('pr.title', 'priority_title'),
|
|
$db->quoteName('pr.alias', 'priority_alias'),
|
|
$db->quoteName('pr.color', 'priority_color'),
|
|
])
|
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
|
->leftJoin($db->quoteName('#__contact_details', 'ct') . ' ON ct.id = t.contact_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 'st') . ' ON st.id = t.status_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'pr') . ' ON pr.id = t.priority_id')
|
|
->where($db->quoteName('t.id') . ' = ' . $id);
|
|
$db->setQuery($query);
|
|
$ticket = $db->loadObject();
|
|
|
|
if (!$ticket)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Load replies
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('r') . '.*',
|
|
$db->quoteName('u.name', 'user_name'),
|
|
])
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
|
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
|
->where($db->quoteName('r.ticket_id') . ' = ' . $id)
|
|
->order($db->quoteName('r.created') . ' ASC');
|
|
$db->setQuery($query);
|
|
$ticket->replies = $db->loadObjectList() ?: [];
|
|
|
|
// Reply count
|
|
$ticket->reply_count = \count($ticket->replies);
|
|
|
|
// Load assignees (users + groups)
|
|
$ticket->assignees = $this->getTicketAssignees($id);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
/**
|
|
* Create a new ticket.
|
|
*/
|
|
public function createTicket(array $data): array
|
|
{
|
|
try
|
|
{
|
|
$db = $this->getDatabase();
|
|
$user = Factory::getApplication()->getIdentity();
|
|
$now = Factory::getDate()->toSql();
|
|
|
|
// Resolve default status/priority from lookup tables
|
|
$defaultStatus = $this->getDefaultStatus();
|
|
$defaultPriority = $this->getDefaultPriority();
|
|
|
|
$ticket = (object) [
|
|
'subject' => $data['subject'] ?? '',
|
|
'body' => $data['body'] ?? '',
|
|
'status' => $defaultStatus->alias ?? 'open',
|
|
'status_id' => (int) ($data['status_id'] ?? $defaultStatus->id ?? 1),
|
|
'priority' => $defaultPriority->alias ?? 'normal',
|
|
'priority_id' => (int) ($data['priority_id'] ?? $defaultPriority->id ?? 2),
|
|
'category_id' => (int) ($data['category_id'] ?? 0) ?: null,
|
|
'contact_id' => (int) ($data['contact_id'] ?? 0) ?: null,
|
|
'created_by' => $user->id,
|
|
'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null,
|
|
'created' => $now,
|
|
'modified' => $now,
|
|
];
|
|
|
|
// Auto-assign from category
|
|
if (!$ticket->assigned_to && $ticket->category_id)
|
|
{
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('auto_assign_user'))
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_categories'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
|
|
$db->setQuery($query);
|
|
$autoAssign = (int) $db->loadResult();
|
|
|
|
if ($autoAssign)
|
|
{
|
|
$ticket->assigned_to = $autoAssign;
|
|
}
|
|
}
|
|
|
|
// SLA deadlines from category
|
|
if ($ticket->category_id)
|
|
{
|
|
$query = $db->getQuery(true)
|
|
->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')])
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_categories'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
|
|
$db->setQuery($query);
|
|
$sla = $db->loadObject();
|
|
|
|
if ($sla)
|
|
{
|
|
$ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql();
|
|
$ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql();
|
|
}
|
|
}
|
|
|
|
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
|
|
|
// Handle multi-assignee (users and groups)
|
|
$assignUsers = array_filter(array_map('intval', (array) ($data['assign_users'] ?? [])));
|
|
$assignGroups = array_filter(array_map('intval', (array) ($data['assign_groups'] ?? [])));
|
|
|
|
// Backward compat: single assigned_to becomes a user assignee
|
|
if (empty($assignUsers) && $ticket->assigned_to)
|
|
{
|
|
$assignUsers = [$ticket->assigned_to];
|
|
}
|
|
|
|
if (!empty($assignUsers) || !empty($assignGroups))
|
|
{
|
|
$this->setTicketAssignees((int) $ticket->id, $assignUsers, $assignGroups);
|
|
}
|
|
|
|
// Save custom field values
|
|
$fieldValues = (array) ($data['custom_fields'] ?? []);
|
|
|
|
if (!empty($fieldValues))
|
|
{
|
|
$this->saveFieldValues((int) $ticket->id, $fieldValues);
|
|
}
|
|
|
|
// Run automation + notifications
|
|
$this->runAutomation('ticket_created', (int) $ticket->id);
|
|
NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id));
|
|
|
|
return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a reply to a ticket.
|
|
*/
|
|
public function addReply(int $ticketId, string $body, bool $isInternal = false): array
|
|
{
|
|
try
|
|
{
|
|
$db = $this->getDatabase();
|
|
$user = Factory::getApplication()->getIdentity();
|
|
$now = Factory::getDate()->toSql();
|
|
|
|
$reply = (object) [
|
|
'ticket_id' => $ticketId,
|
|
'user_id' => $user->id,
|
|
'body' => $body,
|
|
'is_internal' => $isInternal ? 1 : 0,
|
|
'created' => $now,
|
|
];
|
|
|
|
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
|
|
|
// Mark SLA as responded only for staff replies (not customer self-replies)
|
|
$ticket = $this->getTicket($ticketId);
|
|
$isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by;
|
|
|
|
$updateQuery = $db->getQuery(true)
|
|
->update($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
|
->where($db->quoteName('id') . ' = ' . $ticketId);
|
|
|
|
if ($isStaffReply)
|
|
{
|
|
$updateQuery->set($db->quoteName('sla_responded') . ' = 1')
|
|
->where($db->quoteName('sla_responded') . ' = 0');
|
|
}
|
|
|
|
$db->setQuery($updateQuery)->execute();
|
|
|
|
// Run automation + notifications (skip internal notes)
|
|
$this->runAutomation('ticket_replied', $ticketId);
|
|
|
|
if (!$isInternal)
|
|
{
|
|
NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]);
|
|
}
|
|
|
|
return ['success' => true, 'message' => 'Reply added.'];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/** @var bool Guard against automation recursion */
|
|
private bool $automationRunning = false;
|
|
|
|
/**
|
|
* Update ticket status by status ID (lookup table).
|
|
*/
|
|
public function updateStatus(int $ticketId, int $statusId): array
|
|
{
|
|
try
|
|
{
|
|
$db = $this->getDatabase();
|
|
$now = Factory::getDate()->toSql();
|
|
|
|
// Validate status ID against lookup table
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_statuses'))
|
|
->where($db->quoteName('id') . ' = ' . $statusId)
|
|
);
|
|
$status = $db->loadObject();
|
|
|
|
if (!$status)
|
|
{
|
|
return ['success' => false, 'message' => 'Invalid status.'];
|
|
}
|
|
|
|
// Capture old status for notification
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('status_id'))
|
|
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->where($db->quoteName('id') . ' = ' . $ticketId)
|
|
);
|
|
$oldStatusId = (int) $db->loadResult();
|
|
|
|
$sets = [
|
|
$db->quoteName('status') . ' = ' . $db->quote($status->alias),
|
|
$db->quoteName('status_id') . ' = ' . $statusId,
|
|
$db->quoteName('modified') . ' = ' . $db->quote($now),
|
|
];
|
|
|
|
if ($status->is_closed)
|
|
{
|
|
$sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now);
|
|
}
|
|
|
|
// Set resolved timestamp for "resolved" alias (backward compat)
|
|
if ($status->alias === 'resolved')
|
|
{
|
|
$sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now);
|
|
}
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->set($sets)
|
|
->where($db->quoteName('id') . ' = ' . $ticketId)
|
|
)->execute();
|
|
|
|
// Run automation + notifications (with recursion guard)
|
|
if (!$this->automationRunning)
|
|
{
|
|
$this->automationRunning = true;
|
|
$this->runAutomation('status_changed', $ticketId);
|
|
NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status_id' => $oldStatusId]);
|
|
$this->automationRunning = false;
|
|
}
|
|
|
|
return ['success' => true, 'message' => 'Status updated to ' . $status->title . '.'];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
$this->automationRunning = false;
|
|
|
|
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all ticket categories.
|
|
*/
|
|
public function getCategories(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_categories'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get assignees for a ticket (users and groups with resolved names).
|
|
*/
|
|
public function getTicketAssignees(int $ticketId): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_assignees'))
|
|
->where($db->quoteName('ticket_id') . ' = ' . $ticketId)
|
|
);
|
|
$rows = $db->loadObjectList() ?: [];
|
|
|
|
foreach ($rows as $row)
|
|
{
|
|
if ($row->assignee_type === 'user')
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('name'))
|
|
->from($db->quoteName('#__users'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $row->assignee_id)
|
|
);
|
|
$row->name = (string) $db->loadResult() ?: 'User #' . $row->assignee_id;
|
|
}
|
|
else
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('title'))
|
|
->from($db->quoteName('#__usergroups'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $row->assignee_id)
|
|
);
|
|
$row->name = (string) $db->loadResult() ?: 'Group #' . $row->assignee_id;
|
|
}
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Set assignees for a ticket (replaces existing assignments).
|
|
*
|
|
* @param int $ticketId Ticket ID
|
|
* @param array $users Array of user IDs
|
|
* @param array $groups Array of user group IDs
|
|
*/
|
|
public function setTicketAssignees(int $ticketId, array $users = [], array $groups = []): void
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
// Clear existing
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->delete($db->quoteName('#__mokosuiteclient_ticket_assignees'))
|
|
->where($db->quoteName('ticket_id') . ' = ' . $ticketId)
|
|
)->execute();
|
|
|
|
// Insert users
|
|
foreach ($users as $uid)
|
|
{
|
|
$uid = (int) $uid;
|
|
|
|
if ($uid > 0)
|
|
{
|
|
$db->insertObject('#__mokosuiteclient_ticket_assignees', (object) [
|
|
'ticket_id' => $ticketId,
|
|
'assignee_type' => 'user',
|
|
'assignee_id' => $uid,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Insert groups
|
|
foreach ($groups as $gid)
|
|
{
|
|
$gid = (int) $gid;
|
|
|
|
if ($gid > 0)
|
|
{
|
|
$db->insertObject('#__mokosuiteclient_ticket_assignees', (object) [
|
|
'ticket_id' => $ticketId,
|
|
'assignee_type' => 'group',
|
|
'assignee_id' => $gid,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all published Joomla contact records for ticket linking.
|
|
*/
|
|
public function getContacts(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select([$db->quoteName('id'), $db->quoteName('name')])
|
|
->from($db->quoteName('#__contact_details'))
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->order($db->quoteName('name') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get the default ticket status.
|
|
*/
|
|
public function getDefaultStatus(): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_statuses'))
|
|
->where($db->quoteName('is_default') . ' = 1')
|
|
->setLimit(1)
|
|
);
|
|
|
|
return $db->loadObject() ?: null;
|
|
}
|
|
|
|
/**
|
|
* Get the default ticket priority.
|
|
*/
|
|
public function getDefaultPriority(): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_priorities'))
|
|
->where($db->quoteName('is_default') . ' = 1')
|
|
->setLimit(1)
|
|
);
|
|
|
|
return $db->loadObject() ?: null;
|
|
}
|
|
|
|
/**
|
|
* Get all ticket statuses.
|
|
*/
|
|
public function getStatuses(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_statuses'))
|
|
->order($db->quoteName('ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get all ticket priorities.
|
|
*/
|
|
public function getPriorities(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_priorities'))
|
|
->order($db->quoteName('ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get backend users for assignee selection.
|
|
*/
|
|
public function getBackendUsers(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select(['u.id', 'u.name', 'u.username'])
|
|
->from($db->quoteName('#__users', 'u'))
|
|
->where($db->quoteName('u.block') . ' = 0')
|
|
->order($db->quoteName('u.name') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get Joomla user groups for assignee selection.
|
|
*/
|
|
public function getUserGroups(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select(['id', 'title'])
|
|
->from($db->quoteName('#__usergroups'))
|
|
->order($db->quoteName('title') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get Joomla custom field groups assigned to a ticket category.
|
|
*/
|
|
public function getFieldGroupsForCategory(int $categoryId): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select([$db->quoteName('fg.id'), $db->quoteName('fg.title')])
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_category_field_groups', 'cfg'))
|
|
->innerJoin($db->quoteName('#__fields_groups', 'fg') . ' ON fg.id = cfg.field_group_id')
|
|
->where($db->quoteName('cfg.category_id') . ' = ' . $categoryId)
|
|
->where($db->quoteName('fg.state') . ' = 1')
|
|
->order($db->quoteName('fg.ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get Joomla custom fields for given field group IDs (context: com_mokosuiteclient.ticket).
|
|
*/
|
|
public function getFieldsForGroups(array $groupIds): array
|
|
{
|
|
if (empty($groupIds))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
$db = $this->getDatabase();
|
|
$ids = implode(',', array_map('intval', $groupIds));
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__fields'))
|
|
->where($db->quoteName('context') . ' = ' . $db->quote('com_mokosuiteclient.ticket'))
|
|
->where($db->quoteName('group_id') . ' IN (' . $ids . ')')
|
|
->where($db->quoteName('state') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get custom field values for a ticket.
|
|
*/
|
|
public function getFieldValues(int $ticketId): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select([$db->quoteName('field_id'), $db->quoteName('value')])
|
|
->from($db->quoteName('#__fields_values'))
|
|
->where($db->quoteName('item_id') . ' = ' . $db->quote((string) $ticketId))
|
|
);
|
|
$rows = $db->loadObjectList() ?: [];
|
|
|
|
$values = [];
|
|
|
|
foreach ($rows as $row)
|
|
{
|
|
$values[(int) $row->field_id] = $row->value;
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* Save custom field values for a ticket.
|
|
*/
|
|
public function saveFieldValues(int $ticketId, array $fieldValues): void
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
foreach ($fieldValues as $fieldId => $value)
|
|
{
|
|
$fieldId = (int) $fieldId;
|
|
|
|
// Delete existing
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->delete($db->quoteName('#__fields_values'))
|
|
->where($db->quoteName('field_id') . ' = ' . $fieldId)
|
|
->where($db->quoteName('item_id') . ' = ' . $db->quote((string) $ticketId))
|
|
)->execute();
|
|
|
|
// Insert new value (skip empty)
|
|
if ($value !== '' && $value !== null)
|
|
{
|
|
$db->insertObject('#__fields_values', (object) [
|
|
'field_id' => $fieldId,
|
|
'item_id' => (string) $ticketId,
|
|
'value' => $value,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get canned responses, optionally filtered by category.
|
|
*/
|
|
public function getCannedResponses(int $categoryId = 0): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_canned'))
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
|
|
if ($categoryId)
|
|
{
|
|
$query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId
|
|
. ' OR ' . $db->quoteName('category_id') . ' IS NULL)');
|
|
}
|
|
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get ticket counts by status for dashboard.
|
|
*/
|
|
public function getStatusCounts(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('s.id'),
|
|
$db->quoteName('s.title'),
|
|
$db->quoteName('s.alias'),
|
|
$db->quoteName('s.color'),
|
|
$db->quoteName('s.is_closed'),
|
|
'COUNT(' . $db->quoteName('t.id') . ') AS ' . $db->quoteName('cnt'),
|
|
])
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_statuses', 's'))
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_tickets', 't') . ' ON t.status_id = s.id')
|
|
->group($db->quoteName('s.id'))
|
|
->order($db->quoteName('s.ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Get overdue tickets (SLA breached).
|
|
*/
|
|
public function getOverdueTickets(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$now = Factory::getDate()->toSql();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select(['t.' . $db->quoteName('id'), $db->quoteName('t.subject'), $db->quoteName('t.priority'),
|
|
$db->quoteName('t.sla_response_due'), $db->quoteName('t.sla_resolution_due'), $db->quoteName('t.sla_responded')])
|
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
|
->where('(' . $db->quoteName('s.is_closed') . ' = 0 OR ' . $db->quoteName('s.is_closed') . ' IS NULL)')
|
|
->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)'
|
|
. ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')')
|
|
->order($db->quoteName('sla_resolution_due') . ' ASC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
// ==================================================================
|
|
// Automation Engine
|
|
// ==================================================================
|
|
|
|
/**
|
|
* Run automation rules for a specific trigger event against a ticket.
|
|
*
|
|
* @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled
|
|
* @param int $ticketId The ticket to evaluate
|
|
*/
|
|
public function runAutomation(string $event, int $ticketId): void
|
|
{
|
|
try
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
// Load enabled rules for this event
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_automation'))
|
|
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
|
->where($db->quoteName('enabled') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
$rules = $db->loadObjectList() ?: [];
|
|
|
|
if (empty($rules))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Load the ticket
|
|
$ticket = $this->getTicket($ticketId);
|
|
|
|
if (!$ticket)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Calculate age in hours
|
|
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
|
|
|
|
foreach ($rules as $rule)
|
|
{
|
|
$conditions = json_decode($rule->conditions, true) ?: [];
|
|
$actions = json_decode($rule->actions, true) ?: [];
|
|
|
|
if ($this->evaluateConditions($conditions, $ticket))
|
|
{
|
|
$this->executeActions($actions, $ticketId, $ticket);
|
|
}
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
\Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run all scheduled automation rules against all open tickets.
|
|
*/
|
|
public function runScheduledAutomation(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$results = ['evaluated' => 0, 'acted' => 0];
|
|
|
|
// Load scheduled rules
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_automation'))
|
|
->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled'))
|
|
->where($db->quoteName('enabled') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
$rules = $db->loadObjectList() ?: [];
|
|
|
|
if (empty($rules))
|
|
{
|
|
return $results;
|
|
}
|
|
|
|
// Load all non-closed tickets
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->where($db->quoteName('status') . ' != ' . $db->quote('closed'));
|
|
$db->setQuery($query);
|
|
$tickets = $db->loadObjectList() ?: [];
|
|
|
|
foreach ($tickets as $ticket)
|
|
{
|
|
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
|
|
$ticket->replies = [];
|
|
$results['evaluated']++;
|
|
|
|
foreach ($rules as $rule)
|
|
{
|
|
$conditions = json_decode($rule->conditions, true) ?: [];
|
|
$actions = json_decode($rule->actions, true) ?: [];
|
|
|
|
if ($this->evaluateConditions($conditions, $ticket))
|
|
{
|
|
$this->executeActions($actions, (int) $ticket->id, $ticket);
|
|
$results['acted']++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a set of conditions against a ticket (all must match).
|
|
*/
|
|
private function evaluateConditions(array $conditions, object $ticket): bool
|
|
{
|
|
foreach ($conditions as $cond)
|
|
{
|
|
$field = $cond['field'] ?? '';
|
|
$op = $cond['op'] ?? 'eq';
|
|
$value = $cond['value'] ?? '';
|
|
|
|
$ticketValue = $ticket->{$field} ?? null;
|
|
|
|
if ($ticketValue === null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch ($op)
|
|
{
|
|
case 'eq':
|
|
if ((string) $ticketValue !== (string) $value) return false;
|
|
break;
|
|
case 'neq':
|
|
if ((string) $ticketValue === (string) $value) return false;
|
|
break;
|
|
case 'gt':
|
|
if ((float) $ticketValue <= (float) $value) return false;
|
|
break;
|
|
case 'lt':
|
|
if ((float) $ticketValue >= (float) $value) return false;
|
|
break;
|
|
case 'in':
|
|
$list = array_map('trim', explode(',', $value));
|
|
if (!\in_array((string) $ticketValue, $list, true)) return false;
|
|
break;
|
|
case 'not_in':
|
|
$list = array_map('trim', explode(',', $value));
|
|
if (\in_array((string) $ticketValue, $list, true)) return false;
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Execute a set of actions on a ticket.
|
|
*/
|
|
private function executeActions(array $actions, int $ticketId, object $ticket): void
|
|
{
|
|
$db = $this->getDatabase();
|
|
$now = Factory::getDate()->toSql();
|
|
|
|
foreach ($actions as $action)
|
|
{
|
|
$type = $action['type'] ?? '';
|
|
$value = $action['value'] ?? '';
|
|
|
|
switch ($type)
|
|
{
|
|
case 'set_status':
|
|
$this->updateStatus($ticketId, $value);
|
|
break;
|
|
|
|
case 'set_priority':
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->set($db->quoteName('priority') . ' = ' . $db->quote($value))
|
|
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
|
->where($db->quoteName('id') . ' = ' . $ticketId)
|
|
)->execute();
|
|
break;
|
|
|
|
case 'assign':
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->set($db->quoteName('assigned_to') . ' = ' . (int) $value)
|
|
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
|
->where($db->quoteName('id') . ' = ' . $ticketId)
|
|
)->execute();
|
|
break;
|
|
|
|
case 'add_note':
|
|
$reply = (object) [
|
|
'ticket_id' => $ticketId,
|
|
'user_id' => 0,
|
|
'body' => $value,
|
|
'is_internal' => 1,
|
|
'created' => $now,
|
|
];
|
|
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
|
break;
|
|
|
|
case 'send_email':
|
|
// value = email address or comma-separated list
|
|
$emails = array_filter(array_map('trim', explode(',', $value)));
|
|
|
|
foreach ($emails as $email)
|
|
{
|
|
try
|
|
{
|
|
$mailer = Factory::getMailer();
|
|
$mailer->addRecipient($email);
|
|
$mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert');
|
|
$mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? ''));
|
|
$mailer->isHtml(false);
|
|
$mailer->Send();
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
\Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'create_ticket':
|
|
// value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"}
|
|
$ticketData = json_decode($value, true) ?: [];
|
|
$behavior = $ticketData['behavior'] ?? 'append';
|
|
$userId = (int) ($ticket->created_by ?? 0);
|
|
$catId = (int) ($ticketData['category_id'] ?? 0);
|
|
|
|
if ($behavior === 'append' && $userId > 0)
|
|
{
|
|
// Check for existing open ticket from this user in this category
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select($db->quoteName('id'))
|
|
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->where($db->quoteName('created_by') . ' = ' . $userId)
|
|
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
|
|
->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1')
|
|
->order($db->quoteName('created') . ' DESC')
|
|
->setLimit(1)
|
|
);
|
|
$existingId = (int) $db->loadResult();
|
|
|
|
if ($existingId)
|
|
{
|
|
$this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true);
|
|
break;
|
|
}
|
|
}
|
|
elseif ($behavior === 'skip_if_open' && $userId > 0)
|
|
{
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->where($db->quoteName('created_by') . ' = ' . $userId)
|
|
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
|
|
);
|
|
|
|
if ((int) $db->loadResult() > 0)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Create new ticket
|
|
$this->createTicket([
|
|
'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'),
|
|
'body' => $ticketData['body'] ?? '',
|
|
'priority' => $ticketData['priority'] ?? 'normal',
|
|
'category_id' => $catId,
|
|
]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run automation for a system event (not tied to a specific ticket).
|
|
* Creates a virtual ticket context from event data.
|
|
*/
|
|
public function runSystemEventAutomation(string $event, array $eventData = []): void
|
|
{
|
|
try
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_automation'))
|
|
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
|
->where($db->quoteName('enabled') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
$rules = $db->loadObjectList() ?: [];
|
|
|
|
if (empty($rules))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Build a virtual ticket-like object from event data
|
|
$context = (object) array_merge([
|
|
'id' => 0,
|
|
'subject' => $eventData['subject'] ?? $event,
|
|
'body' => $eventData['body'] ?? '',
|
|
'status' => 'open',
|
|
'priority' => $eventData['priority'] ?? 'normal',
|
|
'created_by' => $eventData['user_id'] ?? 0,
|
|
'created' => gmdate('Y-m-d H:i:s'),
|
|
'age_hours' => 0,
|
|
], $eventData);
|
|
|
|
foreach ($rules as $rule)
|
|
{
|
|
$conditions = json_decode($rule->conditions, true) ?: [];
|
|
$actions = json_decode($rule->actions, true) ?: [];
|
|
|
|
if (empty($conditions) || $this->evaluateConditions($conditions, $context))
|
|
{
|
|
$this->executeActions($actions, 0, $context);
|
|
}
|
|
}
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
\Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuiteclient');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all automation rules.
|
|
*/
|
|
public function getAutomationRules(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokosuiteclient_ticket_automation'))
|
|
->order($db->quoteName('ordering') . ' ASC')
|
|
);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
}
|
|
|
|
// ==================================================================
|
|
// Status/Priority CRUD
|
|
// ==================================================================
|
|
|
|
public function saveStatus(array $data): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$obj = (object) $data;
|
|
|
|
if (!empty($obj->title) && empty($obj->alias))
|
|
{
|
|
$obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title));
|
|
}
|
|
|
|
if (empty($obj->id))
|
|
{
|
|
unset($obj->id);
|
|
$db->insertObject('#__mokosuiteclient_ticket_statuses', $obj, 'id');
|
|
|
|
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status created'];
|
|
}
|
|
|
|
$db->updateObject('#__mokosuiteclient_ticket_statuses', $obj, 'id');
|
|
|
|
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status updated'];
|
|
}
|
|
|
|
public function deleteStatus(int $id): array
|
|
{
|
|
if ($id < 1)
|
|
{
|
|
return ['status' => 'error', 'message' => 'Invalid ID'];
|
|
}
|
|
|
|
$db = $this->getDatabase();
|
|
|
|
// Check no tickets use this status
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->where($db->quoteName('status_id') . ' = ' . $id)
|
|
);
|
|
|
|
if ((int) $db->loadResult() > 0)
|
|
{
|
|
return ['status' => 'error', 'message' => 'Cannot delete — status is in use by tickets'];
|
|
}
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->delete($db->quoteName('#__mokosuiteclient_ticket_statuses'))
|
|
->where($db->quoteName('id') . ' = ' . $id)
|
|
)->execute();
|
|
|
|
return ['status' => 'ok', 'message' => 'Status deleted'];
|
|
}
|
|
|
|
public function savePriority(array $data): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$obj = (object) $data;
|
|
|
|
if (!empty($obj->title) && empty($obj->alias))
|
|
{
|
|
$obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title));
|
|
}
|
|
|
|
if (empty($obj->id))
|
|
{
|
|
unset($obj->id);
|
|
$db->insertObject('#__mokosuiteclient_ticket_priorities', $obj, 'id');
|
|
|
|
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority created'];
|
|
}
|
|
|
|
$db->updateObject('#__mokosuiteclient_ticket_priorities', $obj, 'id');
|
|
|
|
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority updated'];
|
|
}
|
|
|
|
public function deletePriority(int $id): array
|
|
{
|
|
if ($id < 1)
|
|
{
|
|
return ['status' => 'error', 'message' => 'Invalid ID'];
|
|
}
|
|
|
|
$db = $this->getDatabase();
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
|
->where($db->quoteName('priority_id') . ' = ' . $id)
|
|
);
|
|
|
|
if ((int) $db->loadResult() > 0)
|
|
{
|
|
return ['status' => 'error', 'message' => 'Cannot delete — priority is in use by tickets'];
|
|
}
|
|
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->delete($db->quoteName('#__mokosuiteclient_ticket_priorities'))
|
|
->where($db->quoteName('id') . ' = ' . $id)
|
|
)->execute();
|
|
|
|
return ['status' => 'ok', 'message' => 'Priority deleted'];
|
|
}
|
|
|
|
// ==================================================================
|
|
// Akeeba Ticket System Importer
|
|
// ==================================================================
|
|
|
|
/**
|
|
* Check if ATS tables exist and return counts.
|
|
*/
|
|
public function checkAtsAvailable(): ?object
|
|
{
|
|
$db = $this->getDatabase();
|
|
|
|
try
|
|
{
|
|
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
|
|
$tickets = (int) $db->loadResult();
|
|
|
|
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
|
|
$posts = (int) $db->loadResult();
|
|
|
|
$db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies');
|
|
$canned = (int) $db->loadResult();
|
|
|
|
return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import tickets, replies, and canned responses from Akeeba Ticket System.
|
|
*/
|
|
public function importFromAts(): array
|
|
{
|
|
$db = $this->getDatabase();
|
|
$results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []];
|
|
|
|
try
|
|
{
|
|
// Status mapping: ATS → MokoSuiteClient
|
|
$statusMap = [
|
|
'O' => 'open', // Open
|
|
'P' => 'in_progress', // Pending (staff action needed)
|
|
'C' => 'closed', // Closed
|
|
];
|
|
// Numeric statuses 1-99 are custom — map to open
|
|
for ($i = 1; $i <= 99; $i++)
|
|
{
|
|
$statusMap[(string) $i] = 'open';
|
|
}
|
|
|
|
// Priority mapping: ATS uses 1-5, we use enum
|
|
$priorityMap = [
|
|
1 => 'low',
|
|
2 => 'low',
|
|
3 => 'normal',
|
|
4 => 'high',
|
|
5 => 'urgent',
|
|
];
|
|
|
|
// Category mapping: ATS uses Joomla categories, map catid to our category
|
|
// Default all to General Support (1) — admin can reassign later
|
|
$defaultCategory = 1;
|
|
|
|
// Import canned replies first
|
|
$db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering');
|
|
$atsCanned = $db->loadObjectList() ?: [];
|
|
|
|
foreach ($atsCanned as $c)
|
|
{
|
|
$exists = $db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from('#__mokosuiteclient_ticket_canned')
|
|
->where($db->quoteName('title') . ' = ' . $db->quote($c->title))
|
|
)->loadResult();
|
|
|
|
if ((int) $exists > 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$row = (object) [
|
|
'title' => $c->title,
|
|
'body' => strip_tags($c->reply ?? ''),
|
|
'category_id' => null,
|
|
'ordering' => (int) ($c->ordering ?? 0),
|
|
];
|
|
$db->insertObject('#__mokosuiteclient_ticket_canned', $row, 'id');
|
|
$results['canned']++;
|
|
}
|
|
|
|
// Import tickets
|
|
$db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id');
|
|
$atsTickets = $db->loadObjectList() ?: [];
|
|
|
|
$ticketIdMap = []; // ATS id → MokoSuiteClient id
|
|
|
|
foreach ($atsTickets as $t)
|
|
{
|
|
// Skip if already imported (check by subject + created_by + created)
|
|
$exists = $db->setQuery(
|
|
$db->getQuery(true)
|
|
->select('COUNT(*)')
|
|
->from('#__mokosuiteclient_tickets')
|
|
->where($db->quoteName('subject') . ' = ' . $db->quote($t->title))
|
|
->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by)
|
|
)->loadResult();
|
|
|
|
if ((int) $exists > 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$status = $statusMap[$t->status] ?? 'open';
|
|
$priority = $priorityMap[(int) $t->priority] ?? 'normal';
|
|
|
|
$row = (object) [
|
|
'subject' => $t->title,
|
|
'body' => '',
|
|
'status' => $status,
|
|
'priority' => $priority,
|
|
'category_id' => $defaultCategory,
|
|
'created_by' => (int) $t->created_by,
|
|
'assigned_to' => (int) $t->assigned_to ?: null,
|
|
'created' => $t->created ?: Factory::getDate()->toSql(),
|
|
'modified' => $t->modified,
|
|
'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
|
|
'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
|
|
'sla_responded' => 1,
|
|
];
|
|
|
|
$db->insertObject('#__mokosuiteclient_tickets', $row, 'id');
|
|
$ticketIdMap[(int) $t->id] = (int) $row->id;
|
|
$results['tickets']++;
|
|
}
|
|
|
|
// Import posts (replies)
|
|
$db->setQuery('SELECT * FROM #__ats_posts ORDER BY id');
|
|
$atsPosts = $db->loadObjectList() ?: [];
|
|
|
|
foreach ($atsPosts as $p)
|
|
{
|
|
$newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null;
|
|
|
|
if (!$newTicketId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// First post of a ticket is usually the ticket body — update the ticket
|
|
if (empty($results['first_post_' . $p->ticket_id]))
|
|
{
|
|
$results['first_post_' . $p->ticket_id] = true;
|
|
$body = strip_tags($p->content_html ?? '');
|
|
$db->setQuery(
|
|
$db->getQuery(true)
|
|
->update('#__mokosuiteclient_tickets')
|
|
->set($db->quoteName('body') . ' = ' . $db->quote($body))
|
|
->where($db->quoteName('id') . ' = ' . $newTicketId)
|
|
)->execute();
|
|
|
|
continue;
|
|
}
|
|
|
|
$row = (object) [
|
|
'ticket_id' => $newTicketId,
|
|
'user_id' => (int) $p->created_by,
|
|
'body' => strip_tags($p->content_html ?? ''),
|
|
'is_internal' => 0,
|
|
'created' => $p->created ?: Factory::getDate()->toSql(),
|
|
];
|
|
|
|
$db->insertObject('#__mokosuiteclient_ticket_replies', $row, 'id');
|
|
$results['replies']++;
|
|
}
|
|
|
|
// Clean up temp tracking keys
|
|
foreach (array_keys($results) as $k)
|
|
{
|
|
if (str_starts_with($k, 'first_post_'))
|
|
{
|
|
unset($results[$k]);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => sprintf(
|
|
'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.',
|
|
$results['tickets'], $results['replies'], $results['canned']
|
|
),
|
|
'counts' => $results,
|
|
];
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
}
|