diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index c2b02a6f..f1c15c1b 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: moko-platform.Automation -# VERSION: 01.00.00 +# VERSION: 02.46.84 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b01328..a596832f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: ./CHANGELOG.md - VERSION: 02.44.00 + VERSION: 02.46.84 BRIEF: Version history using `Keep a Changelog` --> diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 33660849..9e6d27bb 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: ./CODE_OF_CONDUCT.md BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default --> diff --git a/GOVERNANCE.md b/GOVERNANCE.md index b3710a9a..504f1735 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand --> diff --git a/LICENSE.md b/LICENSE.md index 0232bcda..66293cba 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,7 @@ INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: ./LICENSE.md - VERSION: 02.44.00 + VERSION: 02.46.84 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 74771db5..c2fd7203 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /README.md BRIEF: MokoSuiteClient platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index 59465a5f..d0ed7097 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME] INGROUP: [PROJECT_NAME].Documentation REPO: [REPOSITORY_URL] PATH: /SECURITY.md -VERSION: 02.44.00 +VERSION: 02.46.84 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index ea0bb175..7ecc6cf3 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,13 @@ INGROUP: MokoSuiteClient.Build REPO: https://github.com/mokoconsulting-tech/mokosuiteclient FILE: build-guide.md - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/ BRIEF: Build and packaging guide for the MokoSuiteClient system plugin NOTE: Defines environment setup, repository layout, packaging rules, and release preparation --> -# MokoSuiteClient Build Guide (VERSION: 02.44.00) +# MokoSuiteClient Build Guide (VERSION: 02.46.84) ## 1. Purpose diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index d9b3e90b..c5df0be6 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoSuiteClient system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> -# MokoSuiteClient Configuration Guide (VERSION: 02.44.00) +# MokoSuiteClient Configuration Guide (VERSION: 02.46.84) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index 3c8b6901..a9367ab0 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/installation-guide.md BRIEF: Installation guide for the MokoSuiteClient system plugin NOTE: First document in the guide set --> -# MokoSuiteClient Installation Guide (VERSION: 02.44.00) +# MokoSuiteClient Installation Guide (VERSION: 02.46.84) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 716cd674..bc5884ca 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> -# MokoSuiteClient Operations Guide (VERSION: 02.44.00) +# MokoSuiteClient Operations Guide (VERSION: 02.46.84) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 217eb509..e2d9d35f 100644 --- a/docs/guides/rollback-and-recovery-guide.md +++ b/docs/guides/rollback-and-recovery-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/rollback-and-recovery-guide.md BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents NOTE: Completes the core guide set for Suite plugin governance --> -# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.44.00) +# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.46.84) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index 0d7a992e..cdd64dfe 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/testing-guide.md BRIEF: Testing guide for MokoSuiteClient v02.01.08 NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration --> -# MokoSuiteClient Testing Guide (VERSION: 02.44.00) +# MokoSuiteClient Testing Guide (VERSION: 02.46.84) ## 1. Prerequisites diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index e41e419f..b558a2af 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/troubleshooting-guide.md BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin NOTE: Designed for administrators and Suite operations teams --> -# MokoSuiteClient Troubleshooting Guide (VERSION: 02.44.00) +# MokoSuiteClient Troubleshooting Guide (VERSION: 02.46.84) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index 0d53b295..96817003 100644 --- a/docs/guides/upgrade-and-versioning-guide.md +++ b/docs/guides/upgrade-and-versioning-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/guides/upgrade-and-versioning-guide.md BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin NOTE: Defines release flow, version rules, and upgrade validation --> -# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.44.00) +# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.46.84) ## Introduction diff --git a/docs/index.md b/docs/index.md index 770e4045..bcbb145d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.44.00 + VERSION: 02.46.84 PATH: /docs/index.md BRIEF: Master index of all documentation for the MokoSuiteClient plugin NOTE: Automatically maintained index for all guide canvases --> -# MokoSuiteClient Documentation Index (VERSION: 02.44.00) +# MokoSuiteClient Documentation Index (VERSION: 02.46.84) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index c65be220..d0c1dfc3 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,12 @@ INGROUP: MokoSuiteClient REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: /docs/plugin-basic.md - VERSION: 02.44.00 + VERSION: 02.46.84 BRIEF: Baseline documentation for the MokoSuiteClient system plugin NOTE: Foundational reference for internal and external stakeholders --> -# MokoSuiteClient Plugin Overview (VERSION: 02.44.00) +# MokoSuiteClient Plugin Overview (VERSION: 02.46.84) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index aa18204e..5788a8df 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient PATH: /docs/update-server.md -VERSION: 02.44.00 +VERSION: 02.46.84 BRIEF: How this extension's Joomla update server file (update.xml) is managed --> diff --git a/source/packages/com_mokosuiteclient/admin/language/en-GB/com_mokosuiteclient.sys.ini b/source/packages/com_mokosuiteclient/admin/language/en-GB/com_mokosuiteclient.sys.ini index cf66bc07..1773cc68 100644 --- a/source/packages/com_mokosuiteclient/admin/language/en-GB/com_mokosuiteclient.sys.ini +++ b/source/packages/com_mokosuiteclient/admin/language/en-GB/com_mokosuiteclient.sys.ini @@ -2,7 +2,7 @@ ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOSUITECLIENT="MokoSuiteClient" +COM_MOKOSUITECLIENT="MokoSuite" COM_MOKOSUITECLIENT_DESCRIPTION="MokoSuiteClient admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management." COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel" COM_MOKOSUITECLIENT_MENU_DASHBOARD="Dashboard" diff --git a/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql b/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql index 75b55c91..51e2ce1f 100644 --- a/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql +++ b/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql @@ -215,3 +215,15 @@ INSERT IGNORE INTO `#__mokosuiteclient_retention_policies` (`id`, `content_type` (4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'), (5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)'); + + +-- ============================================================ +-- License Cache — stores MokoGitea validation results +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` ( + `dlid_hash` CHAR(64) NOT NULL COMMENT 'SHA-256 of DLID (never store raw DLID)', + `response_data` TEXT NOT NULL COMMENT 'JSON validation response from MokoGitea', + `checked_at` DATETIME NOT NULL, + PRIMARY KEY (`dlid_hash`), + KEY `idx_checked` (`checked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php b/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php deleted file mode 100644 index 1d0c58fb..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php +++ /dev/null @@ -1,1448 +0,0 @@ -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()]; - } - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php index a0899a8f..1e85f947 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php @@ -26,6 +26,7 @@ class HtmlView extends BaseHtmlView protected $wafChartData = []; protected $loginChartData = []; protected $mokoExtensions = []; + public $supportPin = ''; public function display($tpl = null) { @@ -33,6 +34,28 @@ class HtmlView extends BaseHtmlView $this->plugins = $model->getFeaturePlugins(); $this->siteInfo = $model->getSiteInfo(); + + // Daily support PIN from health token + try + { + $db = \Joomla\CMS\Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + ); + $token = (json_decode((string) $db->loadResult()))->health_api_token ?? ''; + + if (!empty($token)) + { + $hash = hash_hmac('sha256', gmdate('Y-m-d'), $token); + $this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + } + } + catch (\Throwable $e) {} $this->recentLogins = $model->getRecentLogins(5); $this->pendingUpdates = $model->getPendingUpdates(); $this->checkedOutItems = $model->getCheckedOutItems(); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php deleted file mode 100644 index 6e209ffe..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php +++ /dev/null @@ -1,72 +0,0 @@ -getModel('Tickets'); - $id = Factory::getApplication()->getInput()->getInt('id', 0); - - $this->ticket = $model->getTicket($id); - $this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0)); - $this->statuses = $model->getStatuses(); - $this->priorities = $model->getPriorities(); - - // Load custom fields for this ticket's category - if ($this->ticket && $this->ticket->category_id) - { - $groups = $model->getFieldGroupsForCategory((int) $this->ticket->category_id); - $groupIds = array_column($groups, 'id'); - $this->customFields = $model->getFieldsForGroups($groupIds); - $this->fieldValues = $model->getFieldValues($id); - } - - // Load attachments - $this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id); - - if (!$this->ticket) - { - Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); - Factory::getApplication()->redirect('index.php?option=com_mokosuiteclient&view=tickets'); - - return; - } - - $this->addToolbar(); - - $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); - $wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css'); - - parent::display($tpl); - } - - protected function addToolbar(): void - { - $title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket'; - ToolbarHelper::title($title, 'headphones'); - ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets'); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php deleted file mode 100644 index 16c65e31..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php +++ /dev/null @@ -1,67 +0,0 @@ -getModel(); - $app = Factory::getApplication(); - - $filters = [ - 'status_id' => $app->getInput()->getInt('filter_status', 0), - 'priority_id' => $app->getInput()->getInt('filter_priority', 0), - 'category_id' => $app->getInput()->getInt('filter_category', 0), - 'contact_id' => $app->getInput()->getInt('filter_contact', 0), - ]; - - $this->tickets = $model->getTickets($filters); - $this->categories = $model->getCategories(); - $this->statuses = $model->getStatuses(); - $this->priorities = $model->getPriorities(); - $this->statusCounts = $model->getStatusCounts(); - $this->overdue = $model->getOverdueTickets(); - $this->atsAvailable = $model->checkAtsAvailable(); - $this->contacts = $model->getContacts(); - $this->backendUsers = $model->getBackendUsers(); - $this->userGroups = $model->getUserGroups(); - - $this->addToolbar(); - - $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); - $wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css'); - - parent::display($tpl); - } - - protected function addToolbar(): void - { - ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKETS_TITLE'), 'headphones'); - ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient'); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/HtmlView.php deleted file mode 100644 index daac6e96..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/HtmlView.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * @package MokoSuiteClient - * @subpackage Component - */ - -namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings; - -defined('_JEXEC') or die; - -use Joomla\CMS\Language\Text; -use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; -use Joomla\CMS\Toolbar\ToolbarHelper; - -class HtmlView extends BaseHtmlView -{ - protected $statuses = []; - protected $priorities = []; - - public function display($tpl = null) - { - $model = $this->getModel('Tickets'); - - $this->statuses = $model->getStatuses(); - $this->priorities = $model->getPriorities(); - - $this->addToolbar(); - - parent::display($tpl); - } - - protected function addToolbar(): void - { - ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKET_SETTINGS'), 'cog'); - ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets'); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/index.html b/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/index.html deleted file mode 100644 index 94906bce..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/index.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php index 483da496..495a4f67 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php @@ -48,6 +48,12 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; MokoSuiteClient escape($siteInfo->mokosuiteclient_version); ?> + supportPin)): ?> +escape($block->ip); ?>escape($login->ip_address ?? ''); ?>| Status | escape($t->status_title ?? $t->status); ?> |
| Priority | escape($t->priority_title ?? $t->priority); ?> |
| Category | escape($t->category_title ?? '—'); ?> |
| Created By | escape($t->created_by_name); ?> escape($t->created_by_email ?? ''); ?> |
| Assigned To | assignees)) {
- foreach ($t->assignees as $a) {
- $icon = $a->assignee_type === 'group' ? ' ' : ' ';
- echo ' ' . $icon . $this->escape($a->name) . ' ';
- }
- } else {
- echo 'Unassigned';
- }
- ?> |
| Contact |
-
- escape($t->contact_name ?? 'Contact #' . $t->contact_id); ?>
-
- contact_email)): ?> escape($t->contact_email); ?> - contact_phone)): ?> escape($t->contact_phone); ?> - |
| Created | created, 'M d, Y H:i'); ?> |
| Resolved | resolved, 'M d, Y H:i'); ?> |
| Closed | closed, 'M d, Y H:i'); ?> |
| Replies | reply_count; ?> |
escape($t->satisfaction_feedback); ?>
- -| escape($field->title); ?> | -escape($this->fieldValues[(int) $field->id] ?? '—'); ?> | -
| # | -Subject | -Status | -Priority | -Category | -Contact | -Created By | -Assigned To | -Created | -SLA | -
|---|---|---|---|---|---|---|---|---|---|
| No tickets found. | |||||||||
| id; ?> | -escape(mb_substr($t->subject, 0, 60)); ?> | -escape($t->status_title ?? $t->status); ?> | -escape($t->priority_title ?? $t->priority); ?> | -escape($t->category_title ?? '—'); ?> | -contact_name ? '' . $this->escape($t->contact_name) . '' : '—'; ?> | -escape($t->created_by_name ?? ''); ?> | -assignees)) { - $names = []; - foreach ($t->assignees as $a) { - $icon = $a->assignee_type === 'group' ? ' ' : ''; - $names[] = $icon . $this->escape($a->name); - } - echo implode(', ', $names); - } else { - echo 'Unassigned'; - } - ?> | -created, 'M d H:i'); ?> | -- sla_response_due && !$t->sla_responded): ?> - sla_response_due, 'M d H:i'); ?> - sla_resolution_due): ?> - sla_resolution_due, 'M d H:i'); ?> - — - | -
| Title | -Color | -Default | -Closed? | -Order | -Actions | -
|---|---|---|---|---|---|
| escape($s->title); ?> (escape($s->alias); ?>) | -- | is_default ? 'Yes' : ''; ?> | -is_closed ? 'Closed' : ''; ?> | -ordering; ?> | -- - - - - | -
| Title | -Color | -Default | -Order | -Actions | -
|---|---|---|---|---|
| escape($p->title); ?> (escape($p->alias); ?>) | -- | is_default ? 'Yes' : ''; ?> | -ordering; ?> | -- - - - - | -
ip); ?>ip); ?>