diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index b1bcc1ee..06aaeb8e 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -9,7 +9,7 @@ Package - MokoSuite MokoConsulting White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments - 02.34.50 + 02.34.83 GNU General Public License v3 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 92d94ac3..a80d4c26 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: 02.34.50 +# VERSION: 02.34.83 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 5951541c..24c47e08 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -20,6 +20,7 @@ on: - 'patch/**' - 'hotfix/**' - 'bugfix/**' + - 'chore/**' - alpha - beta - rc diff --git a/.mokogitea/workflows/rc-revert.yml b/.mokogitea/workflows/rc-revert.yml new file mode 100644 index 00000000..f54b1840 --- /dev/null +++ b/.mokogitea/workflows/rc-revert.yml @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoPlatform.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.mokogitea/workflows/rc-revert.yml +# VERSION: 09.23.00 +# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge + +name: "RC Revert" + +on: + pull_request: + types: [closed] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + revert: + name: Rename rc/ back to dev/ + runs-on: ubuntu-latest + if: >- + github.event.pull_request.merged == false && + startsWith(github.event.pull_request.head.ref, 'rc/') + + steps: + - name: Rename branch + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + SUFFIX="${BRANCH#rc/}" + DEV_BRANCH="dev/${SUFFIX}" + API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Create dev/ branch from rc/ branch + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \ + "${API}" 2>/dev/null || true) + + if [ "$STATUS" = "201" ]; then + echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})" + exit 1 + fi + + # Delete rc/ branch + ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${API}/${ENCODED}" 2>/dev/null || true) + + if [ "$STATUS" = "204" ]; then + echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})" + fi + + echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY + echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c396ba..4216b080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ INGROUP: MokoSuite.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuite PATH: ./CHANGELOG.md - VERSION: 02.34.50 + VERSION: 02.34.83 BRIEF: Version history using `Keep a Changelog` --> @@ -23,6 +23,19 @@ ## [Unreleased] ### Added +- plg_system_mokosuite_dbip — IP geolocation plugin using DB-IP MMDB databases (CDN auto-download, local file mode, bundled MaxMind reader) +- Admin sidebar menu restructure — each Moko component gets its own collapsible section, com_mokosuitehq pinned first +- rc-revert workflow for release candidate rollbacks +- Ntfy push notifications for ticket events and security alerts (#205) — configurable server/topic/token +- Canned responses admin UI with edit modal, category filter, drag-and-drop reorder (#138) +- Ticket categories drag-and-drop reorder (#139) +- File attachments on tickets and replies (#141) — upload/download/delete with type and size validation +- Satisfaction ratings on resolved tickets (#140) — 1-5 star widget with optional feedback +- Helpdesk REST API (#142) — GET/POST/PATCH tickets, POST replies, filters, pagination +- Component config options UI (#149) — general, notification (email + ntfy), helpdesk settings +- Automation rule visual builder (#137) — condition/action dropdowns, edit existing, reorder, XSS-safe DOM +- Admin login and failed login security notifications (#147) +- Automation engine with Joomla event triggers (#151) — user_login, user_register, content_save, extension_install, behavior modes (append/always_new/skip_if_open), create_ticket action - RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ - Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server - Send Heartbeat button on health token field for manual heartbeat testing diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ecad5cb3..259d628c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 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 2696ef49..0280192e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.MokoSuiteBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand --> diff --git a/LICENSE.md b/LICENSE.md index 23cc6208..9af3d913 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,7 @@ INGROUP: MokoSuite.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuite PATH: ./LICENSE.md - VERSION: 02.34.50 + VERSION: 02.34.83 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 1a9620cd..4a17eea9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /README.md BRIEF: MokoSuite platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index bce7fda6..2e7951c9 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.34.50 +VERSION: 02.34.83 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index 96b47b06..c73b5276 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,13 @@ INGROUP: MokoSuite.Build REPO: https://github.com/mokoconsulting-tech/mokosuite FILE: build-guide.md - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/ BRIEF: Build and packaging guide for the MokoSuite system plugin NOTE: Defines environment setup, repository layout, packaging rules, and release preparation --> -# MokoSuite Build Guide (VERSION: 02.34.50) +# MokoSuite Build Guide (VERSION: 02.34.83) ## 1. Purpose diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index 43c04cb9..18832d21 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoSuite system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> -# MokoSuite Configuration Guide (VERSION: 02.34.50) +# MokoSuite Configuration Guide (VERSION: 02.34.83) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index 866f006f..16871e34 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/installation-guide.md BRIEF: Installation guide for the MokoSuite system plugin NOTE: First document in the guide set --> -# MokoSuite Installation Guide (VERSION: 02.34.50) +# MokoSuite Installation Guide (VERSION: 02.34.83) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 6bcdcc18..a90bc7aa 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoSuite system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> -# MokoSuite Operations Guide (VERSION: 02.34.50) +# MokoSuite Operations Guide (VERSION: 02.34.83) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 6ec87d21..45bab182 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: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 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 --> -# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.50) +# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.83) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index f257623d..3c1bb632 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/testing-guide.md BRIEF: Testing guide for MokoSuite v02.01.08 NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration --> -# MokoSuite Testing Guide (VERSION: 02.34.50) +# MokoSuite Testing Guide (VERSION: 02.34.83) ## 1. Prerequisites diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index 29297cf6..8bd1634c 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/troubleshooting-guide.md BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin NOTE: Designed for administrators and Suite operations teams --> -# MokoSuite Troubleshooting Guide (VERSION: 02.34.50) +# MokoSuite Troubleshooting Guide (VERSION: 02.34.83) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index b6975d4e..c778d251 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: MokoSuite.Guides REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/guides/upgrade-and-versioning-guide.md BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin NOTE: Defines release flow, version rules, and upgrade validation --> -# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.50) +# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.83) ## Introduction diff --git a/docs/index.md b/docs/index.md index 45f8e2ee..17d48939 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuite.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuite - VERSION: 02.34.50 + VERSION: 02.34.83 PATH: /docs/index.md BRIEF: Master index of all documentation for the MokoSuite plugin NOTE: Automatically maintained index for all guide canvases --> -# MokoSuite Documentation Index (VERSION: 02.34.50) +# MokoSuite Documentation Index (VERSION: 02.34.83) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index 0f559a1a..33185be8 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,12 @@ INGROUP: MokoSuite REPO: https://github.com/mokoconsulting-tech/mokosuite PATH: /docs/plugin-basic.md - VERSION: 02.34.50 + VERSION: 02.34.83 BRIEF: Baseline documentation for the MokoSuite system plugin NOTE: Foundational reference for internal and external stakeholders --> -# MokoSuite Plugin Overview (VERSION: 02.34.50) +# MokoSuite Plugin Overview (VERSION: 02.34.83) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index 4fd5d6b5..45d20bb2 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,7 @@ DEFGROUP: MokoSuite.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoSuite PATH: /docs/update-server.md -VERSION: 02.34.50 +VERSION: 02.34.83 BRIEF: How this extension's Joomla update server file (update.xml) is managed --> diff --git a/source/packages/com_mokosuite/admin/catalog.xml b/source/packages/com_mokosuite/admin/catalog.xml index 9a65db60..fbbd9e43 100644 --- a/source/packages/com_mokosuite/admin/catalog.xml +++ b/source/packages/com_mokosuite/admin/catalog.xml @@ -50,14 +50,14 @@ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml - MokoJoomBackup + MokoSuiteBackup pkg_mokojoombackup package - Automated backup system with Borg integration, scheduled tasks, and remote storage. + Full-site backup and restore for Joomla — database, files, and configuration. icon-archive Tools -
https://mokoconsulting.tech/support/products/mokojoombackup
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/dev/updates.xml +
https://mokoconsulting.tech/support/products/mokosuitebackup
+ https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/dev/updates.xml
MokoJoomHero diff --git a/source/packages/com_mokosuite/admin/config.xml b/source/packages/com_mokosuite/admin/config.xml index f1ca058b..637a9b7e 100644 --- a/source/packages/com_mokosuite/admin/config.xml +++ b/source/packages/com_mokosuite/admin/config.xml @@ -1,5 +1,16 @@ +
+ + +
+
JYES + + + + + + + + + +
@@ -33,6 +69,44 @@ + + + + + +
+ +
+ + + + + + + + + +
'core.admin', 'categories' => 'mokosuite.tickets', 'canned' => 'mokosuite.tickets', - 'automation' => 'core.admin', - 'database' => 'core.admin', - 'cleanup' => 'mokosuite.cache', + 'automation' => 'core.admin', + 'database' => 'core.admin', + 'cleanup' => 'mokosuite.cache', + 'ticketsettings' => 'core.admin', ]; public function display($cachable = false, $urlparams = []) @@ -365,10 +366,14 @@ class DisplayController extends BaseController $input = Factory::getApplication()->getInput(); $this->jsonResponse($this->getModel('Tickets')->createTicket([ - 'subject' => $input->getString('subject', ''), - 'body' => $input->getRaw('body', ''), - 'priority' => $input->getString('priority', 'normal'), - 'category_id' => $input->getInt('category_id', 0), + 'subject' => $input->getString('subject', ''), + 'body' => $input->getRaw('body', ''), + 'priority' => $input->getString('priority', 'normal'), + 'category_id' => $input->getInt('category_id', 0), + 'contact_id' => $input->getInt('contact_id', 0), + 'assign_users' => $input->get('assign_users', [], 'ARRAY'), + 'assign_groups' => $input->get('assign_groups', [], 'ARRAY'), + 'custom_fields' => $input->get('custom_fields', [], 'ARRAY'), ])); } @@ -405,10 +410,85 @@ class DisplayController extends BaseController $this->jsonResponse($this->getModel('Tickets')->updateStatus( $input->getInt('ticket_id', 0), - $input->getString('status', '') + $input->getInt('status', 0) )); } + // ================================================================== + // Ticket Settings — Status/Priority CRUD + // ================================================================== + + public function saveStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + $this->jsonResponse($this->getModel('Tickets')->saveStatus([ + 'id' => $input->getInt('id', 0), + 'title' => $input->getString('title', ''), + 'alias' => $input->getString('alias', ''), + 'color' => $input->getString('color', 'bg-secondary'), + 'is_default' => $input->getInt('is_default', 0), + 'is_closed' => $input->getInt('is_closed', 0), + 'ordering' => $input->getInt('ordering', 0), + ])); + } + + public function deleteStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $this->jsonResponse($this->getModel('Tickets')->deleteStatus($id)); + } + + public function savePriority() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + $this->jsonResponse($this->getModel('Tickets')->savePriority([ + 'id' => $input->getInt('id', 0), + 'title' => $input->getString('title', ''), + 'alias' => $input->getString('alias', ''), + 'color' => $input->getString('color', 'bg-secondary'), + 'is_default' => $input->getInt('is_default', 0), + 'ordering' => $input->getInt('ordering', 0), + ])); + } + + public function deletePriority() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $this->jsonResponse($this->getModel('Tickets')->deletePriority($id)); + } + // ================================================================== // KB Search // ================================================================== @@ -526,6 +606,19 @@ class DisplayController extends BaseController $this->jsonResponse(['success' => true, 'message' => 'Category deleted.']); } + public function reorderCategory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); } + $order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true); + if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; } + $db = Factory::getDbo(); + foreach ($order as $i => $id) { + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute(); + } + $this->jsonResponse(['success' => true, 'message' => 'Order saved.']); + } + public function saveCanned() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); @@ -553,6 +646,83 @@ class DisplayController extends BaseController $this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']); } + public function reorderCanned() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); } + $order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true); + if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; } + $db = Factory::getDbo(); + foreach ($order as $i => $id) { + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute(); + } + $this->jsonResponse(['success' => true, 'message' => 'Order saved.']); + } + + public function uploadAttachment() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); } + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('ticket_id', 0); + $replyId = $input->getInt('reply_id', 0) ?: null; + if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; } + $files = $input->files->get('attachments', [], 'raw'); + if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; } + $saved = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files); + $this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]); + } + + public function downloadAttachment() + { + if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); } + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_ticket_attachments')->where('id = ' . $id)); + $att = $db->loadObject(); + if (!$att) { throw new \RuntimeException('Attachment not found', 404); } + $path = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getAbsolutePath($att); + if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); } + $app = Factory::getApplication(); + $app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream'); + $app->setHeader('Content-Disposition', 'attachment; filename="' . $att->filename . '"'); + $app->setHeader('Content-Length', (string) filesize($path)); + $app->sendHeaders(); + readfile($path); + $app->close(); + } + + public function deleteAttachment() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); } + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $ok = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::delete($id); + $this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']); + } + + public function rateTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('ticket_id', 0); + $rating = $input->getInt('rating', 0); + $feedback = $input->getString('feedback', ''); + if (!$ticketId || $rating < 1 || $rating > 5) { + $this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']); + return; + } + $db = Factory::getDbo(); + $db->setQuery( + 'UPDATE ' . $db->quoteName('#__mokosuite_tickets') + . ' SET satisfaction_rating = ' . $rating + . ', satisfaction_feedback = ' . $db->quote($feedback) + . ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql()) + . ' WHERE id = ' . $ticketId + )->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']); + } + public function saveAutomation() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); @@ -564,6 +734,7 @@ class DisplayController extends BaseController 'trigger_event' => $input->getString('trigger_event', 'ticket_created'), 'conditions' => $input->getRaw('conditions', '[]'), 'actions' => $input->getRaw('actions', '[]'), + 'behavior' => $input->getString('behavior', 'append'), 'enabled' => 1, 'ordering' => 0, ]; @@ -594,6 +765,19 @@ class DisplayController extends BaseController $this->jsonResponse(['success' => true, 'message' => 'Rule updated.']); } + public function reorderAutomation() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + $order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true); + if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; } + $db = Factory::getDbo(); + foreach ($order as $i => $id) { + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute(); + } + $this->jsonResponse(['success' => true, 'message' => 'Order saved.']); + } + // ================================================================== // Settings Import/Export (#132) // ================================================================== diff --git a/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php b/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php index 1ba4c52c..df73885e 100644 --- a/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php +++ b/source/packages/com_mokosuite/admin/src/Model/TicketsModel.php @@ -575,6 +575,39 @@ class TicketsModel extends BaseDatabaseModel 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. */ @@ -1100,6 +1133,117 @@ class TicketsModel extends BaseDatabaseModel 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('#__mokosuite_ticket_statuses', $obj, 'id'); + + return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status created']; + } + + $db->updateObject('#__mokosuite_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('#__mokosuite_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('#__mokosuite_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('#__mokosuite_ticket_priorities', $obj, 'id'); + + return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority created']; + } + + $db->updateObject('#__mokosuite_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('#__mokosuite_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('#__mokosuite_ticket_priorities')) + ->where($db->quoteName('id') . ' = ' . $id) + )->execute(); + + return ['status' => 'ok', 'message' => 'Priority deleted']; + } + // ================================================================== // Akeeba Ticket System Importer // ================================================================== diff --git a/source/packages/com_mokosuite/admin/src/Service/AttachmentService.php b/source/packages/com_mokosuite/admin/src/Service/AttachmentService.php new file mode 100644 index 00000000..4db481af --- /dev/null +++ b/source/packages/com_mokosuite/admin/src/Service/AttachmentService.php @@ -0,0 +1,177 @@ + [$files['name']], + 'type' => [$files['type']], + 'tmp_name' => [$files['tmp_name']], + 'error' => [$files['error']], + 'size' => [$files['size']], + ]; + } + + $ticketDir = self::STORAGE_DIR . '/' . $ticketId; + + if (!is_dir($ticketDir)) { + Folder::create($ticketDir); + } + + $userId = (int) Factory::getUser()->id; + $db = Factory::getDbo(); + + for ($i = 0, $count = count($files['name']); $i < $count; $i++) + { + if ($files['error'][$i] !== UPLOAD_ERR_OK) { + continue; + } + + $originalName = File::makeSafe($files['name'][$i]); + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + + // Validate extension + if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuite'); + continue; + } + + // Validate size + if ($files['size'][$i] > self::MAX_FILE_SIZE) { + Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuite'); + continue; + } + + // Generate unique filename to prevent overwrites + $storedName = uniqid('att_', true) . '.' . $ext; + $destPath = $ticketDir . '/' . $storedName; + + if (!File::upload($files['tmp_name'][$i], $destPath)) { + Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuite'); + continue; + } + + $record = (object) [ + 'ticket_id' => $ticketId, + 'reply_id' => $replyId, + 'filename' => $originalName, + 'filepath' => $ticketId . '/' . $storedName, + 'filesize' => $files['size'][$i], + 'mimetype' => $files['type'][$i], + 'uploaded_by' => $userId, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuite_ticket_attachments', $record, 'id'); + $saved[] = $record; + } + + return $saved; + } + + /** + * Get attachments for a ticket. + */ + public static function getForTicket(int $ticketId): array + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('a.*, u.name AS uploader_name') + ->from($db->quoteName('#__mokosuite_ticket_attachments', 'a')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by') + ->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId) + ->order('a.created ASC') + ); + return $db->loadObjectList() ?: []; + } + + /** + * Get the absolute filesystem path for an attachment. + */ + public static function getAbsolutePath(object $attachment): string + { + return self::STORAGE_DIR . '/' . $attachment->filepath; + } + + /** + * Delete an attachment (file + DB record). + */ + public static function delete(int $attachmentId): bool + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from('#__mokosuite_ticket_attachments') + ->where('id = ' . $attachmentId) + ); + $att = $db->loadObject(); + + if (!$att) { + return false; + } + + $path = self::STORAGE_DIR . '/' . $att->filepath; + + if (file_exists($path)) { + File::delete($path); + } + + $db->setQuery( + $db->getQuery(true) + ->delete('#__mokosuite_ticket_attachments') + ->where('id = ' . $attachmentId) + )->execute(); + + return true; + } + + /** + * Format file size for display. + */ + public static function formatSize(int $bytes): string + { + if ($bytes < 1024) return $bytes . ' B'; + if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; + return round($bytes / 1048576, 1) . ' MB'; + } +} diff --git a/source/packages/com_mokosuite/admin/src/Service/AutomationEngine.php b/source/packages/com_mokosuite/admin/src/Service/AutomationEngine.php new file mode 100644 index 00000000..f0ff1802 --- /dev/null +++ b/source/packages/com_mokosuite/admin/src/Service/AutomationEngine.php @@ -0,0 +1,241 @@ +conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if (self::evaluateConditions($conditions, $context)) + { + self::executeActions($actions, $rule, $context); + } + } + } + catch (\Throwable $e) + { + Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuite'); + } + } + + /** + * Get active automation rules for a trigger event. + */ + private static function getActiveRules(string $event): array + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from('#__mokosuite_ticket_automation') + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) + ->where($db->quoteName('enabled') . ' = 1') + ->order('ordering ASC') + ); + return $db->loadObjectList() ?: []; + } + + /** + * Evaluate all conditions (AND logic). + */ + private static function evaluateConditions(array $conditions, array $context): bool + { + foreach ($conditions as $c) + { + $field = $c['field'] ?? ''; + $op = $c['op'] ?? 'eq'; + $expected = $c['value'] ?? ''; + $actual = $context[$field] ?? ''; + + switch ($op) + { + case 'eq': if ((string) $actual !== (string) $expected) return false; break; + case 'neq': if ((string) $actual === (string) $expected) return false; break; + case 'gt': if ((float) $actual <= (float) $expected) return false; break; + case 'lt': if ((float) $actual >= (float) $expected) return false; break; + case 'in': + $values = array_map('trim', explode(',', $expected)); + if (!in_array((string) $actual, $values, true)) return false; + break; + case 'not_in': + $values = array_map('trim', explode(',', $expected)); + if (in_array((string) $actual, $values, true)) return false; + break; + } + } + return true; + } + + /** + * Execute actions for a matched rule. + */ + private static function executeActions(array $actions, object $rule, array $context): void + { + $db = Factory::getDbo(); + $ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0); + + foreach ($actions as $action) + { + $type = $action['type'] ?? ''; + $value = $action['value'] ?? ''; + + try + { + switch ($type) + { + case 'set_status': + if ($ticketId) { + $db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'set_priority': + if ($ticketId) { + $db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'assign': + if ($ticketId) { + $db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET assigned_to = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'add_note': + if ($ticketId) { + $note = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => 0, + 'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']', + 'is_internal' => 1, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuite_ticket_replies', $note); + } + break; + + case 'send_email': + NotificationService::securityAlert( + 'automation', + 'Automation: ' . ($rule->title ?? ''), + $value ?: 'Rule triggered for ticket #' . $ticketId + ); + break; + + case 'send_ntfy': + NotificationService::pushNtfySecurity( + 'automation', + 'Automation: ' . ($rule->title ?? ''), + $value ?: 'Rule triggered for ticket #' . $ticketId + ); + break; + + case 'close': + if ($ticketId) { + $db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'create_ticket': + self::createTicketFromAutomation($rule, $context, $value); + break; + } + } + catch (\Throwable $e) + { + Log::add("Automation action {$type} failed: " . $e->getMessage(), Log::WARNING, 'mokosuite'); + } + } + } + + /** + * Create a ticket from automation (with behavior: append/always_new/skip_if_open). + */ + private static function createTicketFromAutomation(object $rule, array $context, string $subject): void + { + $db = Factory::getDbo(); + $behavior = $rule->behavior ?? 'append'; + $userId = (int) ($context['user_id'] ?? 0); + $catId = (int) ($context['category_id'] ?? 0); + + if ($behavior !== 'always_new' && $userId > 0) + { + // Check for existing open ticket + $query = $db->getQuery(true) + ->select('id') + ->from('#__mokosuite_tickets') + ->where('created_by = ' . $userId) + ->where("status NOT IN ('closed', 'resolved')"); + + if ($catId > 0) { + $query->where('category_id = ' . $catId); + } + + $db->setQuery($query, 0, 1); + $existingId = (int) $db->loadResult(); + + if ($existingId > 0) + { + if ($behavior === 'skip_if_open') return; + + // append — add reply to existing ticket + $reply = (object) [ + 'ticket_id' => $existingId, + 'user_id' => 0, + 'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']', + 'is_internal' => 1, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuite_ticket_replies', $reply); + return; + } + } + + // Create new ticket + $ticket = (object) [ + 'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''), + 'body' => $context['body'] ?? '', + 'status' => 'open', + 'priority' => $context['priority'] ?? 'normal', + 'category_id' => $catId ?: null, + 'created_by' => $userId, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuite_tickets', $ticket, 'id'); + } +} diff --git a/source/packages/com_mokosuite/admin/src/Service/NotificationService.php b/source/packages/com_mokosuite/admin/src/Service/NotificationService.php index f0b94f77..27bf3c5b 100644 --- a/source/packages/com_mokosuite/admin/src/Service/NotificationService.php +++ b/source/packages/com_mokosuite/admin/src/Service/NotificationService.php @@ -70,6 +70,9 @@ class NotificationService Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuite'); } } + + // Push notification via ntfy + self::pushNtfy($event, $ticket, $subject); } catch (\Throwable $e) { @@ -332,6 +335,159 @@ class NotificationService } } + // ================================================================== + // Ntfy Push Notifications (#205) + // ================================================================== + + /** + * Send a push notification via ntfy for ticket events. + */ + private static function pushNtfy(string $event, object $ticket, string $title): void + { + $config = self::getNotificationConfig(); + $ntfyEnabled = $config['ntfy_enabled'] ?? '0'; + + if (!$ntfyEnabled) + { + return; + } + + $ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/'); + $ntfyTopic = $config['ntfy_topic'] ?? 'mokosuite-tickets'; + $ntfyToken = $config['ntfy_token'] ?? ''; + + $tagMap = [ + 'ticket_created' => 'ticket,new', + 'ticket_replied' => 'speech_balloon', + 'status_changed' => 'arrows_counterclockwise', + 'ticket_assigned' => 'bust_in_silhouette', + ]; + + $priorityMap = [ + 'ticket_created' => '4', + 'ticket_replied' => '3', + 'status_changed' => '3', + 'ticket_assigned' => '3', + ]; + + $siteUrl = rtrim(Uri::root(), '/'); + $ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuite&view=ticket&id=' . ($ticket->id ?? 0); + + $message = self::buildNtfyMessage($event, $ticket); + + $headers = [ + 'Title: ' . $title, + 'Priority: ' . ($priorityMap[$event] ?? '3'), + 'Tags: ' . ($tagMap[$event] ?? 'ticket'), + 'Click: ' . $ticketUrl, + ]; + + if ($ntfyToken !== '') + { + $headers[] = 'Authorization: Bearer ' . $ntfyToken; + } + + $url = $ntfyServer . '/' . $ntfyTopic; + + try + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $message); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300) + { + Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuite'); + } + } + catch (\Throwable $e) + { + Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite'); + } + } + + /** + * Build a short ntfy message body for ticket events. + */ + private static function buildNtfyMessage(string $event, object $ticket): string + { + $subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?'); + + switch ($event) + { + case 'ticket_created': + $priority = ucfirst($ticket->priority ?? 'normal'); + return "New ticket: {$subject}\nPriority: {$priority}"; + + case 'ticket_replied': + return "Reply on: {$subject}"; + + case 'status_changed': + $status = ucwords(str_replace('_', ' ', $ticket->status ?? '')); + return "Status → {$status}: {$subject}"; + + case 'ticket_assigned': + return "Assigned to you: {$subject}"; + + default: + return $subject; + } + } + + /** + * Send a push notification via ntfy for security events. + */ + public static function pushNtfySecurity(string $event, string $title, string $body): void + { + $config = self::getNotificationConfig(); + $ntfyEnabled = $config['ntfy_enabled'] ?? '0'; + + if (!$ntfyEnabled) + { + return; + } + + $ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/'); + $ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuite-security'; + $ntfyToken = $config['ntfy_token'] ?? ''; + + $headers = [ + 'Title: [Security] ' . $title, + 'Priority: 5', + 'Tags: warning,shield', + ]; + + if ($ntfyToken !== '') + { + $headers[] = 'Authorization: Bearer ' . $ntfyToken; + } + + $url = $ntfyServer . '/' . $ntfyTopic; + + try + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + curl_close($ch); + } + catch (\Throwable $e) + { + Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite'); + } + } + // ================================================================== // Security Event Notifications (#131) // ================================================================== @@ -407,6 +563,9 @@ class NotificationService Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite'); } } + + // Also push via ntfy + self::pushNtfySecurity($event, $subject, $body); } catch (\Throwable $e) { diff --git a/source/packages/com_mokosuite/admin/src/View/Ticket/HtmlView.php b/source/packages/com_mokosuite/admin/src/View/Ticket/HtmlView.php index 78915e33..09526e24 100644 --- a/source/packages/com_mokosuite/admin/src/View/Ticket/HtmlView.php +++ b/source/packages/com_mokosuite/admin/src/View/Ticket/HtmlView.php @@ -23,6 +23,7 @@ class HtmlView extends BaseHtmlView protected $priorities = []; protected $customFields = []; protected $fieldValues = []; + protected $attachments = []; public function display($tpl = null) { @@ -43,6 +44,9 @@ class HtmlView extends BaseHtmlView $this->fieldValues = $model->getFieldValues($id); } + // Load attachments + $this->attachments = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getForTicket($id); + if (!$this->ticket) { Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); diff --git a/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php b/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php index 77e4e719..fc15a4d4 100644 --- a/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php +++ b/source/packages/com_mokosuite/admin/src/View/Tickets/HtmlView.php @@ -25,6 +25,8 @@ class HtmlView extends BaseHtmlView protected $contacts = []; protected $statuses = []; protected $priorities = []; + protected $backendUsers = []; + protected $userGroups = []; public function display($tpl = null) { @@ -46,6 +48,8 @@ class HtmlView extends BaseHtmlView $this->overdue = $model->getOverdueTickets(); $this->atsAvailable = $model->checkAtsAvailable(); $this->contacts = $model->getContacts(); + $this->backendUsers = $model->getBackendUsers(); + $this->userGroups = $model->getUserGroups(); $this->addToolbar(); diff --git a/source/packages/com_mokosuite/admin/src/View/Ticketsettings/HtmlView.php b/source/packages/com_mokosuite/admin/src/View/Ticketsettings/HtmlView.php new file mode 100644 index 00000000..7cab5fe3 --- /dev/null +++ b/source/packages/com_mokosuite/admin/src/View/Ticketsettings/HtmlView.php @@ -0,0 +1,41 @@ + + * + * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later + * + * @package MokoSuite + * @subpackage Component + */ + +namespace Moko\Component\MokoSuite\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_MOKOSUITE_TICKET_SETTINGS'), 'cog'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuite&view=tickets'); + } +} diff --git a/source/packages/com_mokosuite/admin/src/View/Ticketsettings/index.html b/source/packages/com_mokosuite/admin/src/View/Ticketsettings/index.html new file mode 100644 index 00000000..94906bce --- /dev/null +++ b/source/packages/com_mokosuite/admin/src/View/Ticketsettings/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokosuite/admin/tmpl/automation/default.php b/source/packages/com_mokosuite/admin/tmpl/automation/default.php index 76cf13b2..881f5626 100644 --- a/source/packages/com_mokosuite/admin/tmpl/automation/default.php +++ b/source/packages/com_mokosuite/admin/tmpl/automation/default.php @@ -9,81 +9,110 @@ $token = Session::getFormToken(); $saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveAutomation&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAutomation&format=json'); $toggleUrl = Route::_('index.php?option=com_mokosuite&task=display.toggleAutomation&format=json'); +$reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderAutomation&format=json'); -$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)']; +$triggerLabels = [ + 'ticket_created' => 'On Ticket Created', + 'ticket_replied' => 'On Reply', + 'status_changed' => 'On Status Change', + 'ticket_assigned' => 'On Assignment', + 'user_login' => 'On User Login', + 'user_register' => 'On User Register', + 'user_login_failed' => 'On Failed Login', + 'content_save' => 'On Article Save', + 'extension_install' => 'On Extension Install', + 'scheduled' => 'Scheduled (Cron)', +]; +$conditionFields = ['status', 'priority', 'category_id', 'assigned_to', 'sla_responded', 'age_hours']; +$conditionOps = ['eq' => '=', 'neq' => '≠', 'gt' => '>', 'lt' => '<', 'in' => 'in', 'not_in' => 'not in']; +$actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email', 'send_ntfy', 'close']; ?>

Automation Rules

-
- - conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> -
-
-
-
-
-
- enabled ? 'checked' : ''; ?>> +
+ + conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> +
+
+
+
+
+ +
+ enabled ? 'checked' : ''; ?>> +
+ title); ?> + trigger_event] ?? $r->trigger_event; ?> +
+
+ + IF + $c): ?> + 0 ? ' AND ' : ''; ?> + + + THEN + + = +
- title); ?> - trigger_event] ?? $r->trigger_event; ?> -
-
- IF - $c): ?> - 0 ? ' AND ' : ''; ?> - - THEN - - = -
+
-
-
- + - -
No automation rules. Click "Add Rule" to create one.
- + +
No automation rules. Click "Add Rule" to create one.
+ +
- -