Files
MokoSuiteClient/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php
T
Jonathan Miller 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
Merge main into dev — apply MokoSuite→MokoSuiteClient rename to dev features
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.
2026-06-16 13:09:14 -05:00

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()];
}
}
}