diff --git a/source/packages/com_mokosuite/api/src/Controller/TicketsController.php b/source/packages/com_mokosuite/api/src/Controller/TicketsController.php new file mode 100644 index 00000000..17f699b1 --- /dev/null +++ b/source/packages/com_mokosuite/api/src/Controller/TicketsController.php @@ -0,0 +1,261 @@ +requireAuth('core.manage', 'com_mokosuite'); + + $app = Factory::getApplication(); + $db = Factory::getDbo(); + $input = $app->getInput(); + + $query = $db->getQuery(true) + ->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name') + ->from($db->quoteName('#__mokosuite_tickets', 't')) + ->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id') + ->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'p') . ' ON p.id = t.priority_id') + ->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') + ->order('t.created DESC'); + + // Filters + $status = $input->getString('status', ''); + if ($status) { + $query->where($db->quoteName('t.status') . ' = ' . $db->quote($status)); + } + + $categoryId = $input->getInt('category_id', 0); + if ($categoryId) { + $query->where($db->quoteName('t.category_id') . ' = ' . $categoryId); + } + + $assignedTo = $input->getInt('assigned_to', 0); + if ($assignedTo) { + $query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo); + } + + $limit = min($input->getInt('limit', 25), 100); + $offset = $input->getInt('offset', 0); + $db->setQuery($query, $offset, $limit); + + $tickets = $db->loadObjectList() ?: []; + + // Total count + $countQuery = $db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_tickets'); + $db->setQuery($countQuery); + $total = (int) $db->loadResult(); + + $this->sendJson(200, [ + 'tickets' => $tickets, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]); + } + + /** + * GET /tickets/{id} — single ticket with replies and attachments. + */ + public function displayItem(): void + { + $this->requireAuth('core.manage', 'com_mokosuite'); + + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $db = Factory::getDbo(); + + // Ticket + $db->setQuery( + $db->getQuery(true) + ->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name') + ->from($db->quoteName('#__mokosuite_tickets', 't')) + ->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id') + ->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'p') . ' ON p.id = t.priority_id') + ->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') + ->where('t.id = ' . $id) + ); + $ticket = $db->loadObject(); + + if (!$ticket) { + $this->sendJson(404, ['error' => 'Ticket not found']); + return; + } + + // Replies + $db->setQuery( + $db->getQuery(true) + ->select('r.*, u.name AS user_name') + ->from($db->quoteName('#__mokosuite_ticket_replies', 'r')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') + ->where('r.ticket_id = ' . $id) + ->order('r.created ASC') + ); + $ticket->replies = $db->loadObjectList() ?: []; + + // Attachments + $ticket->attachments = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getForTicket($id); + + $this->sendJson(200, $ticket); + } + + /** + * POST /tickets — create a new ticket. + */ + public function create(): void + { + $this->requireAuth('core.manage', 'com_mokosuite'); + + $input = Factory::getApplication()->getInput(); + $db = Factory::getDbo(); + + $subject = $input->getString('subject', ''); + $body = $input->getRaw('body', ''); + + if (empty($subject)) { + $this->sendJson(400, ['error' => 'Subject is required']); + return; + } + + $ticket = (object) [ + 'subject' => $subject, + 'body' => $body, + 'status' => 'open', + 'status_id' => $input->getInt('status_id', 0) ?: null, + 'priority' => $input->getString('priority', 'normal'), + 'priority_id' => $input->getInt('priority_id', 0) ?: null, + 'category_id' => $input->getInt('category_id', 0) ?: null, + 'created_by' => (int) Factory::getUser()->id, + 'assigned_to' => $input->getInt('assigned_to', 0) ?: null, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuite_tickets', $ticket, 'id'); + + // Trigger notification + \Moko\Component\MokoSuite\Administrator\Service\NotificationService::notify('ticket_created', $ticket); + + $this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']); + } + + /** + * PATCH /tickets/{id} — update ticket fields. + */ + public function update(): void + { + $this->requireAuth('core.manage', 'com_mokosuite'); + + $input = Factory::getApplication()->getInput(); + $id = $input->getInt('id', 0); + $db = Factory::getDbo(); + + $fields = []; + $updatable = ['status', 'status_id', 'priority', 'priority_id', 'category_id', 'assigned_to']; + + foreach ($updatable as $field) { + $value = $input->get($field, null, 'raw'); + if ($value !== null) { + $fields[$field] = $value; + } + } + + if (empty($fields)) { + $this->sendJson(400, ['error' => 'No fields to update']); + return; + } + + $sets = []; + foreach ($fields as $k => $v) { + $sets[] = $db->quoteName($k) . ' = ' . $db->quote($v); + } + $sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql()); + + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute(); + + $this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]); + } + + /** + * POST /tickets/{id}/reply — add a reply. + */ + public function reply(): void + { + $this->requireAuth('core.manage', 'com_mokosuite'); + + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('id', 0); + $body = $input->getRaw('body', ''); + + if (!$ticketId || empty($body)) { + $this->sendJson(400, ['error' => 'ticket_id and body are required']); + return; + } + + $db = Factory::getDbo(); + + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => (int) Factory::getUser()->id, + 'body' => $body, + 'is_internal' => $input->getInt('is_internal', 0), + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuite_ticket_replies', $reply, 'id'); + + // Notify + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_tickets')->where('id = ' . $ticketId)); + $ticket = $db->loadObject(); + if ($ticket) { + \Moko\Component\MokoSuite\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]); + } + + $this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']); + } + + // ── Helpers ────────────────────────────────────────────────── + + private function requireAuth(string $action, string $asset): void + { + $user = Factory::getUser(); + if (!$user->authorise($action, $asset)) { + $this->sendJson(403, ['error' => 'Not authorized']); + } + } + + private function sendJson(int $code, $payload): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json', true); + $app->setHeader('Status', (string) $code, true); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $app->close(); + } +} diff --git a/source/packages/plg_webservices_mokosuite/src/Extension/MokoSuiteApi.php b/source/packages/plg_webservices_mokosuite/src/Extension/MokoSuiteApi.php index a3a9113c..bce85338 100644 --- a/source/packages/plg_webservices_mokosuite/src/Extension/MokoSuiteApi.php +++ b/source/packages/plg_webservices_mokosuite/src/Extension/MokoSuiteApi.php @@ -124,5 +124,23 @@ final class MokoSuiteApi extends CMSPlugin implements SubscriberInterface 'provision', ['component' => 'com_mokosuite'] ); + + // Helpdesk Tickets API (#142) + $router->createCRUDRoutes( + 'v1/mokosuite/tickets', + 'tickets', + ['component' => 'com_mokosuite'] + ); + + // Ticket reply (custom route — POST only) + $router->addRoute( + new \Joomla\Router\Route( + ['POST'], + 'v1/mokosuite/tickets/:id/reply', + 'tickets.reply', + ['id' => '(\d+)'], + ['component' => 'com_mokosuite'] + ) + ); } }