From 57534eec9c5c5242a7f919045d40471a67fbb1ab Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 9 Jun 2026 10:34:43 -0500 Subject: [PATCH] feat(helpdesk): file attachments on tickets and replies (#141) - #__mokosuite_ticket_attachments table - AttachmentService: upload, download, delete with type/size validation - Allowed: jpg,png,gif,webp,pdf,doc,docx,xls,xlsx,csv,txt,zip (10MB max) - Secure storage in /media/com_mokosuite/attachments/{ticket_id}/ - Upload field in reply form, auto-uploads after reply creation - Download links on ticket and reply cards - Controller tasks: uploadAttachment, downloadAttachment, deleteAttachment --- .../com_mokosuite/admin/sql/install.mysql.sql | 15 ++ .../src/Controller/DisplayController.php | 42 +++++ .../admin/src/Service/AttachmentService.php | 177 ++++++++++++++++++ .../admin/src/View/Ticket/HtmlView.php | 4 + .../admin/tmpl/ticket/default.php | 74 +++++++- 5 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 source/packages/com_mokosuite/admin/src/Service/AttachmentService.php diff --git a/source/packages/com_mokosuite/admin/sql/install.mysql.sql b/source/packages/com_mokosuite/admin/sql/install.mysql.sql index 367b0d30..dca5aaa2 100644 --- a/source/packages/com_mokosuite/admin/sql/install.mysql.sql +++ b/source/packages/com_mokosuite/admin/sql/install.mysql.sql @@ -111,6 +111,21 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_canned` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_attachments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ticket_id` INT UNSIGNED NOT NULL, + `reply_id` INT UNSIGNED DEFAULT NULL, + `filename` VARCHAR(255) NOT NULL, + `filepath` VARCHAR(512) NOT NULL, + `filesize` INT UNSIGNED NOT NULL DEFAULT 0, + `mimetype` VARCHAR(100) NOT NULL DEFAULT '', + `uploaded_by` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_ticket` (`ticket_id`), + KEY `idx_reply` (`reply_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_automation` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL, diff --git a/source/packages/com_mokosuite/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuite/admin/src/Controller/DisplayController.php index 23e401d0..8fa94766 100644 --- a/source/packages/com_mokosuite/admin/src/Controller/DisplayController.php +++ b/source/packages/com_mokosuite/admin/src/Controller/DisplayController.php @@ -579,6 +579,48 @@ class DisplayController extends BaseController $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 saveAutomation() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); 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/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/ticket/default.php b/source/packages/com_mokosuite/admin/tmpl/ticket/default.php index bcef0733..c6681f06 100644 --- a/source/packages/com_mokosuite/admin/tmpl/ticket/default.php +++ b/source/packages/com_mokosuite/admin/tmpl/ticket/default.php @@ -8,6 +8,17 @@ use Joomla\CMS\Session\Session; $t = $this->ticket; $canned = $this->cannedResponses; $token = Session::getFormToken(); +$attachments = $this->attachments; +$downloadUrl = Route::_('index.php?option=com_mokosuite&task=display.downloadAttachment'); +$uploadUrl = Route::_('index.php?option=com_mokosuite&task=display.uploadAttachment&format=json'); +$deleteAttUrl = Route::_('index.php?option=com_mokosuite&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 ?? []; @@ -25,7 +36,21 @@ $priorities = $this->priorities ?? []; Original -
escape($t->body)); ?>
+
+ escape($t->body)); ?> + +
+ + +
@@ -40,7 +65,21 @@ $priorities = $this->priorities ?? []; Internal Note -
escape($reply->body)); ?>
+
+ escape($reply->body)); ?> + id])): ?> +
+
+ Attachments: + id] as $att): ?> + + escape($att->filename); ?> + (filesize); ?>) + + +
+ +
@@ -59,6 +98,10 @@ $priorities = $this->priorities ?? []; +
+ +