* @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\Session\Session; use Joomla\Component\MokoSuiteCross\Administrator\Table\PostTable; /** * Calendar controller -- provides AJAX endpoints for the visual post calendar. * * Endpoints: * task=calendar.events -- GET JSON feed for FullCalendar (filtered by start/end) * task=calendar.reschedule -- POST reschedule a post to a new date/time */ class CalendarController extends BaseController { /** * Return posts as FullCalendar-compatible JSON events. * * Query params: start, end (ISO 8601 date range from FullCalendar). * * @return void */ public function events(): void { $app = $this->app; $db = Factory::getDbo(); // ACL check if (!$app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) { $this->sendJsonResponse(['error' => 'Forbidden'], 403); return; } // FullCalendar sends start/end as ISO date strings $start = $this->input->getString('start', ''); $end = $this->input->getString('end', ''); $query = $db->getQuery(true) ->select([ 'p.' . $db->quoteName('id'), 'p.' . $db->quoteName('article_id'), 'p.' . $db->quoteName('service_id'), 'p.' . $db->quoteName('status'), 'p.' . $db->quoteName('scheduled_at'), 'p.' . $db->quoteName('posted_at'), 'p.' . $db->quoteName('created'), 'p.' . $db->quoteName('message'), 'a.' . $db->quoteName('title', 'article_title'), 's.' . $db->quoteName('title', 'service_title'), 's.' . $db->quoteName('service_type'), ]) ->from($db->quoteName('#__mokosuitecross_posts', 'p')) ->leftJoin( $db->quoteName('#__content', 'a') . ' ON ' . $db->quoteName('a.id') . ' = ' . $db->quoteName('p.article_id') ) ->leftJoin( $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id') ) ->order($db->quoteName('p.created') . ' DESC'); // Filter by date range when provided if ($start !== '') { $dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)'; $query->where($dateExpr . ' >= ' . $db->quote($start)); } if ($end !== '') { $dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)'; $query->where($dateExpr . ' <= ' . $db->quote($end)); } $db->setQuery($query); $rows = $db->loadObjectList() ?: []; // Map status to colour $statusColors = [ 'posted' => '#28a745', 'scheduled' => '#007bff', 'queued' => '#ffc107', 'failed' => '#dc3545', 'posting' => '#17a2b8', ]; $events = []; foreach ($rows as $row) { // Pick the best date for the calendar event $eventDate = $row->scheduled_at ?: ($row->posted_at ?: $row->created); // Skip rows with no usable date if (empty($eventDate) || $eventDate === '0000-00-00 00:00:00') { continue; } $title = ($row->article_title ?: 'Post #' . $row->id); if ($row->service_title) { $title .= ' - ' . $row->service_title; } $events[] = [ 'id' => (int) $row->id, 'title' => $title, 'start' => $eventDate, 'color' => $statusColors[$row->status] ?? '#6c757d', 'url' => 'index.php?option=com_mokosuitecross&task=post.edit&id=' . (int) $row->id, 'extendedProps' => [ 'status' => $row->status, 'service_type' => $row->service_type ?? '', 'article_id' => (int) $row->article_id, 'service_id' => (int) $row->service_id, 'message' => mb_substr($row->message ?? '', 0, 200), ], ]; } $this->sendJsonResponse($events, 200); } /** * Reschedule a post to a new date/time via drag-drop. * * POST params: post_id (int), new_date (ISO 8601 datetime string). * * @return void */ public function reschedule(): void { $app = $this->app; // CSRF check if (!Session::checkToken('post')) { $this->sendJsonResponse(['error' => Text::_('JINVALID_TOKEN')], 403); return; } // ACL check if (!$app->getIdentity()->authorise('core.edit', 'com_mokosuitecross')) { $this->sendJsonResponse(['error' => 'Forbidden'], 403); return; } $postId = $this->input->getInt('post_id', 0); $newDate = $this->input->getString('new_date', ''); if ($postId < 1 || $newDate === '') { $this->sendJsonResponse( ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], 400 ); return; } // Validate the date format try { $dateObj = Factory::getDate($newDate); } catch (\Exception $e) { $this->sendJsonResponse( ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], 400 ); return; } // Load the post using Table bind/check/store pattern $db = Factory::getDbo(); $table = new PostTable($db); if (!$table->load($postId)) { $this->sendJsonResponse( ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], 404 ); return; } // Only allow rescheduling of scheduled or queued posts $allowedStatuses = ['scheduled', 'queued']; if (!in_array($table->status, $allowedStatuses, true)) { $this->sendJsonResponse( ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], 400 ); return; } // Update the post $data = [ 'scheduled_at' => $dateObj->toSql(), 'status' => 'scheduled', 'modified' => Factory::getDate()->toSql(), ]; if (!$table->bind($data) || !$table->check() || !$table->store()) { $this->sendJsonResponse( ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], 500 ); return; } // Log the reschedule $log = (object) [ 'post_id' => $postId, 'service_id' => (int) $table->service_id, 'level' => 'info', 'message' => sprintf('Post rescheduled to %s via calendar drag-drop', $dateObj->toSql()), 'context' => '{}', 'created' => Factory::getDate()->toSql(), ]; $db->insertObject('#__mokosuitecross_logs', $log); $this->sendJsonResponse( [ 'success' => true, 'message' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS'), ], 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(); } }