* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * SPDX-License-Identifier: GPL-3.0-or-later */ namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Plugin\PluginHelper; use Joomla\CMS\Uri\Uri; use Joomla\Component\MokoSuiteCross\Administrator\Helper\CrossPostDispatcher; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; /** * REST API controller for dispatching cross-posts. * * Endpoint: POST /api/index.php/v1/mokosuitecross/dispatch * * JSON body: * { * "article_id": 123, * "service_ids": [1, 2, 3] // optional — omit to post to all enabled services * } * * Returns JSON with the created post IDs and status. * * Authentication is handled by Joomla's API application (token or session). * The webservices plugin routes POST requests here via the API router. */ class DispatchController extends BaseController { /** * Dispatch cross-posts for an article to one or more services. * * @return void */ public function dispatch(): void { $app = $this->app; // Enforce POST method — this is a state-changing action endpoint if (strtoupper($this->input->getMethod()) !== 'POST') { $this->sendJsonResponse(['error' => 'Method not allowed. Use POST.'], 405); return; } // ACL check — require core.manage on the component if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) { $this->sendJsonResponse(['error' => 'Forbidden'], 403); return; } // Read JSON body $input = json_decode(file_get_contents('php://input'), true) ?: []; $articleId = (int) ($input['article_id'] ?? 0); $serviceIds = $input['service_ids'] ?? null; if ($articleId < 1) { $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_MISSING_ARTICLE')], 400); return; } // Validate service_ids if provided if ($serviceIds !== null) { if (!is_array($serviceIds) || empty($serviceIds)) { $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_INVALID_SERVICES')], 400); return; } $serviceIds = array_map('intval', $serviceIds); } $db = Factory::getDbo(); // Load the article $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__content')) ->where($db->quoteName('id') . ' = ' . $articleId); $db->setQuery($query); $article = $db->loadObject(); if (!$article) { $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_ARTICLE_NOT_FOUND')], 404); return; } // Load enabled services, optionally filtered by service_ids $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitecross_services')) ->where($db->quoteName('published') . ' = 1') ->order($db->quoteName('ordering') . ' ASC'); if ($serviceIds !== null) { $query->where($db->quoteName('id') . ' IN (' . implode(',', $serviceIds) . ')'); } $db->setQuery($query); $services = $db->loadObjectList() ?: []; if (empty($services)) { $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_NO_SERVICES')], 404); return; } // Import service plugins and build type-to-plugin map. // In Joomla 5+ with SubscriberInterface, plugins receive the Event object // as their first argument. When they do $services[] = $this, they append to // the Event via ArrayAccess at numeric indices starting at 1. PluginHelper::importPlugin('mokosuitecross'); $servicePlugins = []; $event = new \Joomla\Event\Event('onMokoSuiteCrossGetServices', [$servicePlugins]); try { $app->getDispatcher()->dispatch('onMokoSuiteCrossGetServices', $event); } catch (\Throwable $e) { // Dispatcher may not be available } // Read plugins back from the Event's ArrayAccess indices $idx = 1; while (isset($event[$idx])) { $servicePlugins[] = $event[$idx]; $idx++; } $pluginMap = []; foreach ($servicePlugins as $plugin) { if ($plugin instanceof MokoSuiteCrossServiceInterface) { $pluginMap[$plugin->getServiceType()] = $plugin; } } // Create queue entries $now = Factory::getDate()->toSql(); $createdIds = []; $skipped = []; // Build article URL $articleUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; if (!empty($article->catid)) { $articleUrl .= '&catid=' . $article->catid; } // Extract intro image for media $media = []; $images = json_decode($article->images ?? '{}'); if (!empty($images->image_intro)) { $media[] = Uri::root() . ltrim($images->image_intro, '/'); } foreach ($services as $service) { // Duplicate guard — skip if article already posted/queued for this service $query = $db->getQuery(true) ->select('COUNT(*)') ->from($db->quoteName('#__mokosuitecross_posts')) ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) ->where($db->quoteName('service_id') . ' = ' . (int) $service->id) ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')'); $db->setQuery($query); if ((int) $db->loadResult() > 0) { $skipped[] = [ 'service_id' => (int) $service->id, 'service_type' => $service->service_type, 'reason' => 'duplicate', ]; continue; } // Render template via shared dispatcher logic $message = CrossPostDispatcher::renderTemplate($article, $service); // Create queue entry $post = (object) [ 'article_id' => (int) $article->id, 'service_id' => (int) $service->id, 'status' => 'queued', 'message' => $message, 'platform_post_id' => '', 'platform_response' => '', 'error_message' => '', 'retry_count' => 0, 'created' => $now, 'modified' => $now, ]; $db->insertObject('#__mokosuitecross_posts', $post); $postId = (int) $db->insertid(); $createdIds[] = [ 'post_id' => $postId, 'service_id' => (int) $service->id, 'service_type' => $service->service_type, 'status' => 'queued', ]; // Write log entry $log = (object) [ 'post_id' => $postId, 'service_id' => (int) $service->id, 'level' => 'info', 'message' => sprintf('API dispatch: queued article %d to %s', $article->id, $service->service_type), 'context' => '{}', 'created' => $now, ]; $db->insertObject('#__mokosuitecross_logs', $log); } $this->sendJsonResponse([ 'article_id' => (int) $article->id, 'dispatched' => $createdIds, 'skipped' => $skipped, ], 200); } /** * Send a JSON response and close the application. * * @param array $data Response data * @param int $httpCode HTTP status code * * @return void */ private function sendJsonResponse(array $data, int $httpCode): void { $app = $this->app; $app->setHeader('Content-Type', 'application/json; charset=utf-8'); $app->setHeader('Status', (string) $httpCode); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $app->close(); } }