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)): ?> +
+ Support PIN + escape($this->supportPin); ?> +
+
Joomla escape($siteInfo->joomla_version); ?> @@ -311,7 +317,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; escape(mb_substr($item->title, 0, 30)); ?> escape($item->username ?? ''); ?> - checked_out_time, 'M d H:i'); ?> + checked_out_time, Text::_('DATE_FORMAT_LC4')); ?> @@ -342,7 +348,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; escape($block->ip); ?> escape($block->rule); ?> - created, 'M d H:i'); ?> + created, Text::_('DATE_FORMAT_LC4')); ?> @@ -369,7 +375,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; escape($login->username ?? ''); ?> escape($login->ip_address ?? ''); ?> - log_date, 'M d H:i'); ?> + log_date, Text::_('DATE_FORMAT_LC4')); ?> diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/privacy/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/privacy/default.php index 290b8424..81d898fd 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/privacy/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/privacy/default.php @@ -3,6 +3,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; @@ -140,8 +141,8 @@ $typeBadge = [ escape($r->user_name ?? ''); ?>
escape($r->user_email ?? ''); ?> type); ?> status); ?> - created, 'M d, Y H:i'); ?> - processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?> + created, Text::_('DATE_FORMAT_LC2')); ?> + processed ? HTMLHelper::_('date', $r->processed, Text::_('DATE_FORMAT_LC2')) : '—'; ?> status === 'pending'): ?>
diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/ticket/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/ticket/default.php deleted file mode 100644 index defc35e8..00000000 --- a/source/packages/com_mokosuiteclient/admin/tmpl/ticket/default.php +++ /dev/null @@ -1,364 +0,0 @@ -ticket; -$canned = $this->cannedResponses; -$token = Session::getFormToken(); -$attachments = $this->attachments; -$downloadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.downloadAttachment'); -$uploadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.uploadAttachment&format=json'); -$deleteAttUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAttachment&format=json'); - -// Group attachments by reply_id (null = ticket-level) -$attByReply = []; -foreach ($attachments as $att) { - $key = $att->reply_id ?? 0; - $attByReply[$key][] = $att; -} - -$statuses = $this->statuses ?? []; -$priorities = $this->priorities ?? []; -?> - -
- -
- -
-
-
- escape($t->created_by_name); ?> - created, 'M d, Y H:i'); ?> -
- Original -
-
- escape($t->body)); ?> - -
- - -
-
- - - replies as $reply): ?> -
-
-
- escape($reply->user_name ?? 'System'); ?> - created, 'M d, Y H:i'); ?> -
- is_internal): ?> - Internal Note - -
-
- escape($reply->body)); ?> - id])): ?> -
-
- Attachments: - id] as $att): ?> - - escape($att->filename); ?> - (filesize); ?>) - - -
- -
-
- - - -
-
Reply
-
- -
- -
- - -
- -
-
- - -
-
-
-
- - -
-
-
Details
-
- - - - - - - contact_id): ?> - - - - resolved): ?> - closed): ?> - -
Statusescape($t->status_title ?? $t->status); ?>
Priorityescape($t->priority_title ?? $t->priority); ?>
Categoryescape($t->category_title ?? '—'); ?>
Created Byescape($t->created_by_name); ?>
escape($t->created_by_email ?? ''); ?>
Assigned Toassignees)) { - 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); ?> -
Createdcreated, 'M d, Y H:i'); ?>
Resolvedresolved, 'M d, Y H:i'); ?>
Closedclosed, 'M d, Y H:i'); ?>
Repliesreply_count; ?>
-
-
- - - sla_response_due || $t->sla_resolution_due): ?> -
-
SLA
-
- sla_response_due): ?> -
- Response Due
- sla_responded && strtotime($t->sla_response_due) < time(); - ?> - - sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?> - - -
- - sla_resolution_due): ?> -
- Resolution Due
- status_is_closed) && strtotime($t->sla_resolution_due) < time(); - ?> - - status_is_closed) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?> - - -
- -
-
- - - - status, ['resolved', 'closed'], true); - $hasRating = !empty($t->satisfaction_rating); - ?> - -
-
Satisfaction
-
-
- - - -
-
satisfaction_rating; ?>/5
- satisfaction_feedback)): ?> -

escape($t->satisfaction_feedback); ?>

