bf30c3db5b
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
- API update() now uses getInt/getString instead of raw input - API update() syncs status/status_id and priority/priority_id - API update() returns 404 if ticket not found - Pagination total count now uses filtered query (was unfiltered) - AutomationEngine assign action casts value to int
314 lines
11 KiB
PHP
314 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* @package MokoSuiteClient
|
|
* @subpackage com_mokosuiteclient
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Moko\Component\MokoSuiteClient\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/mokosuiteclient/tickets - list tickets
|
|
* GET /api/index.php/v1/mokosuiteclient/tickets/{id} - get single ticket with replies
|
|
* POST /api/index.php/v1/mokosuiteclient/tickets - create ticket
|
|
* PATCH /api/index.php/v1/mokosuiteclient/tickets/{id} - update ticket fields
|
|
* POST /api/index.php/v1/mokosuiteclient/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_mokosuiteclient');
|
|
|
|
$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('#__mokosuiteclient_tickets', 't'))
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_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 (with same filters applied)
|
|
$countQuery = clone $query;
|
|
$countQuery->clear('select')->clear('order')->select('COUNT(*)');
|
|
$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_mokosuiteclient');
|
|
|
|
$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('#__mokosuiteclient_tickets', 't'))
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
|
->leftJoin($db->quoteName('#__mokosuiteclient_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('#__mokosuiteclient_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\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
|
|
|
$this->sendJson(200, $ticket);
|
|
}
|
|
|
|
/**
|
|
* POST /tickets — create a new ticket.
|
|
*/
|
|
public function create(): void
|
|
{
|
|
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
|
|
|
$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;
|
|
}
|
|
|
|
$statusId = $input->getInt('status_id', 0) ?: null;
|
|
$priorityId = $input->getInt('priority_id', 0) ?: null;
|
|
$status = $input->getString('status', 'open');
|
|
$priority = $input->getString('priority', 'normal');
|
|
|
|
// Resolve status_id from alias if not provided
|
|
if (!$statusId && $status) {
|
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
|
->where($db->quoteName('alias') . ' = ' . $db->quote($status));
|
|
$statusId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
|
}
|
|
if (!$priorityId && $priority) {
|
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
|
->where($db->quoteName('alias') . ' = ' . $db->quote($priority));
|
|
$priorityId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
|
}
|
|
|
|
$ticket = (object) [
|
|
'subject' => $subject,
|
|
'body' => $body,
|
|
'status' => $status,
|
|
'status_id' => $statusId,
|
|
'priority' => $priority,
|
|
'priority_id' => $priorityId,
|
|
'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('#__mokosuiteclient_tickets', $ticket, 'id');
|
|
|
|
// Trigger notification
|
|
\Moko\Component\MokoSuiteClient\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_mokosuiteclient');
|
|
|
|
$input = Factory::getApplication()->getInput();
|
|
$id = $input->getInt('id', 0);
|
|
$db = Factory::getDbo();
|
|
|
|
// Type-safe input extraction
|
|
$fields = [];
|
|
$intFields = ['status_id', 'priority_id', 'category_id', 'assigned_to'];
|
|
$strFields = ['status', 'priority'];
|
|
|
|
foreach ($intFields as $field) {
|
|
$value = $input->getInt($field, 0);
|
|
if ($value > 0) { $fields[$field] = $value; }
|
|
}
|
|
foreach ($strFields as $field) {
|
|
$value = $input->getString($field, '');
|
|
if ($value !== '') { $fields[$field] = $value; }
|
|
}
|
|
|
|
if (empty($fields)) {
|
|
$this->sendJson(400, ['error' => 'No fields to update']);
|
|
return;
|
|
}
|
|
|
|
// Sync status/status_id if only one is provided
|
|
if (isset($fields['status']) && !isset($fields['status_id'])) {
|
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
|
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['status']));
|
|
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
|
if ($resolved) { $fields['status_id'] = $resolved; }
|
|
} elseif (isset($fields['status_id']) && !isset($fields['status'])) {
|
|
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_statuses')
|
|
->where('id = ' . (int) $fields['status_id']);
|
|
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
|
if ($alias) { $fields['status'] = $alias; }
|
|
}
|
|
if (isset($fields['priority']) && !isset($fields['priority_id'])) {
|
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
|
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['priority']));
|
|
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
|
if ($resolved) { $fields['priority_id'] = $resolved; }
|
|
} elseif (isset($fields['priority_id']) && !isset($fields['priority'])) {
|
|
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_priorities')
|
|
->where('id = ' . (int) $fields['priority_id']);
|
|
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
|
if ($alias) { $fields['priority'] = $alias; }
|
|
}
|
|
|
|
$sets = [];
|
|
foreach ($fields as $k => $v) {
|
|
$sets[] = $db->quoteName($k) . ' = ' . (is_int($v) ? $v : $db->quote($v));
|
|
}
|
|
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
|
|
|
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
|
|
|
|
if ($db->getAffectedRows() === 0) {
|
|
$this->sendJson(404, ['error' => 'Ticket not found']);
|
|
return;
|
|
}
|
|
|
|
$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_mokosuiteclient');
|
|
|
|
$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('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
|
|
|
// Notify
|
|
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
|
$ticket = $db->loadObject();
|
|
if ($ticket) {
|
|
\Moko\Component\MokoSuiteClient\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']);
|
|
throw new \RuntimeException('Not authorized', 403);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|