fix: deep-dive critical/medium bug fixes (reconciled with dev) #260
@@ -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`;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/* 01.13.04 — no schema changes */
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user