- -
-
- -
-
Rate this Support
-
-
- - - -
- - -
-
- - - -
-
Actions
-
- - id !== (int) $t->status_id): ?> - - - -
-
- - - customFields)): ?> -
-
Custom Fields
-
- - customFields as $field): ?> - - - - - -
escape($field->title); ?>escape($this->fieldValues[(int) $field->id] ?? '—'); ?>
-
-
- -
-
- - diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/tickets/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/tickets/default.php deleted file mode 100644 index 4dbf1a9e..00000000 --- a/source/packages/com_mokosuiteclient/admin/tmpl/tickets/default.php +++ /dev/null @@ -1,317 +0,0 @@ -tickets; -$categories = $this->categories; -$statuses = $this->statuses; -$priorities = $this->priorities; -$counts = $this->statusCounts; -$overdue = $this->overdue; -$atsAvailable = $this->atsAvailable; -$token = Session::getFormToken(); -?> - -
- -
- -
cnt; ?>escape($sc->title); ?>
- - 0): ?> -
SLA Overdue
- -
- - -
-
- - - - -
-
- - - - -
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger'; - elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && empty($t->status_is_closed)) $slaClass = 'table-danger'; - elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning'; - ?> - - - - - - - - - - - - - - - -
#SubjectStatusPriorityCategoryContactCreated ByAssigned ToCreatedSLA
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'); ?> - -
-
-
-
- - - - - diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/ticketsettings/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/ticketsettings/default.php deleted file mode 100644 index e75f6d09..00000000 --- a/source/packages/com_mokosuiteclient/admin/tmpl/ticketsettings/default.php +++ /dev/null @@ -1,203 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * @package MokoSuiteClient - * @subpackage Component - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\HTML\HTMLHelper; -use Joomla\CMS\Router\Route; -use Joomla\CMS\Session\Session; - -$token = Session::getFormToken(); - -$colorOptions = [ - 'bg-primary', 'bg-secondary', 'bg-success', 'bg-danger', - 'bg-warning text-dark', 'bg-info text-dark', 'bg-dark', 'bg-light text-dark', -]; -?> - -
- -
-
-
- Ticket Statuses -
-
- - - - - - - - - - - - - statuses as $s): ?> - - - - - - - - - - -
TitleColorDefaultClosed?OrderActions
escape($s->title); ?> (escape($s->alias); ?>)   is_default ? 'Yes' : ''; ?>is_closed ? 'Closed' : ''; ?>ordering; ?> - - - - -
-
- -
-
- - -
-
-
- Ticket Priorities -
-
- - - - - - - - - - - - priorities as $p): ?> - - - - - - - - - -
TitleColorDefaultOrderActions
escape($p->title); ?> (escape($p->alias); ?>)   is_default ? 'Yes' : ''; ?>ordering; ?> - - - - -
-
- -
-
-
- - diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/ticketsettings/index.html b/source/packages/com_mokosuiteclient/admin/tmpl/ticketsettings/index.html deleted file mode 100644 index 94906bce..00000000 --- a/source/packages/com_mokosuiteclient/admin/tmpl/ticketsettings/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/waflog/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/waflog/default.php index 703347d3..41f3f1d5 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/waflog/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/waflog/default.php @@ -3,6 +3,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; @@ -98,7 +99,7 @@ $ruleBadge = [ - created, 'M d H:i:s'); ?> + created, Text::_('DATE_FORMAT_LC4')); ?> ip); ?> rule); ?> uri, 0, 60)); ?> @@ -148,7 +149,7 @@ $ruleBadge = [ ip); ?> cnt; ?> - last_seen, 'M d'); ?> + last_seen, Text::_('DATE_FORMAT_LC4')); ?> ' + . '
'; + } +} diff --git a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml index 769488da..5f2a0220 100644 --- a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml +++ b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.44.00 + 02.46.84 PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC Moko\Plugin\System\MokoSuiteClientLicense srcserviceslanguage diff --git a/source/packages/plg_system_mokosuiteclient_monitor/language/en-GB/plg_system_mokosuiteclient_monitor.ini b/source/packages/plg_system_mokosuiteclient_monitor/language/en-GB/plg_system_mokosuiteclient_monitor.ini deleted file mode 100644 index d9c564fd..00000000 --- a/source/packages/plg_system_mokosuiteclient_monitor/language/en-GB/plg_system_mokosuiteclient_monitor.ini +++ /dev/null @@ -1,13 +0,0 @@ -; MokoSuiteClient Health Monitor Plugin -; Copyright (C) 2026 Moko Consulting. All rights reserved. -; License: GPL-3.0-or-later - -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR="System - MokoSuiteClient Monitor" -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC="Sends heartbeat data to a MokoSuiteClientHQ control panel for centralized site monitoring." - -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC="Monitoring" -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC_DESC="Configure heartbeat reporting to MokoSuiteClientHQ." -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_LABEL="Send Heartbeat" -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_DESC="Send heartbeat data to MokoSuiteClientHQ when plugin settings are saved." -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_LABEL="MokoSuiteClientHQ URL" -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC="URL of the MokoSuiteClientHQ control panel (e.g. https://mokoconsulting.tech). The heartbeat is sent to /api/index.php/v1/mokosuiteclienthq/heartbeat on this host." diff --git a/source/packages/plg_system_mokosuiteclient_monitor/language/en-GB/plg_system_mokosuiteclient_monitor.sys.ini b/source/packages/plg_system_mokosuiteclient_monitor/language/en-GB/plg_system_mokosuiteclient_monitor.sys.ini deleted file mode 100644 index fd50fdf0..00000000 --- a/source/packages/plg_system_mokosuiteclient_monitor/language/en-GB/plg_system_mokosuiteclient_monitor.sys.ini +++ /dev/null @@ -1,3 +0,0 @@ -; MokoSuiteClient Health Monitor Plugin - System strings -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR="System - MokoSuiteClient Monitor" -PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC="Site health monitoring, MokoSuiteClientHQ heartbeat integration, and diagnostics." diff --git a/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml b/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml deleted file mode 100644 index 1a4a8d82..00000000 --- a/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - System - MokoSuiteClient Monitor - mokosuiteclient_monitor - Moko Consulting - 2026-06-02 - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.44.00 - PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC - Moko\Plugin\System\MokoSuiteClientMonitor - - - src - services - language - - - - en-GB/plg_system_mokosuiteclient_monitor.ini - en-GB/plg_system_mokosuiteclient_monitor.sys.ini - - - - -
- - - - - - - - - -
-
-
-
diff --git a/source/packages/plg_system_mokosuiteclient_monitor/services/provider.php b/source/packages/plg_system_mokosuiteclient_monitor/services/provider.php deleted file mode 100644 index 146ed2df..00000000 --- a/source/packages/plg_system_mokosuiteclient_monitor/services/provider.php +++ /dev/null @@ -1,34 +0,0 @@ -set( - PluginInterface::class, - function (Container $container) { - $dispatcher = $container->get(DispatcherInterface::class); - $plugin = new Monitor($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_monitor')); - $plugin->setApplication(Factory::getApplication()); - - return $plugin; - } - ); - } -}; diff --git a/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php b/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php deleted file mode 100644 index 2d8ab2e7..00000000 --- a/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php +++ /dev/null @@ -1,353 +0,0 @@ - 'onExtensionAfterSave', - 'onAfterInitialise' => 'onAfterInitialise', - 'onExtensionAfterInstall' => 'onExtensionAfterInstall', - ]; - } - - /** - * Send heartbeat on first admin page load after install/update. - */ - public function onAfterInitialise(): void - { - $app = $this->getApplication(); - if (!$app->isClient('administrator')) return; - if (!$this->params->get('heartbeat_enabled', 1)) return; - - $session = \Joomla\CMS\Factory::getSession(); - if ($session->get('mokosuiteclient.heartbeat_sent', false)) return; - - // Check if version changed since last heartbeat - $lastVersion = $this->params->get('_last_heartbeat_version', ''); - $currentVersion = $this->getMokoSuiteClientVersion(); - - if ($lastVersion !== $currentVersion) - { - $session->set('mokosuiteclient.heartbeat_sent', true); - $this->sendHeartbeat(); - - // Store version so we don't re-send every session - try - { - $this->params->set('_last_heartbeat_version', $currentVersion); - - $extension = new \Joomla\CMS\Table\Extension(Factory::getDbo()); - $extension->load(['element' => 'mokosuiteclient_monitor', 'folder' => 'system', 'type' => 'plugin']); - $extension->params = $this->params->toString(); - $extension->store(); - } - catch (\Throwable $e) {} - } - } - - /** - * Send heartbeat immediately after package install/update. - */ - public function onExtensionAfterInstall($installer, $eid): void - { - if (!$this->params->get('heartbeat_enabled', 1)) return; - - try { $this->sendHeartbeat(); } - catch (\Throwable $e) {} - } - - /** - * After saving this plugin or the core plugin, send heartbeat. - */ - public function onExtensionAfterSave($event): void - { - // Joomla 6: single event object; Joomla 5: individual args - if (is_object($event) && method_exists($event, 'getArgument')) - { - $context = $event->getArgument('context', $event->getArgument(0, '')); - $table = $event->getArgument('subject', $event->getArgument(1, null)); - } - else - { - $context = $event; - $table = func_get_arg(1); - } - - if ($context !== 'com_plugins.plugin' || !$table) - { - return; - } - - $element = $table->element ?? ''; - - if (!\in_array($element, ['mokosuiteclient', 'mokosuiteclient_monitor'], true)) - { - return; - } - - if (!$this->params->get('heartbeat_enabled', 1)) - { - return; - } - - $this->sendHeartbeat(); - } - - /** - * Send heartbeat to the MokoSuiteClientHQ control panel. - * - * The request is RSA-signed: the client signs domain|timestamp|token - * with its private key. Base verifies with the matching public key. - */ - private function sendHeartbeat(): void - { - $baseUrl = rtrim($this->params->get('base_url', ''), '/'); - - if (empty($baseUrl)) - { - return; - } - - $coreParams = MokoSuiteClientHelper::getCoreParams(); - $healthToken = $coreParams->get('health_api_token', ''); - - if (empty($healthToken)) - { - return; - } - - $siteUrl = rtrim(Uri::root(), '/'); - $domain = parse_url($siteUrl, PHP_URL_HOST) ?: ''; - - if (empty($domain)) - { - return; - } - - $app = $this->getApplication(); - $config = Factory::getConfig(); - $timestamp = time(); - - $payload = [ - 'token' => $healthToken, - 'domain' => $domain, - 'site_name' => $config->get('sitename', 'Joomla'), - 'site_url' => $siteUrl, - 'joomla_version' => (new Version())->getShortVersion(), - 'php_version' => PHP_VERSION, - 'mokosuiteclient_version' => $this->getMokoSuiteClientVersion(), - 'timestamp' => $timestamp, - 'client_info' => [ - 'company' => $config->get('sitename', ''), - 'email' => $config->get('mailfrom', ''), - ], - ]; - - // Include live health data - $healthData = $this->fetchLocalHealth($siteUrl, $healthToken); - - if ($healthData !== null) - { - $payload['health'] = $healthData; - } - - // RSA sign the request - $headers = ['Content-Type: application/json']; - $signature = $this->signRequest($domain, $timestamp, $healthToken); - - if ($signature !== null) - { - $headers[] = 'X-MokoSuite-Signature: ' . $signature; - $headers[] = 'X-MokoSuite-Timestamp: ' . $timestamp; - } - - $endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat'; - $json = json_encode($payload, JSON_UNESCAPED_SLASHES); - - try - { - $http = \Joomla\CMS\Http\HttpFactory::getHttp( - new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]), - ['curl', 'stream'] - ); - - $headerMap = []; - foreach ($headers as $h) - { - [$key, $val] = explode(': ', $h, 2); - $headerMap[$key] = $val; - } - - $response = $http->post($endpoint, $json, $headerMap, 15); - $code = $response->code; - $body = json_decode($response->body, true); - - if ($code >= 200 && $code < 300) - { - $app->enqueueMessage( - 'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'), - 'message' - ); - } - else - { - Log::add( - \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), - Log::WARNING, - 'mokosuiteclient' - ); - $app->enqueueMessage( - 'MokoSuiteClientHQ heartbeat failed (HTTP ' . $code . ')', - 'warning' - ); - } - } - catch (\Throwable $e) - { - Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } - } - - /** - * RSA-sign the request message. - * - * @param string $domain Site domain. - * @param int $timestamp Unix timestamp. - * @param string $token Health API token. - * - * @return string|null Base64-encoded signature, or null if signing fails. - */ - private function signRequest(string $domain, int $timestamp, string $token): ?string - { - $signingKeyB64 = $this->params->get('signing_key', ''); - - // Fall back to manifest XML default if not yet saved in params - if (empty($signingKeyB64)) - { - $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml'; - - if (is_file($manifestFile)) - { - $xml = simplexml_load_file($manifestFile); - - if ($xml) - { - foreach ($xml->xpath('//field[@name="signing_key"]') as $field) - { - $signingKeyB64 = (string) $field['default']; - break; - } - } - } - } - - if (empty($signingKeyB64)) - { - return null; - } - - $privateKeyPem = base64_decode($signingKeyB64); - - if (empty($privateKeyPem)) - { - return null; - } - - $message = $domain . '|' . $timestamp . '|' . $token; - $privateKey = openssl_pkey_get_private($privateKeyPem); - - if ($privateKey === false) - { - return null; - } - - $signature = ''; - - if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256)) - { - return base64_encode($signature); - } - - return null; - } - - /** - * Fetch health data from the local site's health endpoint. - */ - private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array - { - try - { - $http = \Joomla\CMS\Http\HttpFactory::getHttp( - new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]), - ['curl', 'stream'] - ); - - $response = $http->get( - $siteUrl . '/?mokosuiteclient=health', - ['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'], - 10 - ); - - if ($response->code !== 200 || empty($response->body)) - { - return null; - } - - return json_decode($response->body, true) ?: null; - } - catch (\Throwable $e) - { - return null; - } - } - - /** - * Get the installed MokoSuiteClient package version. - */ - private function getMokoSuiteClientVersion(): string - { - try - { - $extension = new \Joomla\CMS\Table\Extension(Factory::getDbo()); - $extension->load(['element' => 'pkg_mokosuiteclient', 'type' => 'package']); - $manifest = json_decode($extension->manifest_cache ?? '{}'); - - return $manifest->version ?? ''; - } - catch (\Throwable $e) - { - return ''; - } - } -} diff --git a/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml b/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml index 53467101..3b67fa03 100644 --- a/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml +++ b/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.44.00 + 02.46.84 PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC Moko\Plugin\System\MokoSuiteClientOffline diff --git a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml index e5f5e02f..21b23b9d 100644 --- a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml +++ b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.44.00 + 02.46.84 PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC Moko\Plugin\System\MokoSuiteClientTenant diff --git a/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini b/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini deleted file mode 100644 index f796d882..00000000 --- a/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini +++ /dev/null @@ -1,8 +0,0 @@ -PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation" -PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules." -PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_TITLE="MokoSuiteClient: Ticket Automation" -PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)." -PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_TITLE="MokoSuiteClient: IMAP Email Polling" -PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_DESC="Polls an IMAP inbox for new emails and creates tickets or replies from unread messages." -PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_TITLE="MokoSuiteClient: Auto-Close Resolved Tickets" -PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_DESC="Automatically closes tickets that have been in resolved status longer than the configured number of days." diff --git a/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.sys.ini b/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.sys.ini deleted file mode 100644 index 8077fabe..00000000 --- a/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.sys.ini +++ /dev/null @@ -1,2 +0,0 @@ -PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation" -PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions." diff --git a/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml b/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml deleted file mode 100644 index 52fa17e0..00000000 --- a/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Task - MokoSuiteClient Ticket Automation - mokosuiteclient_tickets - Moko Consulting - 2026-06-02 - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.44.00 - Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions. - Moko\Plugin\Task\MokoSuiteClientTickets - - - src - services - language - - - - en-GB/plg_task_mokosuiteclient_tickets.ini - en-GB/plg_task_mokosuiteclient_tickets.sys.ini - - diff --git a/source/packages/plg_task_mokosuiteclient_tickets/services/provider.php b/source/packages/plg_task_mokosuiteclient_tickets/services/provider.php deleted file mode 100644 index d885c3f7..00000000 --- a/source/packages/plg_task_mokosuiteclient_tickets/services/provider.php +++ /dev/null @@ -1,27 +0,0 @@ -set( - PluginInterface::class, - function (Container $container) { - $dispatcher = $container->get(DispatcherInterface::class); - $plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokosuiteclient_tickets')); - $plugin->setApplication(Factory::getApplication()); - - return $plugin; - } - ); - } -}; diff --git a/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php b/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php deleted file mode 100644 index 66ea7c08..00000000 --- a/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php +++ /dev/null @@ -1,313 +0,0 @@ - [ - 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION', - 'method' => 'runAutomation', - ], - 'mokosuiteclient.ticket.imap_poll' => [ - 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL', - 'method' => 'runImapPoll', - ], - 'mokosuiteclient.ticket.autoclose' => [ - 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE', - 'method' => 'runAutoClose', - ], - ]; - - protected $autoloadLanguage = true; - - public static function getSubscribedEvents(): array - { - return [ - 'onTaskOptionsList' => 'advertiseRoutines', - 'onExecuteTask' => 'standardRoutineHandler', - 'onContentPrepareForm' => 'enhanceTaskItemForm', - ]; - } - - /** - * Run all scheduled automation rules against open tickets. - */ - private function runAutomation(ExecuteTaskEvent $event): int - { - try - { - $model = new TicketsModel(); - $results = $model->runScheduledAutomation(); - - $this->logTask( - \sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted']) - ); - - return Status::OK; - } - catch (\Throwable $e) - { - $this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error'); - - return Status::KNOCKOUT; - } - } - - /** - * Poll IMAP inbox and create tickets from unread emails (#136). - */ - private function runImapPoll(ExecuteTaskEvent $event): int - { - $config = $this->getComponentConfig(); - $host = $config['imap_host'] ?? ''; - $port = (int) ($config['imap_port'] ?? 993); - $user = $config['imap_user'] ?? ''; - $pass = $config['imap_password'] ?? ''; - $ssl = ($config['imap_ssl'] ?? '1') === '1'; - $folder = $config['imap_folder'] ?? 'INBOX'; - $processed = $config['imap_processed_folder'] ?? 'INBOX.Processed'; - $defaultCat = (int) ($config['default_category'] ?? 0) ?: null; - - if (empty($host) || empty($user) || empty($pass)) - { - $this->logTask('IMAP not configured — skipping', 'warning'); - return Status::OK; - } - - if (!function_exists('imap_open')) - { - $this->logTask('php-imap extension not available', 'error'); - return Status::KNOCKOUT; - } - - $mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder; - $mbox = @imap_open($mailbox, $user, $pass); - - if (!$mbox) - { - $this->logTask('IMAP connection failed: ' . imap_last_error(), 'error'); - return Status::KNOCKOUT; - } - - $db = Factory::getDbo(); - $created = 0; - $replied = 0; - - $emails = imap_search($mbox, 'UNSEEN'); - - if ($emails === false) - { - imap_close($mbox); - $this->logTask('No new emails'); - return Status::OK; - } - - foreach ($emails as $msgNum) - { - try - { - $header = imap_headerinfo($mbox, $msgNum); - $subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)'; - $fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host; - $body = $this->getImapBody($mbox, $msgNum); - - // Match sender to Joomla user - $userId = $this->findUserByEmail($fromAddr); - - // Check if this is a reply (subject contains [#123]) - $ticketId = 0; - if (preg_match('/\[#(\d+)\]/', $subject, $m)) - { - $ticketId = (int) $m[1]; - } - - if ($ticketId > 0) - { - // Add as reply to existing ticket - $reply = (object) [ - 'ticket_id' => $ticketId, - 'user_id' => $userId, - 'body' => $body, - 'is_internal' => 0, - 'created' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id'); - $replied++; - - // Notify - $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId)); - $ticket = $db->loadObject(); - if ($ticket) { - NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]); - } - } - else - { - // Create new ticket - $ticket = (object) [ - 'subject' => $subject, - 'body' => $body, - 'status' => 'open', - 'priority' => 'normal', - 'category_id' => $defaultCat, - 'created_by' => $userId, - 'created' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id'); - $created++; - - NotificationService::notify('ticket_created', $ticket); - } - - // Mark as seen / move to processed folder - imap_setflag_full($mbox, (string) $msgNum, '\\Seen'); - - if ($processed && $processed !== $folder) - { - @imap_mail_move($mbox, (string) $msgNum, $processed); - } - } - catch (\Throwable $e) - { - Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } - } - - imap_expunge($mbox); - imap_close($mbox); - - $this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added"); - return Status::OK; - } - - /** - * Auto-close resolved tickets after configured days. - */ - private function runAutoClose(ExecuteTaskEvent $event): int - { - $config = $this->getComponentConfig(); - $days = (int) ($config['autoclose_days'] ?? 7); - - if ($days <= 0) - { - $this->logTask('Auto-close disabled (days = 0)'); - return Status::OK; - } - - $db = Factory::getDbo(); - $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); - - $db->setQuery( - "UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}" - . " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}" - . " WHERE status = 'resolved'" - . " AND resolved IS NOT NULL" - . " AND resolved < {$db->quote($cutoff)}" - ); - $db->execute(); - $closed = $db->getAffectedRows(); - - $this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)"); - return Status::OK; - } - - // ── Helpers ────────────────────────────────────────────────── - - private function getComponentConfig(): array - { - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select('params') - ->from('#__extensions') - ->where('element = ' . $db->quote('com_mokosuiteclient')) - ->where('type = ' . $db->quote('component')) - ); - return json_decode($db->loadResult() ?? '{}', true) ?: []; - } - catch (\Throwable $e) - { - Log::add('Failed to load component config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); - return []; - } - } - - private function findUserByEmail(string $email): int - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select('id') - ->from('#__users') - ->where('email = ' . $db->quote($email)) - ->setLimit(1) - ); - return (int) $db->loadResult(); - } - - private function getImapBody($mbox, int $msgNum): string - { - $structure = imap_fetchstructure($mbox, $msgNum); - - // Simple single-part message - if (empty($structure->parts)) - { - $body = imap_fetchbody($mbox, $msgNum, '1'); - if ($structure->encoding === 3) $body = base64_decode($body); - if ($structure->encoding === 4) $body = quoted_printable_decode($body); - return trim(strip_tags($body)); - } - - // Multipart — find text/plain or text/html - $textBody = ''; - - foreach ($structure->parts as $i => $part) - { - $partNum = (string) ($i + 1); - - if ($part->type === 0) // text - { - $content = imap_fetchbody($mbox, $msgNum, $partNum); - if ($part->encoding === 3) $content = base64_decode($content); - if ($part->encoding === 4) $content = quoted_printable_decode($content); - - $subtype = strtolower($part->subtype ?? ''); - - if ($subtype === 'plain' && empty($textBody)) - { - $textBody = $content; - } - elseif ($subtype === 'html' && empty($textBody)) - { - $textBody = strip_tags($content); - } - } - } - - return trim($textBody); - } -} diff --git a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml index 234a1386..daf8fd3c 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml +++ b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.44.00 + 02.46.84 PLG_TASK_MOKOSUITECLIENTDEMO_DESC Moko\Plugin\Task\MokoSuiteClientDemo diff --git a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php index 0764ab26..dfca8b1a 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php +++ b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php - * VERSION: 02.44.00 + * VERSION: 02.46.84 * BRIEF: Content-only snapshot/restore for demo site reset */ diff --git a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml index 4800fdbc..4edd5b41 100644 --- a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml +++ b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.44.00 + 02.46.84 PLG_TASK_MOKOSUITECLIENTSYNC_DESC Moko\Plugin\Task\MokoSuiteClientSync diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php index 6cf48079..cd3bc5e1 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php - * VERSION: 02.44.00 + * VERSION: 02.46.84 * BRIEF: Receiver-side content sync — applies incoming payload to local DB */ diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php index 9487a0e7..0d3b9b01 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php - * VERSION: 02.44.00 + * VERSION: 02.46.84 * BRIEF: Sender-side content sync — builds payload and pushes to remote sites */ diff --git a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml index 5f65d57f..85d7ca22 100644 --- a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.44.00 + 02.46.84 Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoSuiteClient diff --git a/source/pkg_mokosuiteclient.xml b/source/pkg_mokosuiteclient.xml index 71f1eed8..165f38c7 100644 --- a/source/pkg_mokosuiteclient.xml +++ b/source/pkg_mokosuiteclient.xml @@ -2,7 +2,7 @@ Package - MokoSuiteClient mokosuiteclient - 02.44.00 + 02.46.84 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -25,11 +25,9 @@ mod_mokosuiteclient_menu.zip mod_mokosuiteclient_cache.zip mod_mokosuiteclient_categories.zip - plg_system_mokosuiteclient_backup.zip plg_webservices_mokosuiteclient.zip plg_task_mokosuiteclientdemo.zip plg_task_mokosuiteclientsync.zip - plg_task_mokosuiteclient_tickets.zip diff --git a/source/script.php b/source/script.php index bc85e657..ec7b16a0 100644 --- a/source/script.php +++ b/source/script.php @@ -46,6 +46,9 @@ class Pkg_MokosuiteclientInstallerScript { $this->saveDownloadKey(); + // Joomla's package installer INSERTs extension rows without element first. + // MySQL strict mode requires a default. Set DEFAULT '' so the INSERT succeeds, + // then postflight cleans up the empty-element rows and stale files. try { $db = Factory::getDbo(); @@ -55,12 +58,16 @@ class Pkg_MokosuiteclientInstallerScript } catch (\Throwable $e) { - // Non-fatal — column may already have a default + // Non-fatal } } + /** @var \Joomla\CMS\Installer\InstallerAdapter|null */ + private $installerParent = null; + public function postflight($type, $parent) { + $this->installerParent = $parent; // Migrate MokoWaaS database tables to MokoSuiteClient naming $this->migrateWaasTables(); @@ -82,11 +89,9 @@ class Pkg_MokosuiteclientInstallerScript $this->enablePlugin('system', 'mokosuiteclient_devtools'); $this->enablePlugin('system', 'mokosuiteclient_offline'); $this->enablePlugin('system', 'mokosuiteclient_dbip'); - $this->enablePlugin('system', 'mokosuiteclient_backup'); $this->enablePlugin('webservices', 'mokosuiteclient'); $this->enablePlugin('task', 'mokosuiteclientdemo'); $this->enablePlugin('task', 'mokosuiteclientsync'); - $this->enablePlugin('task', 'mokosuiteclient_tickets'); // Migrate params from core plugin to feature plugins (one-time) $this->migrateFeatureParams(); @@ -109,21 +114,15 @@ class Pkg_MokosuiteclientInstallerScript // Set up MokoSuiteClient guided tours and unpublish Joomla defaults $this->setupGuidedTours(); + // Clean up orphaned empty-element rows and stale files from old DEFAULT '' bug + $this->cleanupEmptyElements(); + // Mark MokoSuiteClient extensions as protected (prevents disable/uninstall at framework level) $this->protectExtensions(); - // Migrate all Moko update server URLs to new format - $this->migrateUpdateServerUrls(); - - // Clean up stale/duplicate update sites - $this->cleanupStaleUpdateSites(); - // Restore download key saved in preflight $this->restoreDownloadKey(); - // Fix orphaned update records (extension_id=0) - $this->fixUpdateRecords(); - // Trigger heartbeat registration $this->sendHeartbeat(); @@ -464,6 +463,16 @@ class Pkg_MokosuiteclientInstallerScript { try { + // Only enable if the plugin files actually exist on disk + $pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element; + + if (!is_dir($pluginDir)) + { + Log::add('Skipping enable for ' . $group . '/' . $element . ' — files not installed', Log::DEBUG, 'mokosuiteclient'); + + return; + } + $db = Factory::getDbo(); $query = $db->getQuery(true) ->update($db->quoteName('#__extensions')) @@ -497,6 +506,60 @@ class Pkg_MokosuiteclientInstallerScript if ($db->getAffectedRows() > 0) { Log::add('Fixed empty element for plugin ' . $group . '/' . $element, Log::NOTICE, 'mokosuiteclient'); + + return; + } + + // Verify no row exists before inserting (prevent duplicates) + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($group)) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + // No row exists — create one from the manifest XML on disk + $manifestFile = $pluginDir . '/' . $element . '.xml'; + + if (is_file($manifestFile)) + { + $xml = @simplexml_load_file($manifestFile); + $name = $xml ? (string) ($xml->name ?? $manifestName) : $manifestName; + $namespace = $xml ? (string) ($xml->namespace ?? '') : ''; + + $row = (object) [ + 'name' => $name, + 'type' => 'plugin', + 'element' => $element, + 'folder' => $group, + 'client_id' => 0, + 'enabled' => 1, + 'access' => 1, + 'protected' => 0, + 'locked' => 0, + 'params' => '{}', + 'manifest_cache' => '{}', + 'custom_data' => '', + 'state' => 0, + 'ordering' => 0, + 'checked_out' => null, + 'checked_out_time' => null, + ]; + + if (!empty($namespace)) + { + $row->namespace = $namespace; + } + + $db->insertObject('#__extensions', $row, 'extension_id'); + Log::add('Created extension record for plugin ' . $group . '/' . $element, Log::INFO, 'mokosuiteclient'); } } catch (\Throwable $e) @@ -515,6 +578,162 @@ class Pkg_MokosuiteclientInstallerScript * * @since 02.03.10 */ + private function cleanupEmptyElements(): void + { + try + { + $db = Factory::getDbo(); + + // 1. Delete orphaned MokoSuiteClient extension rows with empty element + $db->setQuery("DELETE FROM " . $db->quoteName('#__extensions') + . " WHERE " . $db->quoteName('element') . " = ''" + . " AND " . $db->quoteName('type') . " = " . $db->quote('plugin') + . " AND " . $db->quoteName('name') . " LIKE " . $db->quote('%MokoSuiteClient%')); + $db->execute(); + $deleted = $db->getAffectedRows(); + + // Delete rows where element is the display name (spaces) + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('% %')) + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('%mokosuiteclient%')) + ); + $db->execute(); + $deleted += $db->getAffectedRows(); + + if ($deleted > 0) + { + Log::add("Deleted {$deleted} orphaned plugin row(s)", Log::INFO, 'mokosuiteclient'); + } + + // Deduplicate: keep only the lowest extension_id per element+folder + $db->setQuery( + "DELETE e1 FROM " . $db->quoteName('#__extensions') . " e1" + . " INNER JOIN " . $db->quoteName('#__extensions') . " e2" + . " ON e1.element = e2.element AND e1.folder = e2.folder AND e1.type = e2.type" + . " AND e1.extension_id > e2.extension_id" + . " WHERE e1.element LIKE 'mokosuiteclient%' AND e1.type = 'plugin'" + ); + $db->execute(); + $deduped = $db->getAffectedRows(); + + if ($deduped > 0) + { + Log::add("Removed {$deduped} duplicate extension row(s)", Log::INFO, 'mokosuiteclient'); + } + + // 2. Clean up stale plugin files that leaked to group roots + $groupDirs = [JPATH_PLUGINS . '/system', JPATH_PLUGINS . '/task', JPATH_PLUGINS . '/webservices']; + + foreach ($groupDirs as $groupDir) + { + foreach (['services', 'src', 'language'] as $dir) + { + $path = $groupDir . '/' . $dir; + + if (is_dir($path)) + { + $this->rmdirRecursive($path); + } + } + + // Remove stale manifest XMLs at group root + foreach (glob($groupDir . '/mokosuiteclient*.xml') ?: [] as $staleXml) + { + @unlink($staleXml); + } + + // Remove dirs with spaces (Joomla uses display name as dir) + foreach (glob($groupDir . '/*mokosuiteclient*', GLOB_ONLYDIR) ?: [] as $badDir) + { + if (strpos(basename($badDir), ' ') !== false) + { + $this->rmdirRecursive($badDir); + } + } + } + + // 3. Reinstall plugins that are missing their directory + $this->reinstallBrokenPlugins(); + } + catch (\Throwable $e) + { + Log::add('Empty element cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + + /** + * Reinstall plugins whose files are missing from disk. + * + * Uses the sub-extension zip files from the package source directory + * (still available during postflight) to reinstall any plugin that + * doesn't have its directory on disk. + */ + private function reinstallBrokenPlugins(): void + { + if (!$this->installerParent) + { + return; + } + + try + { + $installer = $this->installerParent->getParent(); + $sourceDir = $installer->getPath('source'); + + if (empty($sourceDir) || !is_dir($sourceDir . '/packages')) + { + return; + } + + // Plugins that should exist on disk + $expected = [ + 'system' => ['mokosuiteclient_offline', 'mokosuiteclient_firewall', 'mokosuiteclient_tenant', 'mokosuiteclient_devtools', 'mokosuiteclient_dbip'], + 'task' => [], + ]; + + foreach ($expected as $group => $elements) + { + foreach ($elements as $element) + { + $pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element; + + if (is_dir($pluginDir)) + { + continue; // Already installed correctly + } + + $zipName = 'plg_' . $group . '_' . $element . '.zip'; + $zipPath = $sourceDir . '/packages/' . $zipName; + + if (!is_file($zipPath)) + { + continue; + } + + // Extract the zip to the correct plugin directory + $zip = new \ZipArchive(); + + if ($zip->open($zipPath) !== true) + { + continue; + } + + @mkdir($pluginDir, 0755, true); + $zip->extractTo($pluginDir); + $zip->close(); + + Log::add("Reinstalled {$group}/{$element} from package zip", Log::INFO, 'mokosuiteclient'); + } + } + } + catch (\Throwable $e) + { + Log::add('Plugin reinstall error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + private function protectExtensions(): void { try @@ -534,8 +753,6 @@ class Pkg_MokosuiteclientInstallerScript $db->quote('mod_mokosuiteclient_cpanel'), $db->quote('mokosuiteclientdemo'), $db->quote('mokosuiteclientsync'), - $db->quote('mokosuiteclient_tickets'), - $db->quote('mokosuiteclient_backup'), $db->quote('mokoonyx'), ]; @@ -547,8 +764,6 @@ class Pkg_MokosuiteclientInstallerScript $db->setQuery($query); $db->execute(); - // Ensure update server stays enabled - $this->enableUpdateServer(); } catch (\Throwable $e) { @@ -970,19 +1185,24 @@ class Pkg_MokosuiteclientInstallerScript if ($error) { Log::add('Heartbeat connection failed: ' . $error, Log::WARNING, 'mokosuiteclient'); + Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $error, 'warning'); } elseif ($code >= 200 && $code < 300) { - Factory::getApplication()->enqueueMessage('MokoSuiteClientHQ heartbeat: site registered', 'message'); + Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat: site registered successfully.', 'message'); } else { + $body = json_decode($response, true); + $msg = $body['error'] ?? $body['message'] ?? ('HTTP ' . $code); Log::add(sprintf('Heartbeat HTTP %d: %s', $code, $response), Log::WARNING, 'mokosuiteclient'); + Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $msg, 'warning'); } } catch (\Throwable $e) { Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $e->getMessage(), 'warning'); } }