diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index b1bcc1ee..25e6406e 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.77 GNU General Public License v3 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 92d94ac3..d622cf3c 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.77 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" 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..9527adc0 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.77 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..4d28e97d 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.77 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..2e726531 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.77 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand --> diff --git a/LICENSE.md b/LICENSE.md index 23cc6208..ca7f28f9 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.77 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 1a9620cd..915ab903 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.77 PATH: /README.md BRIEF: MokoSuite platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index bce7fda6..bfa81c92 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.77 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index 96b47b06..4060e526 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.77 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.77) ## 1. Purpose diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index 43c04cb9..2e8dd26a 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.77 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.77) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index 866f006f..c921b215 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.77 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.77) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 6bcdcc18..ed83cc79 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.77 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.77) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 6ec87d21..2e8a42c1 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.77 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.77) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index f257623d..1645aec4 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.77 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.77) ## 1. Prerequisites diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index 29297cf6..3207acbf 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.77 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.77) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index b6975d4e..4702c0b5 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.77 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.77) ## Introduction diff --git a/docs/index.md b/docs/index.md index 45f8e2ee..48dbbc6a 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.77 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.77) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index 0f559a1a..ca3452fb 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.77 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.77) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index 4fd5d6b5..25d58661 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.77 BRIEF: How this extension's Joomla update server file (update.xml) is managed --> 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 @@ + + + + + +
+ +
+ + + + + + + + + +
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 +566,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 +654,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 +685,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/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/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.
+ +
- -