diff --git a/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql b/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql index 94b03c57..8bdbc187 100644 --- a/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql +++ b/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql @@ -1,5 +1,7 @@ --- MokoSuiteCross — Uninstall +-- MokoSuiteCross -- Uninstall DROP TABLE IF EXISTS `#__mokosuitecross_logs`; +DROP TABLE IF EXISTS `#__mokosuitecross_analytics`; +DROP TABLE IF EXISTS `#__mokosuitecross_category_rules`; DROP TABLE IF EXISTS `#__mokosuitecross_posts`; DROP TABLE IF EXISTS `#__mokosuitecross_templates`; DROP TABLE IF EXISTS `#__mokosuitecross_services`; diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.13.04.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.13.04.sql new file mode 100644 index 00000000..3ed9641a --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.13.04.sql @@ -0,0 +1 @@ +/* 01.13.04 — no schema changes */ diff --git a/source/packages/com_mokosuitecross/src/Controller/PostsController.php b/source/packages/com_mokosuitecross/src/Controller/PostsController.php index 8f049d5e..6a994734 100644 --- a/source/packages/com_mokosuitecross/src/Controller/PostsController.php +++ b/source/packages/com_mokosuitecross/src/Controller/PostsController.php @@ -130,6 +130,10 @@ class PostsController extends AdminController { $this->checkToken(); + if (!$this->app->getIdentity()->authorise('mokosuitecross.queue.manage', 'com_mokosuitecross')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + $db = Factory::getDbo(); $query = $db->getQuery(true) @@ -238,6 +242,10 @@ class PostsController extends AdminController { $this->checkToken(); + if (!$this->app->getIdentity()->authorise('mokosuitecross.queue.manage', 'com_mokosuitecross')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + $db = Factory::getDbo(); $query = $db->getQuery(true) diff --git a/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php index 47e917a9..2839cbd8 100644 --- a/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php +++ b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php @@ -19,13 +19,6 @@ class AnalyticsHelper { /** * Record or update engagement metrics for a post. - * - * @param int $postId The post ID - * @param int $serviceId The service ID - * @param string $serviceType The service type (e.g. twitter, facebook) - * @param array $metrics Engagement metrics: impressions, engagements, clicks, shares, posted_at - * - * @return bool True on success */ public static function recordEngagement(int $postId, int $serviceId, string $serviceType, array $metrics): bool { @@ -51,7 +44,6 @@ class AnalyticsHelper ? round(($engagements / $impressions) * 100, 2) : 0.00; - // Check if a row already exists for this post $query = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__mokosuitecross_analytics')) @@ -96,12 +88,7 @@ class AnalyticsHelper } /** - * Get heatmap data as a 7x24 grid of average engagement rates. - * - * @param string $serviceType Optional service type filter - * @param int $days Number of days to look back (0 = all time) - * - * @return array 7x24 grid: [ day_of_week => [ hour_of_day => avg_engagement_rate ] ] + * Get heatmap data as a 7x24 grid derived from actual post success data. */ public static function getHeatmapData(string $serviceType = '', int $days = 90): array { @@ -109,30 +96,40 @@ class AnalyticsHelper $query = $db->getQuery(true) ->select([ - $db->quoteName('day_of_week'), - $db->quoteName('hour_of_day'), - 'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate', + 'DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS day_of_week', + 'HOUR(' . $db->quoteName('p.posted_at') . ') AS hour_of_day', 'COUNT(*) AS post_count', ]) - ->from($db->quoteName('#__mokosuitecross_analytics')) - ->group($db->quoteName('day_of_week')) - ->group($db->quoteName('hour_of_day')) - ->order($db->quoteName('day_of_week') . ' ASC') - ->order($db->quoteName('hour_of_day') . ' ASC'); + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('posted')) + ->where($db->quoteName('p.posted_at') . ' IS NOT NULL') + ->group('day_of_week') + ->group('hour_of_day') + ->order('day_of_week ASC') + ->order('hour_of_day ASC'); if ($serviceType !== '') { - $query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType)); + $query->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType)); } if ($days > 0) { $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); - $query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)); + $query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($cutoff)); } $db->setQuery($query); $rows = $db->loadObjectList(); - // Build 7x24 grid initialised to zero + $maxCount = 1; + + foreach ($rows as $row) { + if ((int) $row->post_count > $maxCount) { + $maxCount = (int) $row->post_count; + } + } + $grid = []; for ($d = 0; $d < 7; $d++) { @@ -142,9 +139,10 @@ class AnalyticsHelper } foreach ($rows as $row) { + $count = (int) $row->post_count; $grid[(int) $row->day_of_week][(int) $row->hour_of_day] = [ - 'avg_rate' => round((float) $row->avg_rate, 2), - 'post_count' => (int) $row->post_count, + 'avg_rate' => round(($count / $maxCount) * 100, 2), + 'post_count' => $count, ]; } @@ -152,12 +150,7 @@ class AnalyticsHelper } /** - * Get the best times to post ranked by average engagement rate. - * - * @param string $serviceType Optional service type filter - * @param int $limit Number of results to return - * - * @return array List of [day_of_week, hour_of_day, avg_rate, post_count] + * Get the best times to post ranked by post success frequency. */ public static function getBestTimes(string $serviceType = '', int $limit = 5): array { @@ -165,19 +158,22 @@ class AnalyticsHelper $query = $db->getQuery(true) ->select([ - $db->quoteName('day_of_week'), - $db->quoteName('hour_of_day'), - 'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate', + 'DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS day_of_week', + 'HOUR(' . $db->quoteName('p.posted_at') . ') AS hour_of_day', 'COUNT(*) AS post_count', ]) - ->from($db->quoteName('#__mokosuitecross_analytics')) - ->group($db->quoteName('day_of_week')) - ->group($db->quoteName('hour_of_day')) + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('posted')) + ->where($db->quoteName('p.posted_at') . ' IS NOT NULL') + ->group('day_of_week') + ->group('hour_of_day') ->having('COUNT(*) >= 1') - ->order('avg_rate DESC'); + ->order('post_count DESC'); if ($serviceType !== '') { - $query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType)); + $query->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType)); } $db->setQuery($query, 0, $limit); @@ -188,16 +184,16 @@ class AnalyticsHelper $results = []; foreach ($rows as $row) { - $hour = (int) $row['hour_of_day']; - $ampm = $hour < 12 ? 'AM' : 'PM'; - $hour12 = $hour % 12 ?: 12; + $hour = (int) $row['hour_of_day']; + $ampm = $hour < 12 ? 'AM' : 'PM'; + $hour12 = $hour % 12 ?: 12; $results[] = [ 'day_of_week' => (int) $row['day_of_week'], 'day_name' => $dayNames[(int) $row['day_of_week']], 'hour_of_day' => $hour, 'hour_label' => $hour12 . ':00 ' . $ampm, - 'avg_rate' => round((float) $row['avg_rate'], 2), + 'avg_rate' => round((float) $row['post_count'], 2), 'post_count' => (int) $row['post_count'], ]; } @@ -206,11 +202,7 @@ class AnalyticsHelper } /** - * Get engagement stats grouped by service type. - * - * @param int $days Number of days to look back (0 = all time) - * - * @return array List of [service_type, total_posts, avg_engagement_rate, total_impressions, total_engagements] + * Get stats grouped by service type from actual post data. */ public static function getServiceBreakdown(int $days = 30): array { @@ -218,35 +210,41 @@ class AnalyticsHelper $query = $db->getQuery(true) ->select([ - $db->quoteName('service_type'), + $db->quoteName('s.service_type'), 'COUNT(*) AS total_posts', - 'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_engagement_rate', - 'SUM(' . $db->quoteName('impressions') . ') AS total_impressions', - 'SUM(' . $db->quoteName('engagements') . ') AS total_engagements', - 'SUM(' . $db->quoteName('clicks') . ') AS total_clicks', - 'SUM(' . $db->quoteName('shares') . ') AS total_shares', + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS total_succeeded', + 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' IN (' + . $db->quote('failed') . ',' . $db->quote('permanently_failed') + . ') THEN 1 ELSE 0 END) AS total_failed', ]) - ->from($db->quoteName('#__mokosuitecross_analytics')) - ->group($db->quoteName('service_type')) - ->order('avg_engagement_rate DESC'); + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->group($db->quoteName('s.service_type')) + ->order('total_posts DESC'); if ($days > 0) { $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); - $query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)); + $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($cutoff)); } $db->setQuery($query); $rows = $db->loadAssocList(); foreach ($rows as &$row) { - $row['avg_engagement_rate'] = round((float) $row['avg_engagement_rate'], 2); - $row['total_posts'] = (int) $row['total_posts']; - $row['total_impressions'] = (int) $row['total_impressions']; - $row['total_engagements'] = (int) $row['total_engagements']; - $row['total_clicks'] = (int) $row['total_clicks']; - $row['total_shares'] = (int) $row['total_shares']; + $total = (int) $row['total_posts']; + $succeeded = (int) $row['total_succeeded']; + + $row['total_posts'] = $total; + $row['total_succeeded'] = $succeeded; + $row['total_failed'] = (int) $row['total_failed']; + $row['avg_engagement_rate'] = $total > 0 ? round(($succeeded / $total) * 100, 2) : 0; + $row['total_impressions'] = 0; + $row['total_engagements'] = 0; + $row['total_clicks'] = 0; + $row['total_shares'] = 0; } return $rows; } -} \ No newline at end of file +} diff --git a/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php b/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php index b1391fd4..509da82e 100644 --- a/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php +++ b/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php @@ -594,13 +594,26 @@ class CrossPostDispatcher return; } - // Load service plugins + // Load service plugins using Joomla 5/6-compatible dispatcher pattern PluginHelper::importPlugin('mokosuitecross'); - $plugins = []; - Factory::getApplication()->triggerEvent('onMokoSuiteCrossGetServices', [&$plugins]); + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoSuiteCrossGetServices', [$servicePlugins]); + + try { + Factory::getApplication()->getDispatcher()->dispatch('onMokoSuiteCrossGetServices', $event); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } $pluginMap = []; - foreach ($plugins as $plugin) { + foreach ($servicePlugins as $plugin) { $pluginMap[$plugin->getServiceType()] = $plugin; } @@ -613,7 +626,7 @@ class CrossPostDispatcher continue; } - $credentials = json_decode($post->credentials, true) ?: []; + $credentials = CredentialHelper::decrypt($post->credentials ?: ''); try { $result = $plugin->deletePost($post->platform_post_id, $credentials); diff --git a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php index e9bce359..711db717 100644 --- a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php +++ b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php @@ -41,9 +41,8 @@ class MokoSuiteCrossHelper 'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES', 'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES', 'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR', - 'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS', - 'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR', 'analytics' => 'COM_MOKOSUITECROSS_SUBMENU_ANALYTICS', + 'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS', ]; // Joomla 5+ toolbar submenu diff --git a/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php b/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php index c515ca4c..feb5b896 100644 --- a/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php +++ b/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php @@ -91,6 +91,16 @@ class QueueProcessor $db->setQuery($query); $retryPosts = $db->loadObjectList() ?: []; + // 3. Recover stale "posting" entries (stuck > 10 minutes) + $staleQuery = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->where($db->quoteName('modified') . ' < DATE_SUB(NOW(), INTERVAL 600 SECOND)'); + $db->setQuery($staleQuery); + $db->execute(); + $allPosts = array_merge($queuedPosts, $retryPosts); foreach ($allPosts as $post) { diff --git a/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php index caacba03..c94dba51 100644 --- a/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php +++ b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php @@ -22,7 +22,7 @@ use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper; class HtmlView extends BaseHtmlView { - public $sidebar; + public $ajaxUrl; public function display($tpl = null): void @@ -40,7 +40,6 @@ class HtmlView extends BaseHtmlView $this->addToolbar(); MokoSuiteCrossHelper::addSubmenu('calendar'); - $this->sidebar = \Joomla\CMS\HTML\Sidebar::render(); // Set document title Factory::getApplication()->getDocument()->setTitle( diff --git a/source/packages/com_mokosuitecross/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Dashboard/HtmlView.php index 671fc0d0..22b87c21 100644 --- a/source/packages/com_mokosuitecross/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokosuitecross/src/View/Dashboard/HtmlView.php @@ -26,7 +26,7 @@ class HtmlView extends BaseHtmlView protected $serviceBreakdown; protected $dailyTrend; protected $topArticles; - public $sidebar; + public $period; public function display($tpl = null): void @@ -58,7 +58,6 @@ class HtmlView extends BaseHtmlView $this->addToolbar(); MokoSuiteCrossHelper::addSubmenu('dashboard'); - $this->sidebar = \Joomla\CMS\HTML\Sidebar::render(); parent::display($tpl); } diff --git a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php index 99fe1bb1..92193260 100644 --- a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php +++ b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php @@ -535,6 +535,19 @@ XML; continue; } + // Respect first-publish-only: skip if article was previously posted + if ($params->get('post_on_first_publish_only', 0)) { + $existsQuery = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $pk); + $db->setQuery($existsQuery); + + if ((int) $db->loadResult() > 0) { + continue; + } + } + $url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; if (!empty($article->catid)) {