feat(api): helpdesk tickets REST API endpoints (#142)
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- GET /v1/mokosuite/tickets — list with status/category/assigned filters
- GET /v1/mokosuite/tickets/{id} — single ticket with replies + attachments
- POST /v1/mokosuite/tickets — create ticket
- PATCH /v1/mokosuite/tickets/{id} — update status/priority/category/assignment
- POST /v1/mokosuite/tickets/{id}/reply — add reply with notification
- Routes registered in plg_webservices_mokosuite
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuite
|
||||
* @subpackage com_mokosuite
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Helpdesk Tickets REST API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokosuite/tickets - list tickets
|
||||
* GET /api/index.php/v1/mokosuite/tickets/{id} - get single ticket with replies
|
||||
* POST /api/index.php/v1/mokosuite/tickets - create ticket
|
||||
* PATCH /api/index.php/v1/mokosuite/tickets/{id} - update ticket fields
|
||||
* POST /api/index.php/v1/mokosuite/tickets/{id}/reply - add reply
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class TicketsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /tickets — list tickets with optional filters.
|
||||
*/
|
||||
public function displayList(): void
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